@orkestrel/scaffold 0.0.94 → 0.0.96

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.
@@ -230,17 +230,17 @@ body before receiving that response.
230
230
  registers a raw protocol-upgrade claimant; `start` binds the listener and
231
231
  resolves the actually-bound port while accepting an optional caller
232
232
  `AbortSignal`; `stop` gracefully drains then closes; `destroy` is the
233
- terminal, idempotent teardown. `stop` and `destroy` always resolve: an upgraded socket a
234
- handler claimed is drained up to the `drain` deadline and then destroyed,
235
- never waited on forever.
236
-
237
- | Method | Returns | Summary |
238
- | --------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
239
- | `use` | `void` | Appends one middleware, or an array of them in order, to the onion, outer-to-inner in call order. |
240
- | `upgrade` | `void` | Registers an `UpgradeHandler` claimant that runs in registration order; a claimed socket is tracked until it closes. |
241
- | `start` | `Promise<number>` | Binds the configured `host` and `port`, or an ephemeral port, under an optional caller `AbortSignal`, and resolves the actually-bound port. |
242
- | `stop` | `Promise<void>` | Stops gracefully: refuses new connections, fires the stop signal, drains in-flight requests and claimed upgraded sockets up to the `drain` deadline, then closes. |
243
- | `destroy` | `Promise<void>` | Tears down for good: force-closes the listener and every socket, then the emitter — terminal and idempotent from any state. |
233
+ terminal, idempotent teardown. An upgraded socket a handler claimed never holds
234
+ `stop` or `destroy` forever: the `stop` method drains it up to the `drain`
235
+ deadline and then destroys it, and the `destroy` method destroys it at once.
236
+
237
+ | Method | Returns | Summary |
238
+ | --------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
239
+ | `use` | `void` | Appends one middleware, or an array of them in order, to the onion, outer-to-inner in call order. |
240
+ | `upgrade` | `void` | Registers an `UpgradeHandler` claimant that runs in registration order; a claimed socket is tracked until it closes. |
241
+ | `start` | `Promise<number>` | Binds the configured `host` and `port`, or an ephemeral port, under an optional caller `AbortSignal`, and resolves the actually-bound port. |
242
+ | `stop` | `Promise<void>` | Stops gracefully within the `drain` deadline: fires the stop signal, drains in-flight requests and claimed upgraded sockets, then closes the listener; after a clean drain it ends each connection when no request exchange is open on it, and when the deadline expires it destroys every socket still open. |
243
+ | `destroy` | `Promise<void>` | Tears down for good: force-closes the listener and every socket, then the emitter — terminal and idempotent from any state. |
244
244
 
245
245
  ## Contract
246
246
 
@@ -262,33 +262,93 @@ These invariants hold across `src/server` ↔ `server.md`.
262
262
  server is never born aborted; `address` is the real bound `AddressInfo`
263
263
  after a successful start and `undefined` before start and after stop or
264
264
  destroy; `stop()`/`destroy()` are idempotent no-ops from a state with
265
- nothing to tear down; `EADDRINUSE` rejects `start()` outright — no silent
266
- ephemeral fallback (use `discoverPort` up front for a guaranteed-free
267
- port).
265
+ nothing to tear down, so a `stop()` call from `idle`, `starting`, or
266
+ `stopped` while no stop runs resolves at once; a `stop()` call made while
267
+ a stop runs returns that stop's promise and resolves with it after the
268
+ close, including a call made from a `stop` event listener or from a
269
+ listener on the stop signal; `EADDRINUSE` rejects `start()` outright — no
270
+ silent ephemeral fallback (use `discoverPort` up front for a
271
+ guaranteed-free port). A `start()` call made after `destroy()` while an
272
+ earlier `stop()` call has not yet resolved can be reset to `stopped` when
273
+ that stop finishes.
268
274
  4. **Startup is bounded and caller-cancellable.** `start(signal?)` observes
269
275
  caller cancellation only while binding; `timeouts.start` independently
270
- bounds the bind (`0` permits no startup window). Cancellation or deadline
271
- expiry closes the partial listener, clears the startup deadline, resets
272
- the entity to `idle`, and rejects; expiry rejects with a `DOMException`
273
- named `TimeoutError`, while caller cancellation rejects with that signal's
274
- `reason`. A later `start()` is therefore permitted. Aborting the caller
275
- signal after a successful start does not stop a live server.
276
+ bounds the bind (`0` permits no startup window) and is an integer from 0
277
+ through 2_147_483_647, or construction throws a `TypeError`. Cancellation
278
+ or deadline expiry closes the partial listener, clears the startup
279
+ deadline, resets the entity to `idle`, and rejects; expiry rejects with a
280
+ `DOMException` named `TimeoutError`, while caller cancellation rejects
281
+ with that signal's `reason`. A later `start()` is therefore permitted.
282
+ Aborting the caller signal after a successful start does not stop a live
283
+ server.
276
284
  5. **Graceful drain is event-driven, never a busy-loop.** `stop()` fires the
277
285
  stop signal, arms a `@orkestrel/timeout` deadline, and parks on the
278
286
  drainable count reaching zero or the deadline firing (a wake-park, not
279
- polling); it then emits `drain` with the still-pending counts and closes —
280
- dropping idle keep-alive sockets always, force-closing every open socket
281
- only when the deadline fired with work still pending (or on `destroy()`).
287
+ polling); it then emits `drain` with the still-pending counts and closes
288
+ the listener. The server counts the open request exchanges on each
289
+ connection, because a pipelining client can hold several: an exchange
290
+ opens when a request reaches the handler and stays open until its request
291
+ message has ended or closed and its response has finished or closed.
292
+ The `error` listeners that this tracking attaches to the message and the
293
+ response stay attached, so a later error on either raises no uncaught
294
+ exception.
295
+ After a clean drain the close destroys every connection with no open
296
+ exchange. That covers an idle keep-alive connection. It covers a
297
+ connection that never sent a request: Node doesn't count it as idle, and
298
+ the `closeIdleConnections()` call alone would leave it holding the
299
+ `stop()` call open until the peer drops it. It also covers a connection
300
+ partway through its first or a later header block, because a graceful
301
+ stop serves counted work and a request whose headers never completed is
302
+ not counted. A connection that still carries an open exchange stays open,
303
+ including one whose handler answered, for example with a `413` response,
304
+ while its body still uploads: destroying it can reset the connection and
305
+ lose that response. The server ends that connection when its last
306
+ exchange completes, and the `stop()` call resolves after that connection
307
+ closes. Each body byte restarts Node's keep-alive socket timer, so no Node
308
+ timeout bounds an upload that keeps sending. The deadline that bounds the
309
+ drain therefore stays armed through the clean close's wait: when it
310
+ expires before the server's `close` event, the server destroys every
311
+ socket still open, as an expired drain does, so no connection holds the
312
+ `stop()` call past the `drain` deadline. The `drain` event has already
313
+ reported `[0, 0]` for that stop, because every response had finished at
314
+ the clean drain. The cut ends the unread part of an upload its handler had
315
+ already answered, and destroying that socket can also reset the
316
+ connection and lose the answered response the peer has not yet read. No
317
+ event reports that cut or that loss. An upload ends before the deadline
318
+ only because the `buildRequest` function of the `@orkestrel/router`
319
+ package reads a request body eagerly, whether or not the handler reads
320
+ it. A request or an upgrade that a connection sends after the listener
321
+ closes, such as one pipelined behind the end of that upload, is refused:
322
+ the server destroys that connection before the request is counted or a
323
+ handler sees it, so the `stop()` call waits only on work counted before
324
+ the close. That refusal, and the clean close's destroy of a connection
325
+ with no open exchange, can reset the connection when the kernel still
326
+ holds bytes the server never read, as it does for a peer that pipelines a
327
+ request behind a POST request; see
328
+ [the pipelining section of RFC 9112](https://www.rfc-editor.org/rfc/rfc9112#section-9.3.2).
329
+ After an expired drain the close destroys every socket and cuts the work
330
+ still pending. No I/O callback runs between the drain's settle and the
331
+ close, so a request that reached the handler by the close did so before
332
+ the drain settled and was counted. A request Node answers itself never
333
+ reaches the handler and is not counted: Node answers an unknown `Expect`
334
+ value with a `417` response, so that connection carries no open exchange
335
+ and the clean close destroys it. A request that reaches the server after
336
+ the `stop()` call begins and before the listener closes, on a kept-alive
337
+ connection or on a connection the listener accepts, is in-flight work:
338
+ the drain waits for it, and its `request.signal` property is already
339
+ aborted.
282
340
  Drainable work is every in-flight request plus every upgraded socket a
283
341
  handler claimed, because a long-lived upgraded connection is work a
284
342
  graceful stop lets finish rather than cuts mid-frame. `drain` carries
285
- both counts, so a caller can tell a clean stop from a forced one. This is
286
- also what makes `stop()` and `destroy()` always resolve: node detaches an
343
+ both counts, which say whether a request or a claimed socket was cut; a
344
+ cut during the clean close's wait reports `[0, 0]`. A claimed socket
345
+ never holds `stop()` or `destroy()` open forever: Node detaches an
287
346
  upgraded socket from its own connection set, so neither
288
347
  `closeIdleConnections()` nor `closeAllConnections()` reaches it while
289
348
  `server.close()` still waits on it, and the server therefore tracks each
290
- claimed socket until it closes and destroys the survivors itself on a
291
- forced close. The claimant still owns the socket; tracking only watches it.
349
+ claimed socket until it closes and destroys the survivors itself when the
350
+ server closes. The claimant still owns the socket; tracking only watches
351
+ it.
292
352
  A handler that wants a protocol-clean goodbye — a WebSocket close frame —
293
353
  sends it on the `stop` event, which fires before the drain begins, and the
294
354
  drain then settles on that close instead of running the deadline out. A
@@ -326,8 +386,8 @@ These invariants hold across `src/server` ↔ `server.md`.
326
386
  `Request` exists there, only a raw `IncomingMessage` — and never crashes
327
387
  the process) and the fan-out continues; an upgrade nothing claims destroys
328
388
  the socket so it never leaks a dangling connection. A claimed socket is
329
- tracked until it closes, which is what item 5's drain and forced close
330
- act on — ownership stays with the claimant either way.
389
+ tracked until it closes, which is what item 5's drain and close act on —
390
+ ownership stays with the claimant either way.
331
391
  8. **Body read exactly once, capped, zip-bomb-safe, scrubbed.**
332
392
  `MiddlewareContext.body()` is lazy and cached, so a body-parsing middleware
333
393
  and the eventual handler both reading it consume the underlying stream
@@ -534,9 +594,18 @@ function streamHandler(): Response {
534
594
 
535
595
  ### Graceful shutdown
536
596
 
537
- `stop()` refuses new connections, gives in-flight work up to the `drain`
538
- deadline, then closes; `destroy()` is the final, idempotent teardown. In-flight
539
- work is requests and claimed upgraded sockets, so each call always returns.
597
+ The `stop()` call gives in-flight work up to the `drain` deadline, then
598
+ closes the listener; the `destroy()` call is the final, idempotent
599
+ teardown. In-flight work is requests and claimed upgraded sockets. An idle
600
+ keep-alive connection or a connection that never sent a request costs the
601
+ `stop()` call no wait. A connection whose request exchange is still open,
602
+ such as an upload still sending its body after the response, keeps the
603
+ `stop()` call waiting until that exchange completes or the same `drain`
604
+ deadline expires and cuts it. A request that such a connection sends after
605
+ the listener closes is refused, and its connection is destroyed. A second
606
+ `stop()` call made during the stop returns the first call's promise. The
607
+ `drain` option is an integer from 0 through 2_147_483_647 milliseconds, and
608
+ construction throws a `TypeError` for any other value.
540
609
 
541
610
  ```ts
542
611
  import { createServer } from '@orkestrel/server'
@@ -548,7 +617,7 @@ server.emitter.on('drain', (pending, upgraded) =>
548
617
  console.log(`drained with ${pending} requests and ${upgraded} sockets still open`),
549
618
  )
550
619
  await server.start()
551
- await server.stop() // graceful — waits up to 5s for requests and upgraded sockets
620
+ await server.stop() // drains, then closes; the 5 s deadline cuts whatever still holds it
552
621
  await server.destroy() // idempotent final teardown
553
622
  ```
554
623
 
@@ -727,10 +796,26 @@ new TextDecoder().decode(body) // 'hi' — capped decompression, the zip-bomb de
727
796
  the status matrix, restart-fresh-abort, caller-cancelled / timed-out / clean
728
797
  bounded startup, `EADDRINUSE` honesty, host/port binds, ephemeral default,
729
798
  connection / header / per-socket request caps, graceful-vs-forced drain,
730
- the held-upgraded-socket stop (drained to the deadline then cut, settled
731
- early when the claimant closes it, reported on `drain`, and force-closed by
732
- `destroy()`) against a no-socket control, 20-parallel-none-dropped,
733
- connection facts threaded into state,
799
+ the clean stop that ends a never-used connection inside the deadline after
800
+ the request in flight beside it finishes, the clean stop that counts and
801
+ serves a kept-alive request sent during the drain with its signal aborted
802
+ before it closes that idle connection, the clean stop that serves a
803
+ kept-alive request sent during the drain and keeps that connection open
804
+ until the drain settles, the clean stop that destroys a connection whose
805
+ first or second header block never completed and one whose `417` answer
806
+ left a withheld body pending, the clean stop whose drain waits for a
807
+ response after its POST body ended and then ends that connection, the
808
+ clean stop that keeps a connection whose `413` answer left its body
809
+ uploading and ends it when the upload ends, the `drain` deadline that cuts
810
+ that upload when it sends one more byte and stalls or keeps sending one
811
+ byte at a time, the concurrent `stop()` call that returns the stop in
812
+ flight and resolves with it after the close, the `stop()` call from `idle`
813
+ or `starting` that resolves at once, the clean stop that
814
+ refuses a request or an upgrade pipelined behind the end of that body after
815
+ the listener closes, the held-upgraded-socket stop (drained to
816
+ the deadline then cut, settled early when the claimant closes it, reported
817
+ on `drain`, and force-closed by `destroy()`) against a no-socket control,
818
+ 20-parallel-none-dropped, connection facts threaded into state,
734
819
  `context.body()` caching, boundary mapping (`HTTPError`/other/`expose`), the
735
820
  stop-signal-reaches-handlers case, and the real slow-TCP proof that an SSE
736
821
  producer parks at local queue pressure, resumes on drain, and stays bounded
@@ -749,26 +749,32 @@ A `Shape` cell holds the constant's declared type.
749
749
 
750
750
  #### Helpers
751
751
 
752
- | API | Kind | Signature | Summary |
753
- | ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
754
- | `readInventory` | function | `(root: URL \| string, targets: readonly string[], options?: InventoryOptions) => Readonly<Record<string, string>>` | Reads files from selected targets below a root directory. |
755
- | `resolveContained` | function | `(root: string, target: string) => string \| undefined` | Resolves a target that stays below a root directory. |
756
- | `requireContained` | function | `(root: string, target: string) => string` | Resolves a target that stays below a root directory, refusing an escape. |
757
- | `isExcluded` | function | `(key: string, exclusions: readonly string[]) => boolean` | Reports whether a root-relative key matches an exclusion. |
758
- | `readIdentity` | function | `(status: Stats) => ScratchIdentity` | Reads the identity of one allocated directory off a host status. |
759
- | `matchesIdentity` | function | `(current: ScratchIdentity, allocation: ScratchIdentity) => boolean` | Reports whether two directory identities name the same allocation. |
760
- | `readErrorCode` | function | `(error: unknown) => string \| undefined` | Reads the `code` an unknown thrown value carries. |
761
- | `createLink` | function | `(path: string, source: string) => void` | Creates a symbolic link with a directory-junction fallback for hosts that refuse symbolic links. |
762
- | `removeTree` | function | `(path: string) => void` | Removes a directory tree, retrying past a transient Windows handle-release race. |
763
- | `isRunning` | function | `(pid: number) => boolean` | Reports whether a process id names a live process. |
764
- | `waitForSocketClose` | function | `(socket: Socket, options?: WaitOptions) => Promise<void>` | Waits for a socket to close, accepting a peer reset as a forced close. |
765
- | `destroyScratch` | function | `(scratch: ScratchInterface, options?: WaitOptions) => Promise<void>` | Destroys a scratch directory, retrying until the host releases it. |
766
- | `requestUpgrade` | function | `(port: number, options?: UpgradeOptions) => Promise<UpgradeResult>` | Drives a real client upgrade request against a loopback port and reports what the server did. |
767
- | `supportsDirectoryLinks` | function | `() => boolean` | Checks whether this host links a directory, by creating one link and reading through it. |
768
- | `supportsFileLinks` | function | `() => boolean` | Checks whether this host links a file, by creating one link and reading the file through it. |
769
- | `supportsMode` | function | `() => boolean` | Checks whether POSIX permission bits round-trip through this host's `chmod` and `stat`. |
770
- | `supportsCase` | function | `() => boolean` | Checks whether this host treats two names differing only by case as distinct files. |
771
- | `supportsBytes` | function | `() => boolean` | Checks whether this host accepts a filename carrying a raw byte no UTF-8 decoder resolves. |
752
+ | API | Kind | Signature | Summary |
753
+ | ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
754
+ | `readInventory` | function | `(root: URL \| string, targets: readonly string[], options?: InventoryOptions) => Readonly<Record<string, string>>` | Reads files from selected targets below a root directory. |
755
+ | `resolveContained` | function | `(root: string, target: string) => string \| undefined` | Resolves a target that stays below a root directory. |
756
+ | `requireContained` | function | `(root: string, target: string) => string` | Resolves a target that stays below a root directory, refusing an escape. |
757
+ | `hasScratchPath` | function | `(root: string, target: string) => boolean` | Reports whether a contained entry exists under a scratch directory without following its final link. |
758
+ | `isExcluded` | function | `(key: string, exclusions: readonly string[]) => boolean` | Reports whether a root-relative key matches an exclusion. |
759
+ | `readIdentity` | function | `(status: Stats) => ScratchIdentity` | Reads the identity of one allocated directory off a host status. |
760
+ | `matchesIdentity` | function | `(current: ScratchIdentity, allocation: ScratchIdentity) => boolean` | Reports whether two directory identities name the same allocation. |
761
+ | `readErrorCode` | function | `(error: unknown) => string \| undefined` | Reads the `code` an unknown thrown value carries. |
762
+ | `createLink` | function | `(path: string, source: string) => void` | Creates a symbolic link with a directory-junction fallback for hosts that refuse symbolic links. |
763
+ | `removeTree` | function | `(path: string) => void` | Removes a directory tree, retrying past a transient Windows handle-release race. |
764
+ | `isRunning` | function | `(pid: number) => boolean` | Reports whether a process id names a live process. |
765
+ | `waitForSocketClose` | function | `(socket: Socket, options?: WaitOptions) => Promise<void>` | Waits for a socket to close, accepting a peer reset as a forced close. |
766
+ | `destroyScratch` | function | `(scratch: ScratchInterface, options?: WaitOptions) => Promise<void>` | Destroys a scratch directory, retrying until the host releases it. |
767
+ | `requestUpgrade` | function | `(port: number, options?: UpgradeOptions) => Promise<UpgradeResult>` | Drives a real client upgrade request against a loopback port and reports what the server did. |
768
+ | `supportsDirectoryLinks` | function | `() => boolean` | Checks whether this host links a directory, by creating one link and reading through it. |
769
+ | `supportsFileLinks` | function | `() => boolean` | Checks whether this host links a file, by creating one link and reading the file through it. |
770
+ | `supportsMode` | function | `() => boolean` | Checks whether POSIX permission bits round-trip through this host's `chmod` and `stat`. |
771
+ | `supportsCase` | function | `() => boolean` | Checks whether this host treats two names differing only by case as distinct files. |
772
+ | `supportsBytes` | function | `() => boolean` | Checks whether this host accepts a filename carrying a raw byte no UTF-8 decoder resolves. |
773
+
774
+ `hasScratchPath` takes a scratch's absolute path and a relative or absolute target. It checks
775
+ containment before inspecting the root, returns `false` for a missing root or entry, and refuses a
776
+ root that is a symbolic link or not a directory. It follows intermediate links and counts a dangling final link
777
+ as present. It checks no allocation identity.
772
778
 
773
779
  `resolveContained` is the one lexical containment check, and `readInventory` and `createScratch`
774
780
  both call it. It resolves the target against the root — relative or absolute — and returns