react-on-rails-pro-node-renderer 17.0.0-rc.5 → 17.0.0-rc.7

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.
@@ -50,12 +50,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
50
50
  return (mod && mod.__esModule) ? mod : { "default": mod };
51
51
  };
52
52
  Object.defineProperty(exports, "__esModule", { value: true });
53
- exports.delay = exports.majorVersion = exports.isErrorRenderResult = exports.handleStreamError = exports.safePipe = exports.isReadableStream = exports.SHUTDOWN_WORKER_MESSAGE = exports.TRUNCATION_FILLER = void 0;
53
+ exports.delay = exports.majorVersion = exports.isErrorRenderResult = exports.handleStreamError = exports.safePipe = exports.isReadableStream = exports.SHUTDOWN_WORKER_ACK_MESSAGE = exports.SHUTDOWN_WORKER_MESSAGE = exports.TRUNCATION_FILLER = void 0;
54
54
  exports.workerIdLabel = workerIdLabel;
55
55
  exports.smartTrim = smartTrim;
56
56
  exports.badRequestResponseResult = badRequestResponseResult;
57
57
  exports.errorResponseResult = errorResponseResult;
58
58
  exports.formatExceptionMessage = formatExceptionMessage;
59
+ exports.validateAssetFilename = validateAssetFilename;
59
60
  exports.saveMultipartFile = saveMultipartFile;
60
61
  exports.moveUploadedAsset = moveUploadedAsset;
61
62
  exports.copyUploadedAsset = copyUploadedAsset;
@@ -77,6 +78,7 @@ const fileExistsAsync_js_1 = __importDefault(require("./fileExistsAsync.js"));
77
78
  const vmSourceMapSupport_js_1 = require("../worker/vmSourceMapSupport.js");
78
79
  exports.TRUNCATION_FILLER = '\n... TRUNCATED ...\n';
79
80
  exports.SHUTDOWN_WORKER_MESSAGE = 'NODE_RENDERER_SHUTDOWN_WORKER';
81
+ exports.SHUTDOWN_WORKER_ACK_MESSAGE = 'NODE_RENDERER_SHUTDOWN_WORKER_ACK';
80
82
  function workerIdLabel() {
81
83
  return cluster_1.default?.worker?.id || 'NO WORKER ID';
82
84
  }
@@ -142,6 +144,30 @@ ${stack}`;
142
144
  }
143
145
  // https://github.com/fastify/fastify-multipart?tab=readme-ov-file#usage
144
146
  const pump = (0, util_1.promisify)(stream_1.pipeline);
147
+ const hasAsciiControlCharacter = (value) => {
148
+ for (let index = 0; index < value.length; index += 1) {
149
+ const characterCode = value.charCodeAt(index);
150
+ if (characterCode <= 0x1f || characterCode === 0x7f) {
151
+ return true;
152
+ }
153
+ }
154
+ return false;
155
+ };
156
+ function validateAssetFilename(filename) {
157
+ if (typeof filename !== 'string' ||
158
+ !filename ||
159
+ filename === '.' ||
160
+ filename === '..' ||
161
+ filename.includes('/') ||
162
+ filename.includes('\\') ||
163
+ filename.includes(':') ||
164
+ hasAsciiControlCharacter(filename) ||
165
+ // Catches Windows drive-relative values such as "C:file" that have no separator.
166
+ path_1.default.win32.basename(filename) !== filename) {
167
+ throw new Error(`Invalid asset filename: ${JSON.stringify(filename)}. Expected a single filename, not a path.`);
168
+ }
169
+ return filename;
170
+ }
145
171
  async function saveMultipartFile(multipartFile, destinationPath) {
146
172
  await (0, fs_extra_1.ensureDir)(path_1.default.dirname(destinationPath));
147
173
  return pump(multipartFile.file, (0, fs_extra_1.createWriteStream)(destinationPath));
@@ -154,7 +180,7 @@ function copyUploadedAsset(asset, destinationPath, options = {}) {
154
180
  }
155
181
  async function copyUploadedAssets(uploadedAssets, targetDirectory) {
156
182
  const copyMultipleAssets = uploadedAssets.map((asset) => {
157
- const destinationAssetFilePath = path_1.default.join(targetDirectory, asset.filename);
183
+ const destinationAssetFilePath = path_1.default.join(targetDirectory, validateAssetFilename(asset.filename));
158
184
  return copyUploadedAsset(asset, destinationAssetFilePath, { overwrite: true });
159
185
  });
160
186
  await Promise.all(copyMultipleAssets);
@@ -198,7 +224,21 @@ const safePipe = (source, destination, onError) => {
198
224
  return destination;
199
225
  };
200
226
  exports.safePipe = safePipe;
201
- const handleStreamError = (stream, onError) => (0, exports.safePipe)(stream, new stream_1.PassThrough(), onError);
227
+ const handleStreamError = (stream, onError) => {
228
+ const wrapper = new stream_1.PassThrough();
229
+ // `safePipe` propagates source → destination teardown, but not the reverse. The worker hands this
230
+ // wrapper to Fastify and destroys it when the HTTP client disconnects (issue #3885); plain pipe()
231
+ // would leave the source (and the in-flight render upstream of it) running. Propagate a premature
232
+ // wrapper teardown back to the source so the render is aborted. `writableEnded` is true only after a
233
+ // normal end (source finished → safePipe ended the wrapper), so this is a no-op on normal
234
+ // completion and fires only when the wrapper is destroyed before it ends.
235
+ wrapper.once('close', () => {
236
+ if (!wrapper.writableEnded && !stream.destroyed) {
237
+ stream.destroy();
238
+ }
239
+ });
240
+ return (0, exports.safePipe)(stream, wrapper, onError);
241
+ };
202
242
  exports.handleStreamError = handleStreamError;
203
243
  const isErrorRenderResult = (result) => typeof result === 'object' && !(0, exports.isReadableStream)(result) && 'exceptionMessage' in result;
204
244
  exports.isErrorRenderResult = isErrorRenderResult;
@@ -231,7 +271,7 @@ function getRequestBundleFilePath(bundleTimestamp) {
231
271
  }
232
272
  function getAssetPath(bundleTimestamp, filename) {
233
273
  const bundleDirectory = getBundleDirectory(bundleTimestamp);
234
- return path_1.default.join(bundleDirectory, filename);
274
+ return path_1.default.join(bundleDirectory, validateAssetFilename(filename));
235
275
  }
236
276
  async function validateBundlesExist(bundleTimestamp, dependencyBundleTimestamps) {
237
277
  const missingBundles = (await Promise.all([...(dependencyBundleTimestamps ?? []), bundleTimestamp].map(async (timestamp) => {
@@ -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
  };
@@ -1,5 +1,10 @@
1
1
  import type { ResponseResult } from '../shared/utils';
2
2
  import type { ExecutionContext } from './vm';
3
+ export declare const PULL_ENABLED_KEY = "pullEnabled";
4
+ export declare const PUSH_PROPS_KEY = "pushProps";
5
+ export declare const PROP_REQUEST_EMITTER_KEY = "propRequestEmitter";
6
+ export declare const ASYNC_PROPS_MANAGER_KEY = "asyncPropsManager";
7
+ export declare const MAX_PULL_PROP_NAME_LENGTH = 256;
3
8
  export type IncrementalRenderSink = {
4
9
  /** Called for every subsequent NDJSON object after the first one */
5
10
  add: (chunk: unknown) => Promise<void>;
@@ -10,6 +15,8 @@ export type UpdateChunk = {
10
15
  bundleTimestamp: string | number;
11
16
  updateChunk: string;
12
17
  };
18
+ /** @internal Used by protocol regression tests. */
19
+ export declare function catchUpAsyncPropsManagerPullBridge(value: unknown): boolean;
13
20
  export type IncrementalRenderInitialRequest = {
14
21
  firstRequestChunk: unknown;
15
22
  bundleTimestamp: string | number;
@@ -18,6 +25,9 @@ export type IncrementalRenderInitialRequest = {
18
25
  export type FirstIncrementalRenderRequestChunk = {
19
26
  renderingRequest: string;
20
27
  onRequestClosedUpdateChunk?: UpdateChunk;
28
+ pullEnabled?: boolean;
29
+ pushProps?: string[];
30
+ rscStreamObservability?: boolean;
21
31
  };
22
32
  export type IncrementalRenderResult = {
23
33
  response: ResponseResult;
@@ -17,11 +17,22 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
17
17
  return (mod && mod.__esModule) ? mod : { "default": mod };
18
18
  };
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.MAX_PULL_PROP_NAME_LENGTH = exports.ASYNC_PROPS_MANAGER_KEY = exports.PROP_REQUEST_EMITTER_KEY = exports.PUSH_PROPS_KEY = exports.PULL_ENABLED_KEY = void 0;
21
+ exports.catchUpAsyncPropsManagerPullBridge = catchUpAsyncPropsManagerPullBridge;
20
22
  exports.handleIncrementalRenderRequest = handleIncrementalRenderRequest;
23
+ const stream_1 = require("stream");
21
24
  const handleRenderRequest_1 = require("./handleRenderRequest");
22
25
  const log_1 = __importDefault(require("../shared/log"));
23
26
  const utils_1 = require("../shared/utils");
24
27
  const tracing_js_1 = require("../shared/tracing.js");
28
+ const streamingUtils_1 = require("./streamingUtils");
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
31
+ exports.PULL_ENABLED_KEY = 'pullEnabled';
32
+ exports.PUSH_PROPS_KEY = 'pushProps';
33
+ exports.PROP_REQUEST_EMITTER_KEY = 'propRequestEmitter';
34
+ exports.ASYNC_PROPS_MANAGER_KEY = 'asyncPropsManager';
35
+ exports.MAX_PULL_PROP_NAME_LENGTH = 256;
25
36
  class InvalidIncrementalRenderChunkError extends Error {
26
37
  constructor() {
27
38
  super('Invalid incremental render chunk received, missing properties');
@@ -38,6 +49,38 @@ function assertIsUpdateChunk(value) {
38
49
  throw new InvalidIncrementalRenderChunkError();
39
50
  }
40
51
  }
52
+ function isAsyncPropsManagerPullBridge(value) {
53
+ if (typeof value !== 'object' || value === null) {
54
+ return false;
55
+ }
56
+ const candidate = value;
57
+ return typeof candidate.catchUpPropRequests === 'function';
58
+ }
59
+ function isLegacyAsyncPropsManagerPullBridge(value) {
60
+ if (typeof value !== 'object' || value === null) {
61
+ return false;
62
+ }
63
+ const candidate = value;
64
+ return (typeof candidate.flushPendingPullRequests === 'function' &&
65
+ typeof candidate.emitPendingPullRequests === 'function');
66
+ }
67
+ /** @internal Used by protocol regression tests. */
68
+ function catchUpAsyncPropsManagerPullBridge(value) {
69
+ if (isAsyncPropsManagerPullBridge(value)) {
70
+ value.catchUpPropRequests();
71
+ return true;
72
+ }
73
+ if (isLegacyAsyncPropsManagerPullBridge(value)) {
74
+ // Current AsyncPropsManager keeps both methods as aliases of
75
+ // catchUpPropRequests() for older node renderers. Calling both is
76
+ // intentionally harmless: buffered requests drain on the first call and
77
+ // pullRequested flags prevent duplicate emissions on the second.
78
+ value.flushPendingPullRequests();
79
+ value.emitPendingPullRequests();
80
+ return true;
81
+ }
82
+ return false;
83
+ }
41
84
  function assertFirstIncrementalRenderRequestChunk(chunk) {
42
85
  if (typeof chunk !== 'object' ||
43
86
  chunk === null ||
@@ -49,6 +92,20 @@ function assertFirstIncrementalRenderRequestChunk(chunk) {
49
92
  if ('onRequestClosedUpdateChunk' in chunk && chunk.onRequestClosedUpdateChunk) {
50
93
  assertIsUpdateChunk(chunk.onRequestClosedUpdateChunk);
51
94
  }
95
+ if ('pullEnabled' in chunk && chunk.pullEnabled !== undefined && typeof chunk.pullEnabled !== 'boolean') {
96
+ throw new Error('Invalid first incremental render request chunk: pullEnabled must be a boolean');
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
+ }
104
+ if ('pushProps' in chunk &&
105
+ chunk.pushProps !== undefined &&
106
+ (!Array.isArray(chunk.pushProps) || chunk.pushProps.some((propName) => typeof propName !== 'string'))) {
107
+ throw new Error('Invalid first incremental render request chunk: pushProps must be a string array');
108
+ }
52
109
  }
53
110
  /**
54
111
  * Handles the initial request for incremental rendering and returns a "sink" for updates.
@@ -81,7 +138,7 @@ function assertFirstIncrementalRenderRequestChunk(chunk) {
81
138
  async function handleIncrementalRenderRequest(initial) {
82
139
  const { firstRequestChunk, bundleTimestamp, dependencyBundleTimestamps } = initial;
83
140
  assertFirstIncrementalRenderRequestChunk(firstRequestChunk);
84
- const { renderingRequest, onRequestClosedUpdateChunk } = firstRequestChunk;
141
+ const { renderingRequest, onRequestClosedUpdateChunk, rscStreamObservability } = firstRequestChunk;
85
142
  try {
86
143
  // Call handleRenderRequest internally to handle all validation and VM execution
87
144
  // handleRenderRequest is called directly without a TracingContext from worker.ts's
@@ -92,58 +149,161 @@ async function handleIncrementalRenderRequest(initial) {
92
149
  dependencyBundleTimestamps,
93
150
  providedNewBundles: undefined,
94
151
  assetsToCopy: undefined,
152
+ rscStreamObservability,
95
153
  });
96
154
  // If we don't get an execution context, it means there was an early error
97
155
  // (e.g. bundle not found). In this case, the sink will be a no-op.
98
156
  if (!executionContext) {
99
157
  return { response };
100
158
  }
101
- // Return the result with a sink that uses the execution context
102
- return {
103
- response,
104
- sink: {
105
- executionContext,
106
- add: async (chunk) => {
159
+ let finalResponse = response;
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;
107
179
  try {
108
- assertIsUpdateChunk(chunk);
109
- await (0, tracing_js_1.subSpan)({ name: 'ror.incremental.process_chunk' }, async () => {
110
- const bundlePath = (0, utils_1.getRequestBundleFilePath)(chunk.bundleTimestamp);
111
- const result = await executionContext.runInVM(chunk.updateChunk, bundlePath);
112
- if ((0, utils_1.isErrorRenderResult)(result)) {
113
- throw new Error(result.exceptionMessage);
114
- }
115
- });
180
+ injectableStream.write((0, streamingUtils_1.formatRenderCompleteChunk)());
116
181
  }
117
182
  catch (err) {
118
- if (err instanceof InvalidIncrementalRenderChunkError) {
119
- log_1.default.error({ msg: 'Invalid incremental render chunk', err, chunk });
120
- }
121
- else {
122
- log_1.default.error({ msg: 'Error running incremental render chunk', err, chunk });
123
- }
183
+ log_1.default.warn({ msg: 'Failed to write renderComplete chunk', err });
124
184
  }
125
- },
126
- handleRequestClosed: async () => {
127
- 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
+ });
128
222
  return;
129
223
  }
130
- const bundlePath = (0, utils_1.getRequestBundleFilePath)(onRequestClosedUpdateChunk.bundleTimestamp);
131
224
  try {
132
- const result = await executionContext.runInVM(onRequestClosedUpdateChunk.updateChunk, bundlePath);
133
- if ((0, utils_1.isErrorRenderResult)(result)) {
134
- throw new Error(result.exceptionMessage);
135
- }
225
+ injectableStream.write((0, streamingUtils_1.formatPropRequestChunk)(propName));
136
226
  }
137
227
  catch (err) {
138
- log_1.default.error({
139
- msg: 'Error running onRequestClosedUpdateChunk',
140
- err,
141
- onRequestClosedUpdateChunk,
142
- });
228
+ log_1.default.error({ msg: 'Failed to write propRequest chunk', propName, err });
143
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
+ },
144
279
  },
145
- },
146
- };
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
+ }
147
307
  }
148
308
  catch (error) {
149
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;