From d7a742bf958ac17347ee3fccb31808e5ef192150 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 09:59:55 +0000 Subject: [PATCH 01/12] Initial plan From 6417fd3fd38f6a5ca5bb5daab299500a66041f92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:13:17 +0000 Subject: [PATCH 02/12] Implement localStack API Gateway handler for /si_events/ paths Co-authored-by: Blankll <28639911+Blankll@users.noreply.github.com> --- src/stack/localStack/index.ts | 98 ++++++++++++++++++++--- src/stack/localStack/localServer.ts | 19 +++++ tests/stack/localStack.test.ts | 117 ++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 tests/stack/localStack.test.ts diff --git a/src/stack/localStack/index.ts b/src/stack/localStack/index.ts index 0e804f1..e407f91 100644 --- a/src/stack/localStack/index.ts +++ b/src/stack/localStack/index.ts @@ -1,8 +1,89 @@ import { IncomingMessage, ServerResponse } from 'node:http'; import { ParsedRequest, RouteHandler, RouteKind } from '../../types/localStack'; -import { servLocal } from './localServer'; +import { servLocal, stopLocal } from './localServer'; +import { getContext } from '../../common'; +import { parseYaml } from '../../parser'; +import { EventTypes } from '../../types'; +import { logger } from '../../common'; export * from './event'; +export { stopLocal }; + +const createApiGatewayHandler = (): RouteHandler => { + return (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => { + try { + const context = getContext(); + const iac = parseYaml(context.iacLocation); + + // Find the event by matching identifier name with event name + // The identifier name may include region prefix (e.g., "insight-poc-gateway-cn") + // while the actual event name is "insight-poc-gateway" + const event = iac.events?.find( + (e) => e.type === EventTypes.API_GATEWAY && parsed.identifier.name.startsWith(e.name), + ); + + if (!event) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'API Gateway event not found', + eventName: parsed.identifier.name, + }), + ); + logger.warn( + `API Gateway event not found: ${parsed.identifier.name} for ${parsed.method} ${parsed.subPath}`, + ); + return; + } + + // Match the trigger by method and path + const trigger = event.triggers.find( + (t) => t.method === parsed.method && t.path === parsed.subPath, + ); + + if (!trigger) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'No matching trigger found', + method: parsed.method, + path: parsed.subPath, + availableTriggers: event.triggers.map((t) => `${t.method} ${t.path}`), + }), + ); + logger.warn( + `No matching trigger for ${parsed.method} ${parsed.subPath} in event ${event.name}`, + ); + return; + } + + // Successfully matched - return information about the backend function + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + message: 'API Gateway trigger matched', + event: event.name, + method: parsed.method, + path: parsed.subPath, + backend: trigger.backend, + query: parsed.query, + }), + ); + logger.info( + `API Gateway: ${event.name} - ${parsed.method} ${parsed.subPath} -> ${trigger.backend}`, + ); + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error), + }), + ); + logger.error({ err: error }, 'Error processing API Gateway request'); + } + }; +}; const handlers = [ { @@ -16,17 +97,10 @@ const handlers = [ ); }, }, - // { - // 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: 'si_events', + handler: createApiGatewayHandler(), + }, // { // kind: 'bucket', // handler: async (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => { diff --git a/src/stack/localStack/localServer.ts b/src/stack/localStack/localServer.ts index b9ab706..e9e4f36 100644 --- a/src/stack/localStack/localServer.ts +++ b/src/stack/localStack/localServer.ts @@ -114,3 +114,22 @@ export const servLocal = async ( }); }); }; + +export const stopLocal = async (): Promise => { + if (!localServer) { + return; + } + + await new Promise((resolve, reject) => { + localServer!.close((err) => { + if (err) { + logger.error({ err }, 'Error stopping local server'); + reject(err); + } else { + logger.info('Local server stopped'); + localServer = undefined; + resolve(); + } + }); + }); +}; diff --git a/tests/stack/localStack.test.ts b/tests/stack/localStack.test.ts new file mode 100644 index 0000000..fa904f8 --- /dev/null +++ b/tests/stack/localStack.test.ts @@ -0,0 +1,117 @@ +import path from 'node:path'; +import { setContext, SI_LOCALSTACK_GATEWAY_PORT } from '../../src/common'; +import http from 'node:http'; +import { startLocalStack, stopLocal } from '../../src/stack/localStack'; + +describe('localStack API Gateway handler', () => { + const iacLocation = path.resolve(__dirname, '../fixtures/serverless-insight.yml'); + const port = SI_LOCALSTACK_GATEWAY_PORT; + + beforeAll(async () => { + await setContext({ + stage: 'default', + location: iacLocation, + }); + await startLocalStack(); + }); + + afterAll(async () => { + await stopLocal(); + }); + + it('should handle API Gateway request for valid trigger', async () => { + const response = await new Promise<{ + statusCode: number | undefined; + data: string; + }>((resolve, reject) => { + const req = http.request( + { + hostname: 'localhost', + port: port, + path: '/si_events/1-insight-poc-gateway-cn-hangzhou/api/hello', + method: 'GET', + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + resolve({ statusCode: res.statusCode, data }); + }); + }, + ); + req.on('error', reject); + req.end(); + }); + + expect(response.statusCode).toBe(200); + const json = JSON.parse(response.data); + expect(json.message).toBe('API Gateway trigger matched'); + expect(json.event).toBe('insight-poc-gateway'); + expect(json.method).toBe('GET'); + expect(json.path).toBe('/api/hello'); + }); + + it('should return 404 for non-matching path', async () => { + const response = await new Promise<{ + statusCode: number | undefined; + data: string; + }>((resolve, reject) => { + const req = http.request( + { + hostname: 'localhost', + port: port, + path: '/si_events/1-insight-poc-gateway-cn-hangzhou/api/invalid', + method: 'GET', + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + resolve({ statusCode: res.statusCode, data }); + }); + }, + ); + req.on('error', reject); + req.end(); + }); + + 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 new Promise<{ + statusCode: number | undefined; + data: string; + }>((resolve, reject) => { + const req = http.request( + { + hostname: 'localhost', + port: port, + path: '/si_events/1-non-existing-gateway-cn-hangzhou/api/hello', + method: 'GET', + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + resolve({ statusCode: res.statusCode, data }); + }); + }, + ); + req.on('error', reject); + req.end(); + }); + + expect(response.statusCode).toBe(404); + const json = JSON.parse(response.data); + expect(json.error).toBe('API Gateway event not found'); + }); +}); From a14d75ecd8af521435f3096262753825a986d1ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:18:10 +0000 Subject: [PATCH 03/12] Address code review feedback: improve event matching and refactor tests Co-authored-by: Blankll <28639911+Blankll@users.noreply.github.com> --- src/stack/localStack/index.ts | 7 ++- tests/stack/localStack.test.ts | 106 ++++++++++++--------------------- 2 files changed, 44 insertions(+), 69 deletions(-) diff --git a/src/stack/localStack/index.ts b/src/stack/localStack/index.ts index e407f91..b1415c0 100644 --- a/src/stack/localStack/index.ts +++ b/src/stack/localStack/index.ts @@ -16,10 +16,13 @@ const createApiGatewayHandler = (): RouteHandler => { const iac = parseYaml(context.iacLocation); // Find the event by matching identifier name with event name - // The identifier name may include region prefix (e.g., "insight-poc-gateway-cn") + // The identifier name may include region code (e.g., "insight-poc-gateway-cn") // while the actual event name is "insight-poc-gateway" + // Match by checking if identifier name starts with event name followed by a hyphen or exact match const event = iac.events?.find( - (e) => e.type === EventTypes.API_GATEWAY && parsed.identifier.name.startsWith(e.name), + (e) => + e.type === EventTypes.API_GATEWAY && + (parsed.identifier.name === e.name || parsed.identifier.name.startsWith(`${e.name}-`)), ); if (!event) { diff --git a/tests/stack/localStack.test.ts b/tests/stack/localStack.test.ts index fa904f8..e52c974 100644 --- a/tests/stack/localStack.test.ts +++ b/tests/stack/localStack.test.ts @@ -7,29 +7,21 @@ describe('localStack API Gateway handler', () => { const iacLocation = path.resolve(__dirname, '../fixtures/serverless-insight.yml'); const port = SI_LOCALSTACK_GATEWAY_PORT; - beforeAll(async () => { - await setContext({ - stage: 'default', - location: iacLocation, - }); - await startLocalStack(); - }); - - afterAll(async () => { - await stopLocal(); - }); - - it('should handle API Gateway request for valid trigger', async () => { - const response = await new Promise<{ - statusCode: number | undefined; - data: string; - }>((resolve, reject) => { + // Helper function to make HTTP requests + const makeRequest = ( + requestPath: string, + method = 'GET', + ): Promise<{ + statusCode: number | undefined; + data: string; + }> => { + return new Promise((resolve, reject) => { const req = http.request( { hostname: 'localhost', port: port, - path: '/si_events/1-insight-poc-gateway-cn-hangzhou/api/hello', - method: 'GET', + path: requestPath, + method: method, }, (res) => { let data = ''; @@ -44,6 +36,22 @@ describe('localStack API Gateway handler', () => { req.on('error', reject); req.end(); }); + }; + + beforeAll(async () => { + await setContext({ + stage: 'default', + location: iacLocation, + }); + await startLocalStack(); + }); + + afterAll(async () => { + await stopLocal(); + }); + + it('should handle API Gateway request for valid trigger', async () => { + const response = await makeRequest('/si_events/1-insight-poc-gateway-cn-hangzhou/api/hello'); expect(response.statusCode).toBe(200); const json = JSON.parse(response.data); @@ -54,30 +62,7 @@ describe('localStack API Gateway handler', () => { }); it('should return 404 for non-matching path', async () => { - const response = await new Promise<{ - statusCode: number | undefined; - data: string; - }>((resolve, reject) => { - const req = http.request( - { - hostname: 'localhost', - port: port, - path: '/si_events/1-insight-poc-gateway-cn-hangzhou/api/invalid', - method: 'GET', - }, - (res) => { - let data = ''; - res.on('data', (chunk) => { - data += chunk; - }); - res.on('end', () => { - resolve({ statusCode: res.statusCode, data }); - }); - }, - ); - req.on('error', reject); - req.end(); - }); + const response = await makeRequest('/si_events/1-insight-poc-gateway-cn-hangzhou/api/invalid'); expect(response.statusCode).toBe(404); const json = JSON.parse(response.data); @@ -85,33 +70,20 @@ describe('localStack API Gateway handler', () => { }); it('should return 404 for non-existing event', async () => { - const response = await new Promise<{ - statusCode: number | undefined; - data: string; - }>((resolve, reject) => { - const req = http.request( - { - hostname: 'localhost', - port: port, - path: '/si_events/1-non-existing-gateway-cn-hangzhou/api/hello', - method: 'GET', - }, - (res) => { - let data = ''; - res.on('data', (chunk) => { - data += chunk; - }); - res.on('end', () => { - resolve({ statusCode: res.statusCode, data }); - }); - }, - ); - req.on('error', reject); - req.end(); - }); + const response = await makeRequest('/si_events/1-non-existing-gateway-cn-hangzhou/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 () => { + // This tests that 'insight-poc' doesn't match 'insight-poc-gateway' + const response = await makeRequest('/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.eventName).toBe('insight-poc-cn'); }); }); From 82ac6b32ca795b20fec33bdcdca28d425b0e74ca Mon Sep 17 00:00:00 2001 From: seven Date: Wed, 10 Dec 2025 17:05:10 +0800 Subject: [PATCH 04/12] refactor: improve copilot generated code --- src/common/constants.ts | 2 +- src/stack/localStack/event.ts | 82 ++++++++++++++++++++++++++- src/stack/localStack/gateway.ts | 0 src/stack/localStack/index.ts | 86 +---------------------------- src/stack/localStack/localServer.ts | 8 +-- src/types/domains/event.ts | 3 + tests/stack/localStack.test.ts | 4 +- 7 files changed, 91 insertions(+), 94 deletions(-) delete mode 100644 src/stack/localStack/gateway.ts diff --git a/src/common/constants.ts b/src/common/constants.ts index 60311a2..7d3e7f5 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/stack/localStack/event.ts b/src/stack/localStack/event.ts index 88de28a..74cea9f 100644 --- a/src/stack/localStack/event.ts +++ b/src/stack/localStack/event.ts @@ -1,7 +1,9 @@ -import http from 'node:http'; -import { logger } from '../../common'; +import http, { IncomingMessage, ServerResponse } from 'node:http'; +import { getContext, logger } from '../../common'; import { EventDomain, EventTypes } from '../../types'; import { isEmpty } from 'lodash'; +import { ParsedRequest, RouteHandler } from '../../types/localStack'; +import { parseYaml } from '../../parser'; const startApiGatewayServer = (event: EventDomain) => { const server = http.createServer((req, res) => { @@ -26,7 +28,7 @@ const startApiGatewayServer = (event: EventDomain) => { }); }; -export const startEvents = (events: Array | undefined) => { +const servEvents = (events: Array | undefined) => { const apiGateways = events?.filter((event) => event.type === EventTypes.API_GATEWAY); if (isEmpty(apiGateways)) { return; @@ -36,3 +38,77 @@ export const startEvents = (events: Array | undefined) => { startApiGatewayServer(gateway); }); }; + +export const createEventHandler = (): RouteHandler => { + const context = getContext(); + const iac = parseYaml(context.iacLocation); + const apiGatewayEvents = iac.events?.filter((e) => e.type === EventTypes.API_GATEWAY); + + servEvents(apiGatewayEvents); + + return (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => { + try { + const event = apiGatewayEvents?.find((e) => parsed.identifier.name === e.name); + + if (!event) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'API Gateway event not found', + eventName: parsed.identifier.name, + }), + ); + logger.warn( + `API Gateway event not found: ${parsed.identifier.name} for ${parsed.method} ${parsed.subPath}`, + ); + return; + } + + // @TODO implement regex path matching + const trigger = event.triggers.find( + (t) => t.method === parsed.method && t.path === parsed.subPath, + ); + + if (!trigger) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'No matching trigger found', + method: parsed.method, + path: parsed.subPath, + availableTriggers: event.triggers.map((t) => `${t.method} ${t.path}`), + }), + ); + logger.warn( + `No matching trigger for ${parsed.method} ${parsed.subPath} in event ${event.name}`, + ); + return; + } + + // Successfully matched - return information about the backend function + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + message: 'API Gateway trigger matched', + event: event.name, + method: parsed.method, + path: parsed.subPath, + backend: trigger.backend, + query: parsed.query, + }), + ); + logger.info( + `API Gateway: ${event.name} - ${parsed.method} ${parsed.subPath} -> ${trigger.backend}`, + ); + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error), + }), + ); + logger.error({ err: error }, 'Error processing API Gateway request'); + } + }; +}; diff --git a/src/stack/localStack/gateway.ts b/src/stack/localStack/gateway.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/stack/localStack/index.ts b/src/stack/localStack/index.ts index b1415c0..e44bf6f 100644 --- a/src/stack/localStack/index.ts +++ b/src/stack/localStack/index.ts @@ -1,93 +1,11 @@ import { IncomingMessage, ServerResponse } from 'node:http'; import { ParsedRequest, RouteHandler, RouteKind } from '../../types/localStack'; import { servLocal, stopLocal } from './localServer'; -import { getContext } from '../../common'; -import { parseYaml } from '../../parser'; -import { EventTypes } from '../../types'; -import { logger } from '../../common'; +import { createEventHandler } from './event'; export * from './event'; export { stopLocal }; -const createApiGatewayHandler = (): RouteHandler => { - return (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => { - try { - const context = getContext(); - const iac = parseYaml(context.iacLocation); - - // Find the event by matching identifier name with event name - // The identifier name may include region code (e.g., "insight-poc-gateway-cn") - // while the actual event name is "insight-poc-gateway" - // Match by checking if identifier name starts with event name followed by a hyphen or exact match - const event = iac.events?.find( - (e) => - e.type === EventTypes.API_GATEWAY && - (parsed.identifier.name === e.name || parsed.identifier.name.startsWith(`${e.name}-`)), - ); - - if (!event) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'API Gateway event not found', - eventName: parsed.identifier.name, - }), - ); - logger.warn( - `API Gateway event not found: ${parsed.identifier.name} for ${parsed.method} ${parsed.subPath}`, - ); - return; - } - - // Match the trigger by method and path - const trigger = event.triggers.find( - (t) => t.method === parsed.method && t.path === parsed.subPath, - ); - - if (!trigger) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'No matching trigger found', - method: parsed.method, - path: parsed.subPath, - availableTriggers: event.triggers.map((t) => `${t.method} ${t.path}`), - }), - ); - logger.warn( - `No matching trigger for ${parsed.method} ${parsed.subPath} in event ${event.name}`, - ); - return; - } - - // Successfully matched - return information about the backend function - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - message: 'API Gateway trigger matched', - event: event.name, - method: parsed.method, - path: parsed.subPath, - backend: trigger.backend, - query: parsed.query, - }), - ); - logger.info( - `API Gateway: ${event.name} - ${parsed.method} ${parsed.subPath} -> ${trigger.backend}`, - ); - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }), - ); - logger.error({ err: error }, 'Error processing API Gateway request'); - } - }; -}; - const handlers = [ { kind: 'si_functions', @@ -102,7 +20,7 @@ const handlers = [ }, { kind: 'si_events', - handler: createApiGatewayHandler(), + handler: createEventHandler(), }, // { // kind: 'bucket', diff --git a/src/stack/localStack/localServer.ts b/src/stack/localStack/localServer.ts index e9e4f36..f179cf8 100644 --- a/src/stack/localStack/localServer.ts +++ b/src/stack/localStack/localServer.ts @@ -1,5 +1,5 @@ import { ParsedRequest, RouteHandler, RouteKind, ResourceIdentifier } from '../../types/localStack'; -import { logger, SI_LOCALSTACK_GATEWAY_PORT } from '../../common'; +import { logger, SI_LOCALSTACK_SERVER_PORT } from '../../common'; import http, { IncomingMessage, ServerResponse } from 'node:http'; let localServer: http.Server | undefined; @@ -66,7 +66,7 @@ export const servLocal = async ( handlers: Array<{ kind: RouteKind; handler: RouteHandler }>, ): 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; } @@ -103,8 +103,8 @@ export const servLocal = async ( }); 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(); }); diff --git a/src/types/domains/event.ts b/src/types/domains/event.ts index a006eb5..388ae02 100644 --- a/src/types/domains/event.ts +++ b/src/types/domains/event.ts @@ -16,6 +16,9 @@ export type EventRaw = { certificate_body?: string; certificate_private_key?: string; }; + local?: { + port: number; + }; }; export type EventDomain = { diff --git a/tests/stack/localStack.test.ts b/tests/stack/localStack.test.ts index e52c974..695f86e 100644 --- a/tests/stack/localStack.test.ts +++ b/tests/stack/localStack.test.ts @@ -1,11 +1,11 @@ import path from 'node:path'; -import { setContext, SI_LOCALSTACK_GATEWAY_PORT } from '../../src/common'; +import { setContext, SI_LOCALSTACK_SERVER_PORT } from '../../src/common'; import http from 'node:http'; import { startLocalStack, stopLocal } from '../../src/stack/localStack'; describe('localStack API Gateway handler', () => { const iacLocation = path.resolve(__dirname, '../fixtures/serverless-insight.yml'); - const port = SI_LOCALSTACK_GATEWAY_PORT; + const port = SI_LOCALSTACK_SERVER_PORT; // Helper function to make HTTP requests const makeRequest = ( From 0cb3373ce74d0632a0ffcc3efa733f41a54ba54a Mon Sep 17 00:00:00 2001 From: seven Date: Thu, 11 Dec 2025 23:17:06 +0800 Subject: [PATCH 05/12] feat: implement event - apigateway and ablity to call fc --- src/commands/local.ts | 6 +- src/common/iacHelper.ts | 13 ++- src/parser/eventParser.ts | 2 +- src/stack/localStack/event.ts | 128 ++++++---------------------- src/stack/localStack/function.ts | 24 ++++++ src/stack/localStack/index.ts | 14 ++- src/stack/localStack/localServer.ts | 48 +++-------- src/types/localStack/index.ts | 20 +++-- 8 files changed, 97 insertions(+), 158 deletions(-) diff --git a/src/commands/local.ts b/src/commands/local.ts index 3496102..147c35c 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/iacHelper.ts b/src/common/iacHelper.ts index aef3dc6..dd50809 100644 --- a/src/common/iacHelper.ts +++ b/src/common/iacHelper.ts @@ -1,7 +1,7 @@ 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'; @@ -107,6 +107,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'); diff --git a/src/parser/eventParser.ts b/src/parser/eventParser.ts index 6215302..62dae10 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 74cea9f..743c680 100644 --- a/src/stack/localStack/event.ts +++ b/src/stack/localStack/event.ts @@ -1,114 +1,34 @@ -import http, { IncomingMessage, ServerResponse } from 'node:http'; -import { getContext, logger } from '../../common'; -import { EventDomain, EventTypes } from '../../types'; +import { EventTypes, ServerlessIac } from '../../types'; import { isEmpty } from 'lodash'; import { ParsedRequest, RouteHandler } from '../../types/localStack'; -import { parseYaml } from '../../parser'; +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 servEvent = (req: IncomingMessage, parsed: ParsedRequest, iac: ServerlessIac) => { + const event = iac.events?.find( + (event) => event.type === EventTypes.API_GATEWAY && event.key === parsed.identifier, + ); - 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 port = 3000 + Math.floor(Math.random() * 1000); - server.listen(port, () => { - logger.info(`API Gateway "${event.name}" listening on http://localhost:${port}`); - }); -}; - -const servEvents = (events: Array | undefined) => { - const apiGateways = events?.filter((event) => event.type === EventTypes.API_GATEWAY); - if (isEmpty(apiGateways)) { + if (isEmpty(event)) { return; } + logger.info( + `Event trigger ${JSON.stringify(event.triggers)}, req method: ${req.method}, req url${req.url}`, + ); + const matchedTrigger = event.triggers.find( + (trigger) => trigger.method === parsed.method && trigger.path === parsed.url, + ); + if (!matchedTrigger) { + return { status: 404, body: 'Event trigger not found' }; + } - apiGateways!.forEach((gateway) => { - startApiGatewayServer(gateway); - }); + if (matchedTrigger.backend) { + const backendDef = getIacDefinition(iac, matchedTrigger.backend); + return functionsHandler(req, { ...parsed, identifier: backendDef?.key as string }, iac); + } }; -export const createEventHandler = (): RouteHandler => { - const context = getContext(); - const iac = parseYaml(context.iacLocation); - const apiGatewayEvents = iac.events?.filter((e) => e.type === EventTypes.API_GATEWAY); - - servEvents(apiGatewayEvents); - - return (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => { - try { - const event = apiGatewayEvents?.find((e) => parsed.identifier.name === e.name); - - if (!event) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'API Gateway event not found', - eventName: parsed.identifier.name, - }), - ); - logger.warn( - `API Gateway event not found: ${parsed.identifier.name} for ${parsed.method} ${parsed.subPath}`, - ); - return; - } - - // @TODO implement regex path matching - const trigger = event.triggers.find( - (t) => t.method === parsed.method && t.path === parsed.subPath, - ); - - if (!trigger) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'No matching trigger found', - method: parsed.method, - path: parsed.subPath, - availableTriggers: event.triggers.map((t) => `${t.method} ${t.path}`), - }), - ); - logger.warn( - `No matching trigger for ${parsed.method} ${parsed.subPath} in event ${event.name}`, - ); - return; - } - - // Successfully matched - return information about the backend function - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - message: 'API Gateway trigger matched', - event: event.name, - method: parsed.method, - path: parsed.subPath, - backend: trigger.backend, - query: parsed.query, - }), - ); - logger.info( - `API Gateway: ${event.name} - ${parsed.method} ${parsed.subPath} -> ${trigger.backend}`, - ); - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }), - ); - logger.error({ err: error }, 'Error processing API Gateway request'); - } - }; +export const eventsHandler: RouteHandler = (req, parsed, iac) => { + return servEvent(req, parsed, iac); }; diff --git a/src/stack/localStack/function.ts b/src/stack/localStack/function.ts index e69de29..904b6dc 100644 --- a/src/stack/localStack/function.ts +++ b/src/stack/localStack/function.ts @@ -0,0 +1,24 @@ +import { IncomingMessage } from 'http'; +import { ServerlessIac } from '../../types'; +import { ParsedRequest } from '../../types/localStack'; +import { logger } from '../../common'; + +export const functionsHandler = ( + req: IncomingMessage, + parsed: ParsedRequest, + iac: ServerlessIac, +) => { + logger.info( + `Function request received by local gateway -> ${req.method} ${parsed.identifier ?? '/'} `, + ); + const fcDef = iac.functions?.find((fn) => fn.key === parsed.identifier); + if (!fcDef) { + return { status: 404, body: 'Function not found' }; + } + // @TODO implement function invocation logic here + + return { + status: 200, + body: `Function ${fcDef.key} invoked successfully`, + }; +}; diff --git a/src/stack/localStack/index.ts b/src/stack/localStack/index.ts index e44bf6f..f400ae1 100644 --- a/src/stack/localStack/index.ts +++ b/src/stack/localStack/index.ts @@ -1,14 +1,15 @@ import { IncomingMessage, ServerResponse } from 'node:http'; import { ParsedRequest, RouteHandler, RouteKind } from '../../types/localStack'; import { servLocal, stopLocal } from './localServer'; -import { createEventHandler } from './event'; +import { eventsHandler } from './event'; +import { ServerlessIac } from '../../types'; export * from './event'; export { stopLocal }; const handlers = [ { - kind: 'si_functions', + kind: 'SI_FUNCTIONS', handler: (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( @@ -18,10 +19,7 @@ const handlers = [ ); }, }, - { - kind: 'si_events', - handler: createEventHandler(), - }, + { kind: 'SI_EVENTS', handler: eventsHandler }, // { // kind: 'bucket', // handler: async (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => { @@ -35,6 +33,6 @@ const handlers = [ // }, ]; -export const startLocalStack = async () => { - await servLocal(handlers as Array<{ kind: RouteKind; handler: RouteHandler }>); +export const startLocalStack = async (iac: ServerlessIac) => { + await servLocal(handlers as Array<{ kind: RouteKind; handler: RouteHandler }>, iac); }; diff --git a/src/stack/localStack/localServer.ts b/src/stack/localStack/localServer.ts index f179cf8..6d92679 100644 --- a/src/stack/localStack/localServer.ts +++ b/src/stack/localStack/localServer.ts @@ -1,25 +1,10 @@ -import { ParsedRequest, RouteHandler, RouteKind, ResourceIdentifier } from '../../types/localStack'; +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('/') @@ -33,19 +18,11 @@ const respondText = (res: ServerResponse, status: number, text: string) => { const parseRequest = (req: IncomingMessage): ParsedRequest | undefined => { const url = new URL(req.url ?? '/', 'http://localhost'); - const [routeSegment, descriptorSegment, ...rest] = cleanPathSegments(url.pathname); + const [routeSegment, identifierSegment, ...rest] = cleanPathSegments(url.pathname); - const kind = routeSegment as RouteKind; - if (!kind || !['si_functions', 'si_buckets', 'si_website_buckets', 'si_events'].includes(kind)) { - return undefined; - } + const kind = routeSegment.toUpperCase() as RouteKind; - if (!descriptorSegment) { - return undefined; - } - - const identifier = parseIdentifier(descriptorSegment); - if (!identifier) { + if (RouteKind[kind] === undefined || !identifierSegment) { return undefined; } @@ -54,16 +31,17 @@ const parseRequest = (req: IncomingMessage): ParsedRequest | undefined => { return { kind, - identifier, - subPath, + identifier: identifierSegment, + url: subPath, query, method: req.method ?? 'GET', - rawPath: url.pathname, + rawUrl: url.pathname, }; }; export const servLocal = async ( handlers: Array<{ kind: RouteKind; handler: RouteHandler }>, + iac: ServerlessIac, ): Promise => { if (localServer) { logger.info(`localServer already running on http://localhost:${SI_LOCALSTACK_SERVER_PORT}`); @@ -89,10 +67,10 @@ export const servLocal = async ( ); return; } - requestHandler.handler(req, res, parsed); - logger.info( - `Local gateway handled ${parsed.kind}: ${parsed.identifier.name} (${parsed.identifier.region}) ${parsed.subPath}`, - ); + const result = requestHandler.handler(req, parsed, iac); + logger.info(`LocalServer handled ${parsed.kind}: ${parsed.identifier} ${parsed.url}`); + + respondText(res, 200, JSON.stringify(result)); } catch (error) { respondText(res, 500, 'Internal server error'); logger.error( diff --git a/src/types/localStack/index.ts b/src/types/localStack/index.ts index 4e6e9e5..0801f9e 100644 --- a/src/types/localStack/index.ts +++ b/src/types/localStack/index.ts @@ -1,6 +1,12 @@ -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 enum RouteKind { + SI_FUNCTIONS = 'SI_FUNCTIONS', + SI_BUCKETS = 'SI_BUCKETS', + SI_WEBSITE_BUCKETS = 'SI_WEBSITE_BUCKETS', + SI_EVENTS = 'SI_EVENTS', +} export type ResourceIdentifier = { id: string; @@ -10,15 +16,15 @@ export type ResourceIdentifier = { export type ParsedRequest = { kind: RouteKind; - identifier: ResourceIdentifier; - subPath: string; + identifier: string; + url: string; method: string; query: Record; - rawPath: string; + rawUrl: string; }; export type RouteHandler = ( req: IncomingMessage, - res: ServerResponse, parsed: ParsedRequest, -) => void; + iac: ServerlessIac, +) => unknown; From a5fb8f65b52a3ae4aee204cd14a826913454c42e Mon Sep 17 00:00:00 2001 From: seven Date: Fri, 12 Dec 2025 15:14:43 +0800 Subject: [PATCH 06/12] feat: add unit tests for localStack --- src/stack/localStack/event.ts | 63 +++++++++++++-- src/stack/localStack/function.ts | 20 +++-- src/stack/localStack/index.ts | 33 ++------ src/stack/localStack/localServer.ts | 78 ++++++++++--------- src/types/localStack/index.ts | 9 ++- tests/autils/index.ts | 1 + tests/autils/requestHelper.ts | 23 ++++++ tests/stack/localStack.test.ts | 89 ---------------------- tests/stack/localStack/event.test.ts | 54 +++++++++++++ tests/stack/localStack/function.test.ts | 39 ++++++++++ tests/stack/localStack/index.test.ts | 66 ++++++++++++++++ tests/stack/localStack/localServer.test.ts | 47 ++++++++++++ 12 files changed, 356 insertions(+), 166 deletions(-) create mode 100644 tests/autils/index.ts create mode 100644 tests/autils/requestHelper.ts delete mode 100644 tests/stack/localStack.test.ts create mode 100644 tests/stack/localStack/event.test.ts create mode 100644 tests/stack/localStack/function.test.ts create mode 100644 tests/stack/localStack/index.test.ts create mode 100644 tests/stack/localStack/localServer.test.ts diff --git a/src/stack/localStack/event.ts b/src/stack/localStack/event.ts index 743c680..e39b349 100644 --- a/src/stack/localStack/event.ts +++ b/src/stack/localStack/event.ts @@ -1,32 +1,83 @@ import { EventTypes, ServerlessIac } from '../../types'; import { isEmpty } from 'lodash'; -import { ParsedRequest, RouteHandler } from '../../types/localStack'; +import { ParsedRequest, RouteHandler, RouteResponse } from '../../types/localStack'; import { IncomingMessage } from 'http'; import { getIacDefinition, logger } from '../../common'; import { functionsHandler } from './function'; -const servEvent = (req: IncomingMessage, parsed: ParsedRequest, iac: ServerlessIac) => { +const matchTrigger = ( + req: { method: string; path: string }, + trigger: { method: string; path: string }, +): boolean => { + if (req.method !== 'ANY' && req.method !== trigger.method) { + return false; + } + + 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 !== ''; + } + + return triggerSegment === pathSegment; + }); +}; + +const servEvent = ( + req: IncomingMessage, + parsed: ParsedRequest, + iac: ServerlessIac, +): RouteResponse | void => { const event = iac.events?.find( (event) => event.type === EventTypes.API_GATEWAY && event.key === parsed.identifier, ); if (isEmpty(event)) { - return; + 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) => trigger.method === parsed.method && trigger.path === parsed.url, + const matchedTrigger = event.triggers.find((trigger) => + matchTrigger({ method: parsed.method, path: parsed.url }, trigger), ); + if (!matchedTrigger) { - return { status: 404, body: 'Event trigger not found' }; + 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 functionsHandler(req, { ...parsed, identifier: backendDef?.key as string }, iac); } + + return { + statusCode: 202, + body: { message: 'Trigger matched but no backend configured' }, + }; }; export const eventsHandler: RouteHandler = (req, parsed, iac) => { diff --git a/src/stack/localStack/function.ts b/src/stack/localStack/function.ts index 904b6dc..f203152 100644 --- a/src/stack/localStack/function.ts +++ b/src/stack/localStack/function.ts @@ -1,24 +1,32 @@ import { IncomingMessage } from 'http'; import { ServerlessIac } from '../../types'; -import { ParsedRequest } from '../../types/localStack'; +import { ParsedRequest, RouteResponse } from '../../types/localStack'; import { logger } from '../../common'; export const functionsHandler = ( req: IncomingMessage, parsed: ParsedRequest, iac: ServerlessIac, -) => { +): RouteResponse => { logger.info( `Function request received by local gateway -> ${req.method} ${parsed.identifier ?? '/'} `, ); const fcDef = iac.functions?.find((fn) => fn.key === parsed.identifier); if (!fcDef) { - return { status: 404, body: 'Function not found' }; + return { + statusCode: 404, + body: { error: 'Function not found', functionKey: parsed.identifier }, + }; } - // @TODO implement function invocation logic here + // TODO: add actual invocation support (exec container/lambda runtime) return { - status: 200, - body: `Function ${fcDef.key} invoked successfully`, + statusCode: 200, + body: { + message: `Function ${fcDef.name} invoked successfully`, + functionKey: fcDef.key, + method: req.method, + path: parsed.url, + }, }; }; diff --git a/src/stack/localStack/index.ts b/src/stack/localStack/index.ts index f400ae1..a084460 100644 --- a/src/stack/localStack/index.ts +++ b/src/stack/localStack/index.ts @@ -1,38 +1,17 @@ -import { IncomingMessage, ServerResponse } from 'node:http'; -import { ParsedRequest, RouteHandler, RouteKind } from '../../types/localStack'; +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: 'SI_EVENTS', handler: eventsHandler }, - // { - // 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 (iac: ServerlessIac) => { - await servLocal(handlers as Array<{ kind: RouteKind; handler: RouteHandler }>, iac); + await servLocal(handlers, iac); }; diff --git a/src/stack/localStack/localServer.ts b/src/stack/localStack/localServer.ts index 6d92679..1d5e5c0 100644 --- a/src/stack/localStack/localServer.ts +++ b/src/stack/localStack/localServer.ts @@ -11,30 +11,45 @@ const cleanPathSegments = (pathname: string): Array => .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, + payload: unknown, + headers: Record = {}, +) => { + res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); + res.end(JSON.stringify(payload)); +}; + +const parseIdentifier = (segment: string) => { + const [idPart, namePart, regionPart] = segment.split('-'); + if (!idPart || !namePart || !regionPart) { + return undefined; + } + return { id: idPart, name: namePart, region: regionPart }; }; const parseRequest = (req: IncomingMessage): ParsedRequest | undefined => { const url = new URL(req.url ?? '/', 'http://localhost'); const [routeSegment, identifierSegment, ...rest] = cleanPathSegments(url.pathname); - - const kind = routeSegment.toUpperCase() as RouteKind; - - if (RouteKind[kind] === undefined || !identifierSegment) { + if (!routeSegment || !identifierSegment) { + return undefined; + } + const kindKey = routeSegment.toUpperCase(); + const kind = (RouteKind as Record)[kindKey]; + if (!kind) { return undefined; } + const resource = parseIdentifier(identifierSegment); const subPath = rest.length > 0 ? `/${rest.join('/')}` : '/'; - const query = Object.fromEntries(url.searchParams.entries()); - return { kind, identifier: identifierSegment, + resource, url: subPath, - query, method: req.method ?? 'GET', + query: Object.fromEntries(url.searchParams.entries()), rawUrl: url.pathname, }; }; @@ -48,35 +63,30 @@ export const servLocal = async ( 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 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 route = handlers.find((h) => h.kind === parsed.kind); + if (!route) { + respondJson(res, 404, { error: `Handler for ${parsed.kind} not registered` }); return; } - const result = requestHandler.handler(req, parsed, iac); - logger.info(`LocalServer handled ${parsed.kind}: ${parsed.identifier} ${parsed.url}`); - respondText(res, 200, JSON.stringify(result)); - } catch (error) { - respondText(res, 500, 'Internal server error'); - logger.error( - { err: error }, - `Local gateway error -> ${req.method ?? 'GET'} ${req.url ?? '/'}`, - ); + const outcome = await route.handler(req, parsed, iac); + if (!outcome) { + respondJson(res, 204, {}); + return; + } + respondJson(res, outcome.statusCode, outcome.body ?? {}, outcome.headers); + } catch (err) { + logger.error({ err }, 'Local gateway error'); + respondJson(res, 500, { error: 'Local gateway failure' }); } }); @@ -85,11 +95,7 @@ export const servLocal = async ( logger.info(`Local Server listening on http://localhost:${SI_LOCALSTACK_SERVER_PORT}`); resolve(); }); - - localServer!.once('error', (err) => { - logger.error({ err }, 'Failed to start local server'); - reject(err); - }); + localServer!.once('error', reject); }); }; @@ -101,10 +107,8 @@ export const stopLocal = async (): Promise => { await new Promise((resolve, reject) => { localServer!.close((err) => { if (err) { - logger.error({ err }, 'Error stopping local server'); reject(err); } else { - logger.info('Local server stopped'); localServer = undefined; resolve(); } diff --git a/src/types/localStack/index.ts b/src/types/localStack/index.ts index 0801f9e..bc9de6b 100644 --- a/src/types/localStack/index.ts +++ b/src/types/localStack/index.ts @@ -17,14 +17,21 @@ export type ResourceIdentifier = { export type ParsedRequest = { kind: RouteKind; identifier: string; + resource?: ResourceIdentifier; url: string; method: string; query: Record; rawUrl: string; }; +export type RouteResponse = { + statusCode: number; + headers?: Record; + body?: unknown; +}; + export type RouteHandler = ( req: IncomingMessage, parsed: ParsedRequest, iac: ServerlessIac, -) => unknown; +) => Promise | RouteResponse | void; diff --git a/tests/autils/index.ts b/tests/autils/index.ts new file mode 100644 index 0000000..d673e86 --- /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 0000000..51e56e2 --- /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/stack/localStack.test.ts b/tests/stack/localStack.test.ts deleted file mode 100644 index 695f86e..0000000 --- a/tests/stack/localStack.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import path from 'node:path'; -import { setContext, SI_LOCALSTACK_SERVER_PORT } from '../../src/common'; -import http from 'node:http'; -import { startLocalStack, stopLocal } from '../../src/stack/localStack'; - -describe('localStack API Gateway handler', () => { - const iacLocation = path.resolve(__dirname, '../fixtures/serverless-insight.yml'); - const port = SI_LOCALSTACK_SERVER_PORT; - - // Helper function to make HTTP requests - const makeRequest = ( - requestPath: string, - method = 'GET', - ): Promise<{ - statusCode: number | undefined; - data: string; - }> => { - return new Promise((resolve, reject) => { - const req = http.request( - { - hostname: 'localhost', - port: port, - path: requestPath, - 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(); - }); - }; - - beforeAll(async () => { - await setContext({ - stage: 'default', - location: iacLocation, - }); - await startLocalStack(); - }); - - afterAll(async () => { - await stopLocal(); - }); - - it('should handle API Gateway request for valid trigger', async () => { - const response = await makeRequest('/si_events/1-insight-poc-gateway-cn-hangzhou/api/hello'); - - expect(response.statusCode).toBe(200); - const json = JSON.parse(response.data); - expect(json.message).toBe('API Gateway trigger matched'); - expect(json.event).toBe('insight-poc-gateway'); - expect(json.method).toBe('GET'); - expect(json.path).toBe('/api/hello'); - }); - - it('should return 404 for non-matching path', async () => { - const response = await makeRequest('/si_events/1-insight-poc-gateway-cn-hangzhou/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('/si_events/1-non-existing-gateway-cn-hangzhou/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 () => { - // This tests that 'insight-poc' doesn't match 'insight-poc-gateway' - const response = await makeRequest('/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.eventName).toBe('insight-poc-cn'); - }); -}); diff --git a/tests/stack/localStack/event.test.ts b/tests/stack/localStack/event.test.ts new file mode 100644 index 0000000..ebc2797 --- /dev/null +++ b/tests/stack/localStack/event.test.ts @@ -0,0 +1,54 @@ +import { eventsHandler } from '../../../src/stack/localStack'; +import { oneFcOneGatewayIac } from '../../fixtures/deploy-fixtures'; +import { ParsedRequest } from '../../../src/types/localStack'; +import http from 'node:http'; + +describe('eventsHandler', () => { + const mockRequest = (method = 'GET'): http.IncomingMessage => + ({ + method, + url: '/api/hello', + }) 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' }, + oneFcOneGatewayIac, + ); + + 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' }, + oneFcOneGatewayIac, + ); + + expect(res?.statusCode).toBe(404); + expect(res?.body).toEqual({ error: 'No matching trigger found' }); + }); + + it('delegates to backend function when trigger matched', async () => { + const res = await eventsHandler(mockRequest('POST'), parsedBase, oneFcOneGatewayIac); + + expect(res?.statusCode).toBe(200); + expect(res?.body).toMatchObject({ + message: 'Function hello-fn invoked successfully', + functionKey: 'hello_fn', + method: 'POST', + }); + }); +}); diff --git a/tests/stack/localStack/function.test.ts b/tests/stack/localStack/function.test.ts new file mode 100644 index 0000000..ed3b749 --- /dev/null +++ b/tests/stack/localStack/function.test.ts @@ -0,0 +1,39 @@ +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'; + +describe('functionsHandler', () => { + const mockRequest = (method = 'GET'): http.IncomingMessage => + ({ + method, + }) 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', () => { + const res = functionsHandler(mockRequest(), { ...baseParsed, identifier: 'missing' }, oneFcIac); + + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: 'Function not found', functionKey: 'missing' }); + }); + + it('returns 200 when function exists', () => { + const res = functionsHandler(mockRequest('POST'), baseParsed, oneFcIac); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + message: 'Function hello-fn invoked successfully', + functionKey: 'hello_fn', + method: 'POST', + path: '/', + }); + }); +}); diff --git a/tests/stack/localStack/index.test.ts b/tests/stack/localStack/index.test.ts new file mode 100644 index 0000000..2945d7f --- /dev/null +++ b/tests/stack/localStack/index.test.ts @@ -0,0 +1,66 @@ +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, + }); + + 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); + const json = JSON.parse(response.data); + expect(json.message).toBe('Function insight-poc-fn invoked successfully'); + expect(json.method).toBe('GET'); + expect(json.path).toBe('/api/hello'); + }); + + 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 0000000..f2fdbbe --- /dev/null +++ b/tests/stack/localStack/localServer.test.ts @@ -0,0 +1,47 @@ +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; + + beforeEach(async () => { + await servLocal(handlers, iac); + }); + + afterEach(async () => { + await stopLocal(); + }); + + 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); + }); + + it('returns 200 when handler registered', async () => { + const res = await makeRequest( + `http://localhost:${SI_LOCALSTACK_SERVER_PORT}/si_functions/any_function/`, + ); + + expect(res.statusCode).toBe(200); + expect(res.data).toContain('ok'); + }); +}); From dd3e654ee18c1971541a7af82ef55efc71ece770 Mon Sep 17 00:00:00 2001 From: seven Date: Fri, 12 Dec 2025 15:28:16 +0800 Subject: [PATCH 07/12] refactor: remove no needed code --- src/stack/localStack/localServer.ts | 10 ---------- src/types/domains/event.ts | 3 --- src/types/localStack/index.ts | 7 ------- 3 files changed, 20 deletions(-) diff --git a/src/stack/localStack/localServer.ts b/src/stack/localStack/localServer.ts index 1d5e5c0..bd933d1 100644 --- a/src/stack/localStack/localServer.ts +++ b/src/stack/localStack/localServer.ts @@ -21,14 +21,6 @@ const respondJson = ( res.end(JSON.stringify(payload)); }; -const parseIdentifier = (segment: string) => { - const [idPart, namePart, regionPart] = segment.split('-'); - if (!idPart || !namePart || !regionPart) { - return undefined; - } - return { id: idPart, name: namePart, region: regionPart }; -}; - const parseRequest = (req: IncomingMessage): ParsedRequest | undefined => { const url = new URL(req.url ?? '/', 'http://localhost'); const [routeSegment, identifierSegment, ...rest] = cleanPathSegments(url.pathname); @@ -41,12 +33,10 @@ const parseRequest = (req: IncomingMessage): ParsedRequest | undefined => { return undefined; } - const resource = parseIdentifier(identifierSegment); const subPath = rest.length > 0 ? `/${rest.join('/')}` : '/'; return { kind, identifier: identifierSegment, - resource, url: subPath, method: req.method ?? 'GET', query: Object.fromEntries(url.searchParams.entries()), diff --git a/src/types/domains/event.ts b/src/types/domains/event.ts index 388ae02..a006eb5 100644 --- a/src/types/domains/event.ts +++ b/src/types/domains/event.ts @@ -16,9 +16,6 @@ export type EventRaw = { certificate_body?: string; certificate_private_key?: string; }; - local?: { - port: number; - }; }; export type EventDomain = { diff --git a/src/types/localStack/index.ts b/src/types/localStack/index.ts index bc9de6b..8551e08 100644 --- a/src/types/localStack/index.ts +++ b/src/types/localStack/index.ts @@ -8,16 +8,9 @@ export enum RouteKind { SI_EVENTS = 'SI_EVENTS', } -export type ResourceIdentifier = { - id: string; - name: string; - region: string; -}; - export type ParsedRequest = { kind: RouteKind; identifier: string; - resource?: ResourceIdentifier; url: string; method: string; query: Record; From 27fc5a5fcfce815e504daecca109958a489a7870 Mon Sep 17 00:00:00 2001 From: seven Date: Fri, 12 Dec 2025 15:40:19 +0800 Subject: [PATCH 08/12] fix: fix tests failures --- src/stack/localStack/localServer.ts | 6 +++--- tests/stack/localStack/localServer.test.ts | 20 +++++++++----------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/stack/localStack/localServer.ts b/src/stack/localStack/localServer.ts index bd933d1..f2a8be8 100644 --- a/src/stack/localStack/localServer.ts +++ b/src/stack/localStack/localServer.ts @@ -14,17 +14,17 @@ const cleanPathSegments = (pathname: string): Array => const respondJson = ( res: ServerResponse, status: number, - payload: unknown, + body: unknown, headers: Record = {}, ) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); - res.end(JSON.stringify(payload)); + res.end(JSON.stringify(body)); }; const parseRequest = (req: IncomingMessage): ParsedRequest | undefined => { const url = new URL(req.url ?? '/', 'http://localhost'); const [routeSegment, identifierSegment, ...rest] = cleanPathSegments(url.pathname); - if (!routeSegment || !identifierSegment) { + if (!routeSegment) { return undefined; } const kindKey = routeSegment.toUpperCase(); diff --git a/tests/stack/localStack/localServer.test.ts b/tests/stack/localStack/localServer.test.ts index f2fdbbe..baec390 100644 --- a/tests/stack/localStack/localServer.test.ts +++ b/tests/stack/localStack/localServer.test.ts @@ -20,28 +20,26 @@ describe('localServer routing', () => { provider: { name: undefined, region: 'xx' }, } as unknown as ServerlessIac; - beforeEach(async () => { + beforeAll(async () => { await servLocal(handlers, iac); }); - afterEach(async () => { + afterAll(async () => { await stopLocal(); }); - it('returns 404 for unknown route', async () => { - const res = await makeRequest( - `http://localhost:${SI_LOCALSTACK_SERVER_PORT}/unknown_route/path`, - ); + it('returns 200 when handler registered', async () => { + const res = await makeRequest(`http://localhost:${SI_LOCALSTACK_SERVER_PORT}/si_functions/`); - expect(res.statusCode).toBe(404); + expect(res.statusCode).toBe(200); + expect(res.data).toContain('ok'); }); - it('returns 200 when handler registered', async () => { + it('returns 404 for unknown route', async () => { const res = await makeRequest( - `http://localhost:${SI_LOCALSTACK_SERVER_PORT}/si_functions/any_function/`, + `http://localhost:${SI_LOCALSTACK_SERVER_PORT}/unknown_route/path`, ); - expect(res.statusCode).toBe(200); - expect(res.data).toContain('ok'); + expect(res.statusCode).toBe(404); }); }); From f21978e78566b64f9bd47f3cd364ee08a4d95492 Mon Sep 17 00:00:00 2001 From: seven Date: Mon, 15 Dec 2025 16:51:04 +0800 Subject: [PATCH 09/12] refactor: refactor to functional style --- src/common/domainHelper.ts | 7 - src/common/iacHelper.ts | 33 +++- src/common/index.ts | 2 +- src/common/requestHelper.ts | 14 ++ src/stack/localStack/event.ts | 10 +- src/stack/localStack/function.ts | 146 +++++++++++++-- src/stack/localStack/functionRunner.ts | 239 ++++++++++++++++++++++++ src/types/localStack/index.ts | 8 + tests/stack/localStack/event.test.ts | 50 +++-- tests/stack/localStack/function.test.ts | 50 ++++- tests/stack/localStack/index.test.ts | 6 +- 11 files changed, 504 insertions(+), 61 deletions(-) delete mode 100644 src/common/domainHelper.ts create mode 100644 src/common/requestHelper.ts create mode 100644 src/stack/localStack/functionRunner.ts diff --git a/src/common/domainHelper.ts b/src/common/domainHelper.ts deleted file mode 100644 index a7224bf..0000000 --- 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 dd50809..b26faa8 100644 --- a/src/common/iacHelper.ts +++ b/src/common/iacHelper.ts @@ -5,6 +5,7 @@ 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,29 @@ 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 = Object.entries(iacVars ?? {}).reduce( + (map, [key, value]) => map.set(key, String(value)), + new Map(), + ); + + (ctx.parameters ?? []).forEach(({ key, value }) => mergedParams.set(key, value)); + } + + if (containsVar?.length) { + 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) { @@ -136,3 +159,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 c153197..a8f16b8 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 0000000..eb8f68b --- /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/stack/localStack/event.ts b/src/stack/localStack/event.ts index e39b349..8c85ff4 100644 --- a/src/stack/localStack/event.ts +++ b/src/stack/localStack/event.ts @@ -37,11 +37,11 @@ const matchTrigger = ( }); }; -const servEvent = ( +const servEvent = async ( req: IncomingMessage, parsed: ParsedRequest, iac: ServerlessIac, -): RouteResponse | void => { +): Promise => { const event = iac.events?.find( (event) => event.type === EventTypes.API_GATEWAY && event.key === parsed.identifier, ); @@ -71,7 +71,7 @@ const servEvent = ( body: { error: 'Backend definition missing', backend: matchedTrigger.backend }, }; } - return functionsHandler(req, { ...parsed, identifier: backendDef?.key as string }, iac); + return await functionsHandler(req, { ...parsed, identifier: backendDef?.key as string }, iac); } return { @@ -80,6 +80,6 @@ const servEvent = ( }; }; -export const eventsHandler: RouteHandler = (req, parsed, iac) => { - return servEvent(req, parsed, iac); +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 f203152..1bc9b66 100644 --- a/src/stack/localStack/function.ts +++ b/src/stack/localStack/function.ts @@ -1,16 +1,55 @@ import { IncomingMessage } from 'http'; import { ServerlessIac } from '../../types'; -import { ParsedRequest, RouteResponse } from '../../types/localStack'; -import { logger } from '../../common'; +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'; -export const functionsHandler = ( +// Helper to extract zip file to a temporary directory +const extractZipFile = async (zipPath: string): Promise => { + const zipData = fs.readFileSync(zipPath); + const zip = await JSZip.loadAsync(zipData); + + // Create a temporary directory + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'si-function-')); + + // Extract all files + 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, -): RouteResponse => { +): Promise => { logger.info( - `Function request received by local gateway -> ${req.method} ${parsed.identifier ?? '/'} `, + `Function request received by local server -> ${req.method} ${parsed.identifier ?? '/'} `, ); + const fcDef = iac.functions?.find((fn) => fn.key === parsed.identifier); if (!fcDef) { return { @@ -18,15 +57,94 @@ export const functionsHandler = ( body: { error: 'Function not found', functionKey: parsed.identifier }, }; } - // TODO: add actual invocation support (exec container/lambda runtime) - return { - statusCode: 200, - body: { - message: `Function ${fcDef.name} invoked successfully`, + 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) : {}; + + // Get context for service path + const ctx = getContext(); + logger.debug(`Context parameters: ${JSON.stringify(ctx.parameters)}`); + + // Resolve the code path from project root + const codePath = path.resolve(process.cwd(), calcValue(fcDef.code.path, ctx)); + + let codeDir: string; + + // Check if the code path is a zip file + if (codePath.endsWith('.zip') && fs.existsSync(codePath)) { + // Extract zip file to temporary directory + tempDir = await extractZipFile(codePath); + codeDir = tempDir; + } else if (fs.existsSync(codePath) && fs.statSync(codePath).isDirectory()) { + // Use directory directly + codeDir = codePath; + } else { + // Assume it's a directory path (without .zip extension) + codeDir = path.dirname(codePath); + } + const functionName = calcValue(fcDef.name, ctx); + + const funOptions: FunctionOptions = { + codeDir, functionKey: fcDef.key, - method: req.method, - path: parsed.url, - }, - }; + 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 0000000..f0c11aa --- /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/types/localStack/index.ts b/src/types/localStack/index.ts index 8551e08..d45a1de 100644 --- a/src/types/localStack/index.ts +++ b/src/types/localStack/index.ts @@ -28,3 +28,11 @@ export type RouteHandler = ( parsed: ParsedRequest, iac: ServerlessIac, ) => Promise | RouteResponse | void; + +export type FunctionOptions = { + codeDir: string; + functionKey: string; + handler: string; + servicePath: string; + timeout: number; +}; diff --git a/tests/stack/localStack/event.test.ts b/tests/stack/localStack/event.test.ts index ebc2797..5bd6279 100644 --- a/tests/stack/localStack/event.test.ts +++ b/tests/stack/localStack/event.test.ts @@ -1,14 +1,33 @@ import { eventsHandler } from '../../../src/stack/localStack'; -import { oneFcOneGatewayIac } from '../../fixtures/deploy-fixtures'; 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 mockRequest = (method = 'GET'): http.IncomingMessage => - ({ + 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'], @@ -20,35 +39,28 @@ describe('eventsHandler', () => { }; it('returns 404 when event missing', async () => { - const res = await eventsHandler( - mockRequest(), - { ...parsedBase, identifier: 'missing' }, - oneFcOneGatewayIac, - ); + 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' }, - oneFcOneGatewayIac, - ); + 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 () => { - const res = await eventsHandler(mockRequest('POST'), parsedBase, oneFcOneGatewayIac); + await setContext({ + stage: 'default', + location: iacLocation, + }); + + const res = await eventsHandler(mockRequest('POST'), parsedBase, iac); expect(res?.statusCode).toBe(200); - expect(res?.body).toMatchObject({ - message: 'Function hello-fn invoked successfully', - functionKey: 'hello_fn', - method: 'POST', - }); + expect(res?.body).toBe('ServerlessInsight Hello World'); }); }); diff --git a/tests/stack/localStack/function.test.ts b/tests/stack/localStack/function.test.ts index ed3b749..0a1a85e 100644 --- a/tests/stack/localStack/function.test.ts +++ b/tests/stack/localStack/function.test.ts @@ -2,12 +2,30 @@ 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 mockRequest = (method = 'GET'): http.IncomingMessage => - ({ + 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'], @@ -18,22 +36,34 @@ describe('functionsHandler', () => { rawUrl: '/', }; - it('returns 404 when function missing', () => { - const res = functionsHandler(mockRequest(), { ...baseParsed, identifier: 'missing' }, oneFcIac); + 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 200 when function exists', () => { - const res = functionsHandler(mockRequest('POST'), baseParsed, oneFcIac); + 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(200); + expect(res.statusCode).toBe(400); expect(res.body).toEqual({ - message: 'Function hello-fn invoked successfully', + error: 'Function code configuration not found', functionKey: 'hello_fn', - method: 'POST', - path: '/', }); }); }); diff --git a/tests/stack/localStack/index.test.ts b/tests/stack/localStack/index.test.ts index 2945d7f..1005181 100644 --- a/tests/stack/localStack/index.test.ts +++ b/tests/stack/localStack/index.test.ts @@ -12,6 +12,7 @@ describe('localStack Server', () => { await setContext({ stage: 'default', location: iacLocation, + stages: iac.stages, }); await startLocalStack(iac); @@ -27,10 +28,7 @@ describe('localStack Server', () => { ); expect(response.statusCode).toBe(200); - const json = JSON.parse(response.data); - expect(json.message).toBe('Function insight-poc-fn invoked successfully'); - expect(json.method).toBe('GET'); - expect(json.path).toBe('/api/hello'); + expect(response.data).toBe('"ServerlessInsight Hello World"'); }); it('should return 404 for non-matching path', async () => { From 260dad5108453dab5087a12f745f14443c2cf922 Mon Sep 17 00:00:00 2001 From: seven Date: Mon, 15 Dec 2025 17:11:29 +0800 Subject: [PATCH 10/12] fix: fix tests issue --- src/stack/localStack/function.ts | 7 ------- tests/fixtures/contextFixture.ts | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/stack/localStack/function.ts b/src/stack/localStack/function.ts index 1bc9b66..ed5c225 100644 --- a/src/stack/localStack/function.ts +++ b/src/stack/localStack/function.ts @@ -8,15 +8,12 @@ import fs from 'node:fs'; import JSZip from 'jszip'; import os from 'node:os'; -// Helper to extract zip file to a temporary directory const extractZipFile = async (zipPath: string): Promise => { const zipData = fs.readFileSync(zipPath); const zip = await JSZip.loadAsync(zipData); - // Create a temporary directory const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'si-function-')); - // Extract all files for (const [relativePath, file] of Object.entries(zip.files)) { if (file.dir) { fs.mkdirSync(path.join(tempDir, relativePath), { recursive: true }); @@ -80,16 +77,12 @@ export const functionsHandler = async ( let codeDir: string; - // Check if the code path is a zip file if (codePath.endsWith('.zip') && fs.existsSync(codePath)) { - // Extract zip file to temporary directory tempDir = await extractZipFile(codePath); codeDir = tempDir; } else if (fs.existsSync(codePath) && fs.statSync(codePath).isDirectory()) { - // Use directory directly codeDir = codePath; } else { - // Assume it's a directory path (without .zip extension) codeDir = path.dirname(codePath); } const functionName = calcValue(fcDef.name, ctx); diff --git a/tests/fixtures/contextFixture.ts b/tests/fixtures/contextFixture.ts index 16941d7..5f943f8 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' }, From a6d8eb63e83f03934c257f9477189afa1bb2b11a Mon Sep 17 00:00:00 2001 From: seven Date: Mon, 15 Dec 2025 17:49:11 +0800 Subject: [PATCH 11/12] fix: address review issues --- src/common/iacHelper.ts | 11 ----------- src/stack/localStack/function.ts | 2 -- src/stack/localStack/localServer.ts | 3 +++ 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/common/iacHelper.ts b/src/common/iacHelper.ts index b26faa8..66e659b 100644 --- a/src/common/iacHelper.ts +++ b/src/common/iacHelper.ts @@ -96,17 +96,6 @@ export const calcValue = (rawValue: string, ctx: Context): T => { value = rawValue.replace(/\$\{ctx.stage}/g, ctx.stage); } - if (containsVar?.length) { - const { vars: iacVars } = parseYaml(ctx.iacLocation); - - const mergedParams = Object.entries(iacVars ?? {}).reduce( - (map, [key, value]) => map.set(key, String(value)), - new Map(), - ); - - (ctx.parameters ?? []).forEach(({ key, value }) => mergedParams.set(key, value)); - } - if (containsVar?.length) { const { vars: iacVars } = parseYaml(ctx.iacLocation); diff --git a/src/stack/localStack/function.ts b/src/stack/localStack/function.ts index ed5c225..6de2bee 100644 --- a/src/stack/localStack/function.ts +++ b/src/stack/localStack/function.ts @@ -68,11 +68,9 @@ export const functionsHandler = async ( const rawBody = await readRequestBody(req); const event = rawBody ? JSON.parse(rawBody) : {}; - // Get context for service path const ctx = getContext(); logger.debug(`Context parameters: ${JSON.stringify(ctx.parameters)}`); - // Resolve the code path from project root const codePath = path.resolve(process.cwd(), calcValue(fcDef.code.path, ctx)); let codeDir: string; diff --git a/src/stack/localStack/localServer.ts b/src/stack/localStack/localServer.ts index f2a8be8..5a984b8 100644 --- a/src/stack/localStack/localServer.ts +++ b/src/stack/localStack/localServer.ts @@ -91,15 +91,18 @@ export const servLocal = async ( export const stopLocal = async (): Promise => { if (!localServer) { + logger.info('localServer is not running'); return; } 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(); } }); From 4dbafdb2d661a347b0d4d7f2aaef2d79a6ea8d18 Mon Sep 17 00:00:00 2001 From: seven Date: Mon, 15 Dec 2025 18:01:25 +0800 Subject: [PATCH 12/12] run build before tests --- .github/workflows/node.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index d80a454..3ed77b9 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