react-on-rails-pro-node-renderer 17.0.0-rc.6 → 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.
- package/lib/integrations/opentelemetry.js +4 -4
- package/lib/master/restartWorkers.js +82 -28
- package/lib/master.js +20 -3
- package/lib/shared/checkRscPeerCompatibility.d.ts +1 -2
- package/lib/shared/checkRscPeerCompatibility.js +77 -22
- package/lib/shared/configBuilder.js +25 -17
- package/lib/shared/licenseValidator.js +1 -0
- package/lib/shared/rscPeerSupport.d.ts +2 -5
- package/lib/shared/rscPeerSupport.js +10 -10
- package/lib/shared/runRscPeerCompatibilityCheck.js +0 -5
- package/lib/shared/sharedConsoleHistory.js +3 -0
- package/lib/shared/utils.d.ts +2 -0
- package/lib/shared/utils.js +29 -3
- package/lib/worker/handleGracefulShutdown.js +33 -13
- package/lib/worker/handleIncrementalRenderRequest.d.ts +1 -0
- package/lib/worker/handleIncrementalRenderRequest.js +145 -106
- package/lib/worker/handleIncrementalRenderStream.d.ts +3 -0
- package/lib/worker/handleIncrementalRenderStream.js +79 -5
- package/lib/worker/handleRenderRequest.d.ts +5 -1
- package/lib/worker/handleRenderRequest.js +27 -9
- package/lib/worker/shutdownHooks.js +1 -1
- package/lib/worker/streamingUtils.js +9 -2
- package/lib/worker.d.ts +1 -0
- package/lib/worker.js +201 -13
- package/package.json +7 -9
|
@@ -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
|
-
|
|
79
|
-
|
|
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 (
|
|
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
|
|
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', (
|
|
121
|
+
app.addHook('onRequest', (req, _reply, done) => {
|
|
122
|
+
activeRequests.add(req);
|
|
101
123
|
activeRequestsCount += 1;
|
|
102
124
|
done();
|
|
103
125
|
});
|
|
104
|
-
app.addHook('onResponse', (
|
|
105
|
-
decrementAndMaybeShutdown('onResponse');
|
|
126
|
+
app.addHook('onResponse', (req, _reply, done) => {
|
|
127
|
+
decrementAndMaybeShutdown(req, 'onResponse');
|
|
106
128
|
done();
|
|
107
129
|
});
|
|
108
|
-
|
|
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
|
-
|
|
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 @@ 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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
216
|
-
|
|
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 =
|
|
27
|
+
exports.WORKER_SHUTDOWN_HOOKS_TIMEOUT_MS = 10_000;
|
|
28
28
|
const workerShutdownHooks = [];
|
|
29
29
|
function registerWorkerShutdownHook(hook) {
|
|
30
30
|
workerShutdownHooks.push(hook);
|