@karmaniverous/smoz 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.js +1095 -413
- package/dist/cli/index.cjs +95 -95
- package/dist/index.d.ts +409 -199
- package/dist/mjs/index.js +1091 -409
- package/package.json +185 -185
- package/templates/default/.editorconfig +8 -0
- package/templates/default/.gitattributes +3 -0
- package/templates/default/.prettierrc.json +4 -3
- package/templates/default/.vscode/settings.json +23 -23
- package/templates/default/README.md +21 -1
- package/templates/default/app/config/app.config.ts +14 -3
- package/templates/default/app/config/openapi.ts +12 -12
- package/templates/default/package.json +2 -2
- package/templates/default/serverless.ts +20 -2
- package/templates/default/typedoc.json +1 -1
package/dist/mjs/index.js
CHANGED
|
@@ -1,20 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { relative, dirname, sep, join } from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { baseEventTypeMapSchema as baseEventTypeMapSchema$1 } from '@/src/core/baseEventTypeMapSchema';
|
|
4
|
-
import { buildStageArtifacts } from '@/src/core/buildStageArtifacts';
|
|
5
|
-
import { validateEventTypeMapSchemaIncludesBase, defaultHttpEventTypeTokens } from '@/src/core/httpTokens';
|
|
6
|
-
import { createRegistry } from '@/src/core/registry';
|
|
7
|
-
import { serverlessConfigSchema } from '@/src/core/serverlessConfig';
|
|
8
|
-
import { buildAllOpenApiPaths } from '@/src/openapi/buildOpenApi';
|
|
9
|
-
import { buildAllServerlessFunctions } from '@/src/serverless/buildServerless';
|
|
10
3
|
import { z } from 'zod';
|
|
11
|
-
import {
|
|
4
|
+
import { unique, diff } from 'radash';
|
|
12
5
|
import middy from '@middy/core';
|
|
13
|
-
import { getEnvFromFunctionConfig } from '@/src/core/defineFunctionConfig';
|
|
14
|
-
import { computeHttpMiddleware } from '@/src/http/middleware/httpStackCustomization';
|
|
15
|
-
import { deriveAllKeys, splitKeysBySchema, buildEnvSchema, parseTypedEnv } from '@/src/runtime/envBuilder';
|
|
16
|
-
import '@/src/http/middleware/combine';
|
|
17
|
-
import { tagStep as tagStep$1 } from '@/src/http/middleware/transformUtils';
|
|
18
6
|
import httpContentNegotiation from '@middy/http-content-negotiation';
|
|
19
7
|
import httpCors from '@middy/http-cors';
|
|
20
8
|
import httpErrorHandler from '@middy/http-error-handler';
|
|
@@ -22,157 +10,7 @@ import httpEventNormalizer from '@middy/http-event-normalizer';
|
|
|
22
10
|
import httpHeaderNormalizer from '@middy/http-header-normalizer';
|
|
23
11
|
import httpJsonBodyParser from '@middy/http-json-body-parser';
|
|
24
12
|
import httpResponseSerializer from '@middy/http-response-serializer';
|
|
25
|
-
import {
|
|
26
|
-
import { httpZodValidator } from '@/src/http/middleware/httpZodValidator';
|
|
27
|
-
import { shortCircuitHead } from '@/src/http/middleware/shortCircuitHead';
|
|
28
|
-
import { wrapSerializer } from '@/src/http/middleware/wrapSerializer';
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* App (schema‑first)
|
|
32
|
-
*
|
|
33
|
-
* Central orchestrator for a SMOZ application. You provide:
|
|
34
|
-
* - Global/stage parameter schemas and env exposure keys
|
|
35
|
-
* - Serverless defaults (handler filename/export and context map)
|
|
36
|
-
* - Event‑type map schema (extendable: e.g., add 'step')
|
|
37
|
-
*
|
|
38
|
-
* The instance:
|
|
39
|
-
* - Validates configuration
|
|
40
|
-
* - Exposes env and stage artifacts for Serverless (provider.environment and params)
|
|
41
|
-
* - Provides a registry to define functions (HTTP and non‑HTTP)
|
|
42
|
-
* - Aggregates artifacts:
|
|
43
|
-
* - buildAllServerlessFunctions(): AWS['functions']
|
|
44
|
-
* - buildAllOpenApiPaths(): ZodOpenApiPathsObject
|
|
45
|
-
*
|
|
46
|
-
* @remarks See README for a full quick‑start, and typedoc for detailed API docs.
|
|
47
|
-
*/
|
|
48
|
-
/**
|
|
49
|
-
* Application class. *
|
|
50
|
-
* @typeParam GlobalParamsSchema - Zod object schema for global parameters
|
|
51
|
-
* @typeParam StageParamsSchema - Zod object schema for per‑stage parameters
|
|
52
|
-
* @typeParam EventTypeMapSchema - Zod object schema mapping event tokens to runtime types
|
|
53
|
-
*/ class App {
|
|
54
|
-
/** Helper alias for stage artifacts type */
|
|
55
|
-
static _stageArtifactsType = null;
|
|
56
|
-
appRootAbs;
|
|
57
|
-
// Schemas
|
|
58
|
-
globalParamsSchema;
|
|
59
|
-
stageParamsSchema;
|
|
60
|
-
eventTypeMapSchema; // Serverless config
|
|
61
|
-
serverless;
|
|
62
|
-
// Env exposure
|
|
63
|
-
global;
|
|
64
|
-
stage;
|
|
65
|
-
// Derived stage artifacts
|
|
66
|
-
stages;
|
|
67
|
-
environment;
|
|
68
|
-
buildFnEnv;
|
|
69
|
-
http;
|
|
70
|
-
// HTTP tokens for runtime decision
|
|
71
|
-
httpEventTypeTokens;
|
|
72
|
-
// Registry (delegated to src/app/registry)
|
|
73
|
-
registry;
|
|
74
|
-
constructor(init) {
|
|
75
|
-
this.appRootAbs = init.appRootAbs.replace(/\\/g, '/');
|
|
76
|
-
this.globalParamsSchema = init.globalParamsSchema;
|
|
77
|
-
this.stageParamsSchema = init.stageParamsSchema;
|
|
78
|
-
// Default to base schema when omitted (apply default INSIDE the function)
|
|
79
|
-
this.eventTypeMapSchema = (init.eventTypeMapSchema ??
|
|
80
|
-
baseEventTypeMapSchema$1);
|
|
81
|
-
// Parse serverless input internally
|
|
82
|
-
this.serverless = serverlessConfigSchema.parse(init.serverless);
|
|
83
|
-
// Validate that eventTypeMapSchema includes base keys at runtime
|
|
84
|
-
validateEventTypeMapSchemaIncludesBase(this.eventTypeMapSchema.shape);
|
|
85
|
-
// Env exposure nodes
|
|
86
|
-
this.global = {
|
|
87
|
-
paramsSchema: this.globalParamsSchema,
|
|
88
|
-
envKeys: init.global.envKeys,
|
|
89
|
-
};
|
|
90
|
-
this.stage = {
|
|
91
|
-
paramsSchema: this.stageParamsSchema,
|
|
92
|
-
envKeys: init.stage.envKeys,
|
|
93
|
-
};
|
|
94
|
-
// Build stages/environment/fn-env via helper (applies “stage extends global” and parsing)
|
|
95
|
-
const { stages, environment, buildFnEnv } = buildStageArtifacts(this.globalParamsSchema, this.stageParamsSchema, { params: init.global.params, envKeys: init.global.envKeys }, { params: init.stage.params, envKeys: init.stage.envKeys });
|
|
96
|
-
this.stages = stages;
|
|
97
|
-
this.environment = environment;
|
|
98
|
-
this.buildFnEnv = buildFnEnv;
|
|
99
|
-
// HTTP tokens (runtime decision)
|
|
100
|
-
this.httpEventTypeTokens = (init.httpEventTypeTokens ??
|
|
101
|
-
defaultHttpEventTypeTokens);
|
|
102
|
-
// App-level HTTP customization
|
|
103
|
-
this.http = init.http ?? {};
|
|
104
|
-
// Initialize function registry
|
|
105
|
-
this.registry = createRegistry({
|
|
106
|
-
httpEventTypeTokens: this.httpEventTypeTokens,
|
|
107
|
-
env: { global: this.global, stage: this.stage },
|
|
108
|
-
http: this.http,
|
|
109
|
-
// With exactOptionalPropertyTypes, do not pass an explicit undefined.
|
|
110
|
-
...(init.functionDefaults
|
|
111
|
-
? { functionDefaults: init.functionDefaults }
|
|
112
|
-
: {}),
|
|
113
|
-
});
|
|
114
|
-
} /**
|
|
115
|
-
* Ergonomic constructor for schema‑first inference.
|
|
116
|
-
*
|
|
117
|
-
* @param init - initialization object (schemas, serverless defaults, params/envKeys)
|
|
118
|
-
* @returns a new App instance
|
|
119
|
-
*/
|
|
120
|
-
static create(init) {
|
|
121
|
-
return new App(init);
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* Register a function (HTTP or non‑HTTP).
|
|
125
|
-
*
|
|
126
|
-
* @typeParam EventType - A key from your eventTypeMapSchema (e.g., 'rest' | 'http' | 'sqs' | 'step')
|
|
127
|
-
* @typeParam EventSchema - Optional Zod schema validated BEFORE the handler (refines event shape)
|
|
128
|
-
* @typeParam ResponseSchema - Optional Zod schema validated AFTER the handler (refines response shape)
|
|
129
|
-
* @param options - per‑function configuration (method/basePath/httpContexts for HTTP; serverless extras for non‑HTTP)
|
|
130
|
-
* @returns a per‑function API: { handler(business), openapi(baseOperation), serverless(extras) }
|
|
131
|
-
*/
|
|
132
|
-
defineFunction(options) {
|
|
133
|
-
const functionName = options.functionName ??
|
|
134
|
-
(() => {
|
|
135
|
-
const callerDir = dirname(fileURLToPath(options.callerModuleUrl));
|
|
136
|
-
const rel = relative(this.appRootAbs, callerDir).split(sep).join('/');
|
|
137
|
-
const parts = rel.split('/').filter(Boolean);
|
|
138
|
-
// Drop leading 'app' segment if present, per repo convention
|
|
139
|
-
if (parts[0] === 'app')
|
|
140
|
-
parts.shift();
|
|
141
|
-
return parts.join('_'); // underscore formatting
|
|
142
|
-
})();
|
|
143
|
-
return this.registry.defineFunction({
|
|
144
|
-
functionName,
|
|
145
|
-
eventType: options.eventType,
|
|
146
|
-
...(options.method ? { method: options.method } : {}),
|
|
147
|
-
...(options.basePath ? { basePath: options.basePath } : {}),
|
|
148
|
-
...(options.httpContexts ? { httpContexts: options.httpContexts } : {}),
|
|
149
|
-
...(options.contentType ? { contentType: options.contentType } : {}),
|
|
150
|
-
...(options.eventSchema ? { eventSchema: options.eventSchema } : {}),
|
|
151
|
-
...(options.responseSchema
|
|
152
|
-
? { responseSchema: options.responseSchema }
|
|
153
|
-
: {}),
|
|
154
|
-
...(options.fnEnvKeys ? { fnEnvKeys: options.fnEnvKeys } : {}),
|
|
155
|
-
callerModuleUrl: options.callerModuleUrl,
|
|
156
|
-
endpointsRootAbs: options.endpointsRootAbs,
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* Aggregate Serverless function definitions across the registry.
|
|
161
|
-
*
|
|
162
|
-
* @returns An AWS['functions'] object suitable for serverless.ts
|
|
163
|
-
*/
|
|
164
|
-
buildAllServerlessFunctions() {
|
|
165
|
-
return buildAllServerlessFunctions(this.registry.values(), this.serverless, this.buildFnEnv);
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* Aggregate OpenAPI path items across the registry.
|
|
169
|
-
*
|
|
170
|
-
* @returns ZodOpenApiPathsObject to be embedded in a full OpenAPI document
|
|
171
|
-
*/
|
|
172
|
-
buildAllOpenApiPaths() {
|
|
173
|
-
return buildAllOpenApiPaths(this.registry.values());
|
|
174
|
-
}
|
|
175
|
-
}
|
|
13
|
+
import { packageDirectorySync } from 'package-directory';
|
|
176
14
|
|
|
177
15
|
const baseEventTypeMapSchema = z.object({
|
|
178
16
|
rest: z.custom(),
|
|
@@ -198,234 +36,335 @@ const baseEventTypeMapSchema = z.object({
|
|
|
198
36
|
// - Only tokens listed in your App's httpEventTypeTokens are treated as HTTP by the runtime.
|
|
199
37
|
|
|
200
38
|
/**
|
|
201
|
-
*
|
|
39
|
+
* Stage artifacts factory.
|
|
202
40
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* @param input - serverless defaults and concrete params + env exposure
|
|
208
|
-
* @returns Typed configuration nodes and stage artifacts
|
|
209
|
-
* * @throws Error if envKeys include keys not present in their corresponding schema
|
|
41
|
+
* Validates per‑stage configurations, composes them with global params,
|
|
42
|
+
* and produces:
|
|
43
|
+
* - Serverless `stages` (params), * - provider‑level `environment` mapping,
|
|
44
|
+
* - a helper to build per‑function env mappings (excluding globally exposed keys).
|
|
210
45
|
*/
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Create all stage artifacts from provided configs. This is generic and can
|
|
48
|
+
* be used by both production and tests.
|
|
49
|
+
*
|
|
50
|
+
* @typeParam GlobalParams - global params record
|
|
51
|
+
* @typeParam StageParams - stage params record
|
|
52
|
+
* @param input - schemas, concrete params, env exposure, and per‑stage values
|
|
53
|
+
* @returns stage params object, provider environment, and per‑function env builder
|
|
54
|
+
*
|
|
55
|
+
* @throws Error if a stage fails validation or a required global key is missing
|
|
56
|
+
*/
|
|
57
|
+
const stagesFactory = (input) => {
|
|
58
|
+
const { globalParamsSchema, stageParamsSchema, globalParams, globalEnvKeys, stageEnvKeys, stages, } = input;
|
|
59
|
+
// Validate each stage configuration:
|
|
60
|
+
// 1) Stage object conforms to stage schema
|
|
61
|
+
// 2) Stage merged with global satisfies required global keys
|
|
62
|
+
const entries = Object.entries(stages);
|
|
63
|
+
for (const [, stage] of entries) {
|
|
64
|
+
stageParamsSchema.parse(stage);
|
|
65
|
+
globalParamsSchema.strip().parse({ ...globalParams, ...stage });
|
|
66
|
+
}
|
|
67
|
+
// Build Serverless 'params' structure
|
|
68
|
+
const stagesOut = entries.reduce((acc, [name, params]) => {
|
|
69
|
+
acc[name] = { params };
|
|
70
|
+
return acc;
|
|
71
|
+
}, { default: { params: globalParams } });
|
|
72
|
+
// Build provider.environment mapping for globally exposed keys
|
|
73
|
+
const globallyExposed = unique([].concat(globalEnvKeys, stageEnvKeys));
|
|
74
|
+
const environment = Object.fromEntries(globallyExposed.map((k) => [k, `\${param:${k}}`]));
|
|
75
|
+
// Helper for function-level environment: include only non-globally-exposed
|
|
76
|
+
const buildFnEnv = (fnEnvKeys = []) => {
|
|
77
|
+
const extras = diff(fnEnvKeys, globallyExposed);
|
|
78
|
+
return Object.fromEntries(extras.map((k) => [k, `\${param:${k}}`]));
|
|
217
79
|
};
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
80
|
+
return { stages: stagesOut, environment, buildFnEnv };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Compose stage artifacts (effective schema + parsed stages + env helpers).
|
|
85
|
+
* Extracted from App.ts to keep the class focused on orchestration.
|
|
86
|
+
*/
|
|
87
|
+
function buildStageArtifacts(globalParamsSchema, stageParamsSchema, global, stage) {
|
|
88
|
+
const effectiveStageParamsSchema = globalParamsSchema
|
|
89
|
+
.partial()
|
|
90
|
+
.extend(stageParamsSchema.shape);
|
|
91
|
+
const typedStages = Object.fromEntries(Object.entries(stage.params).map(([name, params]) => {
|
|
92
|
+
const parsed = effectiveStageParamsSchema.parse(params);
|
|
93
|
+
return [name, parsed];
|
|
94
|
+
}));
|
|
95
|
+
return stagesFactory({
|
|
221
96
|
globalParamsSchema,
|
|
222
|
-
stageParamsSchema,
|
|
223
|
-
globalParams:
|
|
224
|
-
globalEnvKeys:
|
|
225
|
-
stageEnvKeys:
|
|
226
|
-
stages:
|
|
97
|
+
stageParamsSchema: effectiveStageParamsSchema,
|
|
98
|
+
globalParams: global.params,
|
|
99
|
+
globalEnvKeys: global.envKeys,
|
|
100
|
+
stageEnvKeys: stage.envKeys,
|
|
101
|
+
stages: typedStages,
|
|
227
102
|
});
|
|
228
|
-
return {
|
|
229
|
-
serverless: input.serverless,
|
|
230
|
-
global: { paramsSchema: globalParamsSchema, envKeys: input.global.envKeys },
|
|
231
|
-
stage: { paramsSchema: stageParamsSchema, envKeys: input.stage.envKeys },
|
|
232
|
-
stages: sf.stages,
|
|
233
|
-
environment: sf.environment,
|
|
234
|
-
buildFnEnv: sf.buildFnEnv,
|
|
235
|
-
};
|
|
236
103
|
}
|
|
237
104
|
|
|
238
|
-
/**
|
|
105
|
+
/** Default HTTP event tokens used by the runtime. */
|
|
106
|
+
const defaultHttpEventTypeTokens = [
|
|
107
|
+
'rest',
|
|
108
|
+
'http',
|
|
109
|
+
];
|
|
110
|
+
/** Base event type keys that MUST be present in any extended map schema. */
|
|
111
|
+
const BASE_EVENT_TYPE_KEYS = ['rest', 'http', 'sqs'];
|
|
239
112
|
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
113
|
+
* Runtime guard: ensure an extended eventTypeMapSchema includes base keys.
|
|
114
|
+
* Pass the .shape object of a ZodObject for checking.
|
|
115
|
+
*
|
|
116
|
+
* @param shape - Zod object `shape` for an extended event type map
|
|
117
|
+
* @throws Error when any base key is missing from the shape
|
|
243
118
|
*/
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
119
|
+
const validateEventTypeMapSchemaIncludesBase = (shape) => {
|
|
120
|
+
for (const k of BASE_EVENT_TYPE_KEYS) {
|
|
121
|
+
if (!(k in shape))
|
|
122
|
+
throw new Error(`eventTypeMapSchema is missing base key "${k}". Ensure it extends baseEventTypeMapSchema.`);
|
|
123
|
+
}
|
|
248
124
|
};
|
|
249
|
-
|
|
125
|
+
|
|
126
|
+
const ENV_CONFIG = Symbol.for('szo.envConfig');
|
|
127
|
+
/** @see {@link runtime/wrapHandler.wrapHandler | wrapHandler} */
|
|
128
|
+
function getEnvFromFunctionConfig(fc) {
|
|
129
|
+
const env = fc[ENV_CONFIG];
|
|
130
|
+
if (!env) {
|
|
131
|
+
throw new Error('FunctionConfig is missing env attachment. Use defineFunctionConfig(env)(...) when authoring.');
|
|
132
|
+
}
|
|
133
|
+
return env;
|
|
134
|
+
}
|
|
135
|
+
|
|
250
136
|
/**
|
|
251
|
-
*
|
|
137
|
+
* Combine several middlewares into one (order-preserving).
|
|
138
|
+
*
|
|
139
|
+
* @param parts - middlewares to compose
|
|
140
|
+
* @returns a middleware that calls before/after/onError in sequence
|
|
141
|
+
* @remarks
|
|
142
|
+
* If a previous middleware sets `request.response` during `before`, the
|
|
143
|
+
* sequence will stop early so the base handler is skipped (e.g. HEAD).
|
|
252
144
|
*/
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
145
|
+
const combine = (...parts) => {
|
|
146
|
+
const befores = parts.map((m) => m.before).filter(Boolean);
|
|
147
|
+
const afters = parts.map((m) => m.after).filter(Boolean);
|
|
148
|
+
const onErrors = parts.map((m) => m.onError).filter(Boolean);
|
|
149
|
+
return {
|
|
150
|
+
before: async (request) => {
|
|
151
|
+
for (const fn of befores) {
|
|
152
|
+
await fn(request);
|
|
153
|
+
// If a prior middleware has produced a response (e.g., HEAD short-circuit),
|
|
154
|
+
// exit early so the base handler will be skipped by Middy.
|
|
155
|
+
if (request.response !== undefined)
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
after: async (request) => {
|
|
160
|
+
for (const fn of afters)
|
|
161
|
+
await fn(request);
|
|
162
|
+
},
|
|
163
|
+
onError: async (request) => {
|
|
164
|
+
for (const fn of onErrors)
|
|
165
|
+
await fn(request);
|
|
166
|
+
},
|
|
167
|
+
};
|
|
260
168
|
};
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return found ? headers[found] : undefined;
|
|
169
|
+
|
|
170
|
+
const ID_PROP = '__id';
|
|
171
|
+
/** Attach a non-enumerable __id to a middleware step. */
|
|
172
|
+
const tagStep = (mw, id) => {
|
|
173
|
+
if (!Object.prototype.hasOwnProperty.call(mw, ID_PROP)) {
|
|
174
|
+
Object.defineProperty(mw, ID_PROP, { value: id, enumerable: false });
|
|
175
|
+
}
|
|
176
|
+
return mw;
|
|
270
177
|
};
|
|
271
|
-
/**
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
178
|
+
/** Retrieve a step's id, if present. */
|
|
179
|
+
const getId = (mw) => mw[ID_PROP];
|
|
180
|
+
/** Find index of a step by id. */
|
|
181
|
+
const findIndex = (list, id) => list.findIndex((m) => getId(m) === id);
|
|
182
|
+
/** Insert a step before the step with given id. */
|
|
183
|
+
const insertBefore = (list, id, mw) => {
|
|
184
|
+
const i = findIndex(list, id);
|
|
185
|
+
if (i < 0)
|
|
186
|
+
return list.slice();
|
|
187
|
+
return [...list.slice(0, i), mw, ...list.slice(i)];
|
|
280
188
|
};
|
|
281
|
-
/**
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
if (
|
|
285
|
-
return
|
|
286
|
-
|
|
287
|
-
.multiValueHeaders, key);
|
|
288
|
-
return multi;
|
|
189
|
+
/** Insert a step after the step with given id. */
|
|
190
|
+
const insertAfter = (list, id, mw) => {
|
|
191
|
+
const i = findIndex(list, id);
|
|
192
|
+
if (i < 0)
|
|
193
|
+
return list.slice();
|
|
194
|
+
return [...list.slice(0, i + 1), mw, ...list.slice(i + 1)];
|
|
289
195
|
};
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
196
|
+
/** Replace a step with given id. */
|
|
197
|
+
const replaceStep = (list, id, mw) => {
|
|
198
|
+
const i = findIndex(list, id);
|
|
199
|
+
if (i < 0)
|
|
200
|
+
return list.slice();
|
|
201
|
+
const out = list.slice();
|
|
202
|
+
out[i] = mw;
|
|
203
|
+
return out;
|
|
295
204
|
};
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return
|
|
301
|
-
|
|
302
|
-
// V2: authorizer may include jwt/iam/lambda shapes
|
|
303
|
-
const authHeader = getHeaderFromEvent(evt, 'authorization');
|
|
304
|
-
if (hasAwsSig(authHeader))
|
|
305
|
-
return true;
|
|
306
|
-
const rc = evt
|
|
307
|
-
.requestContext;
|
|
308
|
-
const { authorizer } = rc;
|
|
309
|
-
if (!authorizer || typeof authorizer !== 'object')
|
|
310
|
-
return false;
|
|
311
|
-
const a = authorizer;
|
|
312
|
-
return !!(a.jwt || a.iam || a.lambda);
|
|
205
|
+
/** Remove a step with given id. */
|
|
206
|
+
const removeStep = (list, id) => {
|
|
207
|
+
const i = findIndex(list, id);
|
|
208
|
+
if (i < 0)
|
|
209
|
+
return list.slice();
|
|
210
|
+
return [...list.slice(0, i), ...list.slice(i + 1)];
|
|
313
211
|
};
|
|
314
|
-
/**
|
|
315
|
-
const
|
|
316
|
-
const
|
|
317
|
-
if (
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
212
|
+
/** Invariant validation helpers */
|
|
213
|
+
const assertInvariants = (phases) => {
|
|
214
|
+
const { before, after } = phases;
|
|
215
|
+
if (before.length === 0 || getId(before[0]) !== 'head') {
|
|
216
|
+
throw new Error("Invariant violation: 'head' must be first in before.");
|
|
217
|
+
}
|
|
218
|
+
if (after.length === 0 ||
|
|
219
|
+
getId(after[after.length - 1]) !== 'serializer') {
|
|
220
|
+
throw new Error("Invariant violation: 'serializer' must be last in after.");
|
|
221
|
+
}
|
|
222
|
+
const shapeIdx = findIndex(after, 'shape');
|
|
223
|
+
const serialIdx = findIndex(after, 'serializer');
|
|
224
|
+
if (shapeIdx < 0 || serialIdx < 0 || shapeIdx >= serialIdx) {
|
|
225
|
+
throw new Error("Invariant violation: 'shape' must precede 'serializer' in after.");
|
|
226
|
+
}
|
|
227
|
+
const illegal = (arr) => arr.find((m) => getId(m) === 'error-handler');
|
|
228
|
+
if (illegal(before) || illegal(after)) {
|
|
229
|
+
throw new Error("Illegal placement: 'error-handler' may only appear in onError phase.");
|
|
328
230
|
}
|
|
329
|
-
return false;
|
|
330
231
|
};
|
|
331
|
-
|
|
232
|
+
|
|
332
233
|
/**
|
|
333
|
-
*
|
|
234
|
+
* Coerce a generic Middy middleware into an API‑Gateway‑typed middleware.
|
|
334
235
|
*
|
|
335
|
-
* @param
|
|
336
|
-
* @returns
|
|
236
|
+
* @param m - middleware object (any event/context signature)
|
|
237
|
+
* @returns middleware object typed to (APIGatewayProxyEvent, Context) */
|
|
238
|
+
const asApiMiddleware = (m) => m;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Create a Plain‑Old‑JSON clone of an arbitrary input.
|
|
242
|
+
*
|
|
243
|
+
* - Drops non‑JSON scalars and containers (Map/Set/etc.)
|
|
244
|
+
* - Avoids circular references
|
|
245
|
+
* - Preserves Date via toJSON
|
|
337
246
|
*/
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (
|
|
342
|
-
return
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
247
|
+
const pojofy = (input) => {
|
|
248
|
+
const seen = new WeakSet();
|
|
249
|
+
const replacer = (_key, value) => {
|
|
250
|
+
if (value === null)
|
|
251
|
+
return null;
|
|
252
|
+
const t = typeof value;
|
|
253
|
+
if (t === 'string' || t === 'number' || t === 'boolean')
|
|
254
|
+
return value;
|
|
255
|
+
// Eliminate non-JSON scalars
|
|
256
|
+
if (t === 'undefined' ||
|
|
257
|
+
t === 'function' ||
|
|
258
|
+
t === 'symbol' ||
|
|
259
|
+
t === 'bigint') {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
// Keep Date as ISO via its built-in toJSON
|
|
263
|
+
if (value instanceof Date)
|
|
264
|
+
return value;
|
|
265
|
+
// Drop common non-JSON containers
|
|
266
|
+
if (value instanceof Map ||
|
|
267
|
+
value instanceof Set ||
|
|
268
|
+
value instanceof WeakMap ||
|
|
269
|
+
value instanceof WeakSet) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
// Drop raw binary; convert typed arrays (incl. Buffer) to JSON-safe arrays
|
|
273
|
+
if (typeof ArrayBuffer !== 'undefined' && value instanceof ArrayBuffer)
|
|
274
|
+
return undefined;
|
|
275
|
+
if (ArrayBuffer.isView(value))
|
|
276
|
+
return Array.from(value);
|
|
277
|
+
const obj = value;
|
|
278
|
+
if (seen.has(obj))
|
|
279
|
+
return undefined; // eliminate circular refs
|
|
280
|
+
seen.add(obj);
|
|
281
|
+
return obj; // let JSON.stringify walk own-enumerable props
|
|
282
|
+
};
|
|
283
|
+
return JSON.parse(JSON.stringify(input, replacer));
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const assertWithZod = (value, schema, logger, kind) => {
|
|
287
|
+
if (!schema)
|
|
288
|
+
return;
|
|
289
|
+
logger.debug('validating with zod', value);
|
|
290
|
+
const result = schema.safeParse(value);
|
|
291
|
+
if (result.success) {
|
|
292
|
+
logger.debug('zod validation succeeded', pojofy(result));
|
|
293
|
+
return;
|
|
354
294
|
}
|
|
355
|
-
|
|
295
|
+
// Log a structured object for easier debugging.
|
|
296
|
+
logger.error('zod validation failed', pojofy(result));
|
|
297
|
+
// Throw a readable error that upstream layers can interpret.
|
|
298
|
+
// We intentionally use "Invalid event"/"Invalid response" so HTTP mode can map to 400,
|
|
299
|
+
// while internal mode simply throws.
|
|
300
|
+
const msg = kind === 'event' ? 'Invalid event' : 'Invalid response';
|
|
301
|
+
const err = Object.assign(new Error(msg), {
|
|
302
|
+
name: 'ZodError',
|
|
303
|
+
issues: result.error.issues,
|
|
304
|
+
});
|
|
305
|
+
throw err;
|
|
356
306
|
};
|
|
307
|
+
/**
|
|
308
|
+
* Validate the *incoming* event before business logic runs, and the *outgoing*
|
|
309
|
+
* response after it returns (unless a shaped HTTP envelope or a raw string).
|
|
310
|
+
*/
|
|
311
|
+
const httpZodValidator = ({ eventSchema, responseSchema, logger = console, }) => ({
|
|
312
|
+
before: async (request) => {
|
|
313
|
+
const event = request.event;
|
|
314
|
+
assertWithZod(event, eventSchema, logger, 'event');
|
|
315
|
+
},
|
|
316
|
+
after: async (request) => {
|
|
317
|
+
const container = request;
|
|
318
|
+
const res = container.response;
|
|
319
|
+
// Skip if the handler already returned a shaped HTTP response...
|
|
320
|
+
const looksShaped = typeof res === 'object' &&
|
|
321
|
+
res !== null &&
|
|
322
|
+
'statusCode' in res &&
|
|
323
|
+
'headers' in res &&
|
|
324
|
+
'body' in res;
|
|
325
|
+
// ...or a raw string (serializer will pass it through).
|
|
326
|
+
if (looksShaped || typeof res === 'string')
|
|
327
|
+
return;
|
|
328
|
+
assertWithZod(res, responseSchema, logger, 'response');
|
|
329
|
+
},
|
|
330
|
+
});
|
|
357
331
|
|
|
358
332
|
/**
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* - Non‑HTTP tokens bypass Middy and call the business function directly.
|
|
333
|
+
* If the request is HEAD, short-circuit the handler and let the serializer
|
|
334
|
+
* produce an empty JSON body. This keeps tests and clients predictable.
|
|
362
335
|
*
|
|
363
|
-
* @
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
* @typeParam EventType - a key of EventTypeMap
|
|
367
|
-
* @typeParam EventSchema - optional Zod schema for event (validated before handler)
|
|
368
|
-
* @typeParam ResponseSchema - optional Zod schema for response (validated after handler)
|
|
369
|
-
* @param functionConfig - per‑function configuration (branded with env nodes)
|
|
370
|
-
* @param business - the business handler implementation
|
|
371
|
-
* @param opts - optional runtime overrides (e.g., widen HTTP tokens)
|
|
372
|
-
* @returns a Lambda‑compatible handler function
|
|
336
|
+
* @remarks
|
|
337
|
+
* This is placed very early in the pipeline. When it sets `request.response`
|
|
338
|
+
* during `before`, the composed middleware sequence will skip the base handler.
|
|
373
339
|
*/
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const all = deriveAllKeys(envConfig.global.envKeys, envConfig.stage.envKeys, (functionConfig.fnEnvKeys ?? []));
|
|
387
|
-
const { globalPick, stagePick } = splitKeysBySchema(all, envConfig.global.paramsSchema, envConfig.stage.paramsSchema);
|
|
388
|
-
const envSchema = buildEnvSchema(globalPick, stagePick, envConfig.global.paramsSchema, envConfig.stage.paramsSchema);
|
|
389
|
-
const env = parseTypedEnv(envSchema, process.env);
|
|
390
|
-
const logger = console;
|
|
391
|
-
// Non-HTTP: call business directly
|
|
392
|
-
const httpTokens = opts?.httpEventTypeTokens ??
|
|
393
|
-
defaultHttpEventTypeTokens;
|
|
394
|
-
const isHttp = httpTokens.includes(functionConfig.eventType);
|
|
395
|
-
if (!isHttp) {
|
|
396
|
-
return business(event, context, { env, logger });
|
|
340
|
+
const shortCircuitHead = {
|
|
341
|
+
before: async (request) => {
|
|
342
|
+
const evt = request.event;
|
|
343
|
+
const method = (evt.httpMethod ??
|
|
344
|
+
evt.requestContext?.http?.method ??
|
|
345
|
+
'').toUpperCase();
|
|
346
|
+
if (method === 'HEAD') {
|
|
347
|
+
request.response = {
|
|
348
|
+
statusCode: 200,
|
|
349
|
+
headers: {},
|
|
350
|
+
body: {},
|
|
351
|
+
};
|
|
397
352
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const maybeContentType = functionConfig
|
|
401
|
-
.contentType;
|
|
402
|
-
const args = {
|
|
403
|
-
functionName: functionConfig.functionName,
|
|
404
|
-
logger,
|
|
405
|
-
...(functionConfig.eventSchema
|
|
406
|
-
? { eventSchema: functionConfig.eventSchema }
|
|
407
|
-
: {}),
|
|
408
|
-
...(functionConfig.responseSchema
|
|
409
|
-
? { responseSchema: functionConfig.responseSchema }
|
|
410
|
-
: {}),
|
|
411
|
-
...(typeof maybeContentType === 'string' && maybeContentType.length > 0
|
|
412
|
-
? { contentType: maybeContentType }
|
|
413
|
-
: {}),
|
|
414
|
-
...(opts?.httpConfig ? { app: opts.httpConfig } : {}),
|
|
415
|
-
...(fnHttp ? { fn: fnHttp } : {}),
|
|
416
|
-
};
|
|
417
|
-
const http = computeHttpMiddleware(args);
|
|
418
|
-
const wrapped = middy(async (e, c) => business(e, c, {
|
|
419
|
-
env,
|
|
420
|
-
logger,
|
|
421
|
-
})).use(http);
|
|
422
|
-
return wrapped(event, context);
|
|
423
|
-
};
|
|
424
|
-
}
|
|
353
|
+
},
|
|
354
|
+
};
|
|
425
355
|
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
356
|
+
const wrapSerializer = (fn, opts) => {
|
|
357
|
+
return ((args) => {
|
|
358
|
+
opts.logger.debug(`serializing ${opts.label} response`);
|
|
359
|
+
const serialized = fn(args);
|
|
360
|
+
opts.logger.debug('serialized response', { serialized });
|
|
361
|
+
return serialized;
|
|
362
|
+
});
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const makeHead = () => tagStep(shortCircuitHead, 'head');
|
|
366
|
+
const makeHeaderNormalizer = (opts) => tagStep(asApiMiddleware(httpHeaderNormalizer(opts?.headerNormalizer ?? { canonical: true })), 'header-normalizer');
|
|
367
|
+
const makeEventNormalizer = () => tagStep(asApiMiddleware(httpEventNormalizer()), 'event-normalizer');
|
|
429
368
|
const makeContentNegotiation = (contentType, opts) => {
|
|
430
369
|
const availableMediaTypes = [contentType];
|
|
431
370
|
const merged = {
|
|
@@ -435,7 +374,7 @@ const makeContentNegotiation = (contentType, opts) => {
|
|
|
435
374
|
availableMediaTypes,
|
|
436
375
|
...(opts?.contentNegotiation ?? {}),
|
|
437
376
|
};
|
|
438
|
-
return tagStep
|
|
377
|
+
return tagStep(asApiMiddleware(httpContentNegotiation(merged)), 'content-negotiation');
|
|
439
378
|
};
|
|
440
379
|
const makeJsonBodyParser = (opts) => {
|
|
441
380
|
const inner = asApiMiddleware(httpJsonBodyParser({
|
|
@@ -459,7 +398,7 @@ const makeJsonBodyParser = (opts) => {
|
|
|
459
398
|
if (inner.before)
|
|
460
399
|
await inner.before(request);
|
|
461
400
|
};
|
|
462
|
-
return tagStep
|
|
401
|
+
return tagStep(mw, 'json-body-parser');
|
|
463
402
|
};
|
|
464
403
|
const makeZodBefore = (logger, eventSchema) => {
|
|
465
404
|
const base = httpZodValidator({
|
|
@@ -469,7 +408,7 @@ const makeZodBefore = (logger, eventSchema) => {
|
|
|
469
408
|
const mw = {};
|
|
470
409
|
if (base.before)
|
|
471
410
|
mw.before = base.before;
|
|
472
|
-
return tagStep
|
|
411
|
+
return tagStep(mw, 'zod-before');
|
|
473
412
|
};
|
|
474
413
|
const makeZodAfter = (logger, responseSchema) => {
|
|
475
414
|
const base = httpZodValidator({
|
|
@@ -479,9 +418,9 @@ const makeZodAfter = (logger, responseSchema) => {
|
|
|
479
418
|
const mw = {};
|
|
480
419
|
if (base.after)
|
|
481
420
|
mw.after = base.after;
|
|
482
|
-
return tagStep
|
|
421
|
+
return tagStep(mw, 'zod-after');
|
|
483
422
|
};
|
|
484
|
-
const makeHeadFinalize = (contentType) => tagStep
|
|
423
|
+
const makeHeadFinalize = (contentType) => tagStep({
|
|
485
424
|
after: (request) => {
|
|
486
425
|
const evt = request
|
|
487
426
|
.event;
|
|
@@ -499,7 +438,7 @@ const makeHeadFinalize = (contentType) => tagStep$1({
|
|
|
499
438
|
};
|
|
500
439
|
},
|
|
501
440
|
}, 'head-finalize');
|
|
502
|
-
const makePreferredMedia = (contentType) => tagStep
|
|
441
|
+
const makePreferredMedia = (contentType) => tagStep({
|
|
503
442
|
before: (request) => {
|
|
504
443
|
const req = request;
|
|
505
444
|
if (!Array.isArray(req.preferredMediaTypes))
|
|
@@ -522,7 +461,7 @@ const makePreferredMedia = (contentType) => tagStep$1({
|
|
|
522
461
|
req.preferredMediaTypes = [contentType];
|
|
523
462
|
},
|
|
524
463
|
}, 'preferred-media');
|
|
525
|
-
const makeErrorExpose = (logger) => tagStep
|
|
464
|
+
const makeErrorExpose = (logger) => tagStep({
|
|
526
465
|
onError: (request) => {
|
|
527
466
|
const maybe = request.error;
|
|
528
467
|
if (!(maybe instanceof Error))
|
|
@@ -537,7 +476,7 @@ const makeErrorExpose = (logger) => tagStep$1({
|
|
|
537
476
|
}
|
|
538
477
|
},
|
|
539
478
|
}, 'error-expose');
|
|
540
|
-
const makeErrorHandler = (opts) => tagStep
|
|
479
|
+
const makeErrorHandler = (opts) => tagStep(asApiMiddleware(httpErrorHandler({
|
|
541
480
|
...(opts?.errorHandler ?? {}),
|
|
542
481
|
logger: (o) => {
|
|
543
482
|
const lg = opts?.logger ?? console;
|
|
@@ -545,12 +484,12 @@ const makeErrorHandler = (opts) => tagStep$1(asApiMiddleware(httpErrorHandler({
|
|
|
545
484
|
lg.error(o);
|
|
546
485
|
},
|
|
547
486
|
})), 'error-handler');
|
|
548
|
-
const makeCors = (opts) => tagStep
|
|
487
|
+
const makeCors = (opts) => tagStep(asApiMiddleware(httpCors({
|
|
549
488
|
credentials: true,
|
|
550
489
|
getOrigin: (o) => o,
|
|
551
490
|
...(opts?.cors ?? {}),
|
|
552
491
|
})), 'cors');
|
|
553
|
-
const makeShapeAndContentType = (contentType) => tagStep
|
|
492
|
+
const makeShapeAndContentType = (contentType) => tagStep({
|
|
554
493
|
after: (request) => {
|
|
555
494
|
const container = request;
|
|
556
495
|
const current = container.response;
|
|
@@ -580,7 +519,7 @@ const makeShapeAndContentType = (contentType) => tagStep$1({
|
|
|
580
519
|
request.response = res;
|
|
581
520
|
},
|
|
582
521
|
}, 'shape');
|
|
583
|
-
const makeSerializer = (contentType, opts) => tagStep
|
|
522
|
+
const makeSerializer = (contentType, opts) => tagStep(asApiMiddleware(httpResponseSerializer({
|
|
584
523
|
serializers: [
|
|
585
524
|
{
|
|
586
525
|
regex: /^application\/(?:[a-z0-9.+-]*\+)?json$/i,
|
|
@@ -618,47 +557,790 @@ const buildDefaultPhases = (args) => {
|
|
|
618
557
|
};
|
|
619
558
|
const buildSafeDefaults = (args) => buildDefaultPhases(args);
|
|
620
559
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
560
|
+
/** Shallow merge HttpStackOptions left→right. */
|
|
561
|
+
const mergeOptions = (a, b) => ({ ...(a ?? {}), ...(b ?? {}) });
|
|
562
|
+
/** Apply extend lists (append in order) */
|
|
563
|
+
const applyExtend = (phases, ext) => {
|
|
564
|
+
if (!ext)
|
|
565
|
+
return;
|
|
566
|
+
if (ext.before?.length)
|
|
567
|
+
phases.before = [...phases.before, ...ext.before];
|
|
568
|
+
if (ext.after?.length)
|
|
569
|
+
phases.after = [...phases.after, ...ext.after];
|
|
570
|
+
if (ext.onError?.length)
|
|
571
|
+
phases.onError = [...phases.onError, ...ext.onError];
|
|
628
572
|
};
|
|
629
|
-
/**
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
573
|
+
/** Apply transform callback and validate invariants. */
|
|
574
|
+
const applyTransform = (phases, transform) => {
|
|
575
|
+
if (!transform)
|
|
576
|
+
return phases;
|
|
577
|
+
const next = transform({
|
|
578
|
+
before: phases.before.slice(),
|
|
579
|
+
after: phases.after.slice(),
|
|
580
|
+
onError: phases.onError.slice(),
|
|
581
|
+
});
|
|
582
|
+
const out = {
|
|
583
|
+
before: next.before ?? phases.before,
|
|
584
|
+
after: next.after ?? phases.after,
|
|
585
|
+
onError: next.onError ?? phases.onError,
|
|
586
|
+
};
|
|
587
|
+
assertInvariants(out);
|
|
588
|
+
return out;
|
|
639
589
|
};
|
|
640
|
-
/**
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
590
|
+
/** Zod enforcement per spec. */
|
|
591
|
+
const enforceZod = (phases, hasSchemas, fnName) => {
|
|
592
|
+
if (!hasSchemas)
|
|
593
|
+
return;
|
|
594
|
+
const hasBefore = phases.before.some((m) => getId(m) === 'zod-before');
|
|
595
|
+
const hasAfter = phases.after.some((m) => getId(m) === 'zod-after');
|
|
596
|
+
if (!hasBefore || !hasAfter) {
|
|
597
|
+
throw new Error(`Zod validation is required (schemas provided) but is missing after stack customization on function '${fnName}'. ` +
|
|
598
|
+
`Include the standard httpZodValidator or tag your custom validator steps as 'zod-before' and 'zod-after'.`);
|
|
599
|
+
}
|
|
646
600
|
};
|
|
647
|
-
/**
|
|
648
|
-
const
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
601
|
+
/** Compute final stack given app-level and function-level customization. */
|
|
602
|
+
const computeHttpMiddleware = (args) => {
|
|
603
|
+
const { functionName, eventSchema, responseSchema, logger: maybeLogger, contentType: maybeContentType, app, fn, } = args;
|
|
604
|
+
const baseContentType = (maybeContentType ?? 'application/json').toLowerCase();
|
|
605
|
+
let effective = {
|
|
606
|
+
contentType: baseContentType,
|
|
607
|
+
logger: maybeLogger ?? console,
|
|
608
|
+
};
|
|
609
|
+
// Layer A: app.defaults.options
|
|
610
|
+
effective = mergeOptions(effective, app?.defaults);
|
|
611
|
+
// Apply profile (options)
|
|
612
|
+
const profile = fn?.profile ? app?.profiles?.[fn.profile] : undefined;
|
|
613
|
+
effective = mergeOptions(effective, profile);
|
|
614
|
+
// Function-level options
|
|
615
|
+
effective = mergeOptions(effective, fn?.options);
|
|
616
|
+
const contentType = (effective.contentType ?? baseContentType).toLowerCase();
|
|
617
|
+
const logger = effective.logger ?? console;
|
|
618
|
+
// Build default phases with resolved options
|
|
619
|
+
let phases = buildDefaultPhases({
|
|
620
|
+
contentType,
|
|
621
|
+
logger,
|
|
622
|
+
opts: effective,
|
|
623
|
+
eventSchema,
|
|
624
|
+
responseSchema,
|
|
625
|
+
});
|
|
626
|
+
// Layer B: extend (app.defaults → profile → function)
|
|
627
|
+
applyExtend(phases, app?.defaults?.extend);
|
|
628
|
+
applyExtend(phases, profile?.extend);
|
|
629
|
+
applyExtend(phases, fn?.extend);
|
|
630
|
+
// Layer C: transform (app.defaults → profile → function)
|
|
631
|
+
phases = applyTransform(phases, app?.defaults?.transform);
|
|
632
|
+
phases = applyTransform(phases, profile?.transform);
|
|
633
|
+
phases = applyTransform(phases, fn?.transform);
|
|
634
|
+
// Invariants (pre-replace)
|
|
635
|
+
assertInvariants(phases);
|
|
636
|
+
// Zod enforcement (pre-replace)
|
|
637
|
+
enforceZod(phases, !!(eventSchema || responseSchema), functionName);
|
|
638
|
+
// Layer D: replace (full override)
|
|
639
|
+
if (fn?.replace?.stack) {
|
|
640
|
+
const rep = fn.replace.stack;
|
|
641
|
+
if (typeof rep === 'object' &&
|
|
642
|
+
'before' in rep) {
|
|
643
|
+
const p = rep;
|
|
644
|
+
const final = {
|
|
645
|
+
before: p.before ?? [],
|
|
646
|
+
after: p.after ?? [],
|
|
647
|
+
onError: p.onError ?? [],
|
|
648
|
+
};
|
|
649
|
+
assertInvariants(final);
|
|
650
|
+
enforceZod(final, !!(eventSchema || responseSchema), functionName);
|
|
651
|
+
return combine(...final.before, ...final.after, ...final.onError);
|
|
652
|
+
}
|
|
653
|
+
// Single middleware replacement: cannot validate presence of zod steps.
|
|
654
|
+
if (eventSchema || responseSchema) {
|
|
655
|
+
throw new Error(`Full replace provided as a single middleware object on function '${functionName}', but schemas are present. ` +
|
|
656
|
+
`To satisfy Zod enforcement, provide phased arrays including steps tagged as 'zod-before' and 'zod-after'.`);
|
|
657
|
+
}
|
|
658
|
+
return rep;
|
|
659
|
+
}
|
|
660
|
+
// Compose final combined stack
|
|
661
|
+
return combine(...phases.before, ...phases.after, ...phases.onError);
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
/** Union all env keys we plan to expose into a single set. */
|
|
665
|
+
/**
|
|
666
|
+
* Derive the union of env keys we plan to expose.
|
|
667
|
+
*
|
|
668
|
+
* @param globalEnv - keys exposed from global params schema
|
|
669
|
+
* @param stageEnv - keys exposed from stage params schema
|
|
670
|
+
* @param fnEnv - optional per‑function extra keys
|
|
671
|
+
*/
|
|
672
|
+
const deriveAllKeys = (globalEnv, stageEnv, fnEnv = []) => {
|
|
673
|
+
const out = new Set();
|
|
674
|
+
globalEnv.forEach((k) => out.add(k));
|
|
675
|
+
stageEnv.forEach((k) => out.add(k));
|
|
676
|
+
fnEnv.forEach((k) => out.add(k));
|
|
654
677
|
return out;
|
|
655
678
|
};
|
|
656
|
-
/**
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
679
|
+
/** Split the unioned keys by which Zod schema defines them. */
|
|
680
|
+
/**
|
|
681
|
+
* Partition a union of env keys by which schema defines each key.
|
|
682
|
+
*
|
|
683
|
+
* @typeParam G - global params Zod object schema
|
|
684
|
+
* @typeParam S - stage params Zod object schema
|
|
685
|
+
* @param allKeys - union set from {@link deriveAllKeys}
|
|
686
|
+
* @param globalParamsSchema - global schema
|
|
687
|
+
* @param stageParamsSchema - stage schema
|
|
688
|
+
* @returns lists of keys to pick from each schema
|
|
689
|
+
*/
|
|
690
|
+
const splitKeysBySchema = (allKeys, globalParamsSchema, stageParamsSchema) => {
|
|
691
|
+
const gKeySet = new Set(Object.keys(globalParamsSchema.shape));
|
|
692
|
+
const sKeySet = new Set(Object.keys(stageParamsSchema.shape));
|
|
693
|
+
const globalPick = [...allKeys].filter((k) => gKeySet.has(String(k)));
|
|
694
|
+
const stagePick = [...allKeys].filter((k) => {
|
|
695
|
+
const key = String(k);
|
|
696
|
+
return sKeySet.has(key) && !gKeySet.has(key);
|
|
697
|
+
});
|
|
698
|
+
return { globalPick, stagePick };
|
|
699
|
+
};
|
|
700
|
+
/** Build a Zod schema that picks only the requested keys from both schemas. */
|
|
701
|
+
/**
|
|
702
|
+
* Build a composed Zod schema to parse process.env, selecting keys from global+stage schemas.
|
|
703
|
+
*
|
|
704
|
+
* @typeParam G - global params Zod schema
|
|
705
|
+
* @typeParam S - stage params Zod schema
|
|
706
|
+
* @param globalPick - keys to select from global schema
|
|
707
|
+
* @param stagePick - keys to select from stage schema
|
|
708
|
+
* @param globalParamsSchema - global params schema
|
|
709
|
+
* @param stageParamsSchema - stage params schema
|
|
710
|
+
* @returns Zod object schema accepting the union of selected keys
|
|
711
|
+
*/
|
|
712
|
+
const buildEnvSchema = (globalPick, stagePick, globalParamsSchema, stageParamsSchema) => {
|
|
713
|
+
const toPick = (keys) => Object.fromEntries(keys.map((k) => [k, true]));
|
|
714
|
+
const gPicked = globalParamsSchema.pick(toPick(globalPick));
|
|
715
|
+
const sPicked = stageParamsSchema.pick(toPick(stagePick));
|
|
716
|
+
return z.object({}).extend(gPicked.shape).extend(sPicked.shape);
|
|
717
|
+
};
|
|
718
|
+
/** Parse an arbitrary source (e.g., process.env) with a provided env schema. */
|
|
719
|
+
/**
|
|
720
|
+
* Parse an arbitrary env source (e.g., process.env) with a provided schema.
|
|
721
|
+
*
|
|
722
|
+
* @param envSchema - Zod schema from {@link buildEnvSchema}
|
|
723
|
+
* @param envSource - map of environment variables
|
|
724
|
+
*/
|
|
725
|
+
const parseTypedEnv = (envSchema, envSource) => envSchema.parse(envSource);
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Wrap a business handler with SMOZ runtime. *
|
|
729
|
+
* - HTTP event tokens receive the full Middy pipeline (validation, shaping, CORS, etc.)
|
|
730
|
+
* - Non‑HTTP tokens bypass Middy and call the business function directly.
|
|
731
|
+
*
|
|
732
|
+
* @typeParam GlobalParamsSchema - global params schema type
|
|
733
|
+
* @typeParam StageParamsSchema - stage params schema type
|
|
734
|
+
* @typeParam EventTypeMap - event token → runtime type map
|
|
735
|
+
* @typeParam EventType - a key of EventTypeMap
|
|
736
|
+
* @typeParam EventSchema - optional Zod schema for event (validated before handler)
|
|
737
|
+
* @typeParam ResponseSchema - optional Zod schema for response (validated after handler)
|
|
738
|
+
* @param functionConfig - per‑function configuration (branded with env nodes)
|
|
739
|
+
* @param business - the business handler implementation
|
|
740
|
+
* @param opts - optional runtime overrides (e.g., widen HTTP tokens)
|
|
741
|
+
* @returns a Lambda‑compatible handler function
|
|
742
|
+
*/
|
|
743
|
+
function wrapHandler(functionConfig, business, opts) {
|
|
744
|
+
const assertKeysSubset = (schema, keys, label) => {
|
|
745
|
+
const allowed = new Set(Object.keys(schema.shape));
|
|
746
|
+
const bad = keys.filter((k) => !allowed.has(k));
|
|
747
|
+
if (bad.length)
|
|
748
|
+
throw new Error(`${label} contains unknown keys: ${bad.join(', ')}`);
|
|
749
|
+
};
|
|
750
|
+
const envConfig = getEnvFromFunctionConfig(functionConfig);
|
|
751
|
+
assertKeysSubset(envConfig.global.paramsSchema, envConfig.global.envKeys, 'global.envKeys');
|
|
752
|
+
assertKeysSubset(envConfig.stage.paramsSchema, envConfig.stage.envKeys, 'stage.envKeys');
|
|
753
|
+
return async (event, context) => {
|
|
754
|
+
// Compose typed env schema and parse process.env
|
|
755
|
+
const all = deriveAllKeys(envConfig.global.envKeys, envConfig.stage.envKeys, (functionConfig.fnEnvKeys ?? []));
|
|
756
|
+
const { globalPick, stagePick } = splitKeysBySchema(all, envConfig.global.paramsSchema, envConfig.stage.paramsSchema);
|
|
757
|
+
const envSchema = buildEnvSchema(globalPick, stagePick, envConfig.global.paramsSchema, envConfig.stage.paramsSchema);
|
|
758
|
+
const env = parseTypedEnv(envSchema, process.env);
|
|
759
|
+
const logger = console;
|
|
760
|
+
// Non-HTTP: call business directly
|
|
761
|
+
const httpTokens = opts?.httpEventTypeTokens ??
|
|
762
|
+
defaultHttpEventTypeTokens;
|
|
763
|
+
const isHttp = httpTokens.includes(functionConfig.eventType);
|
|
764
|
+
if (!isHttp) {
|
|
765
|
+
return business(event, context, { env, logger });
|
|
766
|
+
}
|
|
767
|
+
// HTTP: build middleware stack
|
|
768
|
+
const fnHttp = functionConfig.http;
|
|
769
|
+
const maybeContentType = functionConfig
|
|
770
|
+
.contentType;
|
|
771
|
+
const args = {
|
|
772
|
+
functionName: functionConfig.functionName,
|
|
773
|
+
logger,
|
|
774
|
+
...(functionConfig.eventSchema
|
|
775
|
+
? { eventSchema: functionConfig.eventSchema }
|
|
776
|
+
: {}),
|
|
777
|
+
...(functionConfig.responseSchema
|
|
778
|
+
? { responseSchema: functionConfig.responseSchema }
|
|
779
|
+
: {}),
|
|
780
|
+
...(typeof maybeContentType === 'string' && maybeContentType.length > 0
|
|
781
|
+
? { contentType: maybeContentType }
|
|
782
|
+
: {}),
|
|
783
|
+
...(opts?.httpConfig ? { app: opts.httpConfig } : {}),
|
|
784
|
+
...(fnHttp ? { fn: fnHttp } : {}),
|
|
785
|
+
};
|
|
786
|
+
const http = computeHttpMiddleware(args);
|
|
787
|
+
const wrapped = middy(async (e, c) => business(e, c, {
|
|
788
|
+
env,
|
|
789
|
+
logger,
|
|
790
|
+
})).use(http);
|
|
791
|
+
return wrapped(event, context);
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const handlerFactory = (httpEventTypeTokens, httpConfig) => {
|
|
796
|
+
return (functionConfig, business) => wrapHandler(functionConfig, business, {
|
|
797
|
+
httpEventTypeTokens,
|
|
798
|
+
httpConfig,
|
|
799
|
+
});
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
const createRegistry = (deps) => {
|
|
803
|
+
const map = new Map();
|
|
804
|
+
return {
|
|
805
|
+
defineFunction(options) {
|
|
806
|
+
const key = options.functionName;
|
|
807
|
+
if (map.has(key)) {
|
|
808
|
+
const other = map.get(key);
|
|
809
|
+
throw new Error(`Duplicate functionName "${key}". Existing: ${other.callerModuleUrl}. New: ${options.callerModuleUrl}. Provide a unique functionName.`);
|
|
810
|
+
}
|
|
811
|
+
// Merge default fnEnvKeys with per-function keys (unique, preserve order).
|
|
812
|
+
const mergedFnEnvKeys = (() => {
|
|
813
|
+
const out = new Set();
|
|
814
|
+
(deps.functionDefaults?.fnEnvKeys ?? []).forEach((k) => out.add(k));
|
|
815
|
+
(options.fnEnvKeys ?? []).forEach((k) => out.add(k));
|
|
816
|
+
return Array.from(out);
|
|
817
|
+
})();
|
|
818
|
+
const brandedConfig = {
|
|
819
|
+
functionName: options.functionName,
|
|
820
|
+
eventType: options.eventType,
|
|
821
|
+
...(options.method ? { method: options.method } : {}),
|
|
822
|
+
...(options.basePath ? { basePath: options.basePath } : {}),
|
|
823
|
+
...(options.httpContexts ? { httpContexts: options.httpContexts } : {}),
|
|
824
|
+
...(options.contentType ? { contentType: options.contentType } : {}),
|
|
825
|
+
...(mergedFnEnvKeys.length
|
|
826
|
+
? { fnEnvKeys: mergedFnEnvKeys }
|
|
827
|
+
: {}),
|
|
828
|
+
...(options.eventSchema ? { eventSchema: options.eventSchema } : {}),
|
|
829
|
+
...(options.responseSchema
|
|
830
|
+
? { responseSchema: options.responseSchema }
|
|
831
|
+
: {}),
|
|
832
|
+
[ENV_CONFIG]: {
|
|
833
|
+
global: deps.env.global,
|
|
834
|
+
stage: deps.env.stage,
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
map.set(key, {
|
|
838
|
+
functionName: options.functionName,
|
|
839
|
+
eventType: options.eventType,
|
|
840
|
+
...(options.method ? { method: options.method } : {}),
|
|
841
|
+
...(options.basePath ? { basePath: options.basePath } : {}),
|
|
842
|
+
...(options.httpContexts ? { httpContexts: options.httpContexts } : {}),
|
|
843
|
+
...(options.contentType ? { contentType: options.contentType } : {}),
|
|
844
|
+
...(mergedFnEnvKeys.length
|
|
845
|
+
? { fnEnvKeys: mergedFnEnvKeys }
|
|
846
|
+
: {}),
|
|
847
|
+
...(options.eventSchema ? { eventSchema: options.eventSchema } : {}),
|
|
848
|
+
...(options.responseSchema
|
|
849
|
+
? { responseSchema: options.responseSchema }
|
|
850
|
+
: {}),
|
|
851
|
+
callerModuleUrl: options.callerModuleUrl,
|
|
852
|
+
endpointsRootAbs: options.endpointsRootAbs,
|
|
853
|
+
brandedConfig,
|
|
854
|
+
});
|
|
855
|
+
return {
|
|
856
|
+
handler: (business) => {
|
|
857
|
+
const fnConfig = brandedConfig;
|
|
858
|
+
const make = handlerFactory(deps.httpEventTypeTokens, deps.http);
|
|
859
|
+
return make(fnConfig, business);
|
|
860
|
+
},
|
|
861
|
+
openapi: (baseOperation) => {
|
|
862
|
+
const r = map.get(key);
|
|
863
|
+
r.openapiBaseOperation = baseOperation;
|
|
864
|
+
},
|
|
865
|
+
serverless: (extras) => {
|
|
866
|
+
const r = map.get(key);
|
|
867
|
+
r.serverlessExtras = extras;
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
},
|
|
871
|
+
values() {
|
|
872
|
+
return map.values();
|
|
873
|
+
},
|
|
874
|
+
};
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Zod schema for implementation-wide Serverless config.
|
|
879
|
+
*
|
|
880
|
+
* Extracted to keep App.ts slim and focused on orchestration.
|
|
881
|
+
*/
|
|
882
|
+
const serverlessConfigSchema = z.object({
|
|
883
|
+
/** Context -> event fragment to merge into generated http events */
|
|
884
|
+
httpContextEventMap: z.custom(),
|
|
885
|
+
/** Used to construct default handler string if missing on a function */
|
|
886
|
+
defaultHandlerFileName: z.string().min(1),
|
|
887
|
+
defaultHandlerFileExport: z.string().min(1),
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
const splitPath = (basePath) => basePath.split('/').filter(Boolean);
|
|
891
|
+
/** Prefix non-public contexts and return path elements. */
|
|
892
|
+
/**
|
|
893
|
+
* Build OpenAPI/serverless path elements from a base path and context.
|
|
894
|
+
*
|
|
895
|
+
* @param basePath - base path (e.g., 'users/:id')
|
|
896
|
+
* @param context - 'my' | 'private' | 'public' (public is unprefixed)
|
|
897
|
+
* @returns array of path segments without leading/trailing slashes
|
|
898
|
+
*/
|
|
899
|
+
const buildPathElements = (basePath, context) => {
|
|
900
|
+
const parts = splitPath(sanitizeBasePath(basePath));
|
|
901
|
+
return context === 'public' ? parts : [context, ...parts];
|
|
902
|
+
};
|
|
903
|
+
const sanitizeBasePath = (p) => p.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
904
|
+
|
|
905
|
+
const HTTP_METHODS = new Set([
|
|
906
|
+
'get',
|
|
907
|
+
'put',
|
|
908
|
+
'post',
|
|
909
|
+
'delete',
|
|
910
|
+
'options',
|
|
911
|
+
'head',
|
|
912
|
+
'patch',
|
|
913
|
+
'trace',
|
|
914
|
+
]);
|
|
915
|
+
/**
|
|
916
|
+
* Resolve `(method, basePath, contexts)` for an HTTP function definition.
|
|
917
|
+
*
|
|
918
|
+
* @typeParam EventSchema - optional event Zod schema
|
|
919
|
+
* @typeParam ResponseSchema - optional response Zod schema
|
|
920
|
+
* @typeParam GlobalParams - app global params type
|
|
921
|
+
* @typeParam StageParams - app stage params type
|
|
922
|
+
* @typeParam EventTypeMap - event type token map
|
|
923
|
+
* @typeParam EventType - selected event token
|
|
924
|
+
* @param functionConfig - the per‑function config (may omit method/basePath)
|
|
925
|
+
* @param callerModuleUrl - import.meta.url of the registering module
|
|
926
|
+
* @param endpointsRootAbs - absolute root of endpoints
|
|
927
|
+
* @throws Error if method/basePath cannot be inferred under endpoints root
|
|
928
|
+
* @returns HTTP method key, basePath, and unique contexts list
|
|
929
|
+
*/
|
|
930
|
+
const resolveHttpFromFunctionConfig = (functionConfig, callerModuleUrl, endpointsRootAbs) => {
|
|
931
|
+
const { method: maybeMethod, basePath: maybeBase, httpContexts, } = functionConfig;
|
|
932
|
+
// Compute relative path from endpoints root to the caller directory
|
|
933
|
+
const relPath = relative(endpointsRootAbs, dirname(fileURLToPath(callerModuleUrl)))
|
|
934
|
+
.split(sep)
|
|
935
|
+
.join('/');
|
|
936
|
+
const underEndpointsRoot = !relPath.startsWith('..');
|
|
937
|
+
let method;
|
|
938
|
+
if (maybeMethod && HTTP_METHODS.has(maybeMethod)) {
|
|
939
|
+
method = maybeMethod;
|
|
940
|
+
}
|
|
941
|
+
else {
|
|
942
|
+
if (!underEndpointsRoot) {
|
|
943
|
+
throw new Error('resolveHttpFromFunctionConfig: method missing and caller is not under endpoints root; provide method explicitly.');
|
|
944
|
+
}
|
|
945
|
+
const segs = relPath.split('/').filter(Boolean);
|
|
946
|
+
const tail = segs[segs.length - 1]?.toLowerCase();
|
|
947
|
+
if (HTTP_METHODS.has(tail))
|
|
948
|
+
method = tail;
|
|
949
|
+
else
|
|
950
|
+
throw new Error('resolveHttpFromFunctionConfig: cannot infer method from folder; provide method explicitly.');
|
|
951
|
+
}
|
|
952
|
+
let basePath = sanitizeBasePath(maybeBase ?? '');
|
|
953
|
+
if (!basePath) {
|
|
954
|
+
if (!underEndpointsRoot) {
|
|
955
|
+
throw new Error('resolveHttpFromFunctionConfig: basePath missing and caller is not under endpoints root; provide basePath explicitly.');
|
|
956
|
+
}
|
|
957
|
+
const segs = relPath.split('/').filter(Boolean);
|
|
958
|
+
if (segs.length && segs[segs.length - 1]?.toLowerCase() === method)
|
|
959
|
+
segs.pop();
|
|
960
|
+
basePath = segs.join('/');
|
|
961
|
+
}
|
|
962
|
+
if (!basePath) {
|
|
963
|
+
throw new Error('resolveHttpFromFunctionConfig: derived basePath is empty; ensure file is under endpoints root or set config.basePath.');
|
|
964
|
+
}
|
|
965
|
+
const contexts = unique(httpContexts ?? []);
|
|
966
|
+
return { method, basePath, contexts };
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
const buildAllOpenApiPaths = (registry) => {
|
|
970
|
+
const paths = {};
|
|
971
|
+
for (const r of registry) {
|
|
972
|
+
if (!r.openapiBaseOperation)
|
|
973
|
+
continue;
|
|
974
|
+
const { method, basePath, contexts } = resolveHttpFromFunctionConfig({
|
|
975
|
+
functionName: r.functionName,
|
|
976
|
+
eventType: r.eventType,
|
|
977
|
+
...(r.method ? { method: r.method } : {}),
|
|
978
|
+
...(r.basePath ? { basePath: r.basePath } : {}),
|
|
979
|
+
...(r.httpContexts ? { httpContexts: r.httpContexts } : {}),
|
|
980
|
+
}, r.callerModuleUrl, r.endpointsRootAbs);
|
|
981
|
+
const ctxs = contexts.length > 0 ? contexts : ['public'];
|
|
982
|
+
for (const context of ctxs) {
|
|
983
|
+
const elems = buildPathElements(basePath, context);
|
|
984
|
+
const pathKey = `/${elems.join('/')}`;
|
|
985
|
+
const op = {
|
|
986
|
+
...r.openapiBaseOperation,
|
|
987
|
+
operationId: [...elems, method].join('_'),
|
|
988
|
+
summary: `${r.openapiBaseOperation.summary} (${context})`,
|
|
989
|
+
tags: Array.from(new Set([...(r.openapiBaseOperation.tags ?? []), context])),
|
|
990
|
+
};
|
|
991
|
+
const existing = pathKey in paths ? paths[pathKey] : {};
|
|
992
|
+
paths[pathKey] = { ...existing, [method]: op };
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return paths;
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Registry → Serverless functions aggregator.
|
|
1000
|
+
*
|
|
1001
|
+
* @returns NonNullable<AWS['functions']> with HTTP/non‑HTTP events attached.
|
|
1002
|
+
*/
|
|
1003
|
+
const buildAllServerlessFunctions = (registry, serverless, buildFnEnv) => {
|
|
1004
|
+
const out = {};
|
|
1005
|
+
const repoRoot = packageDirectorySync();
|
|
1006
|
+
for (const r of registry) {
|
|
1007
|
+
const callerDir = dirname(fileURLToPath(r.callerModuleUrl));
|
|
1008
|
+
const handlerFileAbs = join(callerDir, serverless.defaultHandlerFileName);
|
|
1009
|
+
const handlerFileRel = relative(repoRoot, handlerFileAbs)
|
|
1010
|
+
.split(sep)
|
|
1011
|
+
.join('/');
|
|
1012
|
+
const handler = `${handlerFileRel}.${serverless.defaultHandlerFileExport}`;
|
|
1013
|
+
let events = [];
|
|
1014
|
+
try {
|
|
1015
|
+
const { method, basePath, contexts } = resolveHttpFromFunctionConfig({
|
|
1016
|
+
functionName: r.functionName,
|
|
1017
|
+
eventType: r.eventType,
|
|
1018
|
+
...(r.method ? { method: r.method } : {}),
|
|
1019
|
+
...(r.basePath ? { basePath: r.basePath } : {}),
|
|
1020
|
+
...(r.httpContexts ? { httpContexts: r.httpContexts } : {}),
|
|
1021
|
+
}, r.callerModuleUrl, r.endpointsRootAbs);
|
|
1022
|
+
const path = `/${basePath.replace(/^\/+/, '')}`;
|
|
1023
|
+
const ctxs = contexts.length > 0 ? contexts : ['public'];
|
|
1024
|
+
events = ctxs.map(() => ({
|
|
1025
|
+
http: { method, path },
|
|
1026
|
+
}));
|
|
1027
|
+
}
|
|
1028
|
+
catch {
|
|
1029
|
+
events = r.serverlessExtras ?? [];
|
|
1030
|
+
}
|
|
1031
|
+
const def = {
|
|
1032
|
+
handler,
|
|
1033
|
+
events,
|
|
1034
|
+
environment: buildFnEnv((r.fnEnvKeys ?? [])),
|
|
1035
|
+
};
|
|
1036
|
+
out[r.functionName] = def;
|
|
1037
|
+
}
|
|
1038
|
+
return out;
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* App (schema‑first)
|
|
1043
|
+
*
|
|
1044
|
+
* Central orchestrator for a SMOZ application. You provide:
|
|
1045
|
+
* - Global/stage parameter schemas and env exposure keys
|
|
1046
|
+
* - Serverless defaults (handler filename/export and context map)
|
|
1047
|
+
* - Event‑type map schema (extendable: e.g., add 'step')
|
|
1048
|
+
*
|
|
1049
|
+
* The instance:
|
|
1050
|
+
* - Validates configuration
|
|
1051
|
+
* - Exposes env and stage artifacts for Serverless (provider.environment and params)
|
|
1052
|
+
* - Provides a registry to define functions (HTTP and non‑HTTP)
|
|
1053
|
+
* - Aggregates artifacts:
|
|
1054
|
+
* - buildAllServerlessFunctions(): AWS['functions']
|
|
1055
|
+
* - buildAllOpenApiPaths(): ZodOpenApiPathsObject
|
|
1056
|
+
*
|
|
1057
|
+
* @remarks See README for a full quick‑start, and typedoc for detailed API docs.
|
|
1058
|
+
*/
|
|
1059
|
+
/**
|
|
1060
|
+
* Application class. *
|
|
1061
|
+
* @typeParam GlobalParamsSchema - Zod object schema for global parameters
|
|
1062
|
+
* @typeParam StageParamsSchema - Zod object schema for per‑stage parameters
|
|
1063
|
+
* @typeParam EventTypeMapSchema - Zod object schema mapping event tokens to runtime types
|
|
1064
|
+
*/ class App {
|
|
1065
|
+
/** Helper alias for stage artifacts type */
|
|
1066
|
+
static _stageArtifactsType = null;
|
|
1067
|
+
appRootAbs;
|
|
1068
|
+
// Schemas
|
|
1069
|
+
globalParamsSchema;
|
|
1070
|
+
stageParamsSchema;
|
|
1071
|
+
eventTypeMapSchema; // Serverless config
|
|
1072
|
+
serverless;
|
|
1073
|
+
// Env exposure
|
|
1074
|
+
global;
|
|
1075
|
+
stage;
|
|
1076
|
+
// Derived stage artifacts
|
|
1077
|
+
stages;
|
|
1078
|
+
environment;
|
|
1079
|
+
buildFnEnv;
|
|
1080
|
+
http;
|
|
1081
|
+
// HTTP tokens for runtime decision
|
|
1082
|
+
httpEventTypeTokens;
|
|
1083
|
+
// Registry (delegated to src/app/registry)
|
|
1084
|
+
registry;
|
|
1085
|
+
constructor(init) {
|
|
1086
|
+
this.appRootAbs = init.appRootAbs.replace(/\\/g, '/');
|
|
1087
|
+
this.globalParamsSchema = init.globalParamsSchema;
|
|
1088
|
+
this.stageParamsSchema = init.stageParamsSchema;
|
|
1089
|
+
// Default to base schema when omitted (apply default INSIDE the function)
|
|
1090
|
+
this.eventTypeMapSchema = (init.eventTypeMapSchema ??
|
|
1091
|
+
baseEventTypeMapSchema);
|
|
1092
|
+
// Parse serverless input internally
|
|
1093
|
+
this.serverless = serverlessConfigSchema.parse(init.serverless);
|
|
1094
|
+
// Validate that eventTypeMapSchema includes base keys at runtime
|
|
1095
|
+
validateEventTypeMapSchemaIncludesBase(this.eventTypeMapSchema.shape);
|
|
1096
|
+
// Env exposure nodes
|
|
1097
|
+
this.global = {
|
|
1098
|
+
paramsSchema: this.globalParamsSchema,
|
|
1099
|
+
envKeys: init.global.envKeys,
|
|
1100
|
+
};
|
|
1101
|
+
this.stage = {
|
|
1102
|
+
paramsSchema: this.stageParamsSchema,
|
|
1103
|
+
envKeys: init.stage.envKeys,
|
|
1104
|
+
};
|
|
1105
|
+
// Build stages/environment/fn-env via helper (applies “stage extends global” and parsing)
|
|
1106
|
+
const { stages, environment, buildFnEnv } = buildStageArtifacts(this.globalParamsSchema, this.stageParamsSchema, { params: init.global.params, envKeys: init.global.envKeys }, { params: init.stage.params, envKeys: init.stage.envKeys });
|
|
1107
|
+
this.stages = stages;
|
|
1108
|
+
this.environment = environment;
|
|
1109
|
+
this.buildFnEnv = buildFnEnv;
|
|
1110
|
+
// HTTP tokens (runtime decision)
|
|
1111
|
+
this.httpEventTypeTokens = (init.httpEventTypeTokens ??
|
|
1112
|
+
defaultHttpEventTypeTokens);
|
|
1113
|
+
// App-level HTTP customization
|
|
1114
|
+
this.http = init.http ?? {};
|
|
1115
|
+
// Initialize function registry
|
|
1116
|
+
this.registry = createRegistry({
|
|
1117
|
+
httpEventTypeTokens: this.httpEventTypeTokens,
|
|
1118
|
+
env: { global: this.global, stage: this.stage },
|
|
1119
|
+
http: this.http,
|
|
1120
|
+
// With exactOptionalPropertyTypes, do not pass an explicit undefined.
|
|
1121
|
+
...(init.functionDefaults
|
|
1122
|
+
? { functionDefaults: init.functionDefaults }
|
|
1123
|
+
: {}),
|
|
1124
|
+
});
|
|
1125
|
+
} /**
|
|
1126
|
+
* Ergonomic constructor for schema‑first inference.
|
|
1127
|
+
*
|
|
1128
|
+
* @param init - initialization object (schemas, serverless defaults, params/envKeys)
|
|
1129
|
+
* @returns a new App instance
|
|
1130
|
+
*/
|
|
1131
|
+
static create(init) {
|
|
1132
|
+
return new App(init);
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Register a function (HTTP or non‑HTTP).
|
|
1136
|
+
*
|
|
1137
|
+
* @typeParam EventType - A key from your eventTypeMapSchema (e.g., 'rest' | 'http' | 'sqs' | 'step')
|
|
1138
|
+
* @typeParam EventSchema - Optional Zod schema validated BEFORE the handler (refines event shape)
|
|
1139
|
+
* @typeParam ResponseSchema - Optional Zod schema validated AFTER the handler (refines response shape)
|
|
1140
|
+
* @param options - per‑function configuration (method/basePath/httpContexts for HTTP; serverless extras for non‑HTTP)
|
|
1141
|
+
* @returns a per‑function API: { handler(business), openapi(baseOperation), serverless(extras) }
|
|
1142
|
+
*/
|
|
1143
|
+
defineFunction(options) {
|
|
1144
|
+
const functionName = options.functionName ??
|
|
1145
|
+
(() => {
|
|
1146
|
+
const callerDir = dirname(fileURLToPath(options.callerModuleUrl));
|
|
1147
|
+
const rel = relative(this.appRootAbs, callerDir).split(sep).join('/');
|
|
1148
|
+
const parts = rel.split('/').filter(Boolean);
|
|
1149
|
+
// Drop leading 'app' segment if present, per repo convention
|
|
1150
|
+
if (parts[0] === 'app')
|
|
1151
|
+
parts.shift();
|
|
1152
|
+
return parts.join('_'); // underscore formatting
|
|
1153
|
+
})();
|
|
1154
|
+
return this.registry.defineFunction({
|
|
1155
|
+
functionName,
|
|
1156
|
+
eventType: options.eventType,
|
|
1157
|
+
...(options.method ? { method: options.method } : {}),
|
|
1158
|
+
...(options.basePath ? { basePath: options.basePath } : {}),
|
|
1159
|
+
...(options.httpContexts ? { httpContexts: options.httpContexts } : {}),
|
|
1160
|
+
...(options.contentType ? { contentType: options.contentType } : {}),
|
|
1161
|
+
...(options.eventSchema ? { eventSchema: options.eventSchema } : {}),
|
|
1162
|
+
...(options.responseSchema
|
|
1163
|
+
? { responseSchema: options.responseSchema }
|
|
1164
|
+
: {}),
|
|
1165
|
+
...(options.fnEnvKeys ? { fnEnvKeys: options.fnEnvKeys } : {}),
|
|
1166
|
+
callerModuleUrl: options.callerModuleUrl,
|
|
1167
|
+
endpointsRootAbs: options.endpointsRootAbs,
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Aggregate Serverless function definitions across the registry.
|
|
1172
|
+
*
|
|
1173
|
+
* @returns An AWS['functions'] object suitable for serverless.ts
|
|
1174
|
+
*/
|
|
1175
|
+
buildAllServerlessFunctions() {
|
|
1176
|
+
return buildAllServerlessFunctions(this.registry.values(), this.serverless, this.buildFnEnv);
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Aggregate OpenAPI path items across the registry.
|
|
1180
|
+
*
|
|
1181
|
+
* @returns ZodOpenApiPathsObject to be embedded in a full OpenAPI document
|
|
1182
|
+
*/
|
|
1183
|
+
buildAllOpenApiPaths() {
|
|
1184
|
+
return buildAllOpenApiPaths(this.registry.values());
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Define a complete application configuration from schemas and authoring input.
|
|
1190
|
+
*
|
|
1191
|
+
* @typeParam GlobalParamsSchema - Global params schema
|
|
1192
|
+
* @typeParam StageParamsSchema - Per‑stage params schema
|
|
1193
|
+
* @param globalParamsSchema - schema for global params
|
|
1194
|
+
* @param stageParamsSchema - schema for stage params
|
|
1195
|
+
* @param input - serverless defaults and concrete params + env exposure
|
|
1196
|
+
* @returns Typed configuration nodes and stage artifacts
|
|
1197
|
+
* * @throws Error if envKeys include keys not present in their corresponding schema
|
|
1198
|
+
*/
|
|
1199
|
+
function defineAppConfig(globalParamsSchema, stageParamsSchema, input) {
|
|
1200
|
+
const assertKeysSubset = (schema, keys, label) => {
|
|
1201
|
+
const allowed = new Set(Object.keys(schema.shape));
|
|
1202
|
+
const bad = keys.filter((k) => !allowed.has(k));
|
|
1203
|
+
if (bad.length)
|
|
1204
|
+
throw new Error(`${label} contains unknown keys: ${bad.join(', ')}`);
|
|
1205
|
+
};
|
|
1206
|
+
assertKeysSubset(globalParamsSchema, input.global.envKeys, 'global.envKeys');
|
|
1207
|
+
assertKeysSubset(stageParamsSchema, input.stage.envKeys, 'stage.envKeys');
|
|
1208
|
+
const sf = stagesFactory({
|
|
1209
|
+
globalParamsSchema,
|
|
1210
|
+
stageParamsSchema,
|
|
1211
|
+
globalParams: input.global.params,
|
|
1212
|
+
globalEnvKeys: input.global.envKeys,
|
|
1213
|
+
stageEnvKeys: input.stage.envKeys,
|
|
1214
|
+
stages: input.stage.params,
|
|
1215
|
+
});
|
|
1216
|
+
return {
|
|
1217
|
+
serverless: input.serverless,
|
|
1218
|
+
global: { paramsSchema: globalParamsSchema, envKeys: input.global.envKeys },
|
|
1219
|
+
stage: { paramsSchema: stageParamsSchema, envKeys: input.stage.envKeys },
|
|
1220
|
+
stages: sf.stages,
|
|
1221
|
+
environment: sf.environment,
|
|
1222
|
+
buildFnEnv: sf.buildFnEnv,
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** Narrow to API Gateway v1 events with safe property checks. */
|
|
1227
|
+
/**
|
|
1228
|
+
* Type guard for API Gateway v1 events. *
|
|
1229
|
+
* @param evt - unknown event
|
|
1230
|
+
* @returns true if the event looks like a v1 APIGatewayProxyEvent
|
|
1231
|
+
*/
|
|
1232
|
+
const isV1 = (evt) => {
|
|
1233
|
+
if (typeof evt !== 'object' || evt === null)
|
|
1234
|
+
return false;
|
|
1235
|
+
return 'httpMethod' in evt && 'requestContext' in evt;
|
|
1236
|
+
};
|
|
1237
|
+
/** Narrow to API Gateway v2 events with safe property checks. */
|
|
1238
|
+
/**
|
|
1239
|
+
* Type guard for API Gateway v2 events.
|
|
1240
|
+
*/
|
|
1241
|
+
const isV2 = (evt) => {
|
|
1242
|
+
if (typeof evt !== 'object' || evt === null)
|
|
1243
|
+
return false;
|
|
1244
|
+
if (!('version' in evt) || !('requestContext' in evt))
|
|
1245
|
+
return false;
|
|
1246
|
+
const rc = evt.requestContext;
|
|
1247
|
+
return (!!rc && typeof rc === 'object' && 'http' in rc);
|
|
1248
|
+
};
|
|
1249
|
+
/** Case-insensitive single-value header lookup. */
|
|
1250
|
+
const getHeader = (headers, key) => {
|
|
1251
|
+
if (!headers)
|
|
1252
|
+
return undefined;
|
|
1253
|
+
const direct = headers[key];
|
|
1254
|
+
if (typeof direct === 'string')
|
|
1255
|
+
return direct;
|
|
1256
|
+
const found = Object.keys(headers).find((k) => k.toLowerCase() === key.toLowerCase());
|
|
1257
|
+
return found ? headers[found] : undefined;
|
|
1258
|
+
};
|
|
1259
|
+
/** Case-insensitive multi-value header lookup. */
|
|
1260
|
+
const getMultiHeader = (headers, key) => {
|
|
1261
|
+
if (!headers)
|
|
1262
|
+
return undefined;
|
|
1263
|
+
const direct = headers[key];
|
|
1264
|
+
if (Array.isArray(direct) && direct.length > 0)
|
|
1265
|
+
return direct[0];
|
|
1266
|
+
const found = Object.keys(headers).find((k) => k.toLowerCase() === key.toLowerCase());
|
|
1267
|
+
return found ? headers[found]?.[0] : undefined;
|
|
1268
|
+
};
|
|
1269
|
+
/** Get a header value from either single- or multi-value maps. */
|
|
1270
|
+
const getHeaderFromEvent = (evt, key) => {
|
|
1271
|
+
const single = getHeader(evt.headers, key);
|
|
1272
|
+
if (typeof single === 'string')
|
|
1273
|
+
return single;
|
|
1274
|
+
const multi = getMultiHeader(evt
|
|
1275
|
+
.multiValueHeaders, key);
|
|
1276
|
+
return multi;
|
|
1277
|
+
};
|
|
1278
|
+
const hasAwsSig = (auth) => typeof auth === 'string' && auth.startsWith('AWS4-HMAC-SHA256');
|
|
1279
|
+
const hasV1AccessKey = (evt) => {
|
|
1280
|
+
const rc = evt.requestContext;
|
|
1281
|
+
const ak = rc.identity?.accessKey;
|
|
1282
|
+
return typeof ak === 'string' && ak.length > 0;
|
|
1283
|
+
};
|
|
1284
|
+
const hasAuthorizer = (evt) => {
|
|
1285
|
+
// V1: any truthy authorizer object indicates an authenticated request
|
|
1286
|
+
if (isV1(evt)) {
|
|
1287
|
+
const rc = evt.requestContext;
|
|
1288
|
+
return !!rc.authorizer;
|
|
1289
|
+
}
|
|
1290
|
+
// V2: authorizer may include jwt/iam/lambda shapes
|
|
1291
|
+
const authHeader = getHeaderFromEvent(evt, 'authorization');
|
|
1292
|
+
if (hasAwsSig(authHeader))
|
|
1293
|
+
return true;
|
|
1294
|
+
const rc = evt
|
|
1295
|
+
.requestContext;
|
|
1296
|
+
const { authorizer } = rc;
|
|
1297
|
+
if (!authorizer || typeof authorizer !== 'object')
|
|
1298
|
+
return false;
|
|
1299
|
+
const a = authorizer;
|
|
1300
|
+
return !!(a.jwt || a.iam || a.lambda);
|
|
1301
|
+
};
|
|
1302
|
+
/** Detect API key via header or v1 identity.apiKey. */
|
|
1303
|
+
const hasApiKey = (evt) => {
|
|
1304
|
+
const fromHeader = getHeaderFromEvent(evt, 'x-api-key');
|
|
1305
|
+
if (typeof fromHeader === 'string')
|
|
1306
|
+
return true;
|
|
1307
|
+
// Some v1 events surface API key on requestContext.identity.apiKey
|
|
1308
|
+
if (isV1(evt)) {
|
|
1309
|
+
const rc = evt.requestContext;
|
|
1310
|
+
const identKey = rc.identity?.apiKey;
|
|
1311
|
+
if (typeof identKey === 'string' && identKey.length > 0)
|
|
1312
|
+
return true;
|
|
1313
|
+
const identKeyId = rc.identity?.apiKeyId;
|
|
1314
|
+
if (typeof identKeyId === 'string' && identKeyId.length > 0)
|
|
1315
|
+
return true;
|
|
1316
|
+
}
|
|
1317
|
+
return false;
|
|
1318
|
+
};
|
|
1319
|
+
/** Classify the security context from either API Gateway event version. */
|
|
1320
|
+
/**
|
|
1321
|
+
* Detect security context from API Gateway v1/v2 events.
|
|
1322
|
+
*
|
|
1323
|
+
* @param evt - unknown event
|
|
1324
|
+
* @returns 'my' when authorized (Cognito/JWT/IAM), 'private' when API key present, else 'public'
|
|
1325
|
+
*/
|
|
1326
|
+
const detectSecurityContext = (evt) => {
|
|
1327
|
+
if (isV1(evt)) {
|
|
1328
|
+
const auth = getHeaderFromEvent(evt, 'authorization');
|
|
1329
|
+
if (hasAwsSig(auth) || hasV1AccessKey(evt) || hasAuthorizer(evt))
|
|
1330
|
+
return 'my';
|
|
1331
|
+
if (hasApiKey(evt))
|
|
1332
|
+
return 'private';
|
|
1333
|
+
return 'public';
|
|
1334
|
+
}
|
|
1335
|
+
if (isV2(evt)) {
|
|
1336
|
+
const auth = getHeaderFromEvent(evt, 'authorization');
|
|
1337
|
+
if (hasAwsSig(auth) || hasAuthorizer(evt))
|
|
1338
|
+
return 'my';
|
|
1339
|
+
if (hasApiKey(evt))
|
|
1340
|
+
return 'private';
|
|
1341
|
+
return 'public';
|
|
1342
|
+
}
|
|
1343
|
+
return 'public';
|
|
662
1344
|
};
|
|
663
1345
|
|
|
664
1346
|
/**
|