Skip to content

Commit c800c00

Browse files
BlankllCopilot
andauthored
feat: localstack front-door setup (#92)
feat: This pull request introduces a local gateway server to the development workflow, allowing users to run and debug Serverless applications locally with improved configuration and routing. It adds support for specifying a custom IaC (Infrastructure as Code) YAML file, implements a robust local HTTP server with route parsing, and enhances context resolution for IaC files. The most important changes are: **Local Gateway Server Implementation:** * Introduced a new local HTTP server (`servLocal`) that parses incoming requests, matches them to resource kinds (such as functions, buckets, events), and dispatches them to appropriate handlers. This includes robust parsing of resource identifiers and improved error handling. (`src/stack/localStack/localServer.ts`, `src/types/localStack/index.ts`) [[1]](diffhunk://#diff-8f8d2bc2b1e004f4d32fb87251a46ac6a79c4b9d216350c7013c59772c290dceR1-R122) [[2]](diffhunk://#diff-6648cb6cd8f9b251cd085dbc3ea489c6509a3524096f040cc0d385740129dc38R1-R24) * Updated `startLocalStack` to use the new server and register handlers for function routes, with placeholders for additional resource kinds. (`src/stack/localStack/index.ts`) **CLI and IaC File Handling Improvements:** * Added a `--file` (`-f`) option to the `local` CLI command, allowing users to specify a custom YAML file for the stack, and passed this location through the execution context. (`src/commands/index.ts`, `src/commands/local.ts`) [[1]](diffhunk://#diff-a6a1db61476382a80351bf0b40126b3cfca87878293a3fca70dfe04367f4a11fR108-R119) [[2]](diffhunk://#diff-7ebaa5db45f467180b1c6d14302d60b301cdeeb31dbb6137303edd16707cb971L4-R15) * Enhanced the IaC file resolution logic to search for default filenames in both the project root and specified directories, providing clearer error messages when files are not found. (`src/common/context.ts`) **Constants and Configuration:** * Added `SI_LOCALSTACK_GATEWAY_PORT` constant (default 4567) to centralize the local server port configuration. (`src/common/constants.ts`) Refs: #10 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 36f93a3 commit c800c00

File tree

9 files changed

+230
-26
lines changed

9 files changed

+230
-26
lines changed

src/commands/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,16 +105,18 @@ program
105105
program
106106
.command('local <stackName>')
107107
.description('run Serverless application locally for debugging')
108+
.option('-f, --file <path>', 'specify the yaml file')
108109
.option('-s, --stage <stage>', 'specify the stage', 'default')
109110
.option('-p, --port <port>', 'specify the port', '3000')
110111
.option('-d, --debug', 'enable debug mode')
111112
.option('-w, --watch', 'enable file watch', true)
112-
.action(async (stackName, { stage, port, debug, watch }) => {
113+
.action(async (stackName, { stage, port, debug, watch, file }) => {
113114
await runLocal(stackName, {
114115
stage,
115116
port: Number(port) || 3000,
116117
debug: !!debug,
117118
watch: typeof watch === 'boolean' ? watch : true,
119+
location: file,
118120
});
119121
});
120122

src/commands/local.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { logger, setContext } from '../common';
22
import { startLocalStack } from '../stack/localStack';
33

4-
export interface RunLocalOptions {
4+
export type RunLocalOptions = {
55
stage: string;
66
port: number;
77
debug: boolean;
88
watch: boolean;
9-
}
9+
location: string | undefined;
10+
};
1011

1112
export const runLocal = async (stackName: string, opts: RunLocalOptions) => {
12-
const { stage, port, debug, watch } = opts;
13+
const { stage, port, debug, watch, location } = opts;
1314

14-
await setContext({ stage });
15+
await setContext({ stage, location });
1516

1617
logger.info(
1718
`run-local starting: stack=${stackName} stage=${stage} port=${port} debug=${debug} watch=${watch}`,

src/common/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export const CODE_ZIP_SIZE_LIMIT = 300 * 1000; // 300 KB ROS TemplateBody size l
22
export const OSS_DEPLOYMENT_TIMEOUT = 3000; // in seconds
33
export const SI_BOOTSTRAP_FC_PREFIX = 'si-bootstrap-api';
44
export const SI_BOOTSTRAP_BUCKET_PREFIX = 'si-bootstrap-artifacts';
5+
export const SI_LOCALSTACK_GATEWAY_PORT = 4567;

src/common/context.ts

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,55 @@ import { AsyncLocalStorage } from 'node:async_hooks';
66
import { getIamInfo } from './imsClient';
77

88
const asyncLocalStorage = new AsyncLocalStorage<Context>();
9+
const DEFAULT_IAC_FILES = [
10+
'serverlessinsight.yml',
11+
'serverlessInsight.yml',
12+
'ServerlessInsight.yml',
13+
'serverless-insight.yml',
14+
];
915

1016
export const getIacLocation = (location?: string): string => {
1117
const projectRoot = path.resolve(process.cwd());
12-
if (location) {
13-
const candidate = path.isAbsolute(location) ? location : path.resolve(projectRoot, location);
14-
if (fs.existsSync(candidate)) {
15-
return candidate;
18+
const searchTargets = location ? [location] : DEFAULT_IAC_FILES;
19+
const attempted = new Set<string>();
20+
21+
const toAbsolutePath = (target: string) =>
22+
path.isAbsolute(target) ? target : path.resolve(projectRoot, target);
23+
24+
const tryResolveCandidate = (target: string): string | undefined => {
25+
const resolved = toAbsolutePath(target);
26+
attempted.add(resolved);
27+
28+
if (!fs.existsSync(resolved)) {
29+
return undefined;
1630
}
17-
throw new Error(`IaC file not found at '${candidate}'`);
18-
}
1931

20-
const candidates = [
21-
'serverlessinsight.yml',
22-
'serverlessInsight.yml',
23-
'ServerlessInsight.yml',
24-
'serverless-insight.yml',
25-
];
32+
const stats = fs.statSync(resolved);
33+
if (stats.isDirectory()) {
34+
for (const fileName of DEFAULT_IAC_FILES) {
35+
const nested = path.join(resolved, fileName);
36+
attempted.add(nested);
37+
if (fs.existsSync(nested) && fs.statSync(nested).isFile()) {
38+
return nested;
39+
}
40+
}
41+
return undefined;
42+
}
43+
44+
return resolved;
45+
};
2646

27-
for (const name of candidates) {
28-
const candidate = path.resolve(projectRoot, name);
29-
if (fs.existsSync(candidate)) {
30-
return candidate;
47+
for (const candidate of searchTargets) {
48+
const match = tryResolveCandidate(candidate);
49+
if (match) {
50+
return match;
3151
}
3252
}
3353

34-
throw new Error(
35-
`No IaC file found. Tried: ${candidates.map((n) => `'${path.resolve(projectRoot, n)}'`).join(', ')}`,
36-
);
54+
const attemptedList = Array.from(attempted)
55+
.map((n) => `'${n}'`)
56+
.join(', ');
57+
throw new Error(`No IaC file found. Tried: ${attemptedList}`);
3758
};
3859

3960
export const setContext = async (

src/stack/localStack/function.ts

Whitespace-only changes.

src/stack/localStack/gateway.ts

Whitespace-only changes.

src/stack/localStack/index.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,45 @@
1+
import { IncomingMessage, ServerResponse } from 'node:http';
2+
import { ParsedRequest, RouteHandler, RouteKind } from '../../types/localStack';
3+
import { servLocal } from './localServer';
4+
15
export * from './event';
26

7+
const handlers = [
8+
{
9+
kind: 'si_functions',
10+
handler: (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => {
11+
res.writeHead(200, { 'Content-Type': 'application/json' });
12+
res.end(
13+
JSON.stringify({
14+
message: `Function request received by local gateway ${parsed}`,
15+
}),
16+
);
17+
},
18+
},
19+
// {
20+
// kind: 'event',
21+
// handler: async (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => {
22+
// res.writeHead(200, { 'Content-Type': 'application/json' });
23+
// res.end(
24+
// JSON.stringify({
25+
// message: 'Event route invoked locally',
26+
// }),
27+
// );
28+
// },
29+
// },
30+
// {
31+
// kind: 'bucket',
32+
// handler: async (req: IncomingMessage, res: ServerResponse, parsed: ParsedRequest) => {
33+
// res.writeHead(200, { 'Content-Type': 'application/json' });
34+
// res.end(
35+
// JSON.stringify({
36+
// message: 'Bucket API request received by local gateway',
37+
// }),
38+
// );
39+
// },
40+
// },
41+
];
42+
343
export const startLocalStack = async () => {
4-
// Placeholder for starting local stack logic
5-
console.log('Local stack started');
44+
await servLocal(handlers as Array<{ kind: RouteKind; handler: RouteHandler }>);
645
};
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { ParsedRequest, RouteHandler, RouteKind, ResourceIdentifier } from '../../types/localStack';
2+
import { logger, SI_LOCALSTACK_GATEWAY_PORT } from '../../common';
3+
import http, { IncomingMessage, ServerResponse } from 'node:http';
4+
5+
let localServer: http.Server | undefined;
6+
7+
const parseIdentifier = (segment: string): ResourceIdentifier | undefined => {
8+
const parts = segment.split('-');
9+
if (parts.length < 3) {
10+
return undefined;
11+
}
12+
13+
const id = parts.shift()!;
14+
const region = parts.pop()!;
15+
const name = parts.join('-');
16+
if (!id || !name || !region) {
17+
return undefined;
18+
}
19+
20+
return { id, name, region };
21+
};
22+
23+
const cleanPathSegments = (pathname: string): Array<string> =>
24+
pathname
25+
.split('/')
26+
.map((segment) => segment.trim())
27+
.filter((segment) => segment.length > 0);
28+
29+
const respondText = (res: ServerResponse, status: number, text: string) => {
30+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
31+
res.end(`${text}\n`);
32+
};
33+
34+
const parseRequest = (req: IncomingMessage): ParsedRequest | undefined => {
35+
const url = new URL(req.url ?? '/', 'http://localhost');
36+
const [routeSegment, descriptorSegment, ...rest] = cleanPathSegments(url.pathname);
37+
38+
const kind = routeSegment as RouteKind;
39+
if (!kind || !['si_functions', 'si_buckets', 'si_website_buckets', 'si_events'].includes(kind)) {
40+
return undefined;
41+
}
42+
43+
if (!descriptorSegment) {
44+
return undefined;
45+
}
46+
47+
const identifier = parseIdentifier(descriptorSegment);
48+
if (!identifier) {
49+
return undefined;
50+
}
51+
52+
const subPath = rest.length > 0 ? `/${rest.join('/')}` : '/';
53+
const query = Object.fromEntries(url.searchParams.entries());
54+
55+
return {
56+
kind,
57+
identifier,
58+
subPath,
59+
query,
60+
method: req.method ?? 'GET',
61+
rawPath: url.pathname,
62+
};
63+
};
64+
65+
export const servLocal = async (
66+
handlers: Array<{ kind: RouteKind; handler: RouteHandler }>,
67+
): Promise<void> => {
68+
if (localServer) {
69+
logger.info(`Local gateway already running on http://localhost:${SI_LOCALSTACK_GATEWAY_PORT}`);
70+
return;
71+
}
72+
73+
localServer = http.createServer((req, res) => {
74+
try {
75+
const parsed = parseRequest(req);
76+
77+
if (!parsed) {
78+
respondText(res, 404, 'Route not found');
79+
logger.warn(`Local gateway 404 -> ${req.method ?? 'GET'} ${req.url ?? '/'} `);
80+
return;
81+
}
82+
const requestHandler = handlers.find((h) => h.kind === parsed.kind);
83+
if (!requestHandler) {
84+
respondText(res, 501, `No handler for route kind: ${parsed.kind}`);
85+
logger.warn(
86+
`Local gateway 501 -> No handler for ${parsed.kind} ${req.method ?? 'GET'} ${
87+
req.url ?? '/'
88+
}`,
89+
);
90+
return;
91+
}
92+
requestHandler.handler(req, res, parsed);
93+
logger.info(
94+
`Local gateway handled ${parsed.kind}: ${parsed.identifier.name} (${parsed.identifier.region}) ${parsed.subPath}`,
95+
);
96+
} catch (error) {
97+
respondText(res, 500, 'Internal server error');
98+
logger.error(
99+
{ err: error },
100+
`Local gateway error -> ${req.method ?? 'GET'} ${req.url ?? '/'}`,
101+
);
102+
}
103+
});
104+
105+
await new Promise<void>((resolve, reject) => {
106+
localServer!.listen(SI_LOCALSTACK_GATEWAY_PORT, '0.0.0.0', () => {
107+
logger.info(`Local Server listening on http://localhost:${SI_LOCALSTACK_GATEWAY_PORT}`);
108+
resolve();
109+
});
110+
111+
localServer!.once('error', (err) => {
112+
logger.error({ err }, 'Failed to start local server');
113+
reject(err);
114+
});
115+
});
116+
};

src/types/localStack/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { IncomingMessage, ServerResponse } from 'node:http';
2+
3+
export type RouteKind = 'si_functions' | 'si_buckets' | 'si_website_buckets' | 'si_events';
4+
5+
export type ResourceIdentifier = {
6+
id: string;
7+
name: string;
8+
region: string;
9+
};
10+
11+
export type ParsedRequest = {
12+
kind: RouteKind;
13+
identifier: ResourceIdentifier;
14+
subPath: string;
15+
method: string;
16+
query: Record<string, string>;
17+
rawPath: string;
18+
};
19+
20+
export type RouteHandler = (
21+
req: IncomingMessage,
22+
res: ServerResponse,
23+
parsed: ParsedRequest,
24+
) => void;

0 commit comments

Comments
 (0)