@karmaniverous/smoz 0.2.12 → 0.2.14

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 CHANGED
@@ -375,6 +375,98 @@ const wrapSerializer = (fn, opts) => {
375
375
  });
376
376
  };
377
377
 
378
+ /**
379
+ * @module Response shaping helpers for HTTP middleware.
380
+ *
381
+ * Shared utilities for detecting and normalising Lambda Proxy response
382
+ * envelopes, used by both the `after` and `onError` middleware paths.
383
+ * Also exports dedicated `onError` steps that apply CORS headers and
384
+ * response shaping after `errorHandler` — necessary because middy v6+
385
+ * does not re-run the `after` chain on the error path.
386
+ */
387
+ /**
388
+ * Detect a shaped Lambda Proxy response.
389
+ *
390
+ * A response is "shaped" when it has a numeric `statusCode` property —
391
+ * the definitive marker of an API-Gateway-style response envelope.
392
+ * Missing `headers` or `body` are defaulted by {@link shapeResponse}
393
+ * rather than treated as "unshaped", because `{ statusCode, headers }`
394
+ * (no body) is a valid Lambda Proxy return value.
395
+ */
396
+ const isShapedResponse = (v) => typeof v === 'object' &&
397
+ v !== null &&
398
+ 'statusCode' in v &&
399
+ typeof v.statusCode === 'number';
400
+ /**
401
+ * Shape the response into a well-formed Lambda Proxy result.
402
+ *
403
+ * Shaped responses (detected via {@link isShapedResponse}) are normalised
404
+ * in-place — missing `headers` and `body` are defaulted. Non-shaped
405
+ * values are wrapped in a `200` envelope.
406
+ */
407
+ const shapeResponse = (current, contentType) => {
408
+ let res;
409
+ if (isShapedResponse(current)) {
410
+ res = current;
411
+ }
412
+ else {
413
+ res = { statusCode: 200, headers: {}, body: current };
414
+ }
415
+ // Default missing fields for shaped responses without body/headers.
416
+ if (res.body === undefined)
417
+ res.body = '';
418
+ if (res.body !== undefined && typeof res.body !== 'string') {
419
+ try {
420
+ res.body = JSON.stringify(res.body);
421
+ }
422
+ catch {
423
+ res.body = String(res.body);
424
+ }
425
+ }
426
+ const headers = res.headers ?? {};
427
+ headers['Content-Type'] = contentType;
428
+ res.headers = headers;
429
+ return res;
430
+ };
431
+ /**
432
+ * Apply CORS headers to error responses.
433
+ *
434
+ * middy v6+ does not re-run the `after` chain after `onError` handles an
435
+ * error, so the regular CORS `after` hook never fires for error responses.
436
+ * This dedicated `onError` step runs *after* the error handler has set
437
+ * `request.response`, ensuring CORS headers are present on error responses.
438
+ */
439
+ const makeOnErrorCors = (opts) => {
440
+ const cors = httpCors({
441
+ credentials: true,
442
+ getOrigin: (o) => o,
443
+ ...(opts?.cors ?? {}),
444
+ });
445
+ return tagStep({
446
+ onError: async (request) => {
447
+ if (request.response === undefined)
448
+ return;
449
+ if (cors.after)
450
+ await cors.after(request);
451
+ },
452
+ }, 'error-cors');
453
+ };
454
+ /**
455
+ * Shape error responses into well-formed Lambda Proxy results.
456
+ *
457
+ * Mirrors `makeShapeAndContentType` but runs on the `onError` path,
458
+ * ensuring error responses have proper `statusCode`, `headers`, and
459
+ * `body` structure even when the `after` chain is skipped.
460
+ */
461
+ const makeOnErrorShape = (contentType) => tagStep({
462
+ onError: (request) => {
463
+ const container = request;
464
+ if (container.response === undefined)
465
+ return;
466
+ container.response = shapeResponse(container.response, contentType);
467
+ },
468
+ }, 'error-shape');
469
+
378
470
  const makeHead = () => tagStep(shortCircuitHead, 'head');
379
471
  const makeHeaderNormalizer = (opts) => tagStep(asApiMiddleware(httpHeaderNormalizer(opts?.headerNormalizer ?? { canonical: true })), 'header-normalizer');
380
472
  const makeEventNormalizer = () => tagStep(asApiMiddleware(httpEventNormalizer()), 'event-normalizer');
@@ -529,31 +621,9 @@ const makeCors = (opts) => tagStep(asApiMiddleware(httpCors({
529
621
  const makeShapeAndContentType = (contentType) => tagStep({
530
622
  after: (request) => {
531
623
  const container = request;
532
- const current = container.response;
533
- if (current === undefined)
624
+ if (container.response === undefined)
534
625
  return;
535
- const looksShaped = typeof current === 'object' &&
536
- current !== null &&
537
- 'statusCode' in current &&
538
- 'headers' in current &&
539
- 'body' in current;
540
- let res;
541
- if (looksShaped)
542
- res = current;
543
- else
544
- res = { statusCode: 200, headers: {}, body: current };
545
- if (res.body !== undefined && typeof res.body !== 'string') {
546
- try {
547
- res.body = JSON.stringify(res.body);
548
- }
549
- catch {
550
- res.body = String(res.body);
551
- }
552
- }
553
- const headers = res.headers ?? {};
554
- headers['Content-Type'] = contentType;
555
- res.headers = headers;
556
- request.response = res;
626
+ container.response = shapeResponse(container.response, contentType);
557
627
  },
558
628
  }, 'shape');
559
629
  const makeSerializer = (contentType, opts) => tagStep(asApiMiddleware(httpResponseSerializer({
@@ -589,7 +659,15 @@ const buildDefaultPhases = (args) => {
589
659
  makeShapeAndContentType(contentType),
590
660
  makeSerializer(contentType, opts),
591
661
  ];
592
- const onError = [makeErrorExpose(), makeErrorHandler(opts)];
662
+ const onError = [
663
+ makeErrorExpose(),
664
+ makeErrorHandler(opts),
665
+ // Post-error-handler processing: middy v6+ does not re-run the after
666
+ // chain after onError, so CORS and response shaping must happen here.
667
+ // Shape first so CORS can mutate a well-formed headers object.
668
+ makeOnErrorShape(contentType),
669
+ makeOnErrorCors(opts),
670
+ ];
593
671
  return { before, after, onError };
594
672
  };
595
673
  const buildSafeDefaults = (args) => buildDefaultPhases(args);
@@ -789,14 +867,13 @@ function wrapHandler(functionConfig, business, opts) {
789
867
  assertKeysSubset(envConfig.stage.paramsSchema, envConfig.stage.envKeys, 'stage.envKeys');
790
868
  return async (event, context) => {
791
869
  // Compose typed env schema and parse process.env
792
- const all = deriveAllKeys(envConfig.global.envKeys, envConfig.stage.envKeys, (functionConfig.fnEnvKeys ?? []));
870
+ const all = deriveAllKeys(envConfig.global.envKeys, envConfig.stage.envKeys, functionConfig.fnEnvKeys ?? []);
793
871
  const { globalPick, stagePick } = splitKeysBySchema(all, envConfig.global.paramsSchema, envConfig.stage.paramsSchema);
794
872
  const envSchema = buildEnvSchema(globalPick, stagePick, envConfig.global.paramsSchema, envConfig.stage.paramsSchema);
795
873
  const env = parseTypedEnv(envSchema, process.env);
796
874
  const logger = console;
797
875
  // Non-HTTP: call business directly
798
- const httpTokens = opts?.httpEventTypeTokens ??
799
- defaultHttpEventTypeTokens;
876
+ const httpTokens = opts?.httpEventTypeTokens ?? defaultHttpEventTypeTokens;
800
877
  const isHttp = httpTokens.includes(functionConfig.eventType);
801
878
  if (!isHttp) {
802
879
  return business(event, context, { env, logger });
@@ -859,9 +936,7 @@ const createRegistry = (deps) => {
859
936
  ...(options.basePath ? { basePath: options.basePath } : {}),
860
937
  ...(options.httpContexts ? { httpContexts: options.httpContexts } : {}),
861
938
  ...(options.contentType ? { contentType: options.contentType } : {}),
862
- ...(mergedFnEnvKeys.length
863
- ? { fnEnvKeys: mergedFnEnvKeys }
864
- : {}),
939
+ ...(mergedFnEnvKeys.length ? { fnEnvKeys: mergedFnEnvKeys } : {}),
865
940
  ...(options.eventSchema ? { eventSchema: options.eventSchema } : {}),
866
941
  ...(options.responseSchema
867
942
  ? { responseSchema: options.responseSchema }
@@ -878,9 +953,7 @@ const createRegistry = (deps) => {
878
953
  ...(options.basePath ? { basePath: options.basePath } : {}),
879
954
  ...(options.httpContexts ? { httpContexts: options.httpContexts } : {}),
880
955
  ...(options.contentType ? { contentType: options.contentType } : {}),
881
- ...(mergedFnEnvKeys.length
882
- ? { fnEnvKeys: mergedFnEnvKeys }
883
- : {}),
956
+ ...(mergedFnEnvKeys.length ? { fnEnvKeys: mergedFnEnvKeys } : {}),
884
957
  ...(options.eventSchema ? { eventSchema: options.eventSchema } : {}),
885
958
  ...(options.responseSchema
886
959
  ? { responseSchema: options.responseSchema }
@@ -1047,7 +1120,7 @@ const buildAllServerlessFunctions = (registry, serverless, buildFnEnv) => {
1047
1120
  .split(path.sep)
1048
1121
  .join('/');
1049
1122
  const handler = `${handlerFileRel}.${serverless.defaultHandlerFileExport}`;
1050
- let events = [];
1123
+ let events;
1051
1124
  try {
1052
1125
  const { method, basePath, contexts } = resolveHttpFromFunctionConfig({
1053
1126
  functionName: r.functionName,
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- var cliHost = require('@karmaniverous/get-dotenv/cliHost');
4
+ var cli = require('@karmaniverous/get-dotenv/cli');
5
5
  var plugins = require('@karmaniverous/get-dotenv/plugins');
6
+ var cliHost = require('@karmaniverous/get-dotenv/cliHost');
6
7
  var packageDirectory = require('package-directory');
7
8
  var fs = require('node:fs');
8
9
  var path = require('node:path');
@@ -1340,138 +1341,135 @@ const runInit = async (root, template = 'default', opts) => {
1340
1341
  * delegates to existing implementations. No business logic here.
1341
1342
  */
1342
1343
  const repoRoot = () => packageDirectory.packageDirectorySync() ?? process.cwd();
1343
- const smozPlugin = () => cliHost.definePlugin({
1344
- id: 'smoz',
1345
- setup(cli) {
1346
- // init
1347
- cli
1348
- .command('init')
1349
- .description('Scaffold a new SMOZ app from a template')
1350
- .option('-t, --template <name>', 'template name', 'default')
1351
- .option('-y, --yes', 'assume “example” on conflicts')
1352
- .option('--no-install', 'do not install dependencies')
1353
- .option('--install <pm>', 'explicit package manager (npm|pnpm|yarn|bun)')
1354
- .option('--conflict <policy>', 'overwrite|example|skip|ask')
1355
- .action(async (opts) => {
1356
- const root = repoRoot();
1357
- const template = typeof opts.template === 'string' ? opts.template : 'default';
1358
- const yes = Boolean(opts.yes);
1359
- const noInstall = opts.install === undefined &&
1360
- opts['noInstall'] !== undefined
1361
- ? Boolean(opts['noInstall'])
1362
- : Boolean(opts['noInstall']);
1363
- const pmRaw = opts.install ?? undefined;
1364
- const conflict = opts.conflict ?? undefined;
1365
- await runInit(root, template, {
1366
- ...(yes ? { yes } : {}),
1367
- ...(noInstall ? { noInstall } : {}),
1368
- ...(pmRaw ? { install: pmRaw } : {}),
1369
- ...(conflict ? { conflict } : {}),
1370
- });
1371
- });
1372
- // add
1373
- cli
1374
- .command('add')
1375
- .description('Scaffold a function under app/functions')
1376
- .argument('<spec>', 'spec: <eventType>/<segments...>/<method>')
1377
- .action(async (spec) => {
1378
- const root = repoRoot();
1379
- if (typeof spec !== 'string' || !spec.trim()) {
1380
- throw new Error('smoz add: missing <spec> (e.g., rest/foo/get)');
1381
- }
1382
- await runAdd(root, spec);
1383
- });
1384
- // register
1385
- cli
1386
- .command('register')
1387
- .description('Generate side-effect register files')
1388
- .action(async () => {
1389
- const root = repoRoot();
1390
- await runRegister(root);
1391
- });
1392
- // openapi
1393
- cli
1394
- .command('openapi')
1395
- .description('Generate OpenAPI document (app/generated/openapi.json)')
1396
- .option('-V, --verbose', 'verbose output')
1397
- .action(async (opts) => {
1398
- const root = repoRoot();
1399
- await runOpenapi(root, { verbose: !!opts.verbose });
1344
+ const setupSmozCommands = (cli) => {
1345
+ // init
1346
+ cli
1347
+ .command('init')
1348
+ .description('Scaffold a new SMOZ app from a template')
1349
+ .option('-t, --template <name>', 'template name', 'default')
1350
+ .option('-y, --yes', 'assume “example” on conflicts')
1351
+ .option('--no-install', 'do not install dependencies')
1352
+ .option('--install <pm>', 'explicit package manager (npm|pnpm|yarn|bun)')
1353
+ .option('--conflict <policy>', 'overwrite|example|skip|ask')
1354
+ .action(async (opts) => {
1355
+ const root = repoRoot();
1356
+ const template = typeof opts.template === 'string' ? opts.template : 'default';
1357
+ const yes = Boolean(opts.yes);
1358
+ const noInstall = Boolean(opts['noInstall']);
1359
+ const pmRaw = opts.install ?? undefined;
1360
+ const conflict = opts.conflict ?? undefined;
1361
+ await runInit(root, template, {
1362
+ ...(yes ? { yes } : {}),
1363
+ ...(noInstall ? { noInstall } : {}),
1364
+ ...(pmRaw ? { install: pmRaw } : {}),
1365
+ ...(conflict ? { conflict } : {}),
1400
1366
  });
1401
- // dev
1402
- cli
1403
- .command('dev')
1404
- .description('Dev loop: register/openapi + local backend (inline|offline)')
1405
- .option('-r, --register', 'run register step (default on)')
1406
- .option('-R, --no-register', 'disable register step')
1407
- .option('-o, --openapi', 'run openapi step (default on)')
1408
- .option('-O, --no-openapi', 'disable openapi step')
1409
- .option('-l, --local [mode]', 'local mode: inline | offline | false (default inline)')
1410
- .option('-s, --stage <name>', 'stage name')
1411
- .option('-p, --port <number>', 'port (0 = random free port)')
1412
- .option('-V, --verbose', 'verbose output')
1413
- .action(async (opts) => {
1414
- const root = repoRoot();
1415
- // register/openapi defaults (on); explicit negations take precedence.
1416
- const register = opts.register === true
1417
- ? true
1418
- : opts.noRegister
1419
- ? false
1420
- : true;
1421
- const openapi = opts.openapi === true
1422
- ? true
1423
- : opts.noOpenapi
1424
- ? false
1425
- : true;
1426
- // local mode parsing
1427
- const raw = opts.local;
1428
- let local = 'inline';
1429
- if (raw === 'inline' || raw === 'offline')
1430
- local = raw;
1431
- else if (raw === true)
1432
- local = 'inline';
1433
- else if (raw === 'false')
1434
- local = false;
1435
- const port = typeof opts.port === 'string'
1436
- ? Number(opts.port)
1437
- : typeof opts.port === 'number'
1438
- ? opts.port
1439
- : undefined;
1440
- await runDev(root, {
1441
- register,
1442
- openapi,
1443
- local,
1444
- ...(typeof opts.stage === 'string' ? { stage: opts.stage } : {}),
1445
- ...(typeof port === 'number' ? { port } : {}),
1446
- verbose: !!opts.verbose,
1447
- });
1367
+ });
1368
+ // add
1369
+ cli
1370
+ .command('add')
1371
+ .description('Scaffold a function under app/functions')
1372
+ .argument('<spec>', 'spec: <eventType>/<segments...>/<method>')
1373
+ .action(async (spec) => {
1374
+ const root = repoRoot();
1375
+ if (typeof spec !== 'string' || !spec.trim()) {
1376
+ throw new Error('smoz add: missing <spec> (e.g., rest/foo/get)');
1377
+ }
1378
+ await runAdd(root, spec);
1379
+ });
1380
+ // register
1381
+ cli
1382
+ .command('register')
1383
+ .description('Generate side-effect register files')
1384
+ .action(async () => {
1385
+ const root = repoRoot();
1386
+ await runRegister(root);
1387
+ });
1388
+ // openapi
1389
+ cli
1390
+ .command('openapi')
1391
+ .description('Generate OpenAPI document (app/generated/openapi.json)')
1392
+ .option('-V, --verbose', 'verbose output')
1393
+ .action(async (opts) => {
1394
+ const root = repoRoot();
1395
+ await runOpenapi(root, { verbose: !!opts.verbose });
1396
+ });
1397
+ // dev
1398
+ cli
1399
+ .command('dev')
1400
+ .description('Dev loop: register/openapi + local backend (inline|offline)')
1401
+ .option('-r, --register', 'run register step (default on)')
1402
+ .option('-R, --no-register', 'disable register step')
1403
+ .option('-o, --openapi', 'run openapi step (default on)')
1404
+ .option('-O, --no-openapi', 'disable openapi step')
1405
+ .option('-l, --local [mode]', 'local mode: inline | offline | false (default inline)')
1406
+ .option('-s, --stage <name>', 'stage name')
1407
+ .option('-p, --port <number>', 'port (0 = random free port)')
1408
+ .option('-V, --verbose', 'verbose output')
1409
+ .action(async (opts) => {
1410
+ const root = repoRoot();
1411
+ // register/openapi defaults (on); explicit negations take precedence.
1412
+ const register = opts.register === true
1413
+ ? true
1414
+ : opts.noRegister
1415
+ ? false
1416
+ : true;
1417
+ const openapi = opts.openapi === true
1418
+ ? true
1419
+ : opts.noOpenapi
1420
+ ? false
1421
+ : true;
1422
+ // local mode parsing
1423
+ const raw = opts.local;
1424
+ let local = 'inline';
1425
+ if (raw === 'inline' || raw === 'offline')
1426
+ local = raw;
1427
+ else if (raw === true)
1428
+ local = 'inline';
1429
+ else if (raw === 'false')
1430
+ local = false;
1431
+ const port = typeof opts.port === 'string'
1432
+ ? Number(opts.port)
1433
+ : typeof opts.port === 'number'
1434
+ ? opts.port
1435
+ : undefined;
1436
+ await runDev(root, {
1437
+ register,
1438
+ openapi,
1439
+ local,
1440
+ ...(typeof opts.stage === 'string' ? { stage: opts.stage } : {}),
1441
+ ...(typeof port === 'number' ? { port } : {}),
1442
+ verbose: !!opts.verbose,
1448
1443
  });
1449
- },
1444
+ });
1445
+ };
1446
+ const smozPlugin = () => cliHost.definePlugin({
1447
+ ns: 'smoz',
1448
+ setup: setupSmozCommands,
1450
1449
  });
1451
1450
 
1452
1451
  /**
1453
1452
  * SMOZ CLI — plugin-first host built on get-dotenv.
1454
1453
  *
1455
- * - Build a GetDotenvCli host, install included plugins (cmd/batch/aws),
1456
- * and install the SMOZ command plugin (init/add/register/openapi/dev).
1457
- * - Resolve dotenv context once, then parse argv.
1454
+ * - Create the supported GetDotenv CLI runner so root options, resolution hooks,
1455
+ * and plugin installation stay aligned with the get-dotenv host API.
1456
+ * - Install included plugins (cmd/batch/aws) and the SMOZ command plugin
1457
+ * (init/add/register/openapi/dev).
1458
1458
  */
1459
- const main = async () => {
1460
- const cli = new cliHost.GetDotenvCli('smoz');
1461
- await cli.brand({
1462
- importMetaUrl: (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)),
1463
- description: 'SMOZ CLI',
1464
- });
1465
- cli
1466
- .attachRootOptions()
1467
- .use(smozPlugin())
1468
- .use(plugins.awsPlugin())
1469
- .use(plugins.cmdPlugin({
1470
- asDefault: true,
1471
- optionAlias: '-c, --cmd <command...>',
1472
- }))
1473
- .use(plugins.batchPlugin())
1474
- .passOptions();
1475
- await cli.parseAsync();
1476
- };
1459
+ const main = cli.createCli({
1460
+ alias: 'smoz',
1461
+ compose: (cli) => {
1462
+ const setupResult = smozPlugin().setup(cli);
1463
+ if (setupResult !== undefined) {
1464
+ throw new Error('smozPlugin root setup must remain synchronous.');
1465
+ }
1466
+ return cli
1467
+ .use(plugins.awsPlugin())
1468
+ .use(plugins.cmdPlugin({
1469
+ asDefault: true,
1470
+ optionAlias: '-c, --cmd <command...>',
1471
+ }))
1472
+ .use(plugins.batchPlugin());
1473
+ },
1474
+ });
1477
1475
  void main();
package/dist/index.d.ts CHANGED
@@ -264,7 +264,7 @@ type ConsoleLogger = Pick<Console, 'debug' | 'error' | 'info' | 'log'>;
264
264
  */
265
265
 
266
266
  type ApiMiddleware$1 = MiddlewareObj<APIGatewayProxyEvent, Context>;
267
- type StepId = 'head' | 'header-normalizer' | 'event-normalizer' | 'content-negotiation' | 'json-body-parser' | 'zod-before' | 'head-finalize' | 'zod-after' | 'error-expose' | 'error-handler' | 'cors' | 'preferred-media' | 'shape' | 'serializer';
267
+ type StepId = 'head' | 'header-normalizer' | 'event-normalizer' | 'content-negotiation' | 'json-body-parser' | 'zod-before' | 'head-finalize' | 'zod-after' | 'error-expose' | 'error-handler' | 'error-cors' | 'error-shape' | 'cors' | 'preferred-media' | 'shape' | 'serializer';
268
268
  /** Attach a non-enumerable __id to a middleware step. */
269
269
  declare const tagStep: (mw: ApiMiddleware$1, id: StepId) => ApiMiddleware$1;
270
270
  /** Retrieve a step's id, if present. */
@@ -190,8 +190,7 @@ const loadHandlers = async (root, app) => {
190
190
  if (typeof handler !== 'function')
191
191
  continue;
192
192
  for (const evt of def.events) {
193
- const httpEvt = evt
194
- .http;
193
+ const httpEvt = evt.http;
195
194
  const method = (httpEvt?.method ?? '').toUpperCase();
196
195
  const pattern = '/' + (httpEvt?.path ?? '').replace(/^\/+/, '');
197
196
  if (!method || !pattern)
package/dist/mjs/index.js CHANGED
@@ -373,6 +373,98 @@ const wrapSerializer = (fn, opts) => {
373
373
  });
374
374
  };
375
375
 
376
+ /**
377
+ * @module Response shaping helpers for HTTP middleware.
378
+ *
379
+ * Shared utilities for detecting and normalising Lambda Proxy response
380
+ * envelopes, used by both the `after` and `onError` middleware paths.
381
+ * Also exports dedicated `onError` steps that apply CORS headers and
382
+ * response shaping after `errorHandler` — necessary because middy v6+
383
+ * does not re-run the `after` chain on the error path.
384
+ */
385
+ /**
386
+ * Detect a shaped Lambda Proxy response.
387
+ *
388
+ * A response is "shaped" when it has a numeric `statusCode` property —
389
+ * the definitive marker of an API-Gateway-style response envelope.
390
+ * Missing `headers` or `body` are defaulted by {@link shapeResponse}
391
+ * rather than treated as "unshaped", because `{ statusCode, headers }`
392
+ * (no body) is a valid Lambda Proxy return value.
393
+ */
394
+ const isShapedResponse = (v) => typeof v === 'object' &&
395
+ v !== null &&
396
+ 'statusCode' in v &&
397
+ typeof v.statusCode === 'number';
398
+ /**
399
+ * Shape the response into a well-formed Lambda Proxy result.
400
+ *
401
+ * Shaped responses (detected via {@link isShapedResponse}) are normalised
402
+ * in-place — missing `headers` and `body` are defaulted. Non-shaped
403
+ * values are wrapped in a `200` envelope.
404
+ */
405
+ const shapeResponse = (current, contentType) => {
406
+ let res;
407
+ if (isShapedResponse(current)) {
408
+ res = current;
409
+ }
410
+ else {
411
+ res = { statusCode: 200, headers: {}, body: current };
412
+ }
413
+ // Default missing fields for shaped responses without body/headers.
414
+ if (res.body === undefined)
415
+ res.body = '';
416
+ if (res.body !== undefined && typeof res.body !== 'string') {
417
+ try {
418
+ res.body = JSON.stringify(res.body);
419
+ }
420
+ catch {
421
+ res.body = String(res.body);
422
+ }
423
+ }
424
+ const headers = res.headers ?? {};
425
+ headers['Content-Type'] = contentType;
426
+ res.headers = headers;
427
+ return res;
428
+ };
429
+ /**
430
+ * Apply CORS headers to error responses.
431
+ *
432
+ * middy v6+ does not re-run the `after` chain after `onError` handles an
433
+ * error, so the regular CORS `after` hook never fires for error responses.
434
+ * This dedicated `onError` step runs *after* the error handler has set
435
+ * `request.response`, ensuring CORS headers are present on error responses.
436
+ */
437
+ const makeOnErrorCors = (opts) => {
438
+ const cors = httpCors({
439
+ credentials: true,
440
+ getOrigin: (o) => o,
441
+ ...(opts?.cors ?? {}),
442
+ });
443
+ return tagStep({
444
+ onError: async (request) => {
445
+ if (request.response === undefined)
446
+ return;
447
+ if (cors.after)
448
+ await cors.after(request);
449
+ },
450
+ }, 'error-cors');
451
+ };
452
+ /**
453
+ * Shape error responses into well-formed Lambda Proxy results.
454
+ *
455
+ * Mirrors `makeShapeAndContentType` but runs on the `onError` path,
456
+ * ensuring error responses have proper `statusCode`, `headers`, and
457
+ * `body` structure even when the `after` chain is skipped.
458
+ */
459
+ const makeOnErrorShape = (contentType) => tagStep({
460
+ onError: (request) => {
461
+ const container = request;
462
+ if (container.response === undefined)
463
+ return;
464
+ container.response = shapeResponse(container.response, contentType);
465
+ },
466
+ }, 'error-shape');
467
+
376
468
  const makeHead = () => tagStep(shortCircuitHead, 'head');
377
469
  const makeHeaderNormalizer = (opts) => tagStep(asApiMiddleware(httpHeaderNormalizer(opts?.headerNormalizer ?? { canonical: true })), 'header-normalizer');
378
470
  const makeEventNormalizer = () => tagStep(asApiMiddleware(httpEventNormalizer()), 'event-normalizer');
@@ -527,31 +619,9 @@ const makeCors = (opts) => tagStep(asApiMiddleware(httpCors({
527
619
  const makeShapeAndContentType = (contentType) => tagStep({
528
620
  after: (request) => {
529
621
  const container = request;
530
- const current = container.response;
531
- if (current === undefined)
622
+ if (container.response === undefined)
532
623
  return;
533
- const looksShaped = typeof current === 'object' &&
534
- current !== null &&
535
- 'statusCode' in current &&
536
- 'headers' in current &&
537
- 'body' in current;
538
- let res;
539
- if (looksShaped)
540
- res = current;
541
- else
542
- res = { statusCode: 200, headers: {}, body: current };
543
- if (res.body !== undefined && typeof res.body !== 'string') {
544
- try {
545
- res.body = JSON.stringify(res.body);
546
- }
547
- catch {
548
- res.body = String(res.body);
549
- }
550
- }
551
- const headers = res.headers ?? {};
552
- headers['Content-Type'] = contentType;
553
- res.headers = headers;
554
- request.response = res;
624
+ container.response = shapeResponse(container.response, contentType);
555
625
  },
556
626
  }, 'shape');
557
627
  const makeSerializer = (contentType, opts) => tagStep(asApiMiddleware(httpResponseSerializer({
@@ -587,7 +657,15 @@ const buildDefaultPhases = (args) => {
587
657
  makeShapeAndContentType(contentType),
588
658
  makeSerializer(contentType, opts),
589
659
  ];
590
- const onError = [makeErrorExpose(), makeErrorHandler(opts)];
660
+ const onError = [
661
+ makeErrorExpose(),
662
+ makeErrorHandler(opts),
663
+ // Post-error-handler processing: middy v6+ does not re-run the after
664
+ // chain after onError, so CORS and response shaping must happen here.
665
+ // Shape first so CORS can mutate a well-formed headers object.
666
+ makeOnErrorShape(contentType),
667
+ makeOnErrorCors(opts),
668
+ ];
591
669
  return { before, after, onError };
592
670
  };
593
671
  const buildSafeDefaults = (args) => buildDefaultPhases(args);
@@ -787,14 +865,13 @@ function wrapHandler(functionConfig, business, opts) {
787
865
  assertKeysSubset(envConfig.stage.paramsSchema, envConfig.stage.envKeys, 'stage.envKeys');
788
866
  return async (event, context) => {
789
867
  // Compose typed env schema and parse process.env
790
- const all = deriveAllKeys(envConfig.global.envKeys, envConfig.stage.envKeys, (functionConfig.fnEnvKeys ?? []));
868
+ const all = deriveAllKeys(envConfig.global.envKeys, envConfig.stage.envKeys, functionConfig.fnEnvKeys ?? []);
791
869
  const { globalPick, stagePick } = splitKeysBySchema(all, envConfig.global.paramsSchema, envConfig.stage.paramsSchema);
792
870
  const envSchema = buildEnvSchema(globalPick, stagePick, envConfig.global.paramsSchema, envConfig.stage.paramsSchema);
793
871
  const env = parseTypedEnv(envSchema, process.env);
794
872
  const logger = console;
795
873
  // Non-HTTP: call business directly
796
- const httpTokens = opts?.httpEventTypeTokens ??
797
- defaultHttpEventTypeTokens;
874
+ const httpTokens = opts?.httpEventTypeTokens ?? defaultHttpEventTypeTokens;
798
875
  const isHttp = httpTokens.includes(functionConfig.eventType);
799
876
  if (!isHttp) {
800
877
  return business(event, context, { env, logger });
@@ -857,9 +934,7 @@ const createRegistry = (deps) => {
857
934
  ...(options.basePath ? { basePath: options.basePath } : {}),
858
935
  ...(options.httpContexts ? { httpContexts: options.httpContexts } : {}),
859
936
  ...(options.contentType ? { contentType: options.contentType } : {}),
860
- ...(mergedFnEnvKeys.length
861
- ? { fnEnvKeys: mergedFnEnvKeys }
862
- : {}),
937
+ ...(mergedFnEnvKeys.length ? { fnEnvKeys: mergedFnEnvKeys } : {}),
863
938
  ...(options.eventSchema ? { eventSchema: options.eventSchema } : {}),
864
939
  ...(options.responseSchema
865
940
  ? { responseSchema: options.responseSchema }
@@ -876,9 +951,7 @@ const createRegistry = (deps) => {
876
951
  ...(options.basePath ? { basePath: options.basePath } : {}),
877
952
  ...(options.httpContexts ? { httpContexts: options.httpContexts } : {}),
878
953
  ...(options.contentType ? { contentType: options.contentType } : {}),
879
- ...(mergedFnEnvKeys.length
880
- ? { fnEnvKeys: mergedFnEnvKeys }
881
- : {}),
954
+ ...(mergedFnEnvKeys.length ? { fnEnvKeys: mergedFnEnvKeys } : {}),
882
955
  ...(options.eventSchema ? { eventSchema: options.eventSchema } : {}),
883
956
  ...(options.responseSchema
884
957
  ? { responseSchema: options.responseSchema }
@@ -1045,7 +1118,7 @@ const buildAllServerlessFunctions = (registry, serverless, buildFnEnv) => {
1045
1118
  .split(sep)
1046
1119
  .join('/');
1047
1120
  const handler = `${handlerFileRel}.${serverless.defaultHandlerFileExport}`;
1048
- let events = [];
1121
+ let events;
1049
1122
  try {
1050
1123
  const { method, basePath, contexts } = resolveHttpFromFunctionConfig({
1051
1124
  functionName: r.functionName,
package/package.json CHANGED
@@ -13,64 +13,63 @@
13
13
  "url": "https://github.com/karmaniverous/smoz/issues"
14
14
  },
15
15
  "dependencies": {
16
- "@karmaniverous/get-dotenv": "^5.2.6",
17
- "@middy/core": "^6.4.5",
18
- "@middy/http-content-negotiation": "^6.4.5",
19
- "@middy/http-cors": "^6.4.5",
20
- "@middy/http-error-handler": "^6.4.5",
21
- "@middy/http-event-normalizer": "^6.4.5",
22
- "@middy/http-header-normalizer": "^6.4.5",
23
- "@middy/http-json-body-parser": "^6.4.5",
24
- "@middy/http-response-serializer": "^6.4.5",
16
+ "@karmaniverous/get-dotenv": "^7.0.11",
17
+ "@middy/core": "^7.7.0",
18
+ "@middy/http-content-negotiation": "^7.7.0",
19
+ "@middy/http-cors": "^7.7.0",
20
+ "@middy/http-error-handler": "^7.7.0",
21
+ "@middy/http-event-normalizer": "^7.7.0",
22
+ "@middy/http-header-normalizer": "^7.7.0",
23
+ "@middy/http-json-body-parser": "^7.7.0",
24
+ "@middy/http-response-serializer": "^7.7.0",
25
25
  "aws-lambda": "^1.0.7",
26
- "chokidar": "^4.0.3",
26
+ "chokidar": "^5.0.0",
27
27
  "http-errors": "^2.0.1",
28
- "package-directory": "^8.1.0",
28
+ "package-directory": "^8.2.0",
29
29
  "radash": "^12.1.1",
30
- "zod": "^4.1.13"
30
+ "zod": "^4.4.3"
31
31
  },
32
32
  "description": "John Galt Services back end.",
33
33
  "devDependencies": {
34
- "@dotenvx/dotenvx": "^1.51.1",
35
- "@eslint/js": "^9.39.1",
34
+ "@dotenvx/dotenvx": "^2.10.0",
35
+ "@eslint/js": "^10.0.1",
36
36
  "@rollup/plugin-alias": "^6.0.0",
37
37
  "@rollup/plugin-typescript": "^12.3.0",
38
- "@serverless/typescript": "^4.23.0",
39
- "@types/aws-lambda": "^8.10.159",
38
+ "@serverless/typescript": "^4.30.0",
39
+ "@types/aws-lambda": "^8.10.162",
40
40
  "@types/fs-extra": "^11.0.4",
41
41
  "@types/http-errors": "^2.0.5",
42
- "@types/node": "^24",
43
- "@types/serverless": "^3.12.27",
44
- "@vitest/coverage-v8": "^4.0.13",
45
- "auto-changelog": "^2.5.0",
46
- "eslint": "^9.39.1",
42
+ "@types/node": "^26",
43
+ "@types/serverless": "^3.12.28",
44
+ "@vitest/coverage-v8": "^4.1.10",
45
+ "auto-changelog": "^2.6.0",
46
+ "eslint": "^10.7.0",
47
47
  "eslint-config-prettier": "^10.1.8",
48
- "eslint-plugin-prettier": "^5.5.4",
49
- "eslint-plugin-simple-import-sort": "^12.1.1",
50
- "fs-extra": "^11.3.2",
51
- "knip": "^5.70.2",
52
- "lefthook": "^2.0.4",
53
- "prettier": "^3.6.2",
54
- "release-it": "^19.0.6",
55
- "rimraf": "^6.1.2",
56
- "rollup": "^4.53.3",
57
- "rollup-plugin-dts": "^6.2.3",
58
- "serverless": "^4.25.0",
48
+ "eslint-plugin-prettier": "^5.5.6",
49
+ "eslint-plugin-simple-import-sort": "^13.0.0",
50
+ "fs-extra": "^11.3.6",
51
+ "knip": "^6.27.0",
52
+ "lefthook": "^2.1.10",
53
+ "prettier": "^3.9.5",
54
+ "release-it": "^20.2.1",
55
+ "rimraf": "^6.1.3",
56
+ "rollup": "^4.62.2",
57
+ "rollup-plugin-dts": "^6.4.1",
58
+ "serverless": "^4.39.0",
59
59
  "serverless-apigateway-log-retention": "^1.1.0",
60
60
  "serverless-deployment-bucket": "^1.6.0",
61
- "serverless-domain-manager": "^8.0.0",
62
- "serverless-offline": "^14.4.0",
61
+ "serverless-domain-manager": "^10.2.0",
62
+ "serverless-offline": "^14.7.4",
63
63
  "serverless-plugin-common-excludes": "^4.0.0",
64
- "tsx": "^4.20.6",
65
- "typedoc": "^0.28.14",
66
- "typedoc-plugin-mdn-links": "^5.0.10",
64
+ "tsx": "^4.23.1",
65
+ "typedoc": "^0.28.20",
66
+ "typedoc-plugin-mdn-links": "^5.1.1",
67
67
  "typedoc-plugin-replace-text": "^4.2.0",
68
68
  "typedoc-plugin-zod": "^1.4.3",
69
- "typescript": "^5.9.3",
70
- "typescript-eslint": "^8.48.0",
71
- "vite-tsconfig-paths": "^5.1.4",
72
- "vitest": "^4.0.13",
73
- "zod-openapi": "^5.4.3"
69
+ "typescript": "^6.0.3",
70
+ "typescript-eslint": "^8.64.0",
71
+ "vitest": "^4.1.10",
72
+ "zod-openapi": "^6.0.0"
74
73
  },
75
74
  "engines": {
76
75
  "node": ">=22.19.0"
@@ -184,7 +183,7 @@
184
183
  "templates:lint": "eslint --fix -c templates/default/eslint.config.ts \"templates/default/**/*.{ts,tsx,js,jsx}\" \"templates/default/eslint.config.ts\""
185
184
  },
186
185
  "type": "module",
187
- "version": "0.2.12",
186
+ "version": "0.2.14",
188
187
  "volta": {
189
188
  "node": "22.19.0"
190
189
  }