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
@@ -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