react-on-rails-pro-node-renderer 17.0.0-rc.6 → 17.0.0-rc.8

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.
@@ -34,6 +34,7 @@ const handleGracefulShutdown = (app) => {
34
34
  let activeRequestsCount = 0;
35
35
  let isShuttingDown = false;
36
36
  let isDestroying = false;
37
+ const activeRequests = new WeakSet();
37
38
  const destroyWorkerAfterShutdownHooks = (context) => {
38
39
  if (isDestroying) {
39
40
  return;
@@ -75,10 +76,29 @@ const handleGracefulShutdown = (app) => {
75
76
  }
76
77
  }
77
78
  };
78
- // Helper to decrement counter and potentially kill worker
79
- const decrementAndMaybeShutdown = (context) => {
79
+ const acknowledgeGracefulShutdown = () => {
80
+ try {
81
+ worker.send(utils_js_1.SHUTDOWN_WORKER_ACK_MESSAGE);
82
+ }
83
+ catch (error) {
84
+ if (errorCode(error) === 'ERR_IPC_CHANNEL_CLOSED') {
85
+ log_js_1.default.debug('Worker #%d IPC channel was already closed before graceful shutdown ACK', worker.id);
86
+ }
87
+ else {
88
+ log_js_1.default.warn({ msg: 'Error sending graceful shutdown ACK from worker', error });
89
+ }
90
+ }
91
+ };
92
+ const decrementAndMaybeShutdown = (request, context) => {
93
+ if (!activeRequests.delete(request)) {
94
+ log_js_1.default.debug('Worker #%d: request already completed before %s', worker.id, context);
95
+ return;
96
+ }
80
97
  activeRequestsCount -= 1;
81
- if (isShuttingDown && activeRequestsCount === 0) {
98
+ if (activeRequestsCount < 0) {
99
+ log_js_1.default.warn('Worker #%d active request count is negative after %s', worker.id, context);
100
+ }
101
+ if (isShuttingDown && activeRequestsCount <= 0) {
82
102
  log_js_1.default.debug('Worker #%d has no active requests after %s, killing the worker', worker.id, context);
83
103
  destroyWorkerAfterShutdownHooks(context);
84
104
  }
@@ -86,8 +106,9 @@ const handleGracefulShutdown = (app) => {
86
106
  process.on('message', (msg) => {
87
107
  if (msg === utils_js_1.SHUTDOWN_WORKER_MESSAGE) {
88
108
  log_js_1.default.debug('Worker #%d received graceful shutdown message', worker.id);
109
+ acknowledgeGracefulShutdown();
89
110
  isShuttingDown = true;
90
- if (activeRequestsCount === 0) {
111
+ if (activeRequestsCount <= 0) {
91
112
  log_js_1.default.debug('Worker #%d has no active requests, killing the worker', worker.id);
92
113
  destroyWorkerAfterShutdownHooks('shutdown message');
93
114
  }
@@ -97,24 +118,23 @@ const handleGracefulShutdown = (app) => {
97
118
  }
98
119
  }
99
120
  });
100
- app.addHook('onRequest', (_req, _reply, done) => {
121
+ app.addHook('onRequest', (req, _reply, done) => {
122
+ activeRequests.add(req);
101
123
  activeRequestsCount += 1;
102
124
  done();
103
125
  });
104
- app.addHook('onResponse', (_req, _reply, done) => {
105
- decrementAndMaybeShutdown('onResponse');
126
+ app.addHook('onResponse', (req, _reply, done) => {
127
+ decrementAndMaybeShutdown(req, 'onResponse');
106
128
  done();
107
129
  });
108
- // Handle client abort - onResponse is NOT called when client disconnects
109
- app.addHook('onRequestAbort', (_req, done) => {
130
+ app.addHook('onRequestAbort', (req, done) => {
110
131
  log_js_1.default.debug('Worker #%d: request aborted by client', worker.id);
111
- decrementAndMaybeShutdown('onRequestAbort');
132
+ decrementAndMaybeShutdown(req, 'onRequestAbort');
112
133
  done();
113
134
  });
114
- // Handle request timeout - onResponse is NOT called when request times out
115
- app.addHook('onTimeout', (_req, _reply, done) => {
135
+ app.addHook('onTimeout', (req, _reply, done) => {
116
136
  log_js_1.default.debug('Worker #%d: request timed out', worker.id);
117
- decrementAndMaybeShutdown('onTimeout');
137
+ decrementAndMaybeShutdown(req, 'onTimeout');
118
138
  done();
119
139
  });
120
140
  };
@@ -27,6 +27,7 @@ export type FirstIncrementalRenderRequestChunk = {
27
27
  onRequestClosedUpdateChunk?: UpdateChunk;
28
28
  pullEnabled?: boolean;
29
29
  pushProps?: string[];
30
+ rscStreamObservability?: boolean;
30
31
  };
31
32
  export type IncrementalRenderResult = {
32
33
  response: ResponseResult;
@@ -27,6 +27,7 @@ const utils_1 = require("../shared/utils");
27
27
  const tracing_js_1 = require("../shared/tracing.js");
28
28
  const streamingUtils_1 = require("./streamingUtils");
29
29
  // These keys must match the constants in AsyncPropsManager.ts (react-on-rails-pro package)
30
+ // MIRROR VALUES OF: packages/react-on-rails-pro/src/AsyncPropsManager.ts
30
31
  exports.PULL_ENABLED_KEY = 'pullEnabled';
31
32
  exports.PUSH_PROPS_KEY = 'pushProps';
32
33
  exports.PROP_REQUEST_EMITTER_KEY = 'propRequestEmitter';
@@ -94,6 +95,12 @@ function assertFirstIncrementalRenderRequestChunk(chunk) {
94
95
  if ('pullEnabled' in chunk && chunk.pullEnabled !== undefined && typeof chunk.pullEnabled !== 'boolean') {
95
96
  throw new Error('Invalid first incremental render request chunk: pullEnabled must be a boolean');
96
97
  }
98
+ if ('rscStreamObservability' in chunk &&
99
+ chunk.rscStreamObservability !== undefined &&
100
+ // Incremental NDJSON requests are produced in-process, so keep this strict.
101
+ typeof chunk.rscStreamObservability !== 'boolean') {
102
+ throw new Error('Invalid first incremental render request chunk: rscStreamObservability must be a boolean');
103
+ }
97
104
  if ('pushProps' in chunk &&
98
105
  chunk.pushProps !== undefined &&
99
106
  (!Array.isArray(chunk.pushProps) || chunk.pushProps.some((propName) => typeof propName !== 'string'))) {
@@ -131,7 +138,7 @@ function assertFirstIncrementalRenderRequestChunk(chunk) {
131
138
  async function handleIncrementalRenderRequest(initial) {
132
139
  const { firstRequestChunk, bundleTimestamp, dependencyBundleTimestamps } = initial;
133
140
  assertFirstIncrementalRenderRequestChunk(firstRequestChunk);
134
- const { renderingRequest, onRequestClosedUpdateChunk } = firstRequestChunk;
141
+ const { renderingRequest, onRequestClosedUpdateChunk, rscStreamObservability } = firstRequestChunk;
135
142
  try {
136
143
  // Call handleRenderRequest internally to handle all validation and VM execution
137
144
  // handleRenderRequest is called directly without a TracingContext from worker.ts's
@@ -142,129 +149,161 @@ async function handleIncrementalRenderRequest(initial) {
142
149
  dependencyBundleTimestamps,
143
150
  providedNewBundles: undefined,
144
151
  assetsToCopy: undefined,
152
+ rscStreamObservability,
145
153
  });
146
154
  // If we don't get an execution context, it means there was an early error
147
155
  // (e.g. bundle not found). In this case, the sink will be a no-op.
148
156
  if (!executionContext) {
149
157
  return { response };
150
158
  }
151
- // Set up pull mode if enabled: inject propRequest emitter into sharedExecutionContext
152
- // so AsyncPropsManager (inside VM) can emit propRequests to the response stream.
153
159
  let finalResponse = response;
154
- const { pullEnabled, pushProps } = firstRequestChunk;
155
- if (pullEnabled && response.stream) {
156
- const sourceStream = response.stream;
157
- const { sharedExecutionContext } = executionContext;
158
- sharedExecutionContext.set(exports.PULL_ENABLED_KEY, true);
159
- sharedExecutionContext.set(exports.PUSH_PROPS_KEY, new Set(pushProps || []));
160
- // Create injectable PassThrough — sits after the length-prefixed transform.
161
- // Both HTML chunks (from React) and propRequest chunks (from us) flow through it.
162
- const injectableStream = new stream_1.PassThrough();
163
- const writeRenderCompleteAndEnd = () => {
164
- if (injectableStream.destroyed || injectableStream.writableEnded)
165
- return;
166
- try {
167
- injectableStream.write((0, streamingUtils_1.formatRenderCompleteChunk)());
168
- }
169
- catch (err) {
170
- log_1.default.warn({ msg: 'Failed to write renderComplete chunk', err });
171
- }
172
- injectableStream.end();
173
- };
174
- // Register event handlers BEFORE pipe() to guarantee we catch 'end'
175
- // even if the source stream has already buffered all its data.
176
- sourceStream.on('end', () => {
177
- if (injectableStream.writableNeedDrain) {
178
- injectableStream.once('drain', writeRenderCompleteAndEnd);
179
- return;
180
- }
181
- writeRenderCompleteAndEnd();
182
- });
183
- sourceStream.on('error', (err) => {
184
- injectableStream.destroy(err);
185
- });
186
- // Fastify destroys the returned stream when the browser/Rails client disconnects.
187
- // Propagate that premature teardown back through the pull wrapper so upstream
188
- // React/RSC work and async prop fetches abort just like the non-pull path.
189
- injectableStream.once('close', () => {
190
- if (!injectableStream.writableEnded && !sourceStream.destroyed) {
191
- sourceStream.destroy();
192
- }
193
- });
194
- // { end: false } prevents pipe from auto-closing injectableStream when
195
- // the source ends — we write renderComplete in the 'end' handler above.
196
- sourceStream.pipe(injectableStream, { end: false });
197
- // Set the emitter callback — AsyncPropsManager calls this from inside the VM
198
- sharedExecutionContext.set(exports.PROP_REQUEST_EMITTER_KEY, (propName) => {
199
- if (injectableStream.destroyed || injectableStream.writableEnded) {
200
- log_1.default.warn({ msg: 'Skipping propRequest after stream closed', propName });
201
- return;
202
- }
203
- if (propName.length > exports.MAX_PULL_PROP_NAME_LENGTH) {
204
- log_1.default.warn({
205
- msg: 'Skipping oversized propRequest',
206
- propNameLength: propName.length,
207
- maxPropNameLength: exports.MAX_PULL_PROP_NAME_LENGTH,
208
- });
209
- return;
210
- }
211
- try {
212
- injectableStream.write((0, streamingUtils_1.formatPropRequestChunk)(propName));
213
- }
214
- catch (err) {
215
- log_1.default.error({ msg: 'Failed to write propRequest chunk', propName, err });
216
- }
217
- });
218
- const manager = sharedExecutionContext.get(exports.ASYNC_PROPS_MANAGER_KEY);
219
- catchUpAsyncPropsManagerPullBridge(manager);
220
- finalResponse = { ...response, stream: injectableStream };
221
- }
222
- // Return the result with a sink that uses the execution context
223
- return {
224
- response: finalResponse,
225
- sink: {
226
- executionContext,
227
- add: async (chunk) => {
160
+ let pullModeSharedExecutionContext;
161
+ let pullModeStream;
162
+ try {
163
+ // Set up pull mode if enabled: inject propRequest emitter into sharedExecutionContext
164
+ // so AsyncPropsManager (inside VM) can emit propRequests to the response stream.
165
+ const { pullEnabled, pushProps } = firstRequestChunk;
166
+ if (pullEnabled && response.stream) {
167
+ const sourceStream = response.stream;
168
+ const { sharedExecutionContext } = executionContext;
169
+ pullModeSharedExecutionContext = sharedExecutionContext;
170
+ sharedExecutionContext.set(exports.PULL_ENABLED_KEY, true);
171
+ sharedExecutionContext.set(exports.PUSH_PROPS_KEY, new Set(pushProps || []));
172
+ // Create injectable PassThrough — sits after the length-prefixed transform.
173
+ // Both HTML chunks (from React) and propRequest chunks (from us) flow through it.
174
+ const injectableStream = new stream_1.PassThrough();
175
+ pullModeStream = injectableStream;
176
+ const writeRenderCompleteAndEnd = () => {
177
+ if (injectableStream.destroyed || injectableStream.writableEnded)
178
+ return;
228
179
  try {
229
- assertIsUpdateChunk(chunk);
230
- await (0, tracing_js_1.subSpan)({ name: 'ror.incremental.process_chunk' }, async () => {
231
- const bundlePath = (0, utils_1.getRequestBundleFilePath)(chunk.bundleTimestamp);
232
- const result = await executionContext.runInVM(chunk.updateChunk, bundlePath);
233
- if ((0, utils_1.isErrorRenderResult)(result)) {
234
- throw new Error(result.exceptionMessage);
235
- }
236
- });
180
+ injectableStream.write((0, streamingUtils_1.formatRenderCompleteChunk)());
237
181
  }
238
182
  catch (err) {
239
- if (err instanceof InvalidIncrementalRenderChunkError) {
240
- log_1.default.error({ msg: 'Invalid incremental render chunk', err, chunk });
241
- }
242
- else {
243
- log_1.default.error({ msg: 'Error running incremental render chunk', err, chunk });
244
- }
183
+ log_1.default.warn({ msg: 'Failed to write renderComplete chunk', err });
245
184
  }
246
- },
247
- handleRequestClosed: async () => {
248
- if (!onRequestClosedUpdateChunk) {
185
+ injectableStream.end();
186
+ };
187
+ // Register event handlers BEFORE pipe() to guarantee we catch 'end'
188
+ // even if the source stream has already buffered all its data.
189
+ sourceStream.on('end', () => {
190
+ if (injectableStream.writableNeedDrain) {
191
+ injectableStream.once('drain', writeRenderCompleteAndEnd);
192
+ return;
193
+ }
194
+ writeRenderCompleteAndEnd();
195
+ });
196
+ sourceStream.on('error', (err) => {
197
+ injectableStream.destroy(err);
198
+ });
199
+ // Fastify destroys the returned stream when the browser/Rails client disconnects.
200
+ // Propagate that premature teardown back through the pull wrapper so upstream
201
+ // React/RSC work and async prop fetches abort just like the non-pull path.
202
+ injectableStream.once('close', () => {
203
+ if (!injectableStream.writableEnded && !sourceStream.destroyed) {
204
+ sourceStream.destroy();
205
+ }
206
+ });
207
+ // { end: false } prevents pipe from auto-closing injectableStream when
208
+ // the source ends — we write renderComplete in the 'end' handler above.
209
+ sourceStream.pipe(injectableStream, { end: false });
210
+ // Set the emitter callback — AsyncPropsManager calls this from inside the VM
211
+ sharedExecutionContext.set(exports.PROP_REQUEST_EMITTER_KEY, (propName) => {
212
+ if (injectableStream.destroyed || injectableStream.writableEnded) {
213
+ log_1.default.warn({ msg: 'Skipping propRequest after stream closed', propName });
214
+ return;
215
+ }
216
+ if (propName.length > exports.MAX_PULL_PROP_NAME_LENGTH) {
217
+ log_1.default.warn({
218
+ msg: 'Skipping oversized propRequest',
219
+ propNameLength: propName.length,
220
+ maxPropNameLength: exports.MAX_PULL_PROP_NAME_LENGTH,
221
+ });
249
222
  return;
250
223
  }
251
- const bundlePath = (0, utils_1.getRequestBundleFilePath)(onRequestClosedUpdateChunk.bundleTimestamp);
252
224
  try {
253
- const result = await executionContext.runInVM(onRequestClosedUpdateChunk.updateChunk, bundlePath);
254
- if ((0, utils_1.isErrorRenderResult)(result)) {
255
- throw new Error(result.exceptionMessage);
256
- }
225
+ injectableStream.write((0, streamingUtils_1.formatPropRequestChunk)(propName));
257
226
  }
258
227
  catch (err) {
259
- log_1.default.error({
260
- msg: 'Error running onRequestClosedUpdateChunk',
261
- err,
262
- onRequestClosedUpdateChunk,
263
- });
228
+ log_1.default.error({ msg: 'Failed to write propRequest chunk', propName, err });
264
229
  }
230
+ });
231
+ const manager = sharedExecutionContext.get(exports.ASYNC_PROPS_MANAGER_KEY);
232
+ catchUpAsyncPropsManagerPullBridge(manager);
233
+ finalResponse = { ...response, stream: injectableStream };
234
+ }
235
+ // Return the result with a sink that uses the execution context
236
+ return {
237
+ response: finalResponse,
238
+ sink: {
239
+ executionContext,
240
+ add: async (chunk) => {
241
+ try {
242
+ assertIsUpdateChunk(chunk);
243
+ await (0, tracing_js_1.subSpan)({ name: 'ror.incremental.process_chunk' }, async () => {
244
+ const bundlePath = (0, utils_1.getRequestBundleFilePath)(chunk.bundleTimestamp);
245
+ const result = await executionContext.runInVM(chunk.updateChunk, bundlePath);
246
+ if ((0, utils_1.isErrorRenderResult)(result)) {
247
+ throw new Error(result.exceptionMessage);
248
+ }
249
+ });
250
+ }
251
+ catch (err) {
252
+ if (err instanceof InvalidIncrementalRenderChunkError) {
253
+ log_1.default.error({ msg: 'Invalid incremental render chunk', err, chunk });
254
+ }
255
+ else {
256
+ log_1.default.error({ msg: 'Error running incremental render chunk', err, chunk });
257
+ }
258
+ }
259
+ },
260
+ handleRequestClosed: async () => {
261
+ if (!onRequestClosedUpdateChunk) {
262
+ return;
263
+ }
264
+ const bundlePath = (0, utils_1.getRequestBundleFilePath)(onRequestClosedUpdateChunk.bundleTimestamp);
265
+ try {
266
+ const result = await executionContext.runInVM(onRequestClosedUpdateChunk.updateChunk, bundlePath);
267
+ if ((0, utils_1.isErrorRenderResult)(result)) {
268
+ throw new Error(result.exceptionMessage);
269
+ }
270
+ }
271
+ catch (err) {
272
+ log_1.default.error({
273
+ msg: 'Error running onRequestClosedUpdateChunk',
274
+ err,
275
+ onRequestClosedUpdateChunk,
276
+ });
277
+ }
278
+ },
265
279
  },
266
- },
267
- };
280
+ };
281
+ }
282
+ catch (error) {
283
+ if (pullModeSharedExecutionContext) {
284
+ pullModeSharedExecutionContext.delete(exports.PULL_ENABLED_KEY);
285
+ pullModeSharedExecutionContext.delete(exports.PUSH_PROPS_KEY);
286
+ pullModeSharedExecutionContext.delete(exports.PROP_REQUEST_EMITTER_KEY);
287
+ }
288
+ try {
289
+ if (pullModeStream && !pullModeStream.destroyed) {
290
+ pullModeStream.destroy();
291
+ }
292
+ if (response.stream && response.stream !== pullModeStream && !response.stream.destroyed) {
293
+ response.stream.destroy();
294
+ }
295
+ }
296
+ catch (err) {
297
+ log_1.default.error({ msg: 'Error cleaning up incremental render setup failure', err });
298
+ }
299
+ try {
300
+ executionContext.release();
301
+ }
302
+ catch (err) {
303
+ log_1.default.error({ msg: 'Error releasing execution context after incremental render setup failure', err });
304
+ }
305
+ throw error;
306
+ }
268
307
  }
269
308
  catch (error) {
270
309
  // Handle any unexpected errors
@@ -25,6 +25,9 @@ export interface IncrementalRenderStreamHandlerOptions {
25
25
  onResponseStart: (response: ResponseResult) => Promise<void> | void;
26
26
  onUpdateReceived: (updateData: unknown) => Promise<void> | void;
27
27
  onRequestEnded: () => Promise<void> | void;
28
+ getChunkTimeoutMs?: () => number;
29
+ shouldStopReading?: () => boolean;
30
+ waitForStopReading?: () => Promise<void>;
28
31
  }
29
32
  /**
30
33
  * Handles incremental rendering requests with streaming JSON data.
@@ -67,11 +67,57 @@ exports.StreamChunkTimeoutError = StreamChunkTimeoutError;
67
67
  * Wraps an async iterator with a timeout for each chunk.
68
68
  * If no chunk is received within the timeout, throws StreamChunkTimeoutError.
69
69
  */
70
- async function* withChunkTimeout(iterator, timeoutMs) {
70
+ async function* withChunkTimeout(iterator, getTimeoutMs, shouldStopReading, waitForStopReading) {
71
71
  const asyncIterator = iterator[Symbol.asyncIterator]();
72
+ const stopIterator = () => {
73
+ try {
74
+ const returnPromise = asyncIterator.return?.();
75
+ if (returnPromise) {
76
+ void Promise.resolve(returnPromise).catch(() => undefined);
77
+ }
78
+ }
79
+ catch {
80
+ // The caller has already decided to stop reading; cleanup failures should not keep the response open.
81
+ }
82
+ };
72
83
  while (true) {
84
+ if (shouldStopReading()) {
85
+ stopIterator();
86
+ return;
87
+ }
73
88
  let timeoutId;
89
+ const timeoutMs = getTimeoutMs();
74
90
  try {
91
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
92
+ const nextChunkPromise = asyncIterator.next();
93
+ const stopReadingPromise = waitForStopReading?.().then(() => 'stop-reading');
94
+ // eslint-disable-next-line no-await-in-loop
95
+ const result = await (stopReadingPromise
96
+ ? Promise.race([nextChunkPromise, stopReadingPromise])
97
+ : nextChunkPromise);
98
+ if (result === 'stop-reading') {
99
+ if (shouldStopReading()) {
100
+ stopIterator();
101
+ return;
102
+ }
103
+ // The stop signal was stale. Keep waiting for the in-flight read to settle
104
+ // before starting another read on the same async iterator.
105
+ // eslint-disable-next-line no-await-in-loop
106
+ const nextResult = await nextChunkPromise;
107
+ if (nextResult.done) {
108
+ return;
109
+ }
110
+ yield nextResult.value;
111
+ // eslint-disable-next-line no-continue
112
+ continue;
113
+ }
114
+ if (result.done) {
115
+ return;
116
+ }
117
+ yield result.value;
118
+ // eslint-disable-next-line no-continue
119
+ continue;
120
+ }
75
121
  // eslint-disable-next-line no-await-in-loop
76
122
  const result = await Promise.race([
77
123
  asyncIterator.next(),
@@ -97,20 +143,29 @@ async function* withChunkTimeout(iterator, timeoutMs) {
97
143
  }
98
144
  }
99
145
  }
146
+ function reportResponseStartError(err) {
147
+ errorReporter.error(err instanceof Error ? err : new Error(String(err)));
148
+ }
149
+ function observeResponseStartError(promise) {
150
+ void promise.catch(reportResponseStartError);
151
+ }
152
+ function suppressUnhandledResponseStartError(promise) {
153
+ void promise.catch(() => { });
154
+ }
100
155
  /**
101
156
  * Handles incremental rendering requests with streaming JSON data.
102
157
  * The first object triggers rendering, subsequent objects provide incremental updates.
103
158
  */
104
159
  async function handleIncrementalRenderStream(options) {
105
160
  return (0, tracing_js_1.subSpan)({ name: 'ror.incremental.stream' }, async () => {
106
- const { request, onRenderRequestReceived, onResponseStart, onUpdateReceived, onRequestEnded } = options;
161
+ const { request, onRenderRequestReceived, onResponseStart, onUpdateReceived, onRequestEnded, getChunkTimeoutMs = () => constants_1.STREAM_CHUNK_TIMEOUT_MS, shouldStopReading = () => false, waitForStopReading, } = options;
107
162
  let hasReceivedFirstObject = false;
108
163
  const decoder = new string_decoder_1.StringDecoder('utf8');
109
164
  let buffer = '';
110
165
  let totalBytesReceived = 0;
111
166
  let onResponseStartPromise = null;
112
167
  try {
113
- for await (const chunk of withChunkTimeout(request.raw, constants_1.STREAM_CHUNK_TIMEOUT_MS)) {
168
+ for await (const chunk of withChunkTimeout(request.raw, getChunkTimeoutMs, shouldStopReading, waitForStopReading)) {
114
169
  const chunkBuffer = chunk instanceof Buffer ? chunk : Buffer.from(chunk);
115
170
  totalBytesReceived += chunkBuffer.length;
116
171
  // Check total request size limit
@@ -158,7 +213,9 @@ async function handleIncrementalRenderStream(options) {
158
213
  const result = await onRenderRequestReceived(parsed);
159
214
  const { response, shouldContinue: continueFlag } = result;
160
215
  onResponseStartPromise = Promise.resolve(onResponseStart(response));
216
+ suppressUnhandledResponseStartError(onResponseStartPromise);
161
217
  if (!continueFlag) {
218
+ observeResponseStartError(onResponseStartPromise);
162
219
  return;
163
220
  }
164
221
  }
@@ -206,14 +263,31 @@ async function handleIncrementalRenderStream(options) {
206
263
  }
207
264
  }
208
265
  catch (err) {
266
+ if (onResponseStartPromise !== null) {
267
+ observeResponseStartError(onResponseStartPromise);
268
+ }
209
269
  const error = err instanceof Error ? err : new Error(String(err));
210
270
  // Update the error message in place to retain the original stack trace, rather than creating a new error object
211
271
  error.message = `Error while handling the request stream: ${error.message}`;
212
272
  throw error;
213
273
  }
214
274
  // Stream ended normally
215
- await onRequestEnded();
216
- await onResponseStartPromise;
275
+ try {
276
+ await onRequestEnded();
277
+ }
278
+ catch (err) {
279
+ if (onResponseStartPromise !== null) {
280
+ observeResponseStartError(onResponseStartPromise);
281
+ }
282
+ throw err;
283
+ }
284
+ try {
285
+ await onResponseStartPromise;
286
+ }
287
+ catch (err) {
288
+ reportResponseStartError(err);
289
+ throw err;
290
+ }
217
291
  });
218
292
  }
219
293
  //# sourceMappingURL=handleIncrementalRenderStream.js.map
@@ -6,18 +6,22 @@ export type ProvidedNewBundle = {
6
6
  timestamp: string | number;
7
7
  bundle: Asset;
8
8
  };
9
+ export declare function escapeServerTimingDescription(description: string): string;
10
+ /** Adds renderer prepare Server-Timing to streamed responses when enabled. */
11
+ export declare function addRendererServerTiming(response: ResponseResult, startedAtMs: number, enabled: boolean): void;
9
12
  export declare function handleNewBundlesProvided(requestContext: RequestInfo, providedNewBundles: ProvidedNewBundle[], assetsToCopy: Asset[] | null | undefined): Promise<ResponseResult | undefined>;
10
13
  /**
11
14
  * Creates the result for the Fastify server to use.
12
15
  * @returns Promise where the result contains { status, data, headers } to
13
16
  * send back to the browser.
14
17
  */
15
- export declare function handleRenderRequest({ renderingRequest, bundleTimestamp, dependencyBundleTimestamps, providedNewBundles, assetsToCopy, tracingContext, }: {
18
+ export declare function handleRenderRequest({ renderingRequest, bundleTimestamp, dependencyBundleTimestamps, providedNewBundles, assetsToCopy, rscStreamObservability, tracingContext, }: {
16
19
  renderingRequest: string;
17
20
  bundleTimestamp: string | number;
18
21
  dependencyBundleTimestamps?: string[] | number[];
19
22
  providedNewBundles?: ProvidedNewBundle[] | null;
20
23
  assetsToCopy?: Asset[] | null;
24
+ rscStreamObservability?: boolean;
21
25
  tracingContext?: TracingContext;
22
26
  }): Promise<{
23
27
  response: ResponseResult;
@@ -18,6 +18,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
18
18
  };
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.sumUploadedBytes = sumUploadedBytes;
21
+ exports.escapeServerTimingDescription = escapeServerTimingDescription;
22
+ exports.addRendererServerTiming = addRendererServerTiming;
21
23
  exports.handleNewBundlesProvided = handleNewBundlesProvided;
22
24
  exports.handleRenderRequest = handleRenderRequest;
23
25
  /**
@@ -49,6 +51,20 @@ async function sumUploadedBytes(assets) {
49
51
  }));
50
52
  return sizes.reduce((total, size) => total + size, 0);
51
53
  }
54
+ function escapeServerTimingDescription(description) {
55
+ return description.replace(/[\r\n\0]/g, '').replace(/["\\]/g, (char) => `\\${char}`);
56
+ }
57
+ /** Adds renderer prepare Server-Timing to streamed responses when enabled. */
58
+ function addRendererServerTiming(response, startedAtMs, enabled) {
59
+ if (!enabled || !response.stream) {
60
+ return;
61
+ }
62
+ const durMs = (performance.now() - startedAtMs).toFixed(3);
63
+ const desc = 'Node renderer prepare (bundle sync + exec context build + render start)';
64
+ const entry = `ror_renderer_prepare;dur=${durMs};desc="${escapeServerTimingDescription(desc)}"`;
65
+ const existing = response.headers['Server-Timing'];
66
+ response.headers['Server-Timing'] = existing ? `${existing}, ${entry}` : entry;
67
+ }
52
68
  async function prepareResult(renderingRequest, bundleTimestamp, bundleFilePathPerTimestamp, executionContext) {
53
69
  return (0, tracing_js_1.subSpan)({ name: 'ror.result.prepare' }, async (resultSpan) => {
54
70
  try {
@@ -91,6 +107,11 @@ async function prepareResult(renderingRequest, bundleTimestamp, bundleFilePathPe
91
107
  }
92
108
  });
93
109
  }
110
+ async function prepareResponseWithServerTiming(renderingRequest, bundleTimestamp, bundleFilePathPerTimestamp, executionContext, rendererServerTimingStartedAtMs, rscStreamObservability) {
111
+ const response = await prepareResult(renderingRequest, bundleTimestamp, bundleFilePathPerTimestamp, executionContext);
112
+ addRendererServerTiming(response, rendererServerTimingStartedAtMs, rscStreamObservability);
113
+ return { response, executionContext };
114
+ }
94
115
  /**
95
116
  * @param bundleFilePathPerTimestamp
96
117
  * @param providedNewBundle
@@ -173,7 +194,7 @@ async function handleNewBundlesProvided(requestContext, providedNewBundles, asse
173
194
  * @returns Promise where the result contains { status, data, headers } to
174
195
  * send back to the browser.
175
196
  */
176
- async function handleRenderRequest({ renderingRequest, bundleTimestamp, dependencyBundleTimestamps, providedNewBundles, assetsToCopy, tracingContext, }) {
197
+ async function handleRenderRequest({ renderingRequest, bundleTimestamp, dependencyBundleTimestamps, providedNewBundles, assetsToCopy, rscStreamObservability = false, tracingContext, }) {
177
198
  try {
178
199
  // const bundleFilePathPerTimestamp = getRequestBundleFilePath(bundleTimestamp);
179
200
  const allBundleFilePaths = Array.from(new Set([...(dependencyBundleTimestamps ?? []), bundleTimestamp].map(utils_js_1.getRequestBundleFilePath)));
@@ -188,6 +209,9 @@ async function handleRenderRequest({ renderingRequest, bundleTimestamp, dependen
188
209
  },
189
210
  };
190
211
  }
212
+ // Start before the first VM lookup so cache-hit and cache-miss timings cover
213
+ // the same request span, including bundle upload on first deploy.
214
+ const rendererServerTimingStartedAtMs = performance.now();
191
215
  try {
192
216
  const executionContext = await (0, tracing_js_1.subSpan)({
193
217
  name: 'ror.bundle.build_execution_context',
@@ -197,10 +221,7 @@ async function handleRenderRequest({ renderingRequest, bundleTimestamp, dependen
197
221
  'cache.strategy': 'cache-first',
198
222
  },
199
223
  }, () => (0, vm_js_1.buildExecutionContext)(allBundleFilePaths, /* buildVmsIfNeeded */ false));
200
- return {
201
- response: await prepareResult(renderingRequest, bundleTimestamp, entryBundleFilePath, executionContext),
202
- executionContext,
203
- };
224
+ return await prepareResponseWithServerTiming(renderingRequest, bundleTimestamp, entryBundleFilePath, executionContext, rendererServerTimingStartedAtMs, rscStreamObservability);
204
225
  }
205
226
  catch (e) {
206
227
  // Ignore VMContextNotFoundError, it means the bundle does not exist.
@@ -246,10 +267,7 @@ async function handleRenderRequest({ renderingRequest, bundleTimestamp, dependen
246
267
  'cache.strategy': 'cache-miss',
247
268
  },
248
269
  }, () => (0, vm_js_1.buildExecutionContext)(allBundleFilePaths, /* buildVmsIfNeeded */ true));
249
- return {
250
- response: await prepareResult(renderingRequest, bundleTimestamp, entryBundleFilePath, executionContext),
251
- executionContext,
252
- };
270
+ return await prepareResponseWithServerTiming(renderingRequest, bundleTimestamp, entryBundleFilePath, executionContext, rendererServerTimingStartedAtMs, rscStreamObservability);
253
271
  }
254
272
  catch (error) {
255
273
  const msg = (0, utils_js_1.formatExceptionMessage)({ renderingRequest }, error, 'Caught top level error in handleRenderRequest');
@@ -24,7 +24,7 @@ exports.__resetWorkerShutdownHooksForTest = __resetWorkerShutdownHooksForTest;
24
24
  * timeouts (e.g. OpenTelemetry `shutdownTimeoutMs`) must stay below this so
25
25
  * the worker doesn't kill an in-flight hook before it finishes flushing.
26
26
  */
27
- exports.WORKER_SHUTDOWN_HOOKS_TIMEOUT_MS = 10000;
27
+ exports.WORKER_SHUTDOWN_HOOKS_TIMEOUT_MS = 10_000;
28
28
  const workerShutdownHooks = [];
29
29
  function registerWorkerShutdownHook(hook) {
30
30
  workerShutdownHooks.push(hook);