@octanejs/app-core 0.0.56 → 0.0.58

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/app-core",
3
- "version": "0.0.56",
3
+ "version": "0.0.58",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -79,10 +79,10 @@
79
79
  "esbuild": "^0.28.1"
80
80
  },
81
81
  "peerDependencies": {
82
- "octane": "^0.5.0"
82
+ "octane": "^0.7.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "^24.13.3",
86
- "octane": "0.5.0"
86
+ "octane": "0.7.0"
87
87
  }
88
88
  }
@@ -212,7 +212,13 @@ export function nodeRequestToWebRequest(nodeRequest, nodeResponse) {
212
212
 
213
213
  /**
214
214
  * Pipe a Web Response to a Node.js ServerResponse. Streams chunk-by-chunk so a
215
- * streaming SSR body flushes as it renders (no buffering).
215
+ * streaming SSR body flushes as it renders (no buffering). A HEAD response ends
216
+ * with its headers and cancels the body.
217
+ *
218
+ * If the body fails, the returned promise rejects. Once headers were sent, the
219
+ * response has already been destroyed so the client observes an interrupted
220
+ * transfer; the caller must only report the error. Before that, the response's
221
+ * headers have been removed and the caller may send its own error response.
216
222
  *
217
223
  * @param {import('node:http').ServerResponse} nodeResponse
218
224
  * @param {Response} webResponse
@@ -237,6 +243,9 @@ async function sendWebResponseForRequest(nodeResponse, webResponse, nodeRequest)
237
243
  if (nodeRequest && body && shouldGzip(nodeRequest, webResponse.status, headers, true)) {
238
244
  headers.set('Content-Encoding', 'gzip');
239
245
  headers.delete('Content-Length');
246
+ const etag = headers.get('ETag');
247
+ // A strong validator for the identity bytes cannot identify their gzip form.
248
+ if (etag !== null && !etag.startsWith('W/')) headers.delete('ETag');
240
249
  // Sync-flush each input chunk so an SSR shell stays progressively
241
250
  // observable instead of waiting for the final segment to close gzip.
242
251
  const gzip = Duplex.toWeb(createGzip({ flush: zlibConstants.Z_SYNC_FLUSH }));
@@ -247,10 +256,24 @@ async function sendWebResponseForRequest(nodeResponse, webResponse, nodeRequest)
247
256
 
248
257
  nodeResponse.statusCode = webResponse.status;
249
258
  if (webResponse.statusText) nodeResponse.statusMessage = webResponse.statusText;
259
+ // `Headers` yields each Set-Cookie as its own entry, and `setHeader`
260
+ // replaces rather than appends, so cookies go over as one array: Node writes
261
+ // one header line per element. Commas cannot join them because `Expires`
262
+ // contains one.
250
263
  headers.forEach((value, key) => {
251
- nodeResponse.setHeader(key, value);
264
+ if (key !== 'set-cookie') nodeResponse.setHeader(key, value);
252
265
  });
266
+ const cookies = headers.getSetCookie();
267
+ if (cookies.length) nodeResponse.setHeader('set-cookie', cookies);
253
268
  if (body) {
269
+ // HEAD transfers metadata without content, and Node discards body writes.
270
+ // End now and release the producer instead of draining a body that may
271
+ // stay open indefinitely.
272
+ if (nodeResponse.req?.method === 'HEAD') {
273
+ if (!nodeResponse.destroyed) nodeResponse.end();
274
+ await body.cancel().catch(() => {});
275
+ return;
276
+ }
254
277
  const reader = body.getReader();
255
278
  let disconnected = nodeResponse.destroyed;
256
279
  let disconnectReason = new Error('The client disconnected while streaming the response.');
@@ -280,7 +303,21 @@ async function sendWebResponseForRequest(nodeResponse, webResponse, nodeRequest)
280
303
  if (!accepted && !disconnected) await waitForDrain(nodeResponse);
281
304
  }
282
305
  } catch (error) {
283
- if (!disconnected) throw error;
306
+ if (!disconnected) {
307
+ if (nodeResponse.headersSent) {
308
+ // The status is committed, so an interrupted transfer is the only
309
+ // failure signal left; completing it would pass the partial body off
310
+ // as whole. Destroy without the error, which Node would otherwise
311
+ // report as a clientError: the caller reports it.
312
+ nodeResponse.destroy();
313
+ } else {
314
+ // Nothing is on the wire yet. Drop this representation's metadata so
315
+ // the caller's error response does not inherit its framing or encoding.
316
+ for (const name of headers.keys()) nodeResponse.removeHeader(name);
317
+ }
318
+ void cancelReader(error);
319
+ throw error;
320
+ }
284
321
  } finally {
285
322
  nodeResponse.off('close', onClose);
286
323
  nodeResponse.off('error', onError);
@@ -490,10 +527,11 @@ export function createNodeServer(handler, options = {}) {
490
527
  await sendWebResponseForRequest(res, response, req);
491
528
  })().catch((error) => {
492
529
  console.error('[octane] Request error:', error);
493
- if (!res.headersSent) {
494
- res.statusCode = 500;
495
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
496
- }
530
+ // Once headers are sent the sender has aborted the transfer. Appending
531
+ // here would complete it with bytes the application never produced.
532
+ if (res.headersSent) return;
533
+ res.statusCode = 500;
534
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
497
535
  res.end('Internal Server Error');
498
536
  });
499
537
  });
@@ -475,10 +475,10 @@ export async function nodeHandler(req, res) {
475
475
  await sendWebResponse(res, response);
476
476
  } catch (error) {
477
477
  console.error('[octane] Request error:', error);
478
- if (!res.headersSent) {
479
- res.statusCode = 500;
480
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
481
- }
478
+ // sendWebResponse already aborted a response whose headers were sent.
479
+ if (res.headersSent) return;
480
+ res.statusCode = 500;
481
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
482
482
  res.end('Internal Server Error');
483
483
  }
484
484
  }
package/types/node.d.ts CHANGED
@@ -13,7 +13,13 @@ export function nodeRequestToWebRequest(
13
13
 
14
14
  /**
15
15
  * Pipe a Web Response to a Node.js ServerResponse, streaming chunk-by-chunk
16
- * (a streaming SSR body flushes as it renders).
16
+ * (a streaming SSR body flushes as it renders). A HEAD response ends with its
17
+ * headers and cancels the body.
18
+ *
19
+ * Rejects when the body fails. If headers were already sent, the response has
20
+ * been destroyed so the client sees an interrupted transfer; only report the
21
+ * error. Otherwise the Web Response's headers were removed and the caller may
22
+ * send its own error response.
17
23
  */
18
24
  export function sendWebResponse(nodeResponse: ServerResponse, webResponse: Response): Promise<void>;
19
25