diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/controllers/front-component.controller.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/controllers/front-component.controller.ts new file mode 100644 index 0000000000..3bf53404aa --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/controllers/front-component.controller.ts @@ -0,0 +1,79 @@ +import { + Controller, + Get, + Param, + Res, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { pipeline } from 'stream/promises'; + +import { Response } from 'express'; + +import { + FileStorageException, + FileStorageExceptionCode, +} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; + +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { FrontComponentRestApiExceptionFilter } from 'src/engine/metadata-modules/front-component/filters/front-component-rest-api-exception.filter'; +import { + FrontComponentException, + FrontComponentExceptionCode, +} from 'src/engine/metadata-modules/front-component/front-component.exception'; +import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service'; + +@Controller('rest/front-components') +@UseGuards(WorkspaceAuthGuard) +@UseFilters(FrontComponentRestApiExceptionFilter) +export class FrontComponentController { + constructor(private readonly frontComponentService: FrontComponentService) {} + + @Get(':frontComponentId') + @UseGuards(NoPermissionGuard) + async getBuiltJs( + @Res() res: Response, + @Param('frontComponentId') frontComponentId: string, + @AuthWorkspace() workspace: WorkspaceEntity, + ) { + try { + const fileStream = + await this.frontComponentService.getBuiltComponentStream({ + frontComponentId, + workspaceId: workspace.id, + }); + + res.setHeader('Content-Type', 'application/javascript'); + + await pipeline(fileStream, res); + } catch (error) { + // Mid-stream error: client already received partial data, nothing to do + if (res.headersSent) { + return; + } + + if ( + error instanceof FileStorageException && + error.code === FileStorageExceptionCode.FILE_NOT_FOUND + ) { + throw new FrontComponentException( + 'Front component built file not found', + FrontComponentExceptionCode.FRONT_COMPONENT_NOT_FOUND, + ); + } + + if (error instanceof FrontComponentException) { + throw error; + } + + throw new FrontComponentException( + `Error retrieving front component built file: ${error.message}`, + FrontComponentExceptionCode.FRONT_COMPONENT_NOT_READY, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/filters/front-component-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/filters/front-component-rest-api-exception.filter.ts new file mode 100644 index 0000000000..50b765f36f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/filters/front-component-rest-api-exception.filter.ts @@ -0,0 +1,56 @@ +import { + type ArgumentsHost, + Catch, + type ExceptionFilter, + Injectable, +} from '@nestjs/common'; + +import { type Response } from 'express'; + +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { + FrontComponentException, + FrontComponentExceptionCode, +} from 'src/engine/metadata-modules/front-component/front-component.exception'; + +@Catch(FrontComponentException) +@Injectable() +export class FrontComponentRestApiExceptionFilter implements ExceptionFilter { + constructor( + private readonly httpExceptionHandlerService: HttpExceptionHandlerService, + ) {} + + catch(exception: FrontComponentException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + switch (exception.code) { + case FrontComponentExceptionCode.FRONT_COMPONENT_NOT_FOUND: + case FrontComponentExceptionCode.FRONT_COMPONENT_NOT_READY: + return this.httpExceptionHandlerService.handleError( + exception, + response, + 404, + ); + case FrontComponentExceptionCode.FRONT_COMPONENT_CREATE_FAILED: + case FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT: + return this.httpExceptionHandlerService.handleError( + exception, + response, + 400, + ); + case FrontComponentExceptionCode.FRONT_COMPONENT_ALREADY_EXISTS: + return this.httpExceptionHandlerService.handleError( + exception, + response, + 409, + ); + default: + return this.httpExceptionHandlerService.handleError( + exception, + response, + 500, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts index 769835481d..4e22dad4fb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts @@ -3,6 +3,8 @@ import { Module } from '@nestjs/common'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { FlatFrontComponentModule } from 'src/engine/metadata-modules/flat-front-component/flat-front-component.module'; +import { FrontComponentController } from 'src/engine/metadata-modules/front-component/controllers/front-component.controller'; +import { FrontComponentRestApiExceptionFilter } from 'src/engine/metadata-modules/front-component/filters/front-component-rest-api-exception.filter'; import { FrontComponentResolver } from 'src/engine/metadata-modules/front-component/front-component.resolver'; import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service'; import { FrontComponentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor'; @@ -18,10 +20,12 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace PermissionsModule, FlatFrontComponentModule, ], + controllers: [FrontComponentController], providers: [ FrontComponentService, FrontComponentResolver, FrontComponentGraphqlApiExceptionInterceptor, + FrontComponentRestApiExceptionFilter, WorkspaceMigrationGraphqlApiExceptionInterceptor, ], exports: [FrontComponentService], diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts index 569528969d..27759d8e4b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts @@ -1,9 +1,13 @@ import { Injectable } from '@nestjs/common'; +import { type Readable } from 'stream'; + +import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; @@ -27,6 +31,7 @@ export class FrontComponentService { private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly applicationService: ApplicationService, + private readonly fileStorageService: FileStorageService, ) {} async findAll(workspaceId: string): Promise { @@ -282,4 +287,31 @@ export class FrontComponentService { return frontComponent; } + + async getBuiltComponentStream({ + frontComponentId, + workspaceId, + }: { + frontComponentId: string; + workspaceId: string; + }): Promise { + const frontComponent = await this.findByIdOrThrow( + frontComponentId, + workspaceId, + ); + + const application = await this.applicationService.findOneApplicationOrThrow( + { + id: frontComponent.applicationId, + workspaceId, + }, + ); + + return this.fileStorageService.readFile({ + workspaceId, + applicationUniversalIdentifier: application.universalIdentifier, + fileFolder: FileFolder.BuiltFrontComponent, + resourcePath: frontComponent.builtComponentPath, + }); + } } diff --git a/packages/twenty-server/test/integration/rest/suites/front-component-built-js.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/front-component-built-js.integration-spec.ts new file mode 100644 index 0000000000..ddc672b8e1 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/front-component-built-js.integration-spec.ts @@ -0,0 +1,86 @@ +import { createFrontComponent } from 'test/integration/metadata/suites/front-component/utils/create-front-component.util'; +import { deleteFrontComponent } from 'test/integration/metadata/suites/front-component/utils/delete-front-component.util'; +import { seedBuiltFrontComponentFile } from 'test/integration/metadata/suites/front-component/utils/seed-built-front-component-file.util'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; + +const BUILT_COMPONENT_PATH = 'src/front-components/test-endpoint.mjs'; + +describe('Front component built JS endpoint', () => { + let frontComponentId: string; + let cleanupBuiltFile: (() => void) | undefined; + + beforeAll(async () => { + const { cleanup } = await seedBuiltFrontComponentFile({ + builtComponentPath: BUILT_COMPONENT_PATH, + }); + + cleanupBuiltFile = cleanup; + + const { data } = await createFrontComponent({ + expectToFail: false, + input: { + name: 'testBuiltJsEndpoint', + componentName: 'TestBuiltJsEndpoint', + sourceComponentPath: 'src/front-components/test-endpoint.tsx', + builtComponentPath: BUILT_COMPONENT_PATH, + builtComponentChecksum: 'test-checksum-123', + }, + }); + + frontComponentId = data.createFrontComponent.id; + }); + + afterAll(async () => { + if (frontComponentId) { + await deleteFrontComponent({ + expectToFail: false, + input: { id: frontComponentId }, + }); + } + + cleanupBuiltFile?.(); + }); + + it('should serve the built JS file with correct content type', async () => { + await makeRestAPIRequest({ + method: 'get', + path: `/front-components/${frontComponentId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }) + .expect(200) + .expect('Content-Type', /application\/javascript/) + .expect((res) => { + expect(res.text).toBe('dummy built component content'); + }); + }); + + it('should return 404 for a non-existent front component ID', async () => { + const nonExistentId = '00000000-0000-0000-0000-000000000000'; + + await makeRestAPIRequest({ + method: 'get', + path: `/front-components/${nonExistentId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }) + .expect(404) + .expect((res) => { + expect(res.body.statusCode).toBe(404); + }); + }); + + it('should return 403 when no token is provided', async () => { + await makeRestAPIRequest({ + method: 'get', + path: `/front-components/${frontComponentId}`, + bearer: '', + }).expect(403); + }); + + it('should return 401 when an invalid token is provided', async () => { + await makeRestAPIRequest({ + method: 'get', + path: `/front-components/${frontComponentId}`, + bearer: INVALID_ACCESS_TOKEN, + }).expect(401); + }); +});