velocious 1.0.680 → 1.0.682

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.
Files changed (28) hide show
  1. package/build/background-jobs/worker.js +121 -15
  2. package/build/http-server/client/index.js +160 -0
  3. package/build/http-server/client/request-runner.js +13 -5
  4. package/build/http-server/client/response.js +142 -0
  5. package/build/http-server/worker-handler/in-process.js +20 -2
  6. package/build/http-server/worker-handler/worker-thread.js +4 -1
  7. package/build/src/background-jobs/worker.d.ts +59 -10
  8. package/build/src/background-jobs/worker.d.ts.map +1 -1
  9. package/build/src/background-jobs/worker.js +117 -16
  10. package/build/src/http-server/client/index.d.ts +69 -0
  11. package/build/src/http-server/client/index.d.ts.map +1 -1
  12. package/build/src/http-server/client/index.js +147 -1
  13. package/build/src/http-server/client/request-runner.d.ts.map +1 -1
  14. package/build/src/http-server/client/request-runner.js +16 -7
  15. package/build/src/http-server/client/response.d.ts +88 -0
  16. package/build/src/http-server/client/response.d.ts.map +1 -1
  17. package/build/src/http-server/client/response.js +136 -1
  18. package/build/src/http-server/worker-handler/in-process.d.ts.map +1 -1
  19. package/build/src/http-server/worker-handler/in-process.js +20 -3
  20. package/build/src/http-server/worker-handler/worker-thread.d.ts.map +1 -1
  21. package/build/src/http-server/worker-handler/worker-thread.js +4 -2
  22. package/package.json +1 -1
  23. package/src/background-jobs/worker.js +121 -15
  24. package/src/http-server/client/index.js +160 -0
  25. package/src/http-server/client/request-runner.js +13 -5
  26. package/src/http-server/client/response.js +142 -0
  27. package/src/http-server/worker-handler/in-process.js +20 -2
  28. package/src/http-server/worker-handler/worker-thread.js +4 -1
@@ -322,6 +322,28 @@ export default class BackgroundJobsWorker {
322
322
  // Monotonic dispatch counter for round-robin child selection: each dispatch stamps
323
323
  // the chosen child, and selection prefers the child dispatched least recently.
324
324
  this._pooledDispatchSeq = 0
325
+ // Waiters blocked in _runPooledJob because the pool is at its hard cap: a job may
326
+ // not spawn a child while total live children (working + draining) is at the cap.
327
+ /** @type {Set<() => void>} */
328
+ this._pooledSlotWaiters = new Set()
329
+ /** @type {ReturnType<typeof setInterval> | undefined} - Safety poll that re-checks the slot condition. */
330
+ this._pooledSlotWaitTimer = undefined
331
+ }
332
+
333
+ /** Starts the slot-waiter safety poll if it is not already running. */
334
+ _startPooledSlotWaitPoll() {
335
+ if (this._pooledSlotWaitTimer) return
336
+
337
+ this._pooledSlotWaitTimer = setInterval(() => this._wakePooledSlotWaiters(), 50)
338
+ this._pooledSlotWaitTimer.unref()
339
+ }
340
+
341
+ /** Stops the slot-waiter safety poll once no waiter is registered. */
342
+ _stopPooledSlotWaitPollIfIdle() {
343
+ if (this._pooledSlotWaiters.size > 0 || !this._pooledSlotWaitTimer) return
344
+
345
+ clearInterval(this._pooledSlotWaitTimer)
346
+ this._pooledSlotWaitTimer = undefined
325
347
  }
326
348
 
327
349
  /**
@@ -1079,27 +1101,72 @@ export default class BackgroundJobsWorker {
1079
1101
  }
1080
1102
  }
1081
1103
 
1104
+ /**
1105
+ * Hard cap on total live pooled children (working + draining): children the
1106
+ * pool may still spawn. Counting the whole live set — draining children
1107
+ * included — is what bounds pool memory: a draining child still holds its
1108
+ * RSS until its last in-flight job finishes, so it must occupy a cap slot.
1109
+ * @returns {number} - Number of children the pool may still spawn.
1110
+ */
1111
+ _spawnablePooledChildren() {
1112
+ return Math.max(0, this.pooledRunnerCount - this.pooledChildren.size)
1113
+ }
1114
+
1115
+ /**
1116
+ * Resolves once a non-retiring pooled child has a free concurrency slot or the
1117
+ * pool may spawn a new one. Pooled jobs admitted while the pool is at its
1118
+ * hard cap wait here instead of spawning an over-capacity child; the wake
1119
+ * points are the only capacity-freeing transitions (a job outcome and a child
1120
+ * exit), so no polling is needed.
1121
+ * @returns {Promise<void>} - Resolves when a slot is available.
1122
+ */
1123
+ _waitPooledSlot() {
1124
+ this._startPooledSlotWaitPoll()
1125
+
1126
+ return new Promise((resolve) => {
1127
+ const waiter = () => {
1128
+ this._pooledSlotWaiters.delete(waiter)
1129
+ this._stopPooledSlotWaitPollIfIdle()
1130
+ resolve()
1131
+ }
1132
+
1133
+ this._pooledSlotWaiters.add(waiter)
1134
+ })
1135
+ }
1136
+
1137
+ /**
1138
+ * Resolves every registered waiter; waiters re-check the slot condition
1139
+ * themselves and only proceed when it holds.
1140
+ * @returns {void}
1141
+ */
1142
+ _wakePooledSlotWaiters() {
1143
+ if (this._pooledSlotWaiters.size === 0) return
1144
+
1145
+ for (const resolve of [...this._pooledSlotWaiters]) resolve()
1146
+ }
1147
+
1082
1148
  /**
1083
1149
  * Free pooled slots across the pool: open slots in non-retiring children plus
1084
- * the slots we could add by spawning more children up to `pooledRunnerCount`.
1085
- * Retiring children (draining before replacement) never contribute capacity.
1150
+ * the slots we could add by spawning more children up to the hard cap on total
1151
+ * live children. Retiring children (draining before replacement) never
1152
+ * contribute capacity, and they count against the cap: while one is still
1153
+ * draining, no replacement is advertised (or spawned) — the pool advertises
1154
+ * exactly what it can serve instead of phantom capacity.
1086
1155
  * @returns {number} - Number of pooled jobs the worker can accept right now.
1087
1156
  */
1088
1157
  _availablePooledSlots() {
1089
1158
  let openInExisting = 0
1090
- let nonRetiringChildren = 0
1091
1159
  let queuedReservations = 0
1092
1160
 
1093
1161
  for (const child of this.pooledChildren) {
1094
1162
  const state = this.pooledChildStates.get(child)
1095
1163
  if (!state || state.retiring) continue
1096
- nonRetiringChildren += 1
1097
1164
  openInExisting += this.pooledRunnerConcurrency - state.inflight.size
1098
1165
  }
1099
1166
 
1100
1167
  for (const queue of this.pooledJobQueues.values()) queuedReservations += queue.length
1101
1168
 
1102
- const spawnableChildren = Math.max(0, this.pooledRunnerCount - nonRetiringChildren)
1169
+ const spawnableChildren = Math.max(0, this.pooledRunnerCount - this.pooledChildren.size)
1103
1170
 
1104
1171
  return Math.max(0, openInExisting + spawnableChildren * this.pooledRunnerConcurrency - queuedReservations)
1105
1172
  }
@@ -1109,11 +1176,32 @@ export default class BackgroundJobsWorker {
1109
1176
  * new child when every non-retiring child is full and the pool is below
1110
1177
  * `pooledRunnerCount`. Each child runs up to `pooledRunnerConcurrency` jobs at
1111
1178
  * once on its own event loop.
1179
+ *
1180
+ * When the pool is already at its hard cap (total live children, draining
1181
+ * included), the job waits for a slot instead of spawning: that is what keeps
1182
+ * the live-child count — and therefore the pool's total RSS — bounded. The
1183
+ * wait resolves on the only two capacity-freeing transitions (a job outcome,
1184
+ * a child exit); a safety poll covers anything missed.
1112
1185
  * @param {import("./types.js").BackgroundJobPayload & {id: string}} payload - Job payload.
1113
1186
  * @returns {Promise<void>} - Resolves after the durable report.
1114
1187
  */
1115
- _runPooledJob(payload) {
1116
- const child = this._selectPooledChild() || this._createPooledChild()
1188
+ async _runPooledJob(payload) {
1189
+ // At the hard cap (no free slot, no spawnable child) the job waits for a
1190
+ // slot instead of spawning an over-capacity child — that is what bounds
1191
+ // the live-child count and the pool's total RSS.
1192
+ let child = this._selectPooledChild()
1193
+ while (!child) {
1194
+ if (this._spawnablePooledChildren() === 0) {
1195
+ // Shutdown: main no longer dispatches and no slot will ever free —
1196
+ // stop waiting so the tracked job can settle and the drain completes.
1197
+ if (this.shouldStop) return
1198
+ await this._waitPooledSlot()
1199
+ child = this._selectPooledChild()
1200
+ continue
1201
+ }
1202
+ child = this._selectPooledChild() || this._createPooledChild()
1203
+ }
1204
+
1117
1205
  const state = this.pooledChildStates.get(child)
1118
1206
  if (!state) throw new Error("Pooled runner state missing")
1119
1207
 
@@ -1232,12 +1320,16 @@ export default class BackgroundJobsWorker {
1232
1320
  }
1233
1321
 
1234
1322
  /**
1235
- * Creates a reusable pooled child.
1236
- * @returns {import("node:child_process").ChildProcess} - New pooled child.
1323
+ * Creates a reusable pooled child, enforcing the hard cap on total live
1324
+ * children (working + draining). Returns undefined when the cap is already
1325
+ * met — the only way a new child may exist is a slot being open, so the
1326
+ * caller re-checks and waits again.
1327
+ * @returns {import("node:child_process").ChildProcess | undefined} - The new child, or undefined when the pool is at its cap.
1237
1328
  */
1238
1329
  _createPooledChild() {
1239
1330
  const configuration = this.configuration
1240
1331
  if (!configuration) throw new Error("Background jobs worker configuration not initialized")
1332
+ if (this.pooledChildren.size >= this.pooledRunnerCount) return undefined
1241
1333
  const child = fork(POOLED_RUNNER_ENTRY_PATH, [], {
1242
1334
  cwd: configuration.getDirectory(), execArgv: [], stdio: ["ignore", "ignore", "ignore", "ipc"],
1243
1335
  env: Object.assign({}, process.env, this._childBackgroundJobsEnvironment())
@@ -1332,6 +1424,9 @@ export default class BackgroundJobsWorker {
1332
1424
  this._beginRetirePooledChild(child)
1333
1425
  }
1334
1426
  this._terminateIfDrained(child)
1427
+ // A job outcome frees a concurrency slot and may drain a retiring child —
1428
+ // the only two transitions that free capacity for waiters at the hard cap.
1429
+ this._wakePooledSlotWaiters()
1335
1430
  }
1336
1431
 
1337
1432
  /**
@@ -1362,11 +1457,14 @@ export default class BackgroundJobsWorker {
1362
1457
  }
1363
1458
 
1364
1459
  /**
1365
- * Marks a pooled child for retirement and eagerly spawns a single replacement
1366
- * (1-for-1) so its capacity is restored immediately without waiting for it to
1367
- * finish draining. The retiring child stops receiving new jobs and is
1368
- * terminated only once its in-flight set drains, so a long-running job (e.g. a
1369
- * build) is never cut off.
1460
+ * Marks a pooled child for retirement and — when the pool is below its hard
1461
+ * cap — eagerly spawns a single replacement (1-for-1) so its capacity is
1462
+ * restored immediately without waiting for it to finish draining. The
1463
+ * replacement spawn is gated by the cap (the retiring child still counts as
1464
+ * live until it exits), so a full pool simply defers the replacement to the
1465
+ * retiring child's drain instead of spawning over capacity. The retiring
1466
+ * child stops receiving new jobs and is terminated only once its in-flight
1467
+ * set drains, so a long-running job (e.g. a build) is never cut off.
1370
1468
  * @param {import("node:child_process").ChildProcess} child - Child to retire.
1371
1469
  * @returns {void}
1372
1470
  */
@@ -1376,7 +1474,9 @@ export default class BackgroundJobsWorker {
1376
1474
 
1377
1475
  state.retiring = true
1378
1476
  // Best-effort pre-warm: skip when stopping (no new work) or before the
1379
- // worker is initialized (no configuration to fork a child from).
1477
+ // worker is initialized (no configuration to fork a child from). The cap
1478
+ // inside _createPooledChild refuses the spawn while the pool is full, in
1479
+ // which case the replacement is deferred to the drain path.
1380
1480
  if (!this.shouldStop && this.configuration) this._createPooledChild()
1381
1481
  }
1382
1482
 
@@ -1394,6 +1494,9 @@ export default class BackgroundJobsWorker {
1394
1494
 
1395
1495
  /**
1396
1496
  * Retires a drained pooled child (removes it from tracking, then SIGTERMs it).
1497
+ * Because the hard cap counts live children, the exit of this child frees a
1498
+ * slot: any deferred replacement (the pool was full when the child retired)
1499
+ * is spawned now, and capacity is re-advertised so main can dispatch into it.
1397
1500
  * @param {import("node:child_process").ChildProcess} child - Child process to retire.
1398
1501
  * @returns {void}
1399
1502
  */
@@ -1503,6 +1606,9 @@ export default class BackgroundJobsWorker {
1503
1606
  }
1504
1607
  this.pooledChildren.delete(child)
1505
1608
  this.inflightProcessChildren.delete(child)
1609
+ // Child exit frees a hard-cap slot even while its in-flight set is still
1610
+ // being reported — wake waiters now; their reports settle independently.
1611
+ this._wakePooledSlotWaiters()
1506
1612
 
1507
1613
  const entries = state ? [...state.inflight.values()] : []
1508
1614
  const runnerFailure = state
@@ -61,6 +61,11 @@ export default class VeoliciousHttpServerClient {
61
61
 
62
62
  /** @type {Set<(result: "completed" | "aborted") => Promise<void>>} */
63
63
  this.pendingFileResponses = new Set()
64
+
65
+ /**
66
+ * Streams that have started but have not finished or been aborted yet.
67
+ * @type {Map<import("./response.js").default, {clientCount: number, request: import("./request.js").default}>} */
68
+ this._activeStreamResponses = new Map()
64
69
  }
65
70
 
66
71
  /**
@@ -181,6 +186,15 @@ export default class VeoliciousHttpServerClient {
181
186
 
182
187
  this.requestRunners.push(requestRunner)
183
188
 
189
+ // A streaming response emits its headers and chunks to the client while
190
+ // the request is still running, so the response needs the owning client
191
+ // as its transport sink before the handler runs. Sub-requests (e.g.
192
+ // websocket request payloads) have no socket and keep transport null;
193
+ // their responses fail loudly if they attempt to stream.
194
+ const socketRequest = currentRequest
195
+ requestRunner.response.transport = this
196
+ requestRunner.response.transportRequest = socketRequest
197
+
184
198
  requestRunner.events.on("done", this.requestDone)
185
199
  requestRunner.run()
186
200
  }
@@ -423,6 +437,15 @@ export default class VeoliciousHttpServerClient {
423
437
  */
424
438
  async sendResponse(requestRunner) {
425
439
  const response = digg(requestRunner, "response")
440
+
441
+ // A streaming response already emitted its status line, headers, every
442
+ // chunk, and the chunked terminator to the client while the request was
443
+ // running. Nothing left to send here — just log the completed request.
444
+ if (response.isStreaming()) {
445
+ await requestRunner.logCompletedRequest()
446
+ return
447
+ }
448
+
426
449
  const request = requestRunner.getRequest()
427
450
  const filePath = response.getFilePath()
428
451
  const fileOnFinished = response.getFileOnFinished()
@@ -666,6 +689,143 @@ export default class VeoliciousHttpServerClient {
666
689
  await Promise.all([...this.pendingFileResponses].map((settle) => settle("aborted")))
667
690
  }
668
691
 
692
+ /**
693
+ * Sink the owning worker handler wires in for stream output. The
694
+ * in-process handler resolves it after the framed output has been enqueued
695
+ * for delivery to the socket, so stream chunks share the bounded, ordered
696
+ * delivery path (byte/frame limits and socket backpressure) instead of
697
+ * being buffered unboundedly for a stalled client. The worker-thread
698
+ * handler keeps null: its output crosses to the parent over IPC and no
699
+ * per-chunk acknowledgement is available.
700
+ * @type {((output: string) => Promise<void>) | null} */
701
+ streamOutputSink = null
702
+
703
+ /**
704
+ * Narrows the response to the documented streaming transport shape.
705
+ * @param {import("./response.js").default} response - Response to stream.
706
+ * @returns {{streaming: boolean, streamEnded: boolean, streamAborted: boolean, headers: Record<string, string[]>, getStatusCode: () => number, getStatusMessage: () => string, streamCloseCallbacks: Set<() => void>}} - Streaming view of the response.
707
+ */
708
+ _streamingResponse(response) {
709
+ return response
710
+ }
711
+
712
+ /**
713
+ * Starts a live chunked stream for a socket-bound response: emits the
714
+ * status line and headers (with `Transfer-Encoding: chunked`) to the
715
+ * client immediately so subsequent `write()` chunks reach the client as
716
+ * they are produced.
717
+ * @param {import("./response.js").default} response - Response to stream.
718
+ * @param {import("./request.js").default} request - Socket-bound request.
719
+ * @returns {void} - No return value.
720
+ */
721
+ beginStreamResponse(response, request) {
722
+ this._activeStreamResponses.set(response, {clientCount: this.clientCount, request})
723
+
724
+ const httpVersion = request.httpVersion()
725
+
726
+ // HTTP/1.0 has no chunked transfer encoding: terminate the stream with a
727
+ // connection close instead of a zero-length chunk.
728
+ if (httpVersion == "1.0" && !this.shouldCloseConnection(request)) {
729
+ response.setHeader("Connection", "Close")
730
+ }
731
+
732
+ // The chunked framing owns the body length; a Content-Length header set
733
+ // by the application before stream() would desynchronize the framing.
734
+ response.removeHeader("Content-Length")
735
+ if (response.getHeader("Transfer-Encoding").length === 0) {
736
+ response.setHeader("Transfer-Encoding", "chunked")
737
+ }
738
+
739
+ response.setHeader("Date", new Date().toUTCString())
740
+ response.setHeader("Server", "Velocious")
741
+
742
+ const responseView = this._streamingResponse(response)
743
+ let headers = ""
744
+ headers += `HTTP/${httpVersion} ${responseView.getStatusCode()} ${responseView.getStatusMessage()}\r\n`
745
+
746
+ for (const headerKey in responseView.headers) {
747
+ for (const headerValue of responseView.headers[headerKey]) {
748
+ headers += `${headerKey}: ${headerValue}\r\n`
749
+ }
750
+ }
751
+
752
+ headers += "\r\n"
753
+ this.events.emit("output", headers)
754
+ }
755
+
756
+ /**
757
+ * Emits one chunked-encoded body chunk for an active stream.
758
+ * @param {string | Uint8Array} chunk - Chunk to emit.
759
+ * @returns {Promise<void>} - Settles after the chunk has been delivered to
760
+ * the client.
761
+ */
762
+ async writeStreamChunk(chunk) {
763
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk)
764
+ const frame = `${bytes.length.toString(16)}\r\n${bytes}\r\n`
765
+ if (this.streamOutputSink === null) {
766
+ this.events.emit("output", frame)
767
+ return
768
+ }
769
+ await this.streamOutputSink(frame)
770
+ }
771
+
772
+ /**
773
+ * Emits the zero-length chunked terminator for a stream that finished on a
774
+ * live connection, then runs its close callbacks.
775
+ * @param {import("./response.js").default} response - Response to finish.
776
+ * @returns {Promise<void>} - Settles after the terminator was delivered and
777
+ * the close callbacks ran.
778
+ */
779
+ async endStreamResponse(response) {
780
+ if (response.streamEnded) return
781
+ response.streamEnded = true
782
+ this._activeStreamResponses.delete(response)
783
+ if (this.streamOutputSink === null) {
784
+ this.events.emit("output", "0\r\n\r\n")
785
+ } else {
786
+ await this.streamOutputSink("0\r\n\r\n")
787
+ }
788
+ await this._runStreamCloseCallbacks(response)
789
+ }
790
+
791
+ /**
792
+ * Aborts every stream whose client connection went away: marks the stream
793
+ * aborted and runs the close callbacks so in-flight work can settle its
794
+ * resources. No terminator is emitted — the connection is already gone.
795
+ * @returns {Promise<void>} - Resolves after every stream settled.
796
+ */
797
+ async abortStreamResponses() {
798
+ for (const response of [...this._activeStreamResponses.keys()]) {
799
+ if (response.streamAborted) continue
800
+ response.streamAborted = true
801
+ this._activeStreamResponses.delete(response)
802
+ await this._runStreamCloseCallbacks(response)
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Runs the close callbacks of a finished or aborted stream exactly once.
808
+ * @param {import("./response.js").default} response - Finished stream.
809
+ * @returns {Promise<void>} - Resolves after every callback ran.
810
+ */
811
+ async _runStreamCloseCallbacks(response) {
812
+ if (response.streamCloseCallbacks.size === 0) return
813
+
814
+ for (const callback of response.streamCloseCallbacks) {
815
+ try {
816
+ callback()
817
+ } catch (error) {
818
+ const errorPayload = {
819
+ context: {clientCount: this.clientCount, stage: "stream-close-callback"},
820
+ error
821
+ }
822
+ this.configuration.getErrorEvents().emit("framework-error", errorPayload)
823
+ this.configuration.getErrorEvents().emit("all-error", {...errorPayload, errorType: "framework-error"})
824
+ }
825
+ }
826
+ response.streamCloseCallbacks.clear()
827
+ }
828
+
669
829
  /**
670
830
  * Runs should close connection.
671
831
  * @param {import("./request.js").default | import("./websocket-request.js").default} request - Request object.
@@ -305,12 +305,20 @@ export default class VelociousHttpServerClientRequestRunner {
305
305
  })
306
306
 
307
307
  response.setStatus(500)
308
- try {
309
- response.setErrorBody(error)
310
- } catch (responseError) {
311
- if (!(responseError instanceof HttpResponseBodyTooLargeError)) throw responseError
308
+ if (response.isStreaming()) {
309
+ // The headers already went to the client, so a plain error body is
310
+ // impossible. The handler owns the stream lifecycle: it aborts the
311
+ // stream (terminator + close callbacks) or, when it still controls
312
+ // the connection, writes an error SSE frame.
313
+ response.abortStream()
314
+ } else {
315
+ try {
316
+ response.setErrorBody(error)
317
+ } catch (responseError) {
318
+ if (!(responseError instanceof HttpResponseBodyTooLargeError)) throw responseError
312
319
 
313
- response.setBody("")
320
+ response.setBody("")
321
+ }
314
322
  }
315
323
  }
316
324
 
@@ -105,6 +105,41 @@ export default class VelociousHttpServerClientResponse {
105
105
  * @type {boolean} */
106
106
  compressionDisabled = false
107
107
 
108
+ /**
109
+ * Whether this response has been switched to live chunked streaming. Once
110
+ * streaming has started, the status line and headers are emitted to the
111
+ * client immediately and every `write()` is emitted as it happens, instead
112
+ * of the whole body being buffered and sent once after the handler
113
+ * returns.
114
+ * @type {boolean} */
115
+ streaming = false
116
+
117
+ /**
118
+ * Whether the stream has been finished with `end()` (or finalized).
119
+ * @type {boolean} */
120
+ streamEnded = false
121
+
122
+ /**
123
+ * Whether the client connection dropped while the stream was in flight.
124
+ * @type {boolean} */
125
+ streamAborted = false
126
+
127
+ /**
128
+ * Transport sink wired in by the owning client so the response can emit
129
+ * headers and chunks to the socket-bound connection.
130
+ * @type {import("./index.js").default | null} */
131
+ transport = null
132
+
133
+ /**
134
+ * The socket-bound request this response belongs to.
135
+ * @type {import("./request.js").default | null} */
136
+ transportRequest = null
137
+
138
+ /**
139
+ * Callbacks fired when the client disconnects mid-stream.
140
+ * @type {Set<() => void>} */
141
+ streamCloseCallbacks = new Set()
142
+
108
143
  /**
109
144
  * Runs constructor.
110
145
  * @param {object} args - Options object.
@@ -240,6 +275,113 @@ export default class VelociousHttpServerClientResponse {
240
275
  this.body = value
241
276
  }
242
277
 
278
+ /**
279
+ * Whether this response is (or was) a live chunked stream.
280
+ * @returns {boolean} - Whether streaming has started.
281
+ */
282
+ isStreaming() {
283
+ return this.streaming
284
+ }
285
+
286
+ /**
287
+ * Whether the client disconnected mid-stream.
288
+ * @returns {boolean} - Whether the stream was aborted by the client.
289
+ */
290
+ isStreamAborted() {
291
+ return this.streamAborted
292
+ }
293
+
294
+ /**
295
+ * Switches this response to live chunked streaming. The status line and
296
+ * headers are emitted to the client immediately (with a
297
+ * `Transfer-Encoding: chunked` framing header) so `write()` chunks reach
298
+ * the client as they are produced — instead of the whole body being
299
+ * buffered and emitted once after the handler returns.
300
+ *
301
+ * Streaming requires a socket-bound HTTP request and an HTTP version that
302
+ * supports chunked framing, a status that may carry a body, and a
303
+ * non-HEAD request. It cannot be combined with a buffered body, a file
304
+ * response, or a second `stream()` call.
305
+ * @returns {void} - No return value.
306
+ */
307
+ stream() {
308
+ if (this.streaming) throw new Error("The response is already streaming")
309
+ if (this.filePath !== null) throw new Error("A file response cannot be switched to streaming")
310
+ if (this.body !== null && this.body !== undefined) throw new Error("A buffered body was already set; call stream() before setBody() to stream instead")
311
+ if (this.transport === null) throw new Error("Streaming responses require a socket-bound HTTP request")
312
+
313
+ const request = this.transportRequest
314
+ if (!request) throw new Error("Streaming responses require a socket-bound HTTP request")
315
+
316
+ if (request.httpMethod() === "HEAD") throw new Error("HEAD responses cannot stream a body")
317
+
318
+ const statusCode = this.getStatusCode()
319
+ if ((statusCode >= 100 && statusCode < 200) || statusCode === 204 || statusCode === 304) {
320
+ throw new Error(`Status ${statusCode} cannot carry a streaming body`)
321
+ }
322
+
323
+ this.streaming = true
324
+ this.transport.beginStreamResponse(this, request)
325
+ }
326
+
327
+ /**
328
+ * Emits one chunk to the client as soon as it is produced. Returns a
329
+ * promise that settles once the chunk has been delivered to the socket, so
330
+ * a relay loop can `await response.write(chunk)` and get socket
331
+ * backpressure without an ad-hoc drain wait. Rejects when the stream has
332
+ * ended or been aborted, or when the outbound delivery queue cannot accept
333
+ * the chunk (a stalled client).
334
+ * @param {string | Uint8Array} value - Chunk to emit.
335
+ * @returns {Promise<void>} - Settles after the chunk is delivered.
336
+ */
337
+ async write(value) {
338
+ if (this.transport === null) throw new Error("write() requires an active streaming response")
339
+ if (!this.streaming) throw new Error("write() requires stream() to be called first")
340
+ if (this.streamEnded || this.streamAborted) return
341
+
342
+ await this.transport.writeStreamChunk(value)
343
+ }
344
+
345
+ /**
346
+ * Finishes an active stream: emits the chunked terminator and releases the
347
+ * response for completion logging. Settles after the terminator has been
348
+ * delivered to the socket.
349
+ * @returns {Promise<void>} - Settles after the stream is finished.
350
+ */
351
+ async end() {
352
+ if (this.transport === null) throw new Error("end() requires an active streaming response")
353
+ if (!this.streaming) throw new Error("end() requires stream() to be called first")
354
+ if (this.streamEnded) return
355
+
356
+ await this.transport.endStreamResponse(this)
357
+ }
358
+
359
+ /**
360
+ * Registers a callback fired when the client disconnects mid-stream, so
361
+ * the handler can release whatever the in-flight work reserved. Fired at
362
+ * most once; also fires when the stream is finalized after the client is
363
+ * already gone.
364
+ * @param {() => void} callback - Disconnect callback.
365
+ * @returns {void} - No return value.
366
+ */
367
+ onStreamClose(callback) {
368
+ this.streamCloseCallbacks.add(callback)
369
+ }
370
+
371
+ /**
372
+ * Terminates an active stream after a framework-level failure (the handler
373
+ * threw after `stream()` started): emits the chunked terminator and runs
374
+ * the close callbacks so in-flight work settles its resources. No-op when
375
+ * the stream already ended or was aborted.
376
+ * @returns {void} - No return value.
377
+ */
378
+ abortStream() {
379
+ if (!this.streaming || this.streamEnded || this.transport === null) return
380
+
381
+ this.streamEnded = true
382
+ this.transport.endStreamResponse(this)
383
+ }
384
+
243
385
  /**
244
386
  * Runs get file path.
245
387
  * @returns {string | null} - File path.
@@ -69,6 +69,17 @@ export default class VelociousHttpServerInProcessHandler {
69
69
  }
70
70
  })
71
71
 
72
+ // Streaming responses emit chunks while the request is running. Route
73
+ // their framed output through the counted delivery path so a stalled
74
+ // client cannot buffer engine chunks unboundedly: each chunk settles only
75
+ // after it has been delivered to the socket, which gives the handler
76
+ // backpressure, and the byte/frame limits bound what a silent client can
77
+ // retain before the connection is torn down.
78
+ httpClient.streamOutputSink = (output) => deliveryQueue.enqueueFrame({
79
+ byteLength: Buffer.byteLength(output),
80
+ delivery: () => serverClient.send(output)
81
+ })
82
+
72
83
  httpClient.events.on("output", (output, {websocketFrame = false} = {}) => {
73
84
  if (output !== null && output !== undefined) {
74
85
  const delivery = () => serverClient.send(output)
@@ -101,9 +112,13 @@ export default class VelociousHttpServerInProcessHandler {
101
112
 
102
113
  serverClient.events.on("close", () => {
103
114
  deliveryQueue.destroy()
104
- const cleanup = httpClient.abortPendingFileResponses()
115
+ const cleanup = Promise.all([
116
+ httpClient.abortPendingFileResponses(),
117
+ httpClient.abortStreamResponses()
118
+ ])
119
+ .then(() => {})
105
120
  .catch((error) => {
106
- this.logger.warn("Failed to abort file responses after client close", error)
121
+ this.logger.warn("Failed to abort responses after client close", error)
107
122
  })
108
123
  .finally(() => {
109
124
  this.pendingClientCloseCleanups.delete(cleanup)
@@ -160,6 +175,9 @@ export default class VelociousHttpServerInProcessHandler {
160
175
  httpClient.abortPendingFileResponses().catch((error) => {
161
176
  this.logger.warn("Failed to abort file responses during shutdown", error)
162
177
  }),
178
+ httpClient.abortStreamResponses().catch((error) => {
179
+ this.logger.warn("Failed to abort streaming responses during shutdown", error)
180
+ }),
163
181
  serverClient.end().catch((error) => {
164
182
  this.logger.warn("Failed to close client during shutdown", error)
165
183
  })
@@ -217,7 +217,10 @@ export default class VelociousHttpServerWorkerHandlerWorkerThread {
217
217
  const settlements = []
218
218
  const client = this.clients[clientCount]
219
219
 
220
- if (client) settlements.push(client.abortPendingFileResponses())
220
+ if (client) {
221
+ settlements.push(client.abortPendingFileResponses())
222
+ settlements.push(client.abortStreamResponses())
223
+ }
221
224
 
222
225
  for (const [transferId, transfer] of this.fileTransfers) {
223
226
  if (transfer.clientCount !== clientCount) continue