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.
- 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 +44 -4
- package/lib/worker/handleGracefulShutdown.js +33 -13
- package/lib/worker/handleIncrementalRenderRequest.d.ts +10 -0
- package/lib/worker/handleIncrementalRenderRequest.js +196 -36
- 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.d.ts +3 -0
- package/lib/worker/streamingUtils.js +38 -0
- package/lib/worker/vm.d.ts +1 -0
- package/lib/worker/vm.js +1 -0
- package/lib/worker.d.ts +1 -0
- package/lib/worker.js +225 -13
- package/package.json +7 -9
package/lib/shared/utils.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
};
|
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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,
|
|
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;
|