@karmaniverous/smoz 0.1.2
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/LICENSE +28 -0
- package/README.md +66 -0
- package/dist/cjs/index.js +706 -0
- package/dist/cjs/serverless-plugin.js +43 -0
- package/dist/index.d.ts +422 -0
- package/dist/mjs/index.js +690 -0
- package/dist/mjs/serverless-plugin.js +41 -0
- package/dist/serverless-plugin.d.ts +6 -0
- package/package.json +190 -0
- package/templates/.check/eslint.minimal.config.ts +41 -0
- package/templates/.check/eslint.templates.config.ts +60 -0
- package/templates/.check/tsconfig.eslintconfig.json +6 -0
- package/templates/.check/tsconfig.minimal.json +14 -0
- package/templates/.manifests/package.minimal.json +19 -0
- package/templates/.manifests/package.project.json +23 -0
- package/templates/minimal/app/config/app.config.ts +50 -0
- package/templates/minimal/app/config/openapi.ts +45 -0
- package/templates/minimal/app/functions/rest/hello/get/handler.ts +18 -0
- package/templates/minimal/app/functions/rest/hello/get/lambda.ts +28 -0
- package/templates/minimal/app/functions/rest/hello/get/openapi.ts +23 -0
- package/templates/minimal/serverless.ts +34 -0
- package/templates/minimal/tsconfig.json +25 -0
- package/templates/minimal/types/registers.d.ts +11 -0
- package/templates/project/.prettierrc.json +3 -0
- package/templates/project/README.md +85 -0
- package/templates/project/eslint.config.ts +47 -0
- package/templates/project/tsconfig.base.json +21 -0
- package/templates/project/tsconfig.json +16 -0
- package/templates/project/typedoc.json +14 -0
- package/templates/project/vitest.config.ts +8 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var path = require('node:path');
|
|
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
|
+
var zod = require('zod');
|
|
13
|
+
var stagesFactory = require('@/src/serverless/stagesFactory');
|
|
14
|
+
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
|
+
var httpContentNegotiation = require('@middy/http-content-negotiation');
|
|
21
|
+
var httpCors = require('@middy/http-cors');
|
|
22
|
+
var httpErrorHandler = require('@middy/http-error-handler');
|
|
23
|
+
var httpEventNormalizer = require('@middy/http-event-normalizer');
|
|
24
|
+
var httpHeaderNormalizer = require('@middy/http-header-normalizer');
|
|
25
|
+
var httpJsonBodyParser = require('@middy/http-json-body-parser');
|
|
26
|
+
var httpResponseSerializer = require('@middy/http-response-serializer');
|
|
27
|
+
var asApiMiddleware = require('@/src/http/middleware/asApiMiddleware');
|
|
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
|
+
}
|
|
178
|
+
|
|
179
|
+
const baseEventTypeMapSchema = zod.z.object({
|
|
180
|
+
rest: zod.z.custom(),
|
|
181
|
+
http: zod.z.custom(),
|
|
182
|
+
alb: zod.z.custom(),
|
|
183
|
+
sqs: zod.z.custom(),
|
|
184
|
+
sns: zod.z.custom(),
|
|
185
|
+
s3: zod.z.custom(),
|
|
186
|
+
dynamodb: zod.z.custom(),
|
|
187
|
+
kinesis: zod.z.custom(),
|
|
188
|
+
eventbridge: zod.z.custom(),
|
|
189
|
+
'cloudwatch-logs': zod.z.custom(),
|
|
190
|
+
ses: zod.z.custom(),
|
|
191
|
+
cloudfront: zod.z.custom(),
|
|
192
|
+
firehose: zod.z.custom(),
|
|
193
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- Upstream AWS types mark this as deprecated; we retain the token for compatibility with existing apps.
|
|
194
|
+
'cognito-userpool': zod.z.custom(),
|
|
195
|
+
});
|
|
196
|
+
// Notes:
|
|
197
|
+
// - This list intentionally includes widely used, generic AWS events.
|
|
198
|
+
// - Apps can extend the schema with custom or specialized tokens:
|
|
199
|
+
// const EventMap = baseEventTypeMapSchema.extend({ step: z.custom<MyStepEvent>() })
|
|
200
|
+
// - Only tokens listed in your App's httpEventTypeTokens are treated as HTTP by the runtime.
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Define a complete application configuration from schemas and authoring input.
|
|
204
|
+
*
|
|
205
|
+
* @typeParam GlobalParamsSchema - Global params schema
|
|
206
|
+
* @typeParam StageParamsSchema - Per‑stage params schema
|
|
207
|
+
* @param globalParamsSchema - schema for global params
|
|
208
|
+
* @param stageParamsSchema - schema for stage params
|
|
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
|
|
212
|
+
*/
|
|
213
|
+
function defineAppConfig(globalParamsSchema, stageParamsSchema, input) {
|
|
214
|
+
const assertKeysSubset = (schema, keys, label) => {
|
|
215
|
+
const allowed = new Set(Object.keys(schema.shape));
|
|
216
|
+
const bad = keys.filter((k) => !allowed.has(k));
|
|
217
|
+
if (bad.length)
|
|
218
|
+
throw new Error(`${label} contains unknown keys: ${bad.join(', ')}`);
|
|
219
|
+
};
|
|
220
|
+
assertKeysSubset(globalParamsSchema, input.global.envKeys, 'global.envKeys');
|
|
221
|
+
assertKeysSubset(stageParamsSchema, input.stage.envKeys, 'stage.envKeys');
|
|
222
|
+
const sf = stagesFactory.stagesFactory({
|
|
223
|
+
globalParamsSchema,
|
|
224
|
+
stageParamsSchema,
|
|
225
|
+
globalParams: input.global.params,
|
|
226
|
+
globalEnvKeys: input.global.envKeys,
|
|
227
|
+
stageEnvKeys: input.stage.envKeys,
|
|
228
|
+
stages: input.stage.params,
|
|
229
|
+
});
|
|
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
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Narrow to API Gateway v1 events with safe property checks. */
|
|
241
|
+
/**
|
|
242
|
+
* Type guard for API Gateway v1 events. *
|
|
243
|
+
* @param evt - unknown event
|
|
244
|
+
* @returns true if the event looks like a v1 APIGatewayProxyEvent
|
|
245
|
+
*/
|
|
246
|
+
const isV1 = (evt) => {
|
|
247
|
+
if (typeof evt !== 'object' || evt === null)
|
|
248
|
+
return false;
|
|
249
|
+
return 'httpMethod' in evt && 'requestContext' in evt;
|
|
250
|
+
};
|
|
251
|
+
/** Narrow to API Gateway v2 events with safe property checks. */
|
|
252
|
+
/**
|
|
253
|
+
* Type guard for API Gateway v2 events.
|
|
254
|
+
*/
|
|
255
|
+
const isV2 = (evt) => {
|
|
256
|
+
if (typeof evt !== 'object' || evt === null)
|
|
257
|
+
return false;
|
|
258
|
+
if (!('version' in evt) || !('requestContext' in evt))
|
|
259
|
+
return false;
|
|
260
|
+
const rc = evt.requestContext;
|
|
261
|
+
return (!!rc && typeof rc === 'object' && 'http' in rc);
|
|
262
|
+
};
|
|
263
|
+
/** Case-insensitive single-value header lookup. */
|
|
264
|
+
const getHeader = (headers, key) => {
|
|
265
|
+
if (!headers)
|
|
266
|
+
return undefined;
|
|
267
|
+
const direct = headers[key];
|
|
268
|
+
if (typeof direct === 'string')
|
|
269
|
+
return direct;
|
|
270
|
+
const found = Object.keys(headers).find((k) => k.toLowerCase() === key.toLowerCase());
|
|
271
|
+
return found ? headers[found] : undefined;
|
|
272
|
+
};
|
|
273
|
+
/** Case-insensitive multi-value header lookup. */
|
|
274
|
+
const getMultiHeader = (headers, key) => {
|
|
275
|
+
if (!headers)
|
|
276
|
+
return undefined;
|
|
277
|
+
const direct = headers[key];
|
|
278
|
+
if (Array.isArray(direct) && direct.length > 0)
|
|
279
|
+
return direct[0];
|
|
280
|
+
const found = Object.keys(headers).find((k) => k.toLowerCase() === key.toLowerCase());
|
|
281
|
+
return found ? headers[found]?.[0] : undefined;
|
|
282
|
+
};
|
|
283
|
+
/** Get a header value from either single- or multi-value maps. */
|
|
284
|
+
const getHeaderFromEvent = (evt, key) => {
|
|
285
|
+
const single = getHeader(evt.headers, key);
|
|
286
|
+
if (typeof single === 'string')
|
|
287
|
+
return single;
|
|
288
|
+
const multi = getMultiHeader(evt
|
|
289
|
+
.multiValueHeaders, key);
|
|
290
|
+
return multi;
|
|
291
|
+
};
|
|
292
|
+
const hasAwsSig = (auth) => typeof auth === 'string' && auth.startsWith('AWS4-HMAC-SHA256');
|
|
293
|
+
const hasV1AccessKey = (evt) => {
|
|
294
|
+
const rc = evt.requestContext;
|
|
295
|
+
const ak = rc.identity?.accessKey;
|
|
296
|
+
return typeof ak === 'string' && ak.length > 0;
|
|
297
|
+
};
|
|
298
|
+
const hasAuthorizer = (evt) => {
|
|
299
|
+
// V1: any truthy authorizer object indicates an authenticated request
|
|
300
|
+
if (isV1(evt)) {
|
|
301
|
+
const rc = evt.requestContext;
|
|
302
|
+
return !!rc.authorizer;
|
|
303
|
+
}
|
|
304
|
+
// V2: authorizer may include jwt/iam/lambda shapes
|
|
305
|
+
const authHeader = getHeaderFromEvent(evt, 'authorization');
|
|
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);
|
|
315
|
+
};
|
|
316
|
+
/** Detect API key via header or v1 identity.apiKey. */
|
|
317
|
+
const hasApiKey = (evt) => {
|
|
318
|
+
const fromHeader = getHeaderFromEvent(evt, 'x-api-key');
|
|
319
|
+
if (typeof fromHeader === 'string')
|
|
320
|
+
return true;
|
|
321
|
+
// Some v1 events surface API key on requestContext.identity.apiKey
|
|
322
|
+
if (isV1(evt)) {
|
|
323
|
+
const rc = evt.requestContext;
|
|
324
|
+
const identKey = rc.identity?.apiKey;
|
|
325
|
+
if (typeof identKey === 'string' && identKey.length > 0)
|
|
326
|
+
return true;
|
|
327
|
+
const identKeyId = rc.identity?.apiKeyId;
|
|
328
|
+
if (typeof identKeyId === 'string' && identKeyId.length > 0)
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
return false;
|
|
332
|
+
};
|
|
333
|
+
/** Classify the security context from either API Gateway event version. */
|
|
334
|
+
/**
|
|
335
|
+
* Detect security context from API Gateway v1/v2 events.
|
|
336
|
+
*
|
|
337
|
+
* @param evt - unknown event
|
|
338
|
+
* @returns 'my' when authorized (Cognito/JWT/IAM), 'private' when API key present, else 'public'
|
|
339
|
+
*/
|
|
340
|
+
const detectSecurityContext = (evt) => {
|
|
341
|
+
if (isV1(evt)) {
|
|
342
|
+
const auth = getHeaderFromEvent(evt, 'authorization');
|
|
343
|
+
if (hasAwsSig(auth) || hasV1AccessKey(evt) || hasAuthorizer(evt))
|
|
344
|
+
return 'my';
|
|
345
|
+
if (hasApiKey(evt))
|
|
346
|
+
return 'private';
|
|
347
|
+
return 'public';
|
|
348
|
+
}
|
|
349
|
+
if (isV2(evt)) {
|
|
350
|
+
const auth = getHeaderFromEvent(evt, 'authorization');
|
|
351
|
+
if (hasAwsSig(auth) || hasAuthorizer(evt))
|
|
352
|
+
return 'my';
|
|
353
|
+
if (hasApiKey(evt))
|
|
354
|
+
return 'private';
|
|
355
|
+
return 'public';
|
|
356
|
+
}
|
|
357
|
+
return 'public';
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Wrap a business handler with SMOZ runtime. *
|
|
362
|
+
* - HTTP event tokens receive the full Middy pipeline (validation, shaping, CORS, etc.)
|
|
363
|
+
* - Non‑HTTP tokens bypass Middy and call the business function directly.
|
|
364
|
+
*
|
|
365
|
+
* @typeParam GlobalParamsSchema - global params schema type
|
|
366
|
+
* @typeParam StageParamsSchema - stage params schema type
|
|
367
|
+
* @typeParam EventTypeMap - event token → runtime type map
|
|
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
|
|
375
|
+
*/
|
|
376
|
+
function wrapHandler(functionConfig, business, opts) {
|
|
377
|
+
const assertKeysSubset = (schema, keys, label) => {
|
|
378
|
+
const allowed = new Set(Object.keys(schema.shape));
|
|
379
|
+
const bad = keys.filter((k) => !allowed.has(k));
|
|
380
|
+
if (bad.length)
|
|
381
|
+
throw new Error(`${label} contains unknown keys: ${bad.join(', ')}`);
|
|
382
|
+
};
|
|
383
|
+
const envConfig = defineFunctionConfig.getEnvFromFunctionConfig(functionConfig);
|
|
384
|
+
assertKeysSubset(envConfig.global.paramsSchema, envConfig.global.envKeys, 'global.envKeys');
|
|
385
|
+
assertKeysSubset(envConfig.stage.paramsSchema, envConfig.stage.envKeys, 'stage.envKeys');
|
|
386
|
+
return async (event, context) => {
|
|
387
|
+
// Compose typed env schema and parse process.env
|
|
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 });
|
|
399
|
+
}
|
|
400
|
+
// HTTP: build middleware stack
|
|
401
|
+
const fnHttp = functionConfig.http;
|
|
402
|
+
const maybeContentType = functionConfig
|
|
403
|
+
.contentType;
|
|
404
|
+
const args = {
|
|
405
|
+
functionName: functionConfig.functionName,
|
|
406
|
+
logger,
|
|
407
|
+
...(functionConfig.eventSchema
|
|
408
|
+
? { eventSchema: functionConfig.eventSchema }
|
|
409
|
+
: {}),
|
|
410
|
+
...(functionConfig.responseSchema
|
|
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
|
+
}
|
|
427
|
+
|
|
428
|
+
const makeHead = () => transformUtils.tagStep(shortCircuitHead.shortCircuitHead, 'head');
|
|
429
|
+
const makeHeaderNormalizer = (opts) => transformUtils.tagStep(asApiMiddleware.asApiMiddleware(httpHeaderNormalizer(opts?.headerNormalizer ?? { canonical: true })), 'header-normalizer');
|
|
430
|
+
const makeEventNormalizer = () => transformUtils.tagStep(asApiMiddleware.asApiMiddleware(httpEventNormalizer()), 'event-normalizer');
|
|
431
|
+
const makeContentNegotiation = (contentType, opts) => {
|
|
432
|
+
const availableMediaTypes = [contentType];
|
|
433
|
+
const merged = {
|
|
434
|
+
parseLanguages: false,
|
|
435
|
+
parseCharsets: false,
|
|
436
|
+
parseEncodings: false,
|
|
437
|
+
availableMediaTypes,
|
|
438
|
+
...(opts?.contentNegotiation ?? {}),
|
|
439
|
+
};
|
|
440
|
+
return transformUtils.tagStep(asApiMiddleware.asApiMiddleware(httpContentNegotiation(merged)), 'content-negotiation');
|
|
441
|
+
};
|
|
442
|
+
const makeJsonBodyParser = (opts) => {
|
|
443
|
+
const inner = asApiMiddleware.asApiMiddleware(httpJsonBodyParser({
|
|
444
|
+
disableContentTypeError: true,
|
|
445
|
+
...(opts?.jsonBodyParser ?? {}),
|
|
446
|
+
}));
|
|
447
|
+
const mw = {};
|
|
448
|
+
mw.before = async (request) => {
|
|
449
|
+
const event = request
|
|
450
|
+
.event;
|
|
451
|
+
if (!event)
|
|
452
|
+
return;
|
|
453
|
+
const method = (event.httpMethod ||
|
|
454
|
+
event
|
|
455
|
+
.requestContext?.http?.method ||
|
|
456
|
+
'').toUpperCase();
|
|
457
|
+
if (method === 'GET' || method === 'HEAD')
|
|
458
|
+
return;
|
|
459
|
+
if (!event.body)
|
|
460
|
+
return;
|
|
461
|
+
if (inner.before)
|
|
462
|
+
await inner.before(request);
|
|
463
|
+
};
|
|
464
|
+
return transformUtils.tagStep(mw, 'json-body-parser');
|
|
465
|
+
};
|
|
466
|
+
const makeZodBefore = (logger, eventSchema) => {
|
|
467
|
+
const base = httpZodValidator.httpZodValidator({
|
|
468
|
+
logger,
|
|
469
|
+
...(eventSchema ? { eventSchema } : {}),
|
|
470
|
+
});
|
|
471
|
+
const mw = {};
|
|
472
|
+
if (base.before)
|
|
473
|
+
mw.before = base.before;
|
|
474
|
+
return transformUtils.tagStep(mw, 'zod-before');
|
|
475
|
+
};
|
|
476
|
+
const makeZodAfter = (logger, responseSchema) => {
|
|
477
|
+
const base = httpZodValidator.httpZodValidator({
|
|
478
|
+
logger,
|
|
479
|
+
...(responseSchema ? { responseSchema } : {}),
|
|
480
|
+
});
|
|
481
|
+
const mw = {};
|
|
482
|
+
if (base.after)
|
|
483
|
+
mw.after = base.after;
|
|
484
|
+
return transformUtils.tagStep(mw, 'zod-after');
|
|
485
|
+
};
|
|
486
|
+
const makeHeadFinalize = (contentType) => transformUtils.tagStep({
|
|
487
|
+
after: (request) => {
|
|
488
|
+
const evt = request
|
|
489
|
+
.event;
|
|
490
|
+
if (!evt)
|
|
491
|
+
return;
|
|
492
|
+
const method = (evt.httpMethod ||
|
|
493
|
+
evt.requestContext?.http?.method ||
|
|
494
|
+
'').toUpperCase();
|
|
495
|
+
if (method !== 'HEAD')
|
|
496
|
+
return;
|
|
497
|
+
request.response = {
|
|
498
|
+
statusCode: 200,
|
|
499
|
+
headers: { 'Content-Type': contentType },
|
|
500
|
+
body: {},
|
|
501
|
+
};
|
|
502
|
+
},
|
|
503
|
+
}, 'head-finalize');
|
|
504
|
+
const makePreferredMedia = (contentType) => transformUtils.tagStep({
|
|
505
|
+
before: (request) => {
|
|
506
|
+
const req = request;
|
|
507
|
+
if (!Array.isArray(req.preferredMediaTypes))
|
|
508
|
+
req.preferredMediaTypes = [contentType];
|
|
509
|
+
const ri = request;
|
|
510
|
+
if (!ri.internal)
|
|
511
|
+
ri.internal = {};
|
|
512
|
+
const internal = ri.internal;
|
|
513
|
+
if (!Array.isArray(internal.preferredMediaTypes))
|
|
514
|
+
internal.preferredMediaTypes = [contentType];
|
|
515
|
+
},
|
|
516
|
+
after: (request) => {
|
|
517
|
+
const req = request;
|
|
518
|
+
if (!Array.isArray(req.preferredMediaTypes))
|
|
519
|
+
req.preferredMediaTypes = [contentType];
|
|
520
|
+
},
|
|
521
|
+
onError: (request) => {
|
|
522
|
+
const req = request;
|
|
523
|
+
if (!Array.isArray(req.preferredMediaTypes))
|
|
524
|
+
req.preferredMediaTypes = [contentType];
|
|
525
|
+
},
|
|
526
|
+
}, 'preferred-media');
|
|
527
|
+
const makeErrorExpose = (logger) => transformUtils.tagStep({
|
|
528
|
+
onError: (request) => {
|
|
529
|
+
const maybe = request.error;
|
|
530
|
+
if (!(maybe instanceof Error))
|
|
531
|
+
return;
|
|
532
|
+
const msg = typeof maybe.message === 'string' ? maybe.message : '';
|
|
533
|
+
maybe.expose = true;
|
|
534
|
+
if (typeof maybe.statusCode !== 'number' &&
|
|
535
|
+
((typeof maybe.name === 'string' &&
|
|
536
|
+
maybe.name.toLowerCase().includes('zod')) ||
|
|
537
|
+
/invalid (event|response)/i.test(msg))) {
|
|
538
|
+
maybe.statusCode = 400;
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
}, 'error-expose');
|
|
542
|
+
const makeErrorHandler = (opts) => transformUtils.tagStep(asApiMiddleware.asApiMiddleware(httpErrorHandler({
|
|
543
|
+
...(opts?.errorHandler ?? {}),
|
|
544
|
+
logger: (o) => {
|
|
545
|
+
const lg = opts?.logger ?? console;
|
|
546
|
+
if (typeof lg.error === 'function')
|
|
547
|
+
lg.error(o);
|
|
548
|
+
},
|
|
549
|
+
})), 'error-handler');
|
|
550
|
+
const makeCors = (opts) => transformUtils.tagStep(asApiMiddleware.asApiMiddleware(httpCors({
|
|
551
|
+
credentials: true,
|
|
552
|
+
getOrigin: (o) => o,
|
|
553
|
+
...(opts?.cors ?? {}),
|
|
554
|
+
})), 'cors');
|
|
555
|
+
const makeShapeAndContentType = (contentType) => transformUtils.tagStep({
|
|
556
|
+
after: (request) => {
|
|
557
|
+
const container = request;
|
|
558
|
+
const current = container.response;
|
|
559
|
+
if (current === undefined)
|
|
560
|
+
return;
|
|
561
|
+
const looksShaped = typeof current === 'object' &&
|
|
562
|
+
current !== null &&
|
|
563
|
+
'statusCode' in current &&
|
|
564
|
+
'headers' in current &&
|
|
565
|
+
'body' in current;
|
|
566
|
+
let res;
|
|
567
|
+
if (looksShaped)
|
|
568
|
+
res = current;
|
|
569
|
+
else
|
|
570
|
+
res = { statusCode: 200, headers: {}, body: current };
|
|
571
|
+
if (res.body !== undefined && typeof res.body !== 'string') {
|
|
572
|
+
try {
|
|
573
|
+
res.body = JSON.stringify(res.body);
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
res.body = String(res.body);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const headers = res.headers ?? {};
|
|
580
|
+
headers['Content-Type'] = contentType;
|
|
581
|
+
res.headers = headers;
|
|
582
|
+
request.response = res;
|
|
583
|
+
},
|
|
584
|
+
}, 'shape');
|
|
585
|
+
const makeSerializer = (contentType, opts) => transformUtils.tagStep(asApiMiddleware.asApiMiddleware(httpResponseSerializer({
|
|
586
|
+
serializers: [
|
|
587
|
+
{
|
|
588
|
+
regex: /^application\/(?:[a-z0-9.+-]*\+)?json$/i,
|
|
589
|
+
serializer: wrapSerializer.wrapSerializer(({ body }) => typeof body === 'string'
|
|
590
|
+
? body
|
|
591
|
+
: (opts?.serializer?.json?.stringify ?? JSON.stringify)(body), {
|
|
592
|
+
label: opts?.serializer?.json?.label ?? 'json',
|
|
593
|
+
logger: opts?.logger ?? console,
|
|
594
|
+
}),
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
defaultContentType: contentType,
|
|
598
|
+
})), 'serializer');
|
|
599
|
+
const buildDefaultPhases = (args) => {
|
|
600
|
+
const { contentType, logger, opts, eventSchema, responseSchema } = args;
|
|
601
|
+
const before = [
|
|
602
|
+
makeHead(),
|
|
603
|
+
makeHeaderNormalizer(opts),
|
|
604
|
+
makeEventNormalizer(),
|
|
605
|
+
makeContentNegotiation(contentType, opts),
|
|
606
|
+
makeJsonBodyParser(opts),
|
|
607
|
+
makeZodBefore(logger, eventSchema),
|
|
608
|
+
];
|
|
609
|
+
const after = [
|
|
610
|
+
makeHeadFinalize(contentType),
|
|
611
|
+
makeZodAfter(logger, responseSchema),
|
|
612
|
+
makeErrorExpose(),
|
|
613
|
+
makeCors(opts),
|
|
614
|
+
makePreferredMedia(contentType),
|
|
615
|
+
makeShapeAndContentType(contentType),
|
|
616
|
+
makeSerializer(contentType, opts),
|
|
617
|
+
];
|
|
618
|
+
const onError = [makeErrorExpose(), makeErrorHandler(opts)];
|
|
619
|
+
return { before, after, onError };
|
|
620
|
+
};
|
|
621
|
+
const buildSafeDefaults = (args) => buildDefaultPhases(args);
|
|
622
|
+
|
|
623
|
+
const ID_PROP = '__id';
|
|
624
|
+
/** Attach a non-enumerable __id to a middleware step. */
|
|
625
|
+
const tagStep = (mw, id) => {
|
|
626
|
+
if (!Object.prototype.hasOwnProperty.call(mw, ID_PROP)) {
|
|
627
|
+
Object.defineProperty(mw, ID_PROP, { value: id, enumerable: false });
|
|
628
|
+
}
|
|
629
|
+
return mw;
|
|
630
|
+
};
|
|
631
|
+
/** Retrieve a step's id, if present. */
|
|
632
|
+
const getId = (mw) => mw[ID_PROP];
|
|
633
|
+
/** Find index of a step by id. */
|
|
634
|
+
const findIndex = (list, id) => list.findIndex((m) => getId(m) === id);
|
|
635
|
+
/** Insert a step before the step with given id. */
|
|
636
|
+
const insertBefore = (list, id, mw) => {
|
|
637
|
+
const i = findIndex(list, id);
|
|
638
|
+
if (i < 0)
|
|
639
|
+
return list.slice();
|
|
640
|
+
return [...list.slice(0, i), mw, ...list.slice(i)];
|
|
641
|
+
};
|
|
642
|
+
/** Insert a step after the step with given id. */
|
|
643
|
+
const insertAfter = (list, id, mw) => {
|
|
644
|
+
const i = findIndex(list, id);
|
|
645
|
+
if (i < 0)
|
|
646
|
+
return list.slice();
|
|
647
|
+
return [...list.slice(0, i + 1), mw, ...list.slice(i + 1)];
|
|
648
|
+
};
|
|
649
|
+
/** Replace a step with given id. */
|
|
650
|
+
const replaceStep = (list, id, mw) => {
|
|
651
|
+
const i = findIndex(list, id);
|
|
652
|
+
if (i < 0)
|
|
653
|
+
return list.slice();
|
|
654
|
+
const out = list.slice();
|
|
655
|
+
out[i] = mw;
|
|
656
|
+
return out;
|
|
657
|
+
};
|
|
658
|
+
/** Remove a step with given id. */
|
|
659
|
+
const removeStep = (list, id) => {
|
|
660
|
+
const i = findIndex(list, id);
|
|
661
|
+
if (i < 0)
|
|
662
|
+
return list.slice();
|
|
663
|
+
return [...list.slice(0, i), ...list.slice(i + 1)];
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Path utilities for cross-platform hygiene.
|
|
668
|
+
*
|
|
669
|
+
* - toPosixPath: normalize Windows backslashes to POSIX separators.
|
|
670
|
+
* - dirFromHere: resolve a directory from an import.meta.url, N levels up,
|
|
671
|
+
* returning a POSIX-normalized absolute path.
|
|
672
|
+
*/
|
|
673
|
+
/** Normalize a path to POSIX separators. */
|
|
674
|
+
const toPosixPath = (p) => p.replace(/\\/g, '/');
|
|
675
|
+
/**
|
|
676
|
+
* Resolve a directory path relative to the current module URL and normalize it.
|
|
677
|
+
*
|
|
678
|
+
* @param metaUrl - typically import.meta.url
|
|
679
|
+
* @param levelsUp - how many directory levels to ascend (default: 1)
|
|
680
|
+
* @returns absolute, POSIX-normalized directory path
|
|
681
|
+
*/
|
|
682
|
+
const dirFromHere = (metaUrl, levelsUp = 1) => {
|
|
683
|
+
// Build a URL like '../' repeated N times, resolved from metaUrl.
|
|
684
|
+
const up = Array.from({ length: Math.max(0, levelsUp) })
|
|
685
|
+
.map(() => '..')
|
|
686
|
+
.join('/');
|
|
687
|
+
const url = new URL(`${up}/`, metaUrl);
|
|
688
|
+
const abs = node_url.fileURLToPath(url);
|
|
689
|
+
return toPosixPath(abs);
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
exports.App = App;
|
|
693
|
+
exports.baseEventTypeMapSchema = baseEventTypeMapSchema;
|
|
694
|
+
exports.buildSafeDefaults = buildSafeDefaults;
|
|
695
|
+
exports.defineAppConfig = defineAppConfig;
|
|
696
|
+
exports.detectSecurityContext = detectSecurityContext;
|
|
697
|
+
exports.dirFromHere = dirFromHere;
|
|
698
|
+
exports.findIndex = findIndex;
|
|
699
|
+
exports.getId = getId;
|
|
700
|
+
exports.insertAfter = insertAfter;
|
|
701
|
+
exports.insertBefore = insertBefore;
|
|
702
|
+
exports.removeStep = removeStep;
|
|
703
|
+
exports.replaceStep = replaceStep;
|
|
704
|
+
exports.tagStep = tagStep;
|
|
705
|
+
exports.toPosixPath = toPosixPath;
|
|
706
|
+
exports.wrapHandler = wrapHandler;
|