@web-ts-toolkit/express-runtime 0.40.1 → 0.42.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.
package/README.md CHANGED
@@ -9,17 +9,26 @@ path.
9
9
 
10
10
  ```sh
11
11
  pnpm add @web-ts-toolkit/express-runtime express
12
+ pnpm add -D @types/express @types/node
12
13
  ```
13
14
 
15
+ `express` and `@types/express` are peer dependencies because the public
16
+ declarations expose Express request, response, router, and app types. TypeScript
17
+ Node projects should also have Node types available.
18
+
19
+ The installed `wtt-express-runtime --version` command reports the version from
20
+ the installed package manifest, so release-staged packages print the published
21
+ package version.
22
+
14
23
  ## Highlights
15
24
 
16
25
  - `createExpressApp()` — Express factory with pluggable lifecycle slots
17
26
  (`preMiddleware`, `middleware`, `routers`, `postMiddleware`, `finalize`,
18
27
  `errorHandler`), hardening defaults, and per-logger injection.
19
- - `createServerlessHandler()` — wraps an Express app as a platform-agnostic
20
- serverless handler (Netlify, Vercel, AWS Lambda) with a Buffer-body
21
- workaround for serverless-http issue #305, a memoized `init` hook for cold
22
- starts, and a `reset()` escape hatch for failed cold starts.
28
+ - `createServerlessHandler()` — wraps an Express app as a serverless handler
29
+ backed by serverless-http 4, with provider options for supported deployments, body
30
+ stream handling, a memoized `init` hook for cold starts, and a `reset()`
31
+ escape hatch for settled failed cold starts.
23
32
  - `startLocalServer()` — `http.createServer` + `listen` with friendly
24
33
  `EADDRINUSE` / `EACCES` errors, optional graceful `SIGINT` / `SIGTERM`
25
34
  shutdown that drains in-flight requests, and a configurable timeout.
@@ -102,7 +111,7 @@ export default createExpressApp({
102
111
  For TypeScript app modules, run the CLI through `tsx`:
103
112
 
104
113
  ```sh
105
- npx tsx ./node_modules/@web-ts-toolkit/express-runtime/dist/cli.js dev ./src/app.ts
114
+ npx tsx ./node_modules/@web-ts-toolkit/express-runtime/cli.js dev ./src/app.ts
106
115
  ```
107
116
 
108
117
  #### CLI — dev with env, require, and watch
@@ -113,7 +122,7 @@ TS path aliases) before the app module is loaded. `--watch` forks a child
113
122
  process running the server and restarts it on file changes:
114
123
 
115
124
  ```sh
116
- npx tsx ./node_modules/@web-ts-toolkit/express-runtime/dist/cli.js dev ./src/app.ts \
125
+ npx tsx ./node_modules/@web-ts-toolkit/express-runtime/cli.js dev ./src/app.ts \
117
126
  --env .env \
118
127
  --require tsconfig-paths/register \
119
128
  --watch ./src,./shared \
@@ -124,9 +133,12 @@ npx tsx ./node_modules/@web-ts-toolkit/express-runtime/dist/cli.js dev ./src/app
124
133
  > `#` comments). For advanced dotenv features (multiline, variable expansion),
125
134
  > use `--require dotenv/config` instead.
126
135
  >
127
- > `--watch` uses Node 20+'s `fs.watch` with `{ recursive: true }` and forks the
128
- > same CLI as a child process. On file change, the child receives `SIGTERM`,
129
- > waits for it to exit, and respawns after the debounce delay (`--delay`).
136
+ > `--watch` uses Node 20+'s `fs.watch` with `{ recursive: true }` and forks one
137
+ > child running the same CLI without watch flags. File changes are serialized
138
+ > into one restart at a time: the child receives `SIGTERM`, is escalated to
139
+ > `SIGKILL` after 5 seconds if it does not exit, and is respawned after the
140
+ > debounce delay (`--delay`). Shutdown closes owned watchers and signal handlers
141
+ > and cannot respawn after shutdown begins.
130
142
 
131
143
  > The `dev` command evaluates arbitrary code from `<app-module>` in the current
132
144
  > process and inherits its privileges. Init logic (e.g. DB connections) should
@@ -190,8 +202,8 @@ npx wtt-express-runtime build-serverless ./src/app.ts --init ./src/init.ts --out
190
202
  ```
191
203
 
192
204
  This produces `netlify/functions/handler.js` (configurable via `--out-name`)
193
- that exports a `handler` function compatible with Netlify, Vercel, AWS Lambda,
194
- and any platform that calls `(event, context)`.
205
+ that exports a `handler` function using `serverless-http`. Choose provider
206
+ options in `createServerlessHandler()` for the deployment platform you run on.
195
207
 
196
208
  ### CLI — start-serverless (run a bundled handler locally)
197
209
 
@@ -204,9 +216,62 @@ npx wtt-express-runtime build-serverless ./src/app.ts --out-dir dist
204
216
  npx wtt-express-runtime start-serverless ./dist/handler.js --port 9000 --env .env
205
217
  ```
206
218
 
207
- > The adapter passes the raw request body as a Buffer (no body parsing) so the
208
- > handler's request hook, including the serverless-http #305 workaround, runs
209
- > identically to production.
219
+ > The adapter does not parse the HTTP request body. Non-empty bodies are encoded
220
+ > into the AWS v1 event as base64 strings, and the generated handler uses
221
+ > serverless-http 4 to replay the decoded bytes through the Express request
222
+ > stream so Express body parsers can parse JSON once and enforce their own parser
223
+ > limits.
224
+ >
225
+ > The local adapter intentionally emulates one provider shape: **AWS API Gateway
226
+ > REST API v1 / Lambda proxy integration**. It emits `httpMethod`, pathname-only
227
+ > `path`, single-value `headers`, `multiValueHeaders`, `queryStringParameters`,
228
+ > `multiValueQueryStringParameters`, string `body`, `isBase64Encoded`, and the
229
+ > minimal `requestContext.identity.sourceIp` field required by `serverless-http`.
230
+ > It does not emulate Netlify, Vercel, HTTP API v2, ALB, API Gateway cookies,
231
+ > authorizers, stage variables, full request-context metadata, or a trusted source
232
+ > IP.
233
+ >
234
+ > The incoming URL query is split from the path before the handler is invoked.
235
+ > Query keys and values are decoded once from percent-encoding, duplicate keys are
236
+ > preserved in `multiValueQueryStringParameters`, empty values are preserved as
237
+ > `''`, literal `+` signs remain `+`, and encoded delimiters such as `%26` and
238
+ > `%3D` are decoded into the field value rather than being treated as separators.
239
+ > Single-value query/header maps use the last query value and comma-joined header
240
+ > values respectively; multi-value maps are the canonical source for duplicates.
241
+ >
242
+ > Non-empty request bodies are base64-encoded in the AWS v1 event so the local
243
+ > adapter preserves arbitrary bytes. Handler results must be valid AWS v1 Lambda
244
+ > proxy results before any response data is written: `statusCode` must be an
245
+ > integer in `100..599`, headers must be strings, `multiValueHeaders` must be
246
+ > arrays of strings, `body` must be a string, and `isBase64Encoded: true` requires
247
+ > valid standard base64. If `headers` and `multiValueHeaders` contain the same
248
+ > header name, `multiValueHeaders` wins; this preserves repeated `Set-Cookie`
249
+ > values.
250
+ >
251
+ > The adapter bounds request memory: default limit is **1 MiB** (`1048576` bytes).
252
+ > A declared `Content-Length` exceeding the limit is rejected before buffering;
253
+ > chunked bodies are checked incrementally and stop retaining chunks after the
254
+ > limit — the request is drained and a `413 Payload Too Large` is returned without
255
+ > invoking the handler. Client aborts and stream errors release listeners and do
256
+ > not produce an unhandled rejection. Memory retained is at most the limit plus
257
+ > one incoming chunk.
258
+ >
259
+ > Override the limit intentionally:
260
+ >
261
+ > ```sh
262
+ > npx wtt-express-runtime start-serverless ./dist/handler.js --max-body-bytes 2097152
263
+ > ```
264
+ >
265
+ > Programmatic use:
266
+ >
267
+ > ```ts
268
+ > import { createServerlessAdapterApp } from '@web-ts-toolkit/express-runtime/cli';
269
+ > const app = createServerlessAdapterApp(handler, { maxBodyBytes: 2 * 1024 * 1024 });
270
+ > ```
271
+ >
272
+ > `maxBodyBytes` must be a finite non-negative integer. `0` means only empty bodies are allowed
273
+ > (any non-empty body receives `413`). There is no unbounded default — omit the option to use the
274
+ > 1 MiB limit.
210
275
 
211
276
  ## Module API
212
277
 
@@ -227,21 +292,31 @@ Built-in hardening: `x-powered-by` is disabled, `etag` is off. `trust proxy`
227
292
  defaults to **`false`** — opt in explicitly when behind a trusted upstream
228
293
  proxy (otherwise `X-Forwarded-*` headers can be spoofed).
229
294
 
230
- | Option | Type | Default | Description |
231
- | ------------------ | ----------------------------------------- | ----------------------------------- | ------------------------------------------------ |
232
- | `preMiddleware` | `RequestHandler[]` | `[]` | Registered before body parsers |
233
- | `middleware` | `RequestHandler[]` | `[]` | Registered after body parsers, before routers |
234
- | `postMiddleware` | `RequestHandler[]` | `[]` | Registered after all routers |
235
- | `json` | `JsonOptions \| false` | `{ limit: '1mb' }` | `express.json()` options; `false` disables |
236
- | `urlencoded` | `UrlEncodedOptions \| false` | `{ extended: false, limit: '1mb' }` | `express.urlencoded()` options; `false` disables |
237
- | `router` | `RouterMount` | — | Single router convenience |
238
- | `routers` | `RouterMount[]` | — | Multiple routers mounted in order |
239
- | `trustProxy` | `boolean \| number \| string \| string[]` | `false` | Express `trust proxy` setting |
240
- | `disablePoweredBy` | `boolean` | `true` | Disable `x-powered-by` header |
241
- | `etag` | `boolean \| string` | `false` | Express `etag` setting |
242
- | `finalize` | `(app) => void` | — | Hook to add routes that `errorHandler` catches |
243
- | `errorHandler` | `ErrorRequestHandler` | — | Error handler registered last |
244
- | `logger` | `Logger` | `console` | Logger used internally |
295
+ | Option | Type | Default | Description |
296
+ | ------------------ | ------------------------------------------- | ----------------------------------- | ------------------------------------------------ |
297
+ | `preMiddleware` | `(RequestHandler \| ErrorRequestHandler)[]` | `[]` | Registered before body parsers |
298
+ | `middleware` | `(RequestHandler \| ErrorRequestHandler)[]` | `[]` | Registered after body parsers, before routers |
299
+ | `postMiddleware` | `(RequestHandler \| ErrorRequestHandler)[]` | `[]` | Registered after all routers |
300
+ | `json` | `JsonOptions \| false` | `{ limit: '1mb' }` | `express.json()` options; `false` disables |
301
+ | `urlencoded` | `UrlEncodedOptions \| false` | `{ extended: false, limit: '1mb' }` | `express.urlencoded()` options; `false` disables |
302
+ | `router` | `RouterMount` | — | Single router convenience |
303
+ | `routers` | `RouterMount[]` | — | Multiple routers mounted in order |
304
+ | `trustProxy` | `boolean \| number \| string \| string[]` | `false` | Express `trust proxy` setting |
305
+ | `disablePoweredBy` | `boolean` | `true` | Disable `x-powered-by` header |
306
+ | `etag` | `boolean \| string` | `false` | Express `etag` setting |
307
+ | `finalize` | `(app) => void` | — | Hook to add routes that `errorHandler` catches |
308
+ | `errorHandler` | `ErrorRequestHandler` | — | Error handler registered last |
309
+ | `logger` | `Logger` | `console` | Logger used internally |
310
+
311
+ `preMiddleware`, `middleware`, and `postMiddleware` also accept Express
312
+ `ErrorRequestHandler` functions for compatibility with Express' `app.use()`
313
+ semantics, but error handlers are slot-dependent: they only catch errors from
314
+ middleware and routes registered before their slot. Use `errorHandler` for the
315
+ final app-wide error handler, or register routes in `finalize()` so the built-in
316
+ default error logger and `errorHandler` can observe them. When `errorHandler` is
317
+ omitted, `createExpressApp()` logs unhandled errors that reach the factory-owned
318
+ pipeline through `logger.error('Unhandled Express error:', err)` and delegates to
319
+ Express' default final handler.
245
320
 
246
321
  #### `RouterMount`
247
322
 
@@ -252,24 +327,52 @@ proxy (otherwise `X-Forwarded-*` headers can be spoofed).
252
327
 
253
328
  ### `createServerlessHandler(app, options?): ServerlessHandler`
254
329
 
255
- Wraps an Express app into a platform-agnostic serverless handler. Works with
256
- Netlify, Vercel, AWS Lambda, and any platform that calls `(event, context)`.
330
+ Wraps an Express app into a `serverless-http` handler. The generated function
331
+ accepts the provider event shapes supported by `serverless-http` and configured
332
+ through `serverlessOptions`; the local `start-serverless` adapter specifically
333
+ emulates AWS API Gateway REST API v1 / Lambda proxy events and results.
257
334
 
258
- Returns a handler function with an attached `reset()` method to retry a failed
259
- cold-start (`init()` rejections are memoized alongside successes).
335
+ Returns a handler function with an attached `reset()` method to retry a settled
336
+ failed cold-start. `init()` successes, asynchronous rejections, and synchronous
337
+ throws are memoized. Calling `reset()` while initialization is still pending is a
338
+ no-op, so concurrent invocations cannot start multiple initializations.
260
339
 
261
340
  | Option | Type | Default | Description |
262
341
  | ------------------- | ------------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------- |
263
342
  | `init` | `() => Promise<void>` | — | Called once per cold start; memoized (call `reset()` to retry) |
264
- | `request` | `(req) => void` | Buffer-body workaround | Hook called for each request before Express processes it |
265
- | `response` | `(res) => void` | — | Hook called after Express finishes processing |
343
+ | `request` | `(req, event, context) => void` | Buffer-body workaround | Hook called for each request before Express processes it |
344
+ | `response` | `(res, event, context) => void` | — | Hook called after Express finishes processing |
266
345
  | `serverlessOptions` | `Omit<ServerlessHttp.Options, 'request' \| 'response'>` | — | Additional options forwarded to `serverless-http` (`provider`, `binary`, `basePath`, …) |
267
- | `maxBodyBytes` | `number` | `1048576` | Skip parsing bodies larger than this in the default `request` hook |
346
+ | `maxBodyBytes` | `number` | `1048576` | Conversion threshold for the default `request` hook; larger bodies are left unchanged |
268
347
  | `logger` | `Logger` | `console` | Logger used internally |
269
348
 
270
- The default `request` hook works around [serverless-http issue #305](https://github.com/dougmoscrop/serverless-http/issues/305)
271
- by parsing `Buffer` bodies into JSON (when `content-type` starts with
272
- `application/json`, including charset variations) or UTF-8 strings.
349
+ The default `request` hook is intentionally conservative with serverless-http 4.
350
+ For AWS-style event shapes, serverless-http converts string, Buffer, and object
351
+ event bodies into a readable request stream before Express runs. JSON Buffer
352
+ bodies are therefore left for `express.json()` to parse once and for its `limit`
353
+ option to reject when oversized. For plain hook-unit inputs that are not readable
354
+ request streams, JSON is parsed only when the media type is exactly
355
+ `application/json` or a structured `application/*+json` type such as
356
+ `application/vnd.api+json`; parameters such as `charset=utf-8` are ignored for
357
+ matching. Prefix lookalikes such as `application/jsonp` and
358
+ `application/json-evil` are not JSON and are converted to UTF-8 strings like
359
+ other non-JSON Buffer bodies. Malformed JSON is treated as malformed client input:
360
+ the hook leaves the Buffer unchanged and does not log an internal server error.
361
+
362
+ `maxBodyBytes` on `createServerlessHandler()` is only the default hook's
363
+ conversion threshold. It is not an end-to-end request rejection limit and does
364
+ not replace Express parser limits or platform limits. In the local
365
+ `start-serverless` adapter, `--max-body-bytes` / `createServerlessAdapterApp({
366
+ maxBodyBytes })` is the enforced HTTP buffering limit that returns `413 Payload
367
+ Too Large` before invoking the handler.
368
+
369
+ The hook type aliases are generic over provider event and context:
370
+ `ServerlessRequestHook<TEvent, TContext>` and
371
+ `ServerlessResponseHook<TEvent, TContext>`. They mirror serverless-http 4's
372
+ runtime calls, which pass `(request, event, context)` before Express and
373
+ `(response, event, context)` after Express. The default generic is
374
+ `Record<string, unknown>` for both arguments; provide provider-specific event and
375
+ context types when you need typed access in hooks.
273
376
 
274
377
  #### Netlify example
275
378
 
@@ -287,27 +390,46 @@ export const handler: Handler = createServerlessHandler(app, { init: startDB });
287
390
  ### `startLocalServer(app, options?): LocalServer`
288
391
 
289
392
  Binds an Express app to a TCP port (or named pipe) via `http.createServer`,
290
- with friendly error handling and graceful shutdown. Returns `{ server, shutdown }`.
291
-
292
- | Option | Type | Default | Description |
293
- | ------------------- | ----------------------------- | ----------------------------- | ----------------------------------------------------------- |
294
- | `port` | `number \| string` | `process.env.PORT ?? 8080` | Port number or named-pipe path |
295
- | `host` | `string` | `process.env.HOST ?? 0.0.0.0` | Hostname (ignored for named pipes) |
296
- | `init` | `() => Promise<void>` | — | Called once before listening |
297
- | `onShutdown` | `() => Promise<void> \| void` | — | Called on graceful shutdown |
298
- | `onListening` | `() => void` | | Called when listening |
299
- | `onError` | `(error) => void` | logs + exits | Called on server errors |
300
- | `signals` | `boolean \| NodeJS.Signals[]` | `true` (`SIGINT`, `SIGTERM`) | Signal handlers to register |
301
- | `shutdownTimeout` | `number` | `5000` | Max ms to wait for in-flight requests before force-closing |
302
- | `exitAfterShutdown` | `boolean` | `false` | Call `process.exit(0)` after shutdown (the CLI sets `true`) |
303
- | `logger` | `Logger` | `console` | Logger used internally |
393
+ with friendly error handling and graceful shutdown. Returns `{ server, shutdown, ready }`.
394
+
395
+ Lifecycle state machine: `initializing` → `listening` → `stopping` → `stopped`, or `initializing` → `failed` on init/listen failure. Shutdown is single-flight and memoized: concurrent `shutdown()` calls and signals share one operation (logs, force-close, `onShutdown`, optional exit run at most once). Signal handlers owned by the instance are removed after shutdown or terminal failure; unrelated listeners are never removed.
396
+
397
+ Shutdown order and timeout policy: on `shutdown()`, the server first stops accepting new connections (`server.close`), drains in-flight requests up to `shutdownTimeout` (then `closeAllConnections`), and only then runs `onShutdown`. `shutdownTimeout` covers **only** request draining — `onShutdown` runs after draining. If `onShutdown` rejects, the error is logged (`logger.error`) and `shutdown()` rejects for programmatic callers. When `exitAfterShutdown: true`, the failure is reported and the process exits with status `1`; successful CLI-owned shutdown exits with status `0`. If shutdown is requested before the server is listening (e.g. during a pending `init`), the pending `listen` is suppressed, `server.listening` remains `false`, and `ready` rejects. If the server was never started or was closed externally, `shutdown()` resolves deterministically without error.
398
+
399
+ Port `0` logs the actual bound port (e.g. `Server running at http://127.0.0.1:54321/ (port 54321)`).
400
+
401
+ | Option | Type | Default | Description |
402
+ | ------------------- | ----------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------- |
403
+ | `port` | `number \| string` | `process.env.PORT ?? 8080` | Port number or named-pipe path (use `0` for an ephemeral port; actual port is logged) |
404
+ | `host` | `string` | `process.env.HOST ?? 0.0.0.0` | Hostname (ignored for named pipes) |
405
+ | `init` | `() => Promise<void>` | | Called once before listening; rejection rejects `ready` and skips listening |
406
+ | `onShutdown` | `() => Promise<void> \| void` | | Called **after** draining; rejection is logged and fails shutdown |
407
+ | `onListening` | `() => void` | — | Called when listening (after actual-port log) |
408
+ | `onError` | `(error) => void` | logs + exits | Called on listen errors and init failures (init failures are not `listen` syscall errors) |
409
+ | `signals` | `boolean \| NodeJS.Signals[]` | `true` (`SIGINT`, `SIGTERM`) | Signal handlers to register (owned handlers removed on shutdown/terminal failure) |
410
+ | `shutdownTimeout` | `number` | `5000` | Max ms to wait for in-flight requests before force-closing (covers draining only) |
411
+ | `exitAfterShutdown` | `boolean` | `false` | Call `process.exit(0)` after successful shutdown or `process.exit(1)` after cleanup failure |
412
+ | `logger` | `Logger` | `console` | Logger used internally |
304
413
 
305
414
  #### `LocalServer`
306
415
 
307
- | Field | Type | Description |
308
- | ---------- | --------------------- | ----------------------------------------------------- |
309
- | `server` | `http.Server` | Underlying HTTP server |
310
- | `shutdown` | `() => Promise<void>` | Trigger graceful shutdown (drains in-flight requests) |
416
+ | Field | Type | Description |
417
+ | ---------- | --------------------- | ------------------------------------------------------------------------------------ |
418
+ | `server` | `http.Server` | Underlying HTTP server |
419
+ | `shutdown` | `() => Promise<void>` | Trigger graceful shutdown (single-flight; see shutdown order above) |
420
+ | `ready` | `Promise<void>` | Resolves when listening, rejects on init/listen failure or shutdown before listening |
421
+
422
+ Callers can `await local.ready` to observe listening or catch init/listen failures without `unhandledRejection`. Example:
423
+
424
+ ```ts
425
+ const local = startLocalServer(app, { port: 0, host: '127.0.0.1' });
426
+ try {
427
+ await local.ready;
428
+ console.log('listening on', (local.server.address() as { port: number }).port);
429
+ } catch (err) {
430
+ console.error('failed to start', err);
431
+ }
432
+ ```
311
433
 
312
434
  ### `Logger`
313
435
 
@@ -319,6 +441,50 @@ interface Logger {
319
441
  }
320
442
  ```
321
443
 
444
+ ## Public API Ownership
445
+
446
+ The root package is the supported runtime API. Supported consumer API exports are
447
+ `createExpressApp`, `createServerlessHandler`, `startLocalServer`,
448
+ `ExpressAppOptions`, `RouterMount`, `Logger`, `ServerlessHandler`,
449
+ `ServerlessHandlerOptions`, `ServerlessHttpOptions`, `ServerlessRequest`,
450
+ `ServerlessRequestHook`, `ServerlessResponse`, `ServerlessResponseHook`,
451
+ `LocalServer`, `LocalServerOptions`, and `LocalServerState`.
452
+
453
+ Root extension seams kept public for wrappers and advanced integrations are
454
+ `defaultRequestHook`, `normalizePort`, `parsePortValue`, `validateFiniteInteger`,
455
+ `Express`, `RequestHandler`, `ErrorRequestHandler`, and
456
+ `RawServerlessHttpOptions`. No root export is classified as an internal
457
+ test-only detail.
458
+
459
+ The `@web-ts-toolkit/express-runtime/cli` subpath is a supported programmatic CLI
460
+ facade used by packages such as `@web-ts-toolkit/access-router-runtime`.
461
+ Supported consumer API exports are `parseArgs`, `runCliCommand`, `runDevCommand`,
462
+ `runExpressDevCommand`, `runBuildEntryCommand`, `RuntimeCliCommand`,
463
+ `DevCommandRunner`, `BuildEntryCommandOptions`, `DevArgs`, `BuildArgs`,
464
+ `StartArgs`, `StartServerlessArgs`, `ParsedArgs`, `Subcommand`,
465
+ `RuntimeModuleInit`, `GenericHandler`, `ApiGatewayRestEvent`,
466
+ `ServerlessResult`, `ServerlessAdapterOptions`, `CLI_VERSION`,
467
+ `DEFAULT_ADAPTER_MAX_BODY_BYTES`, `TEMP_BUILD_ENTRY_FILENAME`, and
468
+ `TEMP_SERVERLESS_ENTRY_FILENAME`.
469
+
470
+ Intentional `/cli` extension seams for custom wrappers are `readValue`,
471
+ `printHelp`, `isExpressApp`, `extractExport`, `resolveExport`, `loadApp`,
472
+ `loadBuiltApp`, `loadHandler`, `parseEnvFile`, `loadEnvFiles`,
473
+ `preloadModules`, `buildChildArgs`, `runWithWatch`, `generateRuntimeEntry`,
474
+ `generateServerlessEntry`, `validateOutDirForClean`,
475
+ `buildBundleFromEntryContent`, `buildRuntime`, `buildServerless`,
476
+ `validateMaxBodyBytes`, `collectBody`, `toServerlessEvent`,
477
+ `applyServerlessResult`, and `createServerlessAdapterApp`. No `/cli` export is
478
+ classified as an internal test-only detail; low-level names remain documented and
479
+ export-locked for compatibility rather than being justified by source tests.
480
+
481
+ Build tooling is intentionally not split into a second package yet. The current
482
+ package packs to about 68 KiB compressed / 313 KiB unpacked, and a measured root
483
+ CommonJS import loads the runtime surface without loading `tsup` or `esbuild`.
484
+ Splitting the build CLI can be reconsidered if install-size policy changes, but
485
+ it is not required for ordinary `createExpressApp`, `createServerlessHandler`, or
486
+ `startLocalServer` imports.
487
+
322
488
  ## CLI
323
489
 
324
490
  Programmatic CLI helpers are also available from the public subpath `@web-ts-toolkit/express-runtime/cli` when another package wants to reuse the same parsing, build, watch, env-loading, or start logic without shelling out to the `wtt-express-runtime` binary.
@@ -390,15 +556,16 @@ Omitting `<command>` defaults to `dev` for backward compatibility.
390
556
 
391
557
  #### start-serverless options
392
558
 
393
- | Option | Description |
394
- | ------------------------- | -------------------------------------------------------------------------------------------- |
395
- | `<handler-module>` | JS/CJS module path exporting `handler` (named or default) — the output of `build-serverless` |
396
- | `--port <number>` | Port or named pipe (default: `process.env.PORT` or `8080`) |
397
- | `--host <hostname>` | Hostname to bind (default: `process.env.HOST` or `0.0.0.0`) |
398
- | `--no-signals` | Disable `SIGINT` / `SIGTERM` handler registration |
399
- | `--shutdown-timeout <ms>` | Max ms to wait for in-flight requests (default: `5000`) |
400
- | `--require <module>` | Module(s) to preload before handler load (repeatable; comma-separated values supported) |
401
- | `--env <path>` | Env file(s) to load before handler load (repeatable; existing env vars are not overridden) |
559
+ | Option | Description |
560
+ | -------------------------- | -------------------------------------------------------------------------------------------- |
561
+ | `<handler-module>` | JS/CJS module path exporting `handler` (named or default) — the output of `build-serverless` |
562
+ | `--port <number>` | Port or named pipe (default: `process.env.PORT` or `8080`) |
563
+ | `--host <hostname>` | Hostname to bind (default: `process.env.HOST` or `0.0.0.0`) |
564
+ | `--no-signals` | Disable `SIGINT` / `SIGTERM` handler registration |
565
+ | `--shutdown-timeout <ms>` | Max ms to wait for in-flight requests (default: `5000`) |
566
+ | `--max-body-bytes <bytes>` | Max request body bytes for adapter (default: `1048576`; `0` allows empty bodies only) |
567
+ | `--require <module>` | Module(s) to preload before handler load (repeatable; comma-separated values supported) |
568
+ | `--env <path>` | Env file(s) to load before handler load (repeatable; existing env vars are not overridden) |
402
569
 
403
570
  #### global options
404
571
 
@@ -407,10 +574,23 @@ Omitting `<command>` defaults to `dev` for backward compatibility.
407
574
  | `-V, --version` | Print the CLI version |
408
575
  | `-h, --help` | Show help |
409
576
 
577
+ Use `--` to stop option parsing when a positional module path starts with a
578
+ dash, for example `wtt-express-runtime dev -- --app.js`. Numeric CLI values are
579
+ validated before env files, preload modules, app modules, watchers, or servers
580
+ are opened. Ports must be canonical decimal integers in `0..65535` or explicit
581
+ nonnumeric named-pipe paths; timeout, delay, and adapter body-limit values must
582
+ be finite integers in `0..9007199254740991`.
583
+
410
584
  The `dev` command sets `exitAfterShutdown: true` so `SIGINT` / `SIGTERM` cleanly
411
585
  exit the process after the server drains. TypeScript app modules require a TS
412
586
  loader (see the Quick Start CLI section for a `tsx` invocation).
413
587
 
588
+ Watch mode validates all watch paths before opening watchers. Runtime watcher
589
+ errors, child spawn errors, unexpected child exits, and failed child termination
590
+ produce one diagnostic and exit nonzero. Repeated `SIGINT` / `SIGTERM` signals
591
+ share the same shutdown, and file-change timers are canceled once shutdown
592
+ starts.
593
+
414
594
  The `build` command generates a temporary entry file that re-exports the app
415
595
  module and optional `init` hook, then produces a local runtime bundle. The
416
596
  `build-serverless` command instead wraps the app with `createServerlessHandler`