Serve frontend components (#17798)
## Context Ability to serve frontend component See: ```typescript curl -i 'http://localhost:3000/rest/front-components/35063b3f-bc4c-4358-8966-7762677802a3' \ --header 'Authorization: Bearer eyJhb...' HTTP/1.1 200 OK X-Powered-By: Express Access-Control-Allow-Origin: * Content-Type: application/javascript Date: Mon, 09 Feb 2026 10:28:17 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked // react-globals:react/jsx-runtime var jsx = globalThis.jsx; var jsxs = globalThis.jsxs; var Fragment = globalThis.React.Fragment; // src/front-components/test.tsx var RemoteComponents = globalThis.RemoteComponents; var Component = () => { return /* @__PURE__ */ jsxs(RemoteComponents.HtmlDiv, { style: { padding: "20px", fontFamily: "sans-serif" }, children: [ /* @__PURE__ */ jsx(RemoteComponents.HtmlH1, { children: "My new component!" }), /* @__PURE__ */ jsx(RemoteComponents.HtmlP, { children: "This is your front component: test" }) ] }); }; var test_default = globalThis.jsx(Component, {}); export { test_default as default }; //# sourceMappingURL=test.mjs.map ``` readFile_v2 returns a Node.js Stream object (Readable). Here we are using stream pipeline which connects the readable stream (file) to the writable stream (HTTP response) which efficiently streams the file content directly to the HTTP response without loading the entire file into memory. (in chunks, handling backpressure and closing the connection when the file is fully sent)
This commit is contained in:
+79
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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<Response>();
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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],
|
||||
|
||||
+32
@@ -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<FrontComponentDTO[]> {
|
||||
@@ -282,4 +287,31 @@ export class FrontComponentService {
|
||||
|
||||
return frontComponent;
|
||||
}
|
||||
|
||||
async getBuiltComponentStream({
|
||||
frontComponentId,
|
||||
workspaceId,
|
||||
}: {
|
||||
frontComponentId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<Readable> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+86
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user