Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion libs/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"prepare": "npm run build"
},
"dependencies": {
"tslib": "^2.3.0"
"tslib": "^2.3.0",
"@rspack/core": "^1.3.12"
},
"devDependencies": {
"typescript": "^5.5.3",
Expand Down
19 changes: 16 additions & 3 deletions libs/cli/src/commands/build/adapters/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,37 @@ import { AdapterTemplate } from '../types';

/**
* AWS Lambda adapter - serverless deployment on AWS Lambda.
* Compiles to ESM and uses @codegenie/serverless-express to wrap the Express app.
* Compiles to ESM, bundles with rspack to CJS for maximum compatibility.
*
* Prerequisites:
* npm install @codegenie/serverless-express
*
* The build process:
* 1. TypeScript compiles to ESM in dist/
* 2. serverless-setup.js is generated (sets FRONTMCP_SERVERLESS=1)
* 3. index.js imports setup first, then main module
* 4. rspack bundles everything into handler.cjs
*
* @see https://github.com/codegenie/serverless-express
*/
export const lambdaAdapter: AdapterTemplate = {
moduleFormat: 'esnext',
shouldBundle: true,
bundleOutput: 'handler.cjs',

getSetupTemplate: () => `// Serverless environment setup - MUST be imported first
// This sets FRONTMCP_SERVERLESS before any decorators run
// Required because ESM hoists imports before other statements
process.env.FRONTMCP_SERVERLESS = '1';
`,

getEntryTemplate: (mainModulePath: string) => `// Auto-generated AWS Lambda entry point
// Generated by: frontmcp build --adapter lambda
//
// IMPORTANT: This adapter requires @codegenie/serverless-express
// Install it with: npm install @codegenie/serverless-express
//
process.env.FRONTMCP_SERVERLESS = '1';

import './serverless-setup.js';
import '${mainModulePath}';
import { getServerlessHandlerAsync } from '@frontmcp/sdk';
import serverlessExpress from '@codegenie/serverless-express';
Expand Down
23 changes: 18 additions & 5 deletions libs/cli/src/commands/build/adapters/vercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,30 @@ import { AdapterTemplate } from '../types';

/**
* Vercel adapter - serverless deployment on Vercel.
* Compiles to ESM and generates a handler that exports the Express app.
* Compiles to ESM, bundles with rspack to CJS for maximum compatibility.
*
* The build process:
* 1. TypeScript compiles to ESM in dist/
* 2. serverless-setup.js is generated (sets FRONTMCP_SERVERLESS=1)
* 3. index.js imports setup first, then main module
* 4. rspack bundles everything into handler.cjs
*
* @see https://vercel.com/docs/frameworks/express
*/
export const vercelAdapter: AdapterTemplate = {
moduleFormat: 'esnext',
shouldBundle: true,
bundleOutput: 'handler.cjs',

getEntryTemplate: (mainModulePath: string) => `// Auto-generated Vercel entry point
// Generated by: frontmcp build --adapter vercel
getSetupTemplate: () => `// Serverless environment setup - MUST be imported first
// This sets FRONTMCP_SERVERLESS before any decorators run
// Required because ESM hoists imports before other statements
process.env.FRONTMCP_SERVERLESS = '1';
`,

getEntryTemplate: (mainModulePath: string) => `// Auto-generated Vercel entry point
// Generated by: frontmcp build --adapter vercel
import './serverless-setup.js';
import '${mainModulePath}';
import { getServerlessHandlerAsync } from '@frontmcp/sdk';

Expand All @@ -29,8 +42,8 @@ export default async function handler(req, res) {

getConfig: () => ({
version: 2,
builds: [{ src: 'dist/index.js', use: '@vercel/node' }],
routes: [{ src: '/(.*)', dest: '/dist/index.js' }],
builds: [{ src: 'dist/handler.cjs', use: '@vercel/node' }],
routes: [{ src: '/(.*)', dest: '/dist/handler.cjs' }],
}),

configFileName: 'vercel.json',
Expand Down
70 changes: 70 additions & 0 deletions libs/cli/src/commands/build/bundler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { rspack } from '@rspack/core';
import { c } from '../../colors';

/**
* Bundle the serverless entry point into a single CJS file using rspack.
* This resolves ESM/CJS compatibility issues and dynamic import problems.
*
* @param entryPath - Absolute path to the entry file (e.g., dist/index.js)
* @param outDir - Output directory for the bundled file
* @param outputFilename - Name of the output bundle (e.g., 'handler.cjs')
*/
export async function bundleForServerless(
entryPath: string,
outDir: string,
outputFilename: string,
): Promise<void> {
const compiler = rspack({
mode: 'production',
target: 'node',
entry: entryPath,
output: {
path: outDir,
filename: outputFilename,
library: { type: 'commonjs2' },
clean: false,
},
// Use node externals preset for built-in modules
externalsPresets: { node: true },
// Exclude problematic optional dependencies
externals: {
'@swc/core': '@swc/core',
fsevents: 'fsevents',
esbuild: 'esbuild',
},
resolve: {
extensions: ['.js', '.mjs', '.cjs', '.json'],
},
// Don't minimize to preserve readability for debugging
optimization: {
minimize: false,
},
// Suppress verbose output
stats: 'errors-warnings',
});

return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) {
return reject(err);
}
if (stats?.hasErrors()) {
const info = stats.toJson();
const errorMessages = info.errors?.map((e) => e.message).join('\n') || 'Unknown error';
return reject(new Error(`Bundle failed:\n${errorMessages}`));
}
if (stats?.hasWarnings()) {
const info = stats.toJson();
info.warnings?.forEach((w) => {
console.log(c('yellow', ` Warning: ${w.message}`));
});
}
compiler.close((closeErr) => {
if (closeErr) {
console.log(c('yellow', ` Warning closing compiler: ${closeErr.message}`));
}
resolve();
});
});
});
}
18 changes: 18 additions & 0 deletions libs/cli/src/commands/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ensureDir, fileExists, fsp, runCmd, resolveEntry, writeJSON } from '../
import { REQUIRED_DECORATOR_FIELDS } from '../../tsconfig';
import { ADAPTERS } from './adapters';
import { AdapterName } from './types';
import { bundleForServerless } from './bundler';

function isTsLike(p: string): boolean {
return /\.tsx?$/i.test(p);
Expand All @@ -21,6 +22,15 @@ async function generateAdapterFiles(
): Promise<void> {
const template = ADAPTERS[adapter];

// Generate serverless setup file first (if adapter has one)
// This file sets FRONTMCP_SERVERLESS=1 before any imports run
if (template.getSetupTemplate) {
const setupContent = template.getSetupTemplate();
const setupPath = path.join(outDir, 'serverless-setup.js');
await fsp.writeFile(setupPath, setupContent, 'utf8');
console.log(c('green', ` Generated serverless setup at ${path.relative(cwd, setupPath)}`));
}

// Generate index.js entry point
const mainModuleName = entryBasename.replace(/\.tsx?$/, '.js');
const entryContent = template.getEntryTemplate(`./${mainModuleName}`);
Expand All @@ -32,6 +42,14 @@ async function generateAdapterFiles(
console.log(c('green', ` Generated ${adapter} entry at ${path.relative(cwd, entryPath)}`));
}

// Bundle if adapter requires it (creates single CJS file for serverless)
if (template.shouldBundle && template.bundleOutput) {
console.log(c('cyan', `[build] Bundling for ${adapter}...`));
const entryPath = path.join(outDir, 'index.js');
await bundleForServerless(entryPath, outDir, template.bundleOutput);
console.log(c('green', ` Created bundle: ${template.bundleOutput}`));
}

// Generate config file if adapter has one (skip if already exists)
if (template.getConfig && template.configFileName) {
const configPath = path.join(cwd, template.configFileName);
Expand Down
19 changes: 19 additions & 0 deletions libs/cli/src/commands/build/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ export type AdapterTemplate = {
*/
getEntryTemplate: (mainModulePath: string) => string;

/**
* Generate the serverless setup file content.
* This file is imported first to set environment variables before decorators run.
* @returns The content for serverless-setup.js, or undefined if not needed
*/
getSetupTemplate?: () => string;

/**
* Whether to bundle the output with rspack.
* Recommended for serverless deployments to avoid ESM/CJS issues.
*/
shouldBundle?: boolean;

/**
* Output filename for the bundled file (e.g., 'handler.cjs').
* Only used when shouldBundle is true.
*/
bundleOutput?: string;

/**
* Generate the deployment platform config file content.
* @returns Object (for JSON) or string (for TOML/YAML)
Expand Down
52 changes: 32 additions & 20 deletions libs/sdk/src/common/decorators/front-mcp.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FrontMcpTokens } from '../tokens';
import { FrontMcpMetadata, frontMcpMetadataSchema } from '../metadata';
import { FrontMcpInstance } from '../../front-mcp';
import { applyMigration } from '../migrate';
import { InternalMcpError } from '../../errors/mcp.error';

/**
* Decorator that marks a class as a FrontMcp Server and provides metadata
Expand Down Expand Up @@ -50,32 +51,43 @@ export function FrontMcp(providedMetadata: FrontMcpMetadata): ClassDecorator {

if (isServerless) {
// Serverless mode: bootstrap, prepare (no listen), store handler globally
const sdk = '@frontmcp/sdk';
import(sdk)
.then(({ FrontMcpInstance, setServerlessHandler, setServerlessHandlerPromise, setServerlessHandlerError }) => {
if (!FrontMcpInstance) {
throw new Error(
`${sdk} version mismatch, make sure you have the same version for all @frontmcp/* packages`,
);
}
// Use synchronous require for bundler compatibility (rspack/webpack)
// eslint-disable-next-line @typescript-eslint/no-require-imports
const {
FrontMcpInstance: ServerlessInstance,
setServerlessHandler,
setServerlessHandlerPromise,
setServerlessHandlerError,
}: {
FrontMcpInstance: typeof FrontMcpInstance;
setServerlessHandler: (handler: unknown) => void;
setServerlessHandlerPromise: (promise: Promise<unknown>) => void;
setServerlessHandlerError: (error: Error) => void;
} = require('@frontmcp/sdk');

const handlerPromise = FrontMcpInstance.createHandler(metadata);
setServerlessHandlerPromise(handlerPromise);
handlerPromise.then(setServerlessHandler).catch((err: unknown) => {
const e = err instanceof Error ? err : new Error(String(err));
setServerlessHandlerError(e);
console.error('[FrontMCP] Serverless initialization failed:', e);
});
})
.catch((err: unknown) => {
console.error('[FrontMCP] Failed to import @frontmcp/sdk for serverless init:', err);
});
if (!ServerlessInstance) {
throw new InternalMcpError(
'@frontmcp/sdk version mismatch, make sure you have the same version for all @frontmcp/* packages',
'SDK_VERSION_MISMATCH',
);
}

const handlerPromise = ServerlessInstance.createHandler(metadata);
setServerlessHandlerPromise(handlerPromise);
handlerPromise.then(setServerlessHandler).catch((err: unknown) => {
const e = err instanceof Error ? err : new InternalMcpError(String(err), 'SERVERLESS_INIT_FAILED');
setServerlessHandlerError(e);
console.error('[FrontMCP] Serverless initialization failed:', e);
});
} else if (metadata.serve) {
// Normal mode: bootstrap and start server
const sdk = '@frontmcp/sdk';
import(sdk).then(({ FrontMcpInstance }) => {
if (!FrontMcpInstance) {
throw new Error(`${sdk} version mismatch, make sure you have the same version for all @frontmcp/* packages`);
throw new InternalMcpError(
`${sdk} version mismatch, make sure you have the same version for all @frontmcp/* packages`,
'SDK_VERSION_MISMATCH',
);
}

FrontMcpInstance.bootstrap(metadata);
Expand Down
Loading