@web-ts-toolkit/express-runtime 0.43.0 → 0.44.0

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.
@@ -5,6 +5,7 @@ import serverless from "serverless-http";
5
5
 
6
6
  // src/numeric-validation.ts
7
7
  var MAX_INTEGER_OPTION_VALUE = Number.MAX_SAFE_INTEGER;
8
+ var MAX_TIMER_DURATION_MS = 2147483647;
8
9
  function validateFiniteInteger(value, options) {
9
10
  const min = options.min ?? Number.MIN_SAFE_INTEGER;
10
11
  const max = options.max ?? MAX_INTEGER_OPTION_VALUE;
@@ -35,6 +36,9 @@ function parsePortValue(value, name) {
35
36
  }
36
37
  return value;
37
38
  }
39
+ function validateTimerDuration(value, name) {
40
+ return validateFiniteInteger(value, { name, min: 0, max: MAX_TIMER_DURATION_MS });
41
+ }
38
42
 
39
43
  // src/index.ts
40
44
  var defaultLogger = {
@@ -103,20 +107,19 @@ function defaultRequestHook(req, maxBodyBytes = 1024 * 1024, logger = defaultLog
103
107
  logger.debug?.(" Skipping oversized serverless body for content-type parsing");
104
108
  return;
105
109
  }
106
- const bodyStr = req.body.toString("utf8");
107
110
  const contentType = getHeaderValue(req.headers, "content-type");
108
111
  if (isJsonMediaType(contentType)) {
109
112
  if (isReadableRequest(req)) {
110
113
  return;
111
114
  }
112
115
  try {
113
- req.body = JSON.parse(bodyStr);
116
+ req.body = JSON.parse(req.body.toString("utf8"));
114
117
  } catch (_error) {
115
118
  void _error;
116
119
  }
117
120
  return;
118
121
  }
119
- req.body = bodyStr;
122
+ req.body = req.body.toString("utf8");
120
123
  }
121
124
  function getHeaderValue(headers, name) {
122
125
  if (!headers) return "";
@@ -216,11 +219,7 @@ function startLocalServer(app, options = {}) {
216
219
  const logger = options.logger ?? defaultLogger;
217
220
  const port = normalizePort(options.port);
218
221
  const host = options.host ?? process.env.HOST ?? "0.0.0.0";
219
- const shutdownTimeout = validateFiniteInteger(options.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT, {
220
- name: "shutdownTimeout",
221
- min: 0,
222
- max: MAX_INTEGER_OPTION_VALUE
223
- });
222
+ const shutdownTimeout = validateTimerDuration(options.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT, "shutdownTimeout");
224
223
  const server = http.createServer(app);
225
224
  app.set("port", port);
226
225
  let state = "initializing";
@@ -392,6 +391,7 @@ function startLocalServer(app, options = {}) {
392
391
  done();
393
392
  }
394
393
  });
394
+ let shutdownFailed = false;
395
395
  let shutdownError;
396
396
  try {
397
397
  if (options.onShutdown) {
@@ -399,9 +399,10 @@ function startLocalServer(app, options = {}) {
399
399
  }
400
400
  } catch (err) {
401
401
  logger.error("onShutdown hook failed:", err);
402
+ shutdownFailed = true;
402
403
  shutdownError = err;
403
404
  }
404
- if (shutdownError) {
405
+ if (shutdownFailed) {
405
406
  state = "failed";
406
407
  if (options.exitAfterShutdown) {
407
408
  process.exit(1);
@@ -420,7 +421,7 @@ function startLocalServer(app, options = {}) {
420
421
  };
421
422
  if (options.signals !== false) {
422
423
  const list = options.signals === void 0 || options.signals === true ? DEFAULT_SIGNALS : options.signals;
423
- for (const sig of list) {
424
+ for (const sig of new Set(list)) {
424
425
  const handler = () => {
425
426
  void shutdown().catch(() => {
426
427
  });
@@ -477,8 +478,10 @@ function startLocalServer(app, options = {}) {
477
478
 
478
479
  export {
479
480
  MAX_INTEGER_OPTION_VALUE,
481
+ MAX_TIMER_DURATION_MS,
480
482
  validateFiniteInteger,
481
483
  parsePortValue,
484
+ validateTimerDuration,
482
485
  createExpressApp,
483
486
  defaultRequestHook,
484
487
  createServerlessHandler,
package/cli-api.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { LocalServerOptions, LocalServer } from './index.mjs';
2
- import { watch, existsSync } from 'node:fs';
2
+ import { watch, mkdtempSync, lstatSync, writeFileSync, rmSync, existsSync } from 'node:fs';
3
3
  import { ChildProcess, fork } from 'node:child_process';
4
4
  import { Response, Request, Express } from 'express';
5
5
  import 'node:http';
@@ -25,7 +25,7 @@ interface DevArgs {
25
25
  watch: string[];
26
26
  /** File extensions to watch (default: ts,js,mjs,cjs,json). */
27
27
  watchExt: string[];
28
- /** Debounce delay (ms) before restarting on file change (default: 500). */
28
+ /** Debounce delay (ms) before restarting on file change (default: 500). Must be a finite integer in `0..2147483647` (Node timer limit); `0` restarts without debouncing. */
29
29
  watchDelay: number;
30
30
  }
31
31
  interface BuildArgs {
@@ -125,6 +125,13 @@ declare function loadEnvFiles(paths: string[]): void;
125
125
  * loading the app module. Each module is `require()`-ed, running its
126
126
  * side effects (registering hooks, loading configs, etc.).
127
127
  *
128
+ * Resolution uses the current working directory captured when preloading
129
+ * starts, so programmatic consumers that change cwd between invocations
130
+ * resolve relative preloads (and bare dependencies) against the current
131
+ * invocation — consistent with call-time `loadEnvFiles`/`loadApp` — rather
132
+ * than the directory that was current when this module was first evaluated.
133
+ * Preloads run sequentially in list order. No sandbox is applied.
134
+ *
128
135
  * Public helper for programmatic CLI integrations that need the same preload
129
136
  * behavior as the binary before loading an app or handler module.
130
137
  */
@@ -139,6 +146,7 @@ interface WatchSupervisorDeps {
139
146
  watch?: typeof watch;
140
147
  existsSync?: typeof existsSync;
141
148
  logger?: Pick<Console, 'error'>;
149
+ /** Ms before SIGTERM escalates to SIGKILL. Must be a finite integer in `0..2147483647` (Node timer limit). */
142
150
  killTimeoutMs?: number;
143
151
  setTimeout?: typeof setTimeout;
144
152
  clearTimeout?: typeof clearTimeout;
@@ -163,6 +171,11 @@ interface WatchSupervisorController {
163
171
  * Reconstruct the argv for the child process, stripping --watch/--ext/--delay
164
172
  * flags (the child runs without watch mode).
165
173
  *
174
+ * Generated options are placed before `--`, with the positional app module
175
+ * after it, so leading-dash module paths (e.g. `--app.js`, `--help`) parsed
176
+ * via `dev --watch ./src -- --app.js` keep their `--` protection and are not
177
+ * reinterpreted as flags (or help/version requests) by the child parser.
178
+ *
166
179
  * Public helper for CLI wrappers that supervise watch mode themselves and need
167
180
  * the same child argv reconstruction as `runWithWatch`.
168
181
  */
@@ -208,11 +221,53 @@ declare function generateRuntimeEntry(appPath: string, initPath?: string): strin
208
221
  * Public safety check for programmatic build integrations before invoking
209
222
  * `buildBundleFromEntryContent()` with `clean: true`.
210
223
  */
224
+ /**
225
+ * Validate that `outDir` is safe to clean before invoking tsup.
226
+ * Prevents destructive `clean: true` combinations:
227
+ * - filesystem root (physical)
228
+ * - project cwd itself (physical)
229
+ * - ancestors of the project cwd (physical, through symlinked aliases)
230
+ * - symlinked output directories
231
+ * - output that physically contains input files (appPath/initPath), or is
232
+ * physically nested inside an input path
233
+ *
234
+ * Physical comparison canonicalizes cwd, outDir (via its nearest existing
235
+ * ancestor when it does not exist yet), and supplied input paths with
236
+ * `realpath`. Unexpected filesystem errors fail closed. Only `clean: false`
237
+ * skips validation.
238
+ *
239
+ * Public safety check for programmatic build integrations before invoking
240
+ * `buildBundleFromEntryContent()` with `clean: true`.
241
+ */
211
242
  declare function validateOutDirForClean(outDir: string, clean: boolean, appPath?: string, initPath?: string): void;
212
- declare function buildBundleFromEntryContent(args: BuildEntryContentArgs): Promise<void>;
243
+ /** Injectable filesystem/build seams for deterministic staging-failure tests. */
244
+ interface BuildStagingDeps {
245
+ mkdtempSyncImpl?: typeof mkdtempSync;
246
+ lstatSyncImpl?: typeof lstatSync;
247
+ writeFileSyncImpl?: typeof writeFileSync;
248
+ rmSyncImpl?: typeof rmSync;
249
+ buildImpl?: (options: {
250
+ config: false;
251
+ entry: Record<string, string>;
252
+ tsconfig?: string;
253
+ format: string[];
254
+ target: string;
255
+ outDir: string;
256
+ clean: boolean;
257
+ external: string[];
258
+ sourcemap: boolean;
259
+ dts: boolean;
260
+ splitting: boolean;
261
+ }) => Promise<void>;
262
+ }
263
+ declare function buildBundleFromEntryContent(args: BuildEntryContentArgs, deps?: BuildStagingDeps): Promise<void>;
213
264
  /**
214
265
  * Bundle an Express app as a local runtime module. The output default-exports
215
266
  * the app and may additionally export an `init` hook for the `start` command.
267
+ *
268
+ * `express` and `@web-ts-toolkit/express-runtime` are always external, so the
269
+ * bundle must be deployed with both packages installed (`express` is a peer
270
+ * dependency). Additional externals can be passed via `BuildArgs.external`.
216
271
  */
217
272
  declare function buildRuntime(args: BuildArgs): Promise<void>;
218
273
  /**
@@ -220,17 +275,36 @@ declare function buildRuntime(args: BuildArgs): Promise<void>;
220
275
  * to the user's cwd (for node_modules resolution), lazy-loads the bundled
221
276
  * build tool, then cleans up.
222
277
  *
223
- * `express` is always external; additional externals can be passed via
224
- * `BuildArgs.external`.
278
+ * `express` and `@web-ts-toolkit/express-runtime` (imported by the generated
279
+ * entry) are always external; additional externals can be passed via
280
+ * `BuildArgs.external`. Deploy the bundle together with both packages
281
+ * installed (`express` is a peer dependency; `serverless-http` ships with the
282
+ * runtime package).
225
283
  */
226
284
  declare function buildServerless(args: BuildArgs): Promise<void>;
227
285
  /**
228
- * A platform-agnostic serverless handler function (the output of
229
- * `build-serverless`).
286
+ * A serverless handler callable as invoked by the local `start-serverless`
287
+ * adapter. The adapter supplies an AWS API Gateway REST API v1 event (see
288
+ * `ApiGatewayRestEvent`) and an empty record context (`{}`), then validates
289
+ * the unknown result via `applyServerlessResult`.
290
+ *
291
+ * Keep the parameters narrow: handlers requiring provider-specific event
292
+ * fields or rich Lambda-like contexts must not typecheck here, since the
293
+ * local adapter cannot supply them. The default
294
+ * `ServerlessHandler<Record<string, unknown>, Record<string, unknown>>` from
295
+ * `createServerlessHandler(app)` remains assignable, so cast-free
296
+ * `createServerlessAdapterApp(createServerlessHandler(app))` composition
297
+ * compiles.
298
+ */
299
+ type GenericHandler = (event: ApiGatewayRestEvent, context: Record<string, unknown>) => Promise<unknown>;
300
+ /**
301
+ * AWS API Gateway REST API v1 / Lambda proxy event shape emitted by the local adapter.
302
+ *
303
+ * Declared as a type alias (not an interface) so the implicit index signature
304
+ * lets the default provider-generic `ServerlessHandler` (`Record<string,
305
+ * unknown>` event) accept it without casts.
230
306
  */
231
- type GenericHandler = (event: unknown, context: unknown) => Promise<unknown>;
232
- /** AWS API Gateway REST API v1 / Lambda proxy event shape emitted by the local adapter. */
233
- interface ApiGatewayRestEvent {
307
+ type ApiGatewayRestEvent = {
234
308
  httpMethod: string;
235
309
  path: string;
236
310
  headers: Record<string, string>;
@@ -244,7 +318,7 @@ interface ApiGatewayRestEvent {
244
318
  sourceIp: string;
245
319
  };
246
320
  };
247
- }
321
+ };
248
322
  /**
249
323
  * AWS API Gateway REST API v1 / Lambda proxy result shape returned by `serverless-http`.
250
324
  */
@@ -261,7 +335,12 @@ interface ServerlessAdapterOptions {
261
335
  * Maximum bytes to buffer for a single request body.
262
336
  * Default: 1048576 (1 MiB). Must be a finite non-negative integer.
263
337
  * When `0`, no body is allowed — any non-empty body receives `413`.
264
- * The adapter never retains more than this limit plus at most one incoming chunk.
338
+ * Collection retains O(limit) chunk bytes: appending stops once the running
339
+ * total would exceed the limit (at most one chunk over the limit is observed
340
+ * before rejection). `Buffer.concat` then holds the chunks plus one output
341
+ * Buffer, and event translation adds a transient base64 copy (~4/3 of the
342
+ * body), so peak transient memory is a small multiple of the limit rather
343
+ * than an exact limit-plus-chunk ceiling.
265
344
  */
266
345
  maxBodyBytes?: number;
267
346
  }
@@ -277,19 +356,80 @@ declare function validateMaxBodyBytes(value: unknown): number;
277
356
  * bodies with a `LIMIT_EXCEEDED` error (413), stops retaining chunks after the limit,
278
357
  * removes owned listeners, and drains the request.
279
358
  * Distinguishes client aborts (`CLIENT_ABORT`) and stream errors from oversize.
359
+ *
360
+ * Memory phases (no unmeasured total-memory ceiling is claimed): chunk
361
+ * retention is O(limit) — appending stops once the running total would exceed
362
+ * `maxBytes`, so at most one chunk over the limit is observed before
363
+ * rejection; `Buffer.concat` then retains the chunks plus one output Buffer of
364
+ * the accepted size; `toServerlessEvent` adds a transient base64 copy (~4/3 of
365
+ * the body). Peak transient memory is therefore a small multiple of the limit.
280
366
  */
281
367
  declare function collectBody(req: Request, maxBytes: number): Promise<Buffer>;
282
368
  /**
283
369
  * Build an AWS API Gateway REST API v1 / Lambda proxy event from HTTP request components.
284
370
  *
371
+ * Path contract (origin-form request targets are preserved verbatim):
372
+ *
373
+ * - `url` is normally the raw origin-form target from `req.url`
374
+ * (`/path?query`). The path is everything before the first literal `?`
375
+ * (or `#`); it is never dot-segment-resolved, slash-collapsed, or
376
+ * percent-decoded. `//admin/users`, `/a/../private`, `/a/./b`,
377
+ * `/%2E%2E/private`, and `/a%2Fb` all reach the handler unchanged, so
378
+ * wrapped routing sees the same target the client sent. Query splitting
379
+ * and single-decode semantics are unchanged, and only a literal `?`
380
+ * starts the query (an encoded `%3F` stays in the path).
381
+ * - A `#fragment`, never part of a real HTTP request target, is stripped
382
+ * when present.
383
+ * - Absolute-form targets (`scheme://authority/path?query`, as sent to
384
+ * proxies) are supported by stripping the scheme and authority and
385
+ * preserving the raw path remainder (`http://h//a/../b?x=1` yields path
386
+ * `//a/../b`); a missing remainder maps to `/`. Userinfo, host, and port
387
+ * are ignored, not validated.
388
+ * - Asterisk-form (`*`, used by `OPTIONS *`) is supported with path `*`.
389
+ * - The empty string maps to `/` for backwards compatibility. Any other
390
+ * target that is neither origin-form, absolute-form, nor asterisk-form
391
+ * (e.g. `foo/bar`) is rejected with an `Error` (the local adapter turns
392
+ * this into a 500 without invoking the handler).
393
+ *
394
+ * Intentional behavior change: this helper previously split the target via
395
+ * the WHATWG `URL` parser, which rewrote origin-form paths before routing
396
+ * (`//admin/users` was parsed as host `admin` plus path `/users`,
397
+ * `/a/../private` resolved to `/private`, and even encoded `%2E%2E` was
398
+ * decoded and then resolved). Those rewrites silently selected a different
399
+ * route; they are no longer performed.
400
+ *
401
+ * Header contract (HTTP boundary fidelity):
402
+ *
403
+ * - When the Node `rawHeaders` list (`[name, value, ...]` from
404
+ * `IncomingMessage.rawHeaders`, optionally passed as the fifth argument)
405
+ * is available, both header maps are derived from it. Names are
406
+ * lowercased (HTTP names are case-insensitive, so `X-Repeat` and
407
+ * `x-repeat` merge); values are preserved verbatim in wire order and
408
+ * never split on commas (`"one, with comma"` stays one entry).
409
+ * - Otherwise the `headers` map (e.g. `req.headers` or `headersDistinct`)
410
+ * is used as-is: array values are preserved entry-wise, string values
411
+ * become single entries, and commas inside values are never split. Note
412
+ * that `req.headers` has already joined duplicates (`"one, two"`), so
413
+ * callers at the real HTTP boundary should pass `rawHeaders` (the local
414
+ * adapter does) to keep repeated headers distinct.
415
+ * - The single-value map joins each multi-value entry with `", "`; the
416
+ * multi-value map keeps every value in order. Response `set-cookie`
417
+ * handling is unchanged (`applyServerlessResult` still emits each
418
+ * `set-cookie` value as its own header and joins other multi-values
419
+ * with `","`).
420
+ *
285
421
  * Public helper for adapters that need the same AWS REST API v1 event shape as
286
422
  * the `start-serverless` command.
287
423
  */
288
- declare function toServerlessEvent(method: string, url: string, headers: Record<string, string | string[] | undefined>, body: Buffer): ApiGatewayRestEvent;
424
+ declare function toServerlessEvent(method: string, url: string, headers: Record<string, string | string[] | undefined>, body: Buffer, rawHeaders?: unknown): ApiGatewayRestEvent;
289
425
  /**
290
426
  * Write a serverless handler result to an Express response.
291
427
  * Validates the complete AWS API Gateway REST API v1 / Lambda proxy result before writing anything.
292
428
  * `multiValueHeaders` wins over `headers` when the same header appears in both maps.
429
+ * An empty `multiValueHeaders` array is omitted (no header is emitted), but its
430
+ * name is still validated so invalid names fail closed even with zero values.
431
+ * If header/body application fails before headers are sent, any headers staged
432
+ * by this call are removed before the error propagates so a fallback 500 stays clean.
293
433
  *
294
434
  * Public helper for adapters that need the same AWS REST API v1 result-to-HTTP
295
435
  * translation as the `start-serverless` command.
@@ -298,14 +438,22 @@ declare function applyServerlessResult(result: unknown, res: Response): void;
298
438
  /**
299
439
  * Create an Express app that proxies all requests to a serverless handler.
300
440
  * Each HTTP request is translated into a serverless event, the handler is
301
- * invoked, and the result is written back to the response.
441
+ * invoked as `handler(event, {})` where `event` is an `ApiGatewayRestEvent`
442
+ * and the context is an empty record, and the result is written back to the
443
+ * response.
302
444
  *
303
- * Express body parsers are disabled; the raw request body is read directly
304
- * from the stream and passed as a Buffer (so the serverless handler's request
305
- * hook including the #305 workaround works identically to production).
445
+ * Express body parsers are disabled; the raw request body is buffered with
446
+ * `collectBody` and then base64-encoded into the AWS v1 string `body` field
447
+ * (`isBase64Encoded` is true for non-empty bodies, false with `body: ''` for
448
+ * empty ones), so `serverless-http` replays the decoded bytes through the
449
+ * Express request stream identically to production.
306
450
  * Bodies exceeding `maxBodyBytes` (default 1 MiB, 0 = empty bodies only) receive
307
451
  * `413 Payload Too Large` without invoking the handler; the request is drained
308
- * and retained memory is bounded to the limit plus at most one chunk.
452
+ * and chunk retention is O(limit) appending stops once the running total
453
+ * would exceed the limit, so at most one chunk over the limit is observed
454
+ * before rejection. `Buffer.concat` retains the chunks plus one output Buffer
455
+ * and event translation adds a transient base64 copy (~4/3 of the body), so
456
+ * peak transient memory is a small multiple of the limit.
309
457
  */
310
458
  declare function createServerlessAdapterApp(handler: GenericHandler, options?: ServerlessAdapterOptions): Express;
311
459
  /**
package/cli-api.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { LocalServerOptions, LocalServer } from './index.js';
2
- import { watch, existsSync } from 'node:fs';
2
+ import { watch, mkdtempSync, lstatSync, writeFileSync, rmSync, existsSync } from 'node:fs';
3
3
  import { ChildProcess, fork } from 'node:child_process';
4
4
  import { Response, Request, Express } from 'express';
5
5
  import 'node:http';
@@ -25,7 +25,7 @@ interface DevArgs {
25
25
  watch: string[];
26
26
  /** File extensions to watch (default: ts,js,mjs,cjs,json). */
27
27
  watchExt: string[];
28
- /** Debounce delay (ms) before restarting on file change (default: 500). */
28
+ /** Debounce delay (ms) before restarting on file change (default: 500). Must be a finite integer in `0..2147483647` (Node timer limit); `0` restarts without debouncing. */
29
29
  watchDelay: number;
30
30
  }
31
31
  interface BuildArgs {
@@ -125,6 +125,13 @@ declare function loadEnvFiles(paths: string[]): void;
125
125
  * loading the app module. Each module is `require()`-ed, running its
126
126
  * side effects (registering hooks, loading configs, etc.).
127
127
  *
128
+ * Resolution uses the current working directory captured when preloading
129
+ * starts, so programmatic consumers that change cwd between invocations
130
+ * resolve relative preloads (and bare dependencies) against the current
131
+ * invocation — consistent with call-time `loadEnvFiles`/`loadApp` — rather
132
+ * than the directory that was current when this module was first evaluated.
133
+ * Preloads run sequentially in list order. No sandbox is applied.
134
+ *
128
135
  * Public helper for programmatic CLI integrations that need the same preload
129
136
  * behavior as the binary before loading an app or handler module.
130
137
  */
@@ -139,6 +146,7 @@ interface WatchSupervisorDeps {
139
146
  watch?: typeof watch;
140
147
  existsSync?: typeof existsSync;
141
148
  logger?: Pick<Console, 'error'>;
149
+ /** Ms before SIGTERM escalates to SIGKILL. Must be a finite integer in `0..2147483647` (Node timer limit). */
142
150
  killTimeoutMs?: number;
143
151
  setTimeout?: typeof setTimeout;
144
152
  clearTimeout?: typeof clearTimeout;
@@ -163,6 +171,11 @@ interface WatchSupervisorController {
163
171
  * Reconstruct the argv for the child process, stripping --watch/--ext/--delay
164
172
  * flags (the child runs without watch mode).
165
173
  *
174
+ * Generated options are placed before `--`, with the positional app module
175
+ * after it, so leading-dash module paths (e.g. `--app.js`, `--help`) parsed
176
+ * via `dev --watch ./src -- --app.js` keep their `--` protection and are not
177
+ * reinterpreted as flags (or help/version requests) by the child parser.
178
+ *
166
179
  * Public helper for CLI wrappers that supervise watch mode themselves and need
167
180
  * the same child argv reconstruction as `runWithWatch`.
168
181
  */
@@ -208,11 +221,53 @@ declare function generateRuntimeEntry(appPath: string, initPath?: string): strin
208
221
  * Public safety check for programmatic build integrations before invoking
209
222
  * `buildBundleFromEntryContent()` with `clean: true`.
210
223
  */
224
+ /**
225
+ * Validate that `outDir` is safe to clean before invoking tsup.
226
+ * Prevents destructive `clean: true` combinations:
227
+ * - filesystem root (physical)
228
+ * - project cwd itself (physical)
229
+ * - ancestors of the project cwd (physical, through symlinked aliases)
230
+ * - symlinked output directories
231
+ * - output that physically contains input files (appPath/initPath), or is
232
+ * physically nested inside an input path
233
+ *
234
+ * Physical comparison canonicalizes cwd, outDir (via its nearest existing
235
+ * ancestor when it does not exist yet), and supplied input paths with
236
+ * `realpath`. Unexpected filesystem errors fail closed. Only `clean: false`
237
+ * skips validation.
238
+ *
239
+ * Public safety check for programmatic build integrations before invoking
240
+ * `buildBundleFromEntryContent()` with `clean: true`.
241
+ */
211
242
  declare function validateOutDirForClean(outDir: string, clean: boolean, appPath?: string, initPath?: string): void;
212
- declare function buildBundleFromEntryContent(args: BuildEntryContentArgs): Promise<void>;
243
+ /** Injectable filesystem/build seams for deterministic staging-failure tests. */
244
+ interface BuildStagingDeps {
245
+ mkdtempSyncImpl?: typeof mkdtempSync;
246
+ lstatSyncImpl?: typeof lstatSync;
247
+ writeFileSyncImpl?: typeof writeFileSync;
248
+ rmSyncImpl?: typeof rmSync;
249
+ buildImpl?: (options: {
250
+ config: false;
251
+ entry: Record<string, string>;
252
+ tsconfig?: string;
253
+ format: string[];
254
+ target: string;
255
+ outDir: string;
256
+ clean: boolean;
257
+ external: string[];
258
+ sourcemap: boolean;
259
+ dts: boolean;
260
+ splitting: boolean;
261
+ }) => Promise<void>;
262
+ }
263
+ declare function buildBundleFromEntryContent(args: BuildEntryContentArgs, deps?: BuildStagingDeps): Promise<void>;
213
264
  /**
214
265
  * Bundle an Express app as a local runtime module. The output default-exports
215
266
  * the app and may additionally export an `init` hook for the `start` command.
267
+ *
268
+ * `express` and `@web-ts-toolkit/express-runtime` are always external, so the
269
+ * bundle must be deployed with both packages installed (`express` is a peer
270
+ * dependency). Additional externals can be passed via `BuildArgs.external`.
216
271
  */
217
272
  declare function buildRuntime(args: BuildArgs): Promise<void>;
218
273
  /**
@@ -220,17 +275,36 @@ declare function buildRuntime(args: BuildArgs): Promise<void>;
220
275
  * to the user's cwd (for node_modules resolution), lazy-loads the bundled
221
276
  * build tool, then cleans up.
222
277
  *
223
- * `express` is always external; additional externals can be passed via
224
- * `BuildArgs.external`.
278
+ * `express` and `@web-ts-toolkit/express-runtime` (imported by the generated
279
+ * entry) are always external; additional externals can be passed via
280
+ * `BuildArgs.external`. Deploy the bundle together with both packages
281
+ * installed (`express` is a peer dependency; `serverless-http` ships with the
282
+ * runtime package).
225
283
  */
226
284
  declare function buildServerless(args: BuildArgs): Promise<void>;
227
285
  /**
228
- * A platform-agnostic serverless handler function (the output of
229
- * `build-serverless`).
286
+ * A serverless handler callable as invoked by the local `start-serverless`
287
+ * adapter. The adapter supplies an AWS API Gateway REST API v1 event (see
288
+ * `ApiGatewayRestEvent`) and an empty record context (`{}`), then validates
289
+ * the unknown result via `applyServerlessResult`.
290
+ *
291
+ * Keep the parameters narrow: handlers requiring provider-specific event
292
+ * fields or rich Lambda-like contexts must not typecheck here, since the
293
+ * local adapter cannot supply them. The default
294
+ * `ServerlessHandler<Record<string, unknown>, Record<string, unknown>>` from
295
+ * `createServerlessHandler(app)` remains assignable, so cast-free
296
+ * `createServerlessAdapterApp(createServerlessHandler(app))` composition
297
+ * compiles.
298
+ */
299
+ type GenericHandler = (event: ApiGatewayRestEvent, context: Record<string, unknown>) => Promise<unknown>;
300
+ /**
301
+ * AWS API Gateway REST API v1 / Lambda proxy event shape emitted by the local adapter.
302
+ *
303
+ * Declared as a type alias (not an interface) so the implicit index signature
304
+ * lets the default provider-generic `ServerlessHandler` (`Record<string,
305
+ * unknown>` event) accept it without casts.
230
306
  */
231
- type GenericHandler = (event: unknown, context: unknown) => Promise<unknown>;
232
- /** AWS API Gateway REST API v1 / Lambda proxy event shape emitted by the local adapter. */
233
- interface ApiGatewayRestEvent {
307
+ type ApiGatewayRestEvent = {
234
308
  httpMethod: string;
235
309
  path: string;
236
310
  headers: Record<string, string>;
@@ -244,7 +318,7 @@ interface ApiGatewayRestEvent {
244
318
  sourceIp: string;
245
319
  };
246
320
  };
247
- }
321
+ };
248
322
  /**
249
323
  * AWS API Gateway REST API v1 / Lambda proxy result shape returned by `serverless-http`.
250
324
  */
@@ -261,7 +335,12 @@ interface ServerlessAdapterOptions {
261
335
  * Maximum bytes to buffer for a single request body.
262
336
  * Default: 1048576 (1 MiB). Must be a finite non-negative integer.
263
337
  * When `0`, no body is allowed — any non-empty body receives `413`.
264
- * The adapter never retains more than this limit plus at most one incoming chunk.
338
+ * Collection retains O(limit) chunk bytes: appending stops once the running
339
+ * total would exceed the limit (at most one chunk over the limit is observed
340
+ * before rejection). `Buffer.concat` then holds the chunks plus one output
341
+ * Buffer, and event translation adds a transient base64 copy (~4/3 of the
342
+ * body), so peak transient memory is a small multiple of the limit rather
343
+ * than an exact limit-plus-chunk ceiling.
265
344
  */
266
345
  maxBodyBytes?: number;
267
346
  }
@@ -277,19 +356,80 @@ declare function validateMaxBodyBytes(value: unknown): number;
277
356
  * bodies with a `LIMIT_EXCEEDED` error (413), stops retaining chunks after the limit,
278
357
  * removes owned listeners, and drains the request.
279
358
  * Distinguishes client aborts (`CLIENT_ABORT`) and stream errors from oversize.
359
+ *
360
+ * Memory phases (no unmeasured total-memory ceiling is claimed): chunk
361
+ * retention is O(limit) — appending stops once the running total would exceed
362
+ * `maxBytes`, so at most one chunk over the limit is observed before
363
+ * rejection; `Buffer.concat` then retains the chunks plus one output Buffer of
364
+ * the accepted size; `toServerlessEvent` adds a transient base64 copy (~4/3 of
365
+ * the body). Peak transient memory is therefore a small multiple of the limit.
280
366
  */
281
367
  declare function collectBody(req: Request, maxBytes: number): Promise<Buffer>;
282
368
  /**
283
369
  * Build an AWS API Gateway REST API v1 / Lambda proxy event from HTTP request components.
284
370
  *
371
+ * Path contract (origin-form request targets are preserved verbatim):
372
+ *
373
+ * - `url` is normally the raw origin-form target from `req.url`
374
+ * (`/path?query`). The path is everything before the first literal `?`
375
+ * (or `#`); it is never dot-segment-resolved, slash-collapsed, or
376
+ * percent-decoded. `//admin/users`, `/a/../private`, `/a/./b`,
377
+ * `/%2E%2E/private`, and `/a%2Fb` all reach the handler unchanged, so
378
+ * wrapped routing sees the same target the client sent. Query splitting
379
+ * and single-decode semantics are unchanged, and only a literal `?`
380
+ * starts the query (an encoded `%3F` stays in the path).
381
+ * - A `#fragment`, never part of a real HTTP request target, is stripped
382
+ * when present.
383
+ * - Absolute-form targets (`scheme://authority/path?query`, as sent to
384
+ * proxies) are supported by stripping the scheme and authority and
385
+ * preserving the raw path remainder (`http://h//a/../b?x=1` yields path
386
+ * `//a/../b`); a missing remainder maps to `/`. Userinfo, host, and port
387
+ * are ignored, not validated.
388
+ * - Asterisk-form (`*`, used by `OPTIONS *`) is supported with path `*`.
389
+ * - The empty string maps to `/` for backwards compatibility. Any other
390
+ * target that is neither origin-form, absolute-form, nor asterisk-form
391
+ * (e.g. `foo/bar`) is rejected with an `Error` (the local adapter turns
392
+ * this into a 500 without invoking the handler).
393
+ *
394
+ * Intentional behavior change: this helper previously split the target via
395
+ * the WHATWG `URL` parser, which rewrote origin-form paths before routing
396
+ * (`//admin/users` was parsed as host `admin` plus path `/users`,
397
+ * `/a/../private` resolved to `/private`, and even encoded `%2E%2E` was
398
+ * decoded and then resolved). Those rewrites silently selected a different
399
+ * route; they are no longer performed.
400
+ *
401
+ * Header contract (HTTP boundary fidelity):
402
+ *
403
+ * - When the Node `rawHeaders` list (`[name, value, ...]` from
404
+ * `IncomingMessage.rawHeaders`, optionally passed as the fifth argument)
405
+ * is available, both header maps are derived from it. Names are
406
+ * lowercased (HTTP names are case-insensitive, so `X-Repeat` and
407
+ * `x-repeat` merge); values are preserved verbatim in wire order and
408
+ * never split on commas (`"one, with comma"` stays one entry).
409
+ * - Otherwise the `headers` map (e.g. `req.headers` or `headersDistinct`)
410
+ * is used as-is: array values are preserved entry-wise, string values
411
+ * become single entries, and commas inside values are never split. Note
412
+ * that `req.headers` has already joined duplicates (`"one, two"`), so
413
+ * callers at the real HTTP boundary should pass `rawHeaders` (the local
414
+ * adapter does) to keep repeated headers distinct.
415
+ * - The single-value map joins each multi-value entry with `", "`; the
416
+ * multi-value map keeps every value in order. Response `set-cookie`
417
+ * handling is unchanged (`applyServerlessResult` still emits each
418
+ * `set-cookie` value as its own header and joins other multi-values
419
+ * with `","`).
420
+ *
285
421
  * Public helper for adapters that need the same AWS REST API v1 event shape as
286
422
  * the `start-serverless` command.
287
423
  */
288
- declare function toServerlessEvent(method: string, url: string, headers: Record<string, string | string[] | undefined>, body: Buffer): ApiGatewayRestEvent;
424
+ declare function toServerlessEvent(method: string, url: string, headers: Record<string, string | string[] | undefined>, body: Buffer, rawHeaders?: unknown): ApiGatewayRestEvent;
289
425
  /**
290
426
  * Write a serverless handler result to an Express response.
291
427
  * Validates the complete AWS API Gateway REST API v1 / Lambda proxy result before writing anything.
292
428
  * `multiValueHeaders` wins over `headers` when the same header appears in both maps.
429
+ * An empty `multiValueHeaders` array is omitted (no header is emitted), but its
430
+ * name is still validated so invalid names fail closed even with zero values.
431
+ * If header/body application fails before headers are sent, any headers staged
432
+ * by this call are removed before the error propagates so a fallback 500 stays clean.
293
433
  *
294
434
  * Public helper for adapters that need the same AWS REST API v1 result-to-HTTP
295
435
  * translation as the `start-serverless` command.
@@ -298,14 +438,22 @@ declare function applyServerlessResult(result: unknown, res: Response): void;
298
438
  /**
299
439
  * Create an Express app that proxies all requests to a serverless handler.
300
440
  * Each HTTP request is translated into a serverless event, the handler is
301
- * invoked, and the result is written back to the response.
441
+ * invoked as `handler(event, {})` where `event` is an `ApiGatewayRestEvent`
442
+ * and the context is an empty record, and the result is written back to the
443
+ * response.
302
444
  *
303
- * Express body parsers are disabled; the raw request body is read directly
304
- * from the stream and passed as a Buffer (so the serverless handler's request
305
- * hook including the #305 workaround works identically to production).
445
+ * Express body parsers are disabled; the raw request body is buffered with
446
+ * `collectBody` and then base64-encoded into the AWS v1 string `body` field
447
+ * (`isBase64Encoded` is true for non-empty bodies, false with `body: ''` for
448
+ * empty ones), so `serverless-http` replays the decoded bytes through the
449
+ * Express request stream identically to production.
306
450
  * Bodies exceeding `maxBodyBytes` (default 1 MiB, 0 = empty bodies only) receive
307
451
  * `413 Payload Too Large` without invoking the handler; the request is drained
308
- * and retained memory is bounded to the limit plus at most one chunk.
452
+ * and chunk retention is O(limit) appending stops once the running total
453
+ * would exceed the limit, so at most one chunk over the limit is observed
454
+ * before rejection. `Buffer.concat` retains the chunks plus one output Buffer
455
+ * and event translation adds a transient base64 copy (~4/3 of the body), so
456
+ * peak transient memory is a small multiple of the limit.
309
457
  */
310
458
  declare function createServerlessAdapterApp(handler: GenericHandler, options?: ServerlessAdapterOptions): Express;
311
459
  /**