diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index d80a454c..3ed77b9a 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -29,6 +29,7 @@ jobs: - run: npm ci - run: npm run lint:check - run: npm audit --audit-level=critical + - run: npm run build - run: npm run test:ci - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4 diff --git a/src/commands/local.ts b/src/commands/local.ts index 34961025..147c35c0 100644 --- a/src/commands/local.ts +++ b/src/commands/local.ts @@ -1,5 +1,6 @@ -import { logger, setContext } from '../common'; +import { getIacLocation, logger, setContext } from '../common'; import { startLocalStack } from '../stack/localStack'; +import { parseYaml } from '../parser'; export type RunLocalOptions = { stage: string; @@ -13,12 +14,13 @@ export const runLocal = async (stackName: string, opts: RunLocalOptions) => { const { stage, port, debug, watch, location } = opts; await setContext({ stage, location }); + const iac = parseYaml(getIacLocation(location)); logger.info( `run-local starting: stack=${stackName} stage=${stage} port=${port} debug=${debug} watch=${watch}`, ); - await startLocalStack(); + await startLocalStack(iac); // if (watch) { // const cwd = process.cwd(); diff --git a/src/common/constants.ts b/src/common/constants.ts index 60311a23..7d3e7f5b 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -2,4 +2,4 @@ export const CODE_ZIP_SIZE_LIMIT = 300 * 1000; // 300 KB ROS TemplateBody size l export const OSS_DEPLOYMENT_TIMEOUT = 3000; // in seconds export const SI_BOOTSTRAP_FC_PREFIX = 'si-bootstrap-api'; export const SI_BOOTSTRAP_BUCKET_PREFIX = 'si-bootstrap-artifacts'; -export const SI_LOCALSTACK_GATEWAY_PORT = 4567; +export const SI_LOCALSTACK_SERVER_PORT = 4567; diff --git a/src/common/domainHelper.ts b/src/common/domainHelper.ts deleted file mode 100644 index a7224bf8..00000000 --- a/src/common/domainHelper.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const splitDomain = (domain: string) => { - const parts = domain.split('.'); - const rr = parts.length > 2 ? parts[0] : '@'; - const domainName = parts.length > 2 ? parts.slice(1).join('.') : domain; - - return { rr, domainName }; -}; diff --git a/src/common/iacHelper.ts b/src/common/iacHelper.ts index aef3dc6e..66e659bd 100644 --- a/src/common/iacHelper.ts +++ b/src/common/iacHelper.ts @@ -1,10 +1,11 @@ import path from 'node:path'; import fs from 'node:fs'; import * as ros from '@alicloud/ros-cdk-core'; -import { Context } from '../types'; +import { Context, FunctionDomain, ServerlessIac } from '../types'; import * as ossDeployment from '@alicloud/ros-cdk-ossdeployment'; import crypto from 'node:crypto'; import { get } from 'lodash'; +import { parseYaml } from '../parser'; export const resolveCode = (location: string): string => { const filePath = path.resolve(process.cwd(), location); @@ -96,7 +97,18 @@ export const calcValue = (rawValue: string, ctx: Context): T => { } if (containsVar?.length) { - value = value.replace(/\$\{vars\.(\w+)}/g, (_, key) => getParam(key, ctx.parameters)); + const { vars: iacVars } = parseYaml(ctx.iacLocation); + + const mergedParams = Array.from( + new Map( + [ + ...Object.entries(iacVars ?? {}).map(([key, value]) => [key, value]), + ...(ctx.parameters ?? []).map(({ key, value }) => [key, value]), + ].filter(([, v]) => v !== undefined) as Array<[string, string]>, + ).entries(), + ).map(([key, value]) => ({ key, value })); + + value = value.replace(/\$\{vars\.(\w+)}/g, (_, key) => getParam(key, mergedParams)); } if (containsMap?.length) { @@ -107,6 +119,17 @@ export const calcValue = (rawValue: string, ctx: Context): T => { return value as T; }; + +export const getIacDefinition = ( + iac: ServerlessIac, + rawValue: string, +): FunctionDomain | undefined => { + const matchFn = rawValue.match(/^\$\{functions\.(\w+(\.\w+)?)}$/); + if (matchFn?.length) { + return iac.functions?.find((fc) => fc.key === matchFn[1]); + } +}; + export const formatRosId = (id: string): string => { // Insert underscore before uppercase letters, but only when they follow a lowercase letter let result = id.replace(/([a-z])([A-Z])/g, '$1_$2'); @@ -125,3 +148,11 @@ export const formatRosId = (id: string): string => { return result; }; + +export const splitDomain = (domain: string) => { + const parts = domain.split('.'); + const rr = parts.length > 2 ? parts[0] : '@'; + const domainName = parts.length > 2 ? parts.slice(1).join('.') : domain; + + return { rr, domainName }; +}; diff --git a/src/common/index.ts b/src/common/index.ts index c153197d..a8f16b82 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -8,4 +8,4 @@ export * from './constants'; export * from './imsClient'; export * from './base64'; export * from './rosAssets'; -export * from './domainHelper'; +export * from './requestHelper'; diff --git a/src/common/requestHelper.ts b/src/common/requestHelper.ts new file mode 100644 index 00000000..eb8f68b8 --- /dev/null +++ b/src/common/requestHelper.ts @@ -0,0 +1,14 @@ +import { IncomingMessage } from 'http'; + +export const readRequestBody = (req: IncomingMessage): Promise => { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + resolve(body); + }); + req.on('error', reject); + }); +}; diff --git a/src/parser/eventParser.ts b/src/parser/eventParser.ts index 62153028..62dae10f 100644 --- a/src/parser/eventParser.ts +++ b/src/parser/eventParser.ts @@ -8,7 +8,7 @@ export const parseEvent = (events: { [key: string]: EventRaw }): Array ({ ...trigger, method: trigger.method ?? 'GET' })), domain: event.domain, })); }; diff --git a/src/stack/localStack/event.ts b/src/stack/localStack/event.ts index 88de28a6..8c85ff4b 100644 --- a/src/stack/localStack/event.ts +++ b/src/stack/localStack/event.ts @@ -1,38 +1,85 @@ -import http from 'node:http'; -import { logger } from '../../common'; -import { EventDomain, EventTypes } from '../../types'; +import { EventTypes, ServerlessIac } from '../../types'; import { isEmpty } from 'lodash'; +import { ParsedRequest, RouteHandler, RouteResponse } from '../../types/localStack'; +import { IncomingMessage } from 'http'; +import { getIacDefinition, logger } from '../../common'; +import { functionsHandler } from './function'; -const startApiGatewayServer = (event: EventDomain) => { - const server = http.createServer((req, res) => { - const matchedTrigger = event.triggers.find( - (trigger) => trigger.method === req.method && trigger.path === req.url, - ); - if (!matchedTrigger) { - res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); - res.end('Not Found\n'); - logger.warn(`API Gateway Event - ${req.method} ${req.url} -> Not Found`); - return; - } +const matchTrigger = ( + req: { method: string; path: string }, + trigger: { method: string; path: string }, +): boolean => { + if (req.method !== 'ANY' && req.method !== trigger.method) { + return false; + } - res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); - res.end(`Invoked backend: ${matchedTrigger.backend}\n`); - logger.info(`API Gateway Event - ${req.method} ${req.url} -> ${matchedTrigger.backend}`); - }); + const normalize = (s: string) => s.replace(/^\/+|\/+$/g, ''); + const [pathSegments, triggerSegments] = [ + normalize(req.path).split('/'), + normalize(trigger.path).split('/'), + ]; + + const hasWildcard = triggerSegments[triggerSegments.length - 1] === '*'; + + const prefixSegments = hasWildcard ? triggerSegments.slice(0, -1) : triggerSegments; + const minRequiredSegments = prefixSegments.length; + + if (pathSegments.length < minRequiredSegments) return false; + + return prefixSegments.every((triggerSegment, index) => { + const pathSegment = pathSegments[index]; + + if (triggerSegment.startsWith('[') && triggerSegment.endsWith(']')) { + return pathSegment !== ''; + } - const port = 3000 + Math.floor(Math.random() * 1000); - server.listen(port, () => { - logger.info(`API Gateway "${event.name}" listening on http://localhost:${port}`); + return triggerSegment === pathSegment; }); }; -export const startEvents = (events: Array | undefined) => { - const apiGateways = events?.filter((event) => event.type === EventTypes.API_GATEWAY); - if (isEmpty(apiGateways)) { - return; +const servEvent = async ( + req: IncomingMessage, + parsed: ParsedRequest, + iac: ServerlessIac, +): Promise => { + const event = iac.events?.find( + (event) => event.type === EventTypes.API_GATEWAY && event.key === parsed.identifier, + ); + + if (isEmpty(event)) { + return { + statusCode: 404, + body: { error: 'API Gateway event not found', event: parsed.identifier }, + }; } + logger.info( + `Event trigger ${JSON.stringify(event.triggers)}, req method: ${req.method}, req url${req.url}`, + ); + const matchedTrigger = event.triggers.find((trigger) => + matchTrigger({ method: parsed.method, path: parsed.url }, trigger), + ); - apiGateways!.forEach((gateway) => { - startApiGatewayServer(gateway); - }); + if (!matchedTrigger) { + return { statusCode: 404, body: { error: 'No matching trigger found' } }; + } + + if (matchedTrigger.backend) { + const backendDef = getIacDefinition(iac, matchedTrigger.backend); + if (!backendDef) { + return { + statusCode: 500, + body: { error: 'Backend definition missing', backend: matchedTrigger.backend }, + }; + } + return await functionsHandler(req, { ...parsed, identifier: backendDef?.key as string }, iac); + } + + return { + statusCode: 202, + body: { message: 'Trigger matched but no backend configured' }, + }; +}; + +export const eventsHandler: RouteHandler = async (req, parsed, iac) => { + return await servEvent(req, parsed, iac); }; diff --git a/src/stack/localStack/function.ts b/src/stack/localStack/function.ts index e69de29b..6de2bee2 100644 --- a/src/stack/localStack/function.ts +++ b/src/stack/localStack/function.ts @@ -0,0 +1,141 @@ +import { IncomingMessage } from 'http'; +import { ServerlessIac } from '../../types'; +import { FunctionOptions, ParsedRequest, RouteResponse } from '../../types/localStack'; +import { logger, getContext, calcValue, readRequestBody } from '../../common'; +import { invokeFunction } from './functionRunner'; +import path from 'node:path'; +import fs from 'node:fs'; +import JSZip from 'jszip'; +import os from 'node:os'; + +const extractZipFile = async (zipPath: string): Promise => { + const zipData = fs.readFileSync(zipPath); + const zip = await JSZip.loadAsync(zipData); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'si-function-')); + + for (const [relativePath, file] of Object.entries(zip.files)) { + if (file.dir) { + fs.mkdirSync(path.join(tempDir, relativePath), { recursive: true }); + } else { + const content = await file.async('nodebuffer'); + const filePath = path.join(tempDir, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + } + } + + // Check if there's a single root directory in the zip + // If so, return that directory instead of the temp directory + const entries = fs.readdirSync(tempDir); + if (entries.length === 1) { + const singleEntry = path.join(tempDir, entries[0]); + if (fs.statSync(singleEntry).isDirectory()) { + return singleEntry; + } + } + + return tempDir; +}; + +export const functionsHandler = async ( + req: IncomingMessage, + parsed: ParsedRequest, + iac: ServerlessIac, +): Promise => { + logger.info( + `Function request received by local server -> ${req.method} ${parsed.identifier ?? '/'} `, + ); + + const fcDef = iac.functions?.find((fn) => fn.key === parsed.identifier); + if (!fcDef) { + return { + statusCode: 404, + body: { error: 'Function not found', functionKey: parsed.identifier }, + }; + } + + if (!fcDef.code) { + return { + statusCode: 400, + body: { error: 'Function code configuration not found', functionKey: fcDef.key }, + }; + } + + let tempDir: string | null = null; + + try { + const rawBody = await readRequestBody(req); + const event = rawBody ? JSON.parse(rawBody) : {}; + + const ctx = getContext(); + logger.debug(`Context parameters: ${JSON.stringify(ctx.parameters)}`); + + const codePath = path.resolve(process.cwd(), calcValue(fcDef.code.path, ctx)); + + let codeDir: string; + + if (codePath.endsWith('.zip') && fs.existsSync(codePath)) { + tempDir = await extractZipFile(codePath); + codeDir = tempDir; + } else if (fs.existsSync(codePath) && fs.statSync(codePath).isDirectory()) { + codeDir = codePath; + } else { + codeDir = path.dirname(codePath); + } + const functionName = calcValue(fcDef.name, ctx); + + const funOptions: FunctionOptions = { + codeDir, + functionKey: fcDef.key, + handler: calcValue(fcDef.code.handler, ctx), + servicePath: '', + timeout: (fcDef.timeout || 3) * 1000, + }; + + const env = { + ...fcDef.environment, + AWS_REGION: iac.provider.region || 'us-east-1', + FUNCTION_NAME: functionName, + FUNCTION_MEMORY_SIZE: String(fcDef.memory || 128), + FUNCTION_TIMEOUT: String(fcDef.timeout || 3), + }; + + const fcContext = { + functionName, + functionVersion: '$LATEST', + memoryLimitInMB: fcDef.memory || 128, + logGroupName: `/aws/lambda/${functionName}`, + logStreamName: `${new Date().toISOString().split('T')[0]}/[$LATEST]${Math.random().toString(36).substring(7)}`, + invokedFunctionArn: `arn:aws:lambda:${iac.provider.region}:000000000000:function:${functionName}`, + awsRequestId: Math.random().toString(36).substring(2, 15), + }; + + logger.debug( + `Invoking worker with event: ${JSON.stringify(event)} and context: ${JSON.stringify(fcContext)}`, + ); + logger.debug(`Worker codeDir: ${codeDir}, handler: ${funOptions.handler}`); + + const result = await invokeFunction(funOptions, env, event, fcContext); + + logger.info(`Function execution result: ${JSON.stringify(result)}`); + + return { + statusCode: 200, + body: result, + }; + } catch (error) { + logger.error(`Function execution error: ${error}`); + return { + statusCode: 500, + body: { + error: 'Function execution failed', + message: error instanceof Error ? error.message : String(error), + }, + }; + } finally { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } +}; diff --git a/src/stack/localStack/functionRunner.ts b/src/stack/localStack/functionRunner.ts new file mode 100644 index 00000000..f0c11aab --- /dev/null +++ b/src/stack/localStack/functionRunner.ts @@ -0,0 +1,239 @@ +import { existsSync } from 'node:fs'; +import path, { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { MessagePort } from 'node:worker_threads'; +import { MessageChannel, Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'; +import { FunctionOptions } from '../../types/localStack'; + +type WorkerData = { + codeDir: string; + handler: string; + servicePath: string; + timeout: number; + functionKey: string; +}; + +type WorkerMessage = { + event: unknown; + context: unknown; + port: MessagePort; +}; + +type HandlerCallback = (error: Error | null, result?: unknown) => void; +type HandlerFunction = (event: unknown, context: unknown, callback?: HandlerCallback) => unknown; + +// ============================================================================ +// Worker Thread Code (runs in worker context) +// ============================================================================ + +const parseHandler = (handler: string): [string, string] => { + const [handlerFile, handlerMethod] = handler.split('.'); + return [handlerFile, handlerMethod]; +}; + +const resolveHandlerPath = (codeDir: string, servicePath: string, handlerFile: string): string => + servicePath + ? path.resolve(servicePath, codeDir, handlerFile) + : path.resolve(codeDir, handlerFile); + +const loadHandlerModule = async (handlerPath: string): Promise> => { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require(handlerPath) as Record; + } catch { + const fileUrl = pathToFileURL(handlerPath + '.js').href; + return (await import(fileUrl)) as Record; + } +}; + +const getHandlerFunction = ( + handlerModule: Record, + handlerMethod: string, + handlerPath: string, +): HandlerFunction => { + const handlerFn = handlerModule[handlerMethod] as HandlerFunction; + + if (typeof handlerFn !== 'function') { + throw new Error(`Handler "${handlerMethod}" not found or is not a function in ${handlerPath}`); + } + + return handlerFn; +}; + +const invokeHandler = ( + handlerFn: HandlerFunction, + event: unknown, + context: unknown, +): Promise => + new Promise((resolve, reject) => { + // Callback-style handler (3+ parameters) + if (handlerFn.length >= 3) { + handlerFn(event, context, (error: Error | null, result?: unknown) => { + return error ? reject(error) : resolve(result); + }); + } else { + return Promise.resolve(handlerFn(event, context)).then(resolve).catch(reject); + } + }); + +const createTimeoutHandler = ( + port: MessagePort, + timeoutMs: number, +): { timeoutId: NodeJS.Timeout; clearTimer: () => void } => { + const timeoutId = setTimeout(() => { + port.postMessage(new Error(`Function execution timed out after ${timeoutMs}ms`)); + port.close(); + }, timeoutMs); + + return { + timeoutId, + clearTimer: () => clearTimeout(timeoutId), + }; +}; + +const executeHandler = async ({ event, context, port }: WorkerMessage): Promise => { + const { codeDir, handler, servicePath, timeout } = workerData as WorkerData; + const { clearTimer } = createTimeoutHandler(port, timeout); + + try { + const [handlerFile, handlerMethod] = parseHandler(handler); + const handlerPath = resolveHandlerPath(codeDir, servicePath, handlerFile); + const handlerModule = await loadHandlerModule(handlerPath); + const handlerFn = getHandlerFunction(handlerModule, handlerMethod, handlerPath); + const result = await invokeHandler(handlerFn, event, context); + + clearTimer(); + port.postMessage(result); + port.close(); + } catch (error) { + clearTimer(); + port.postMessage(error instanceof Error ? error : new Error(String(error))); + port.close(); + } +}; + +// Initialize worker thread message handler +if (!isMainThread) { + parentPort?.on('message', async (message: WorkerMessage) => { + try { + await executeHandler(message); + } catch (error) { + message.port.postMessage(error instanceof Error ? error : new Error(String(error))); + message.port.close(); + } + }); +} + +// ============================================================================ +// Main Thread Code (functional API) +// ============================================================================ + +const resolveWorkerPath = (): string => { + const localPath = join(__dirname, 'functionRunner.js'); + + if (existsSync(localPath)) { + return localPath; + } + + // Fallback to dist directory + const distPath = __dirname.replace(/src\/stack\/localStack$/, 'dist/src/stack/localStack'); + return join(distPath, 'functionRunner.js'); +}; + +const createWorker = (funOptions: FunctionOptions, env: Record): Worker => { + const { codeDir, functionKey, handler, servicePath, timeout } = funOptions; + const workerPath = resolveWorkerPath(); + + return new Worker(workerPath, { + env, + workerData: { + codeDir, + functionKey, + handler, + servicePath, + timeout, + }, + }); +}; + +const createMessageHandler = ( + port: MessagePort, + resolve: (value: unknown) => void, + reject: (error: Error) => void, +): (() => void) => { + let resolved = false; + + const handleMessage = (value: unknown) => { + if (resolved) return; + resolved = true; + return value instanceof Error ? reject(value) : resolve(value); + }; + + const handleError = (err: Error) => { + if (resolved) return; + resolved = true; + reject(err); + }; + + port.on('message', handleMessage).on('error', handleError); + + return () => { + port.off('message', handleMessage); + port.off('error', handleError); + }; +}; + +const sendMessage = ( + worker: Worker, + event: unknown, + context: unknown, + port2: MessagePort, +): void => { + worker.postMessage( + { + context, + event, + port: port2, + }, + [port2], + ); +}; + +export const runFunction = (funOptions: FunctionOptions, env: Record) => { + const worker = createWorker(funOptions, env); + + const execute = (event: unknown, context: unknown): Promise => + new Promise((resolve, reject) => { + const { port1, port2 } = new MessageChannel(); + const cleanup = createMessageHandler(port1, resolve, reject); + + try { + sendMessage(worker, event, context, port2); + } catch (error) { + cleanup(); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + + const terminate = (): Promise => worker.terminate(); + + return { + execute, + terminate, + }; +}; + +export const invokeFunction = async ( + funOptions: FunctionOptions, + env: Record, + event: unknown, + context: unknown, +): Promise => { + const runner = runFunction(funOptions, env); + + try { + return await runner.execute(event, context); + } finally { + await runner.terminate(); + } +}; diff --git a/src/stack/localStack/gateway.ts b/src/stack/localStack/gateway.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/stack/localStack/index.ts b/src/stack/localStack/index.ts index 0e804f11..a084460e 100644 --- a/src/stack/localStack/index.ts +++ b/src/stack/localStack/index.ts @@ -1,45 +1,17 @@ -import { IncomingMessage, ServerResponse } from 'node:http'; -import { ParsedRequest, RouteHandler, RouteKind } from '../../types/localStack'; -import { servLocal } from './localServer'; +import { RouteHandler, RouteKind } from '../../types/localStack'; +import { servLocal, stopLocal } from './localServer'; +import { eventsHandler } from './event'; +import { functionsHandler } from './function'; +import { ServerlessIac } from '../../types'; export * from './event'; +export { stopLocal }; -const handlers = [ - { - kind: 'si_functions', - handler: (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - message: `Function request received by local gateway ${parsed}`, - }), - ); - }, - }, - // { - // kind: 'event', - // handler: async (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => { - // res.writeHead(200, { 'Content-Type': 'application/json' }); - // res.end( - // JSON.stringify({ - // message: 'Event route invoked locally', - // }), - // ); - // }, - // }, - // { - // kind: 'bucket', - // handler: async (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => { - // res.writeHead(200, { 'Content-Type': 'application/json' }); - // res.end( - // JSON.stringify({ - // message: 'Bucket API request received by local gateway', - // }), - // ); - // }, - // }, +const handlers: Array<{ kind: RouteKind; handler: RouteHandler }> = [ + { kind: RouteKind.SI_FUNCTIONS, handler: functionsHandler }, + { kind: RouteKind.SI_EVENTS, handler: eventsHandler }, ]; -export const startLocalStack = async () => { - await servLocal(handlers as Array<{ kind: RouteKind; handler: RouteHandler }>); +export const startLocalStack = async (iac: ServerlessIac) => { + await servLocal(handlers, iac); }; diff --git a/src/stack/localStack/localServer.ts b/src/stack/localStack/localServer.ts index b9ab706a..5a984b8b 100644 --- a/src/stack/localStack/localServer.ts +++ b/src/stack/localStack/localServer.ts @@ -1,116 +1,110 @@ -import { ParsedRequest, RouteHandler, RouteKind, ResourceIdentifier } from '../../types/localStack'; -import { logger, SI_LOCALSTACK_GATEWAY_PORT } from '../../common'; +import { ParsedRequest, RouteHandler, RouteKind } from '../../types/localStack'; +import { logger, SI_LOCALSTACK_SERVER_PORT } from '../../common'; import http, { IncomingMessage, ServerResponse } from 'node:http'; +import { ServerlessIac } from '../../types'; let localServer: http.Server | undefined; -const parseIdentifier = (segment: string): ResourceIdentifier | undefined => { - const parts = segment.split('-'); - if (parts.length < 3) { - return undefined; - } - - const id = parts.shift()!; - const region = parts.pop()!; - const name = parts.join('-'); - if (!id || !name || !region) { - return undefined; - } - - return { id, name, region }; -}; - const cleanPathSegments = (pathname: string): Array => pathname .split('/') .map((segment) => segment.trim()) .filter((segment) => segment.length > 0); -const respondText = (res: ServerResponse, status: number, text: string) => { - res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' }); - res.end(`${text}\n`); +const respondJson = ( + res: ServerResponse, + status: number, + body: unknown, + headers: Record = {}, +) => { + res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); + res.end(JSON.stringify(body)); }; const parseRequest = (req: IncomingMessage): ParsedRequest | undefined => { const url = new URL(req.url ?? '/', 'http://localhost'); - const [routeSegment, descriptorSegment, ...rest] = cleanPathSegments(url.pathname); - - const kind = routeSegment as RouteKind; - if (!kind || !['si_functions', 'si_buckets', 'si_website_buckets', 'si_events'].includes(kind)) { - return undefined; - } - - if (!descriptorSegment) { + const [routeSegment, identifierSegment, ...rest] = cleanPathSegments(url.pathname); + if (!routeSegment) { return undefined; } - - const identifier = parseIdentifier(descriptorSegment); - if (!identifier) { + const kindKey = routeSegment.toUpperCase(); + const kind = (RouteKind as Record)[kindKey]; + if (!kind) { return undefined; } const subPath = rest.length > 0 ? `/${rest.join('/')}` : '/'; - const query = Object.fromEntries(url.searchParams.entries()); - return { kind, - identifier, - subPath, - query, + identifier: identifierSegment, + url: subPath, method: req.method ?? 'GET', - rawPath: url.pathname, + query: Object.fromEntries(url.searchParams.entries()), + rawUrl: url.pathname, }; }; export const servLocal = async ( handlers: Array<{ kind: RouteKind; handler: RouteHandler }>, + iac: ServerlessIac, ): Promise => { if (localServer) { - logger.info(`Local gateway already running on http://localhost:${SI_LOCALSTACK_GATEWAY_PORT}`); + logger.info(`localServer already running on http://localhost:${SI_LOCALSTACK_SERVER_PORT}`); return; } - localServer = http.createServer((req, res) => { + localServer = http.createServer(async (req, res) => { try { const parsed = parseRequest(req); if (!parsed) { - respondText(res, 404, 'Route not found'); - logger.warn(`Local gateway 404 -> ${req.method ?? 'GET'} ${req.url ?? '/'} `); + respondJson(res, 404, { error: 'Route not found' }); + return; + } + + const route = handlers.find((h) => h.kind === parsed.kind); + if (!route) { + respondJson(res, 404, { error: `Handler for ${parsed.kind} not registered` }); return; } - const requestHandler = handlers.find((h) => h.kind === parsed.kind); - if (!requestHandler) { - respondText(res, 501, `No handler for route kind: ${parsed.kind}`); - logger.warn( - `Local gateway 501 -> No handler for ${parsed.kind} ${req.method ?? 'GET'} ${ - req.url ?? '/' - }`, - ); + + const outcome = await route.handler(req, parsed, iac); + if (!outcome) { + respondJson(res, 204, {}); return; } - requestHandler.handler(req, res, parsed); - logger.info( - `Local gateway handled ${parsed.kind}: ${parsed.identifier.name} (${parsed.identifier.region}) ${parsed.subPath}`, - ); - } catch (error) { - respondText(res, 500, 'Internal server error'); - logger.error( - { err: error }, - `Local gateway error -> ${req.method ?? 'GET'} ${req.url ?? '/'}`, - ); + respondJson(res, outcome.statusCode, outcome.body ?? {}, outcome.headers); + } catch (err) { + logger.error({ err }, 'Local gateway error'); + respondJson(res, 500, { error: 'Local gateway failure' }); } }); await new Promise((resolve, reject) => { - localServer!.listen(SI_LOCALSTACK_GATEWAY_PORT, '0.0.0.0', () => { - logger.info(`Local Server listening on http://localhost:${SI_LOCALSTACK_GATEWAY_PORT}`); + localServer!.listen(SI_LOCALSTACK_SERVER_PORT, '0.0.0.0', () => { + logger.info(`Local Server listening on http://localhost:${SI_LOCALSTACK_SERVER_PORT}`); resolve(); }); + localServer!.once('error', reject); + }); +}; + +export const stopLocal = async (): Promise => { + if (!localServer) { + logger.info('localServer is not running'); + return; + } - localServer!.once('error', (err) => { - logger.error({ err }, 'Failed to start local server'); - reject(err); + await new Promise((resolve, reject) => { + localServer!.close((err) => { + if (err) { + logger.error({ err }, 'Error stopping localServer'); + reject(err); + } else { + localServer = undefined; + logger.info('localServer stopped'); + resolve(); + } }); }); }; diff --git a/src/types/localStack/index.ts b/src/types/localStack/index.ts index 4e6e9e51..d45a1def 100644 --- a/src/types/localStack/index.ts +++ b/src/types/localStack/index.ts @@ -1,24 +1,38 @@ -import { IncomingMessage, ServerResponse } from 'node:http'; +import { IncomingMessage } from 'node:http'; +import { ServerlessIac } from '../index'; -export type RouteKind = 'si_functions' | 'si_buckets' | 'si_website_buckets' | 'si_events'; - -export type ResourceIdentifier = { - id: string; - name: string; - region: string; -}; +export enum RouteKind { + SI_FUNCTIONS = 'SI_FUNCTIONS', + SI_BUCKETS = 'SI_BUCKETS', + SI_WEBSITE_BUCKETS = 'SI_WEBSITE_BUCKETS', + SI_EVENTS = 'SI_EVENTS', +} export type ParsedRequest = { kind: RouteKind; - identifier: ResourceIdentifier; - subPath: string; + identifier: string; + url: string; method: string; query: Record; - rawPath: string; + rawUrl: string; +}; + +export type RouteResponse = { + statusCode: number; + headers?: Record; + body?: unknown; }; export type RouteHandler = ( req: IncomingMessage, - res: ServerResponse, parsed: ParsedRequest, -) => void; + iac: ServerlessIac, +) => Promise | RouteResponse | void; + +export type FunctionOptions = { + codeDir: string; + functionKey: string; + handler: string; + servicePath: string; + timeout: number; +}; diff --git a/tests/autils/index.ts b/tests/autils/index.ts new file mode 100644 index 00000000..d673e861 --- /dev/null +++ b/tests/autils/index.ts @@ -0,0 +1 @@ +export * from './requestHelper'; diff --git a/tests/autils/requestHelper.ts b/tests/autils/requestHelper.ts new file mode 100644 index 00000000..51e56e29 --- /dev/null +++ b/tests/autils/requestHelper.ts @@ -0,0 +1,23 @@ +import http from 'node:http'; + +export const makeRequest = ( + url: string, + method = 'GET', +): Promise<{ + statusCode: number | undefined; + data: string; +}> => { + return new Promise((resolve, reject) => { + const req = http.request(url, { method: method }, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + resolve({ statusCode: res.statusCode, data }); + }); + }); + req.on('error', reject); + req.end(); + }); +}; diff --git a/tests/fixtures/contextFixture.ts b/tests/fixtures/contextFixture.ts index 16941d70..5f943f81 100644 --- a/tests/fixtures/contextFixture.ts +++ b/tests/fixtures/contextFixture.ts @@ -8,7 +8,7 @@ export const context: Context = { region: 'cn-hangzhou', accessKeyId: 'testAccessKeyId', accessKeySecret: 'testAccessKeySecret', - iacLocation: 'path/to/iac/location', + iacLocation: 'tests/fixtures/serverless-insight.yml', parameters: [ { key: 'testVar', value: 'testVarValue' }, { key: 'newTestVar', value: 'newTestVarValue' }, diff --git a/tests/stack/localStack/event.test.ts b/tests/stack/localStack/event.test.ts new file mode 100644 index 00000000..5bd6279a --- /dev/null +++ b/tests/stack/localStack/event.test.ts @@ -0,0 +1,66 @@ +import { eventsHandler } from '../../../src/stack/localStack'; +import { ParsedRequest } from '../../../src/types/localStack'; +import http from 'node:http'; +import { Readable } from 'node:stream'; +import { setContext } from '../../../src/common'; +import path from 'node:path'; +import { parseYaml } from '../../../src/parser'; + +describe('eventsHandler', () => { + const iacLocation = path.resolve(__dirname, '../../fixtures/serverless-insight.yml'); + const iac = parseYaml(iacLocation); + + beforeAll(async () => { + await setContext({ + stage: 'default', + location: iacLocation, + stages: iac.stages, + }); + }); + + const mockRequest = (method = 'GET', body = ''): http.IncomingMessage => { + const readable = new Readable(); + readable.push(body); + readable.push(null); + return Object.assign(readable, { + method, + url: '/api/hello', + headers: {}, + }) as http.IncomingMessage; + }; + + const parsedBase: ParsedRequest = { + kind: undefined as unknown as ParsedRequest['kind'], + identifier: 'gateway_event', + url: '/api/hello', + method: 'GET', + query: {}, + rawUrl: '/si_events/gateway_event/api/hello', + }; + + it('returns 404 when event missing', async () => { + const res = await eventsHandler(mockRequest(), { ...parsedBase, identifier: 'missing' }, iac); + + expect(res?.statusCode).toBe(404); + expect(res?.body).toEqual({ error: 'API Gateway event not found', event: 'missing' }); + }); + + it('returns 404 when trigger not matched', async () => { + const res = await eventsHandler(mockRequest(), { ...parsedBase, url: '/api/unknown' }, iac); + + expect(res?.statusCode).toBe(404); + expect(res?.body).toEqual({ error: 'No matching trigger found' }); + }); + + it('delegates to backend function when trigger matched', async () => { + await setContext({ + stage: 'default', + location: iacLocation, + }); + + const res = await eventsHandler(mockRequest('POST'), parsedBase, iac); + + expect(res?.statusCode).toBe(200); + expect(res?.body).toBe('ServerlessInsight Hello World'); + }); +}); diff --git a/tests/stack/localStack/function.test.ts b/tests/stack/localStack/function.test.ts new file mode 100644 index 00000000..0a1a85e7 --- /dev/null +++ b/tests/stack/localStack/function.test.ts @@ -0,0 +1,69 @@ +import { functionsHandler } from '../../../src/stack/localStack/function'; +import { oneFcIac } from '../../fixtures/deploy-fixtures/oneFc'; +import { ParsedRequest } from '../../../src/types/localStack'; +import http from 'node:http'; +import { setContext } from '../../../src/common'; +import path from 'node:path'; +import { Readable } from 'node:stream'; + +describe('functionsHandler', () => { + const iacLocation = path.resolve(__dirname, '../../fixtures/serverless-insight.yml'); + + beforeAll(async () => { + await setContext({ + stage: 'default', + location: iacLocation, + }); + }); + + const mockRequest = (method = 'GET', body = ''): http.IncomingMessage => { + const readable = new Readable(); + readable.push(body); + readable.push(null); + return Object.assign(readable, { + method, + headers: {}, + url: '/', + }) as http.IncomingMessage; + }; + + const baseParsed: ParsedRequest = { + kind: undefined as unknown as ParsedRequest['kind'], + identifier: 'hello_fn', + url: '/', + method: 'GET', + query: {}, + rawUrl: '/', + }; + + it('returns 404 when function missing', async () => { + const res = await functionsHandler( + mockRequest(), + { ...baseParsed, identifier: 'missing' }, + oneFcIac, + ); + + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: 'Function not found', functionKey: 'missing' }); + }); + + it('returns 400 when function has no code configuration', async () => { + const iacWithoutCode = { + ...oneFcIac, + functions: [ + { + ...oneFcIac.functions![0], + code: undefined, + }, + ], + }; + + const res = await functionsHandler(mockRequest('POST'), baseParsed, iacWithoutCode); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ + error: 'Function code configuration not found', + functionKey: 'hello_fn', + }); + }); +}); diff --git a/tests/stack/localStack/index.test.ts b/tests/stack/localStack/index.test.ts new file mode 100644 index 00000000..1005181b --- /dev/null +++ b/tests/stack/localStack/index.test.ts @@ -0,0 +1,64 @@ +import path from 'node:path'; +import { startLocalStack, stopLocal } from '../../../src/stack/localStack'; +import { setContext, SI_LOCALSTACK_SERVER_PORT } from '../../../src/common'; +import { parseYaml } from '../../../src/parser'; +import { makeRequest } from '../../autils'; + +describe('localStack Server', () => { + const iacLocation = path.resolve(__dirname, '../../fixtures/serverless-insight.yml'); + const iac = parseYaml(iacLocation); + + beforeAll(async () => { + await setContext({ + stage: 'default', + location: iacLocation, + stages: iac.stages, + }); + + await startLocalStack(iac); + }); + + afterAll(async () => { + await stopLocal(); + }); + + it('should handle API Gateway request for valid trigger', async () => { + const response = await makeRequest( + `http://localhost:${SI_LOCALSTACK_SERVER_PORT}/si_events/gateway_event/api/hello`, + ); + + expect(response.statusCode).toBe(200); + expect(response.data).toBe('"ServerlessInsight Hello World"'); + }); + + it('should return 404 for non-matching path', async () => { + const response = await makeRequest( + `http://localhost:${SI_LOCALSTACK_SERVER_PORT}/si_events/gateway_event/api/invalid`, + ); + + expect(response.statusCode).toBe(404); + const json = JSON.parse(response.data); + expect(json.error).toBe('No matching trigger found'); + }); + + it('should return 404 for non-existing event', async () => { + const response = await makeRequest( + `http://localhost:${SI_LOCALSTACK_SERVER_PORT}/si_events/non_gateway_event/api/hello`, + ); + + expect(response.statusCode).toBe(404); + const json = JSON.parse(response.data); + expect(json.error).toBe('API Gateway event not found'); + }); + + it('should not match events with similar but different names', async () => { + const response = await makeRequest( + `http://localhost:${SI_LOCALSTACK_SERVER_PORT}/si_events/1-insight-poc-cn-hangzhou/api/hello`, + ); + + expect(response.statusCode).toBe(404); + const json = JSON.parse(response.data); + expect(json.error).toBe('API Gateway event not found'); + expect(json.event).toBe('1-insight-poc-cn-hangzhou'); + }); +}); diff --git a/tests/stack/localStack/localServer.test.ts b/tests/stack/localStack/localServer.test.ts new file mode 100644 index 00000000..baec3904 --- /dev/null +++ b/tests/stack/localStack/localServer.test.ts @@ -0,0 +1,45 @@ +import { SI_LOCALSTACK_SERVER_PORT } from '../../../src/common'; +import { servLocal, stopLocal } from '../../../src/stack/localStack/localServer'; +import { RouteHandler, RouteKind } from '../../../src/types/localStack'; +import { ServerlessIac } from '../../../src/types'; +import { makeRequest } from '../../autils'; + +describe('localServer routing', () => { + const handlers: Array<{ kind: RouteKind; handler: RouteHandler }> = [ + { + kind: RouteKind.SI_FUNCTIONS, + handler: async () => ({ + statusCode: 200, + body: { ok: true }, + }), + }, + ]; + const iac = { + service: 'test', + version: '0.0.1', + provider: { name: undefined, region: 'xx' }, + } as unknown as ServerlessIac; + + beforeAll(async () => { + await servLocal(handlers, iac); + }); + + afterAll(async () => { + await stopLocal(); + }); + + it('returns 200 when handler registered', async () => { + const res = await makeRequest(`http://localhost:${SI_LOCALSTACK_SERVER_PORT}/si_functions/`); + + expect(res.statusCode).toBe(200); + expect(res.data).toContain('ok'); + }); + + it('returns 404 for unknown route', async () => { + const res = await makeRequest( + `http://localhost:${SI_LOCALSTACK_SERVER_PORT}/unknown_route/path`, + ); + + expect(res.statusCode).toBe(404); + }); +});