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