react-on-rails-pro-node-renderer 17.0.0-rc.3 → 17.0.0-rc.4
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/shared/checkRscPeerCompatibility.js +24 -6
- package/lib/shared/configBuilder.d.ts +1 -0
- package/lib/shared/configBuilder.js +9 -0
- package/lib/shared/rscPeerSupport.d.ts +9 -2
- package/lib/shared/rscPeerSupport.js +8 -1
- package/lib/shared/utils.d.ts +7 -1
- package/lib/shared/utils.js +21 -5
- package/lib/worker/handleIncrementalRenderRequest.d.ts +3 -1
- package/lib/worker/handleIncrementalRenderRequest.js +10 -5
- package/lib/worker/vm.d.ts +16 -0
- package/lib/worker/vm.js +167 -13
- package/lib/worker/vmSourceMapSupport.d.ts +110 -0
- package/lib/worker/vmSourceMapSupport.js +664 -0
- package/lib/worker.d.ts +9 -1
- package/lib/worker.js +336 -12
- package/package.json +5 -2
|
@@ -41,8 +41,20 @@ const isAtLeast = (actual, floor) => {
|
|
|
41
41
|
return true;
|
|
42
42
|
};
|
|
43
43
|
const sameTuple = (left, right) => left.every((value, index) => value === right[index]);
|
|
44
|
-
const
|
|
45
|
-
const
|
|
44
|
+
const supportedRscRange = ({ supportedMajor }, { supportedRanges }) => {
|
|
45
|
+
const supportedMinors = [...new Set(supportedRanges.map((range) => range.rscMinor))].sort((left, right) => left - right);
|
|
46
|
+
return supportedMinors.map((minor) => `${supportedMajor}.${minor}.x`).join(' or ');
|
|
47
|
+
};
|
|
48
|
+
const supportedReactRange = (rscTuple, { supportedMajor, supportedRanges }) => {
|
|
49
|
+
const rscMinor = rscTuple[1];
|
|
50
|
+
const matchingRanges = supportedRanges.filter((range) => range.rscMinor === rscMinor);
|
|
51
|
+
return matchingRanges
|
|
52
|
+
.map(({ minor, minPatch }) => `${supportedMajor}.${minor}.x with patch >= ${supportedMajor}.${minor}.${minPatch}`)
|
|
53
|
+
.join(' or ');
|
|
54
|
+
};
|
|
55
|
+
const isSupportedReactTuple = ([major, minor, patch], rscTuple, { supportedMajor, supportedRanges }) => major === supportedMajor &&
|
|
56
|
+
supportedRanges.some((range) => rscTuple[1] === range.rscMinor && minor === range.minor && patch >= range.minPatch);
|
|
57
|
+
const isSupportedRscMinor = (rscTuple, { supportedRanges }) => supportedRanges.some((range) => rscTuple[1] === range.rscMinor);
|
|
46
58
|
const proLabel = (proVersion) => proVersion ? `React on Rails Pro (${proVersion})` : 'React on Rails Pro';
|
|
47
59
|
const errorMessage = (pkg, found, want, proVersion) => [
|
|
48
60
|
`[ReactOnRails] Incompatible ${pkg} version.`,
|
|
@@ -70,24 +82,30 @@ function checkRscPeerCompatibility(input) {
|
|
|
70
82
|
message: errorMessage('react-on-rails-rsc', rscVersion, `${reactOnRailsRsc.supportedMajor}.x`, proVersion),
|
|
71
83
|
};
|
|
72
84
|
}
|
|
85
|
+
if (!isSupportedRscMinor(rscTuple, react)) {
|
|
86
|
+
return {
|
|
87
|
+
level: 'error',
|
|
88
|
+
message: errorMessage('react-on-rails-rsc', rscVersion, supportedRscRange(reactOnRailsRsc, react), proVersion),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
73
91
|
// If React is not resolvable (unusual, since RSC requires React), skip this check;
|
|
74
92
|
// an app with React truly absent will fail during normal module loading.
|
|
75
93
|
let reactTuple = null;
|
|
76
94
|
if (reactVersion) {
|
|
77
95
|
reactTuple = parseTuple(reactVersion);
|
|
78
|
-
if (!isSupportedReactTuple(reactTuple, react)) {
|
|
96
|
+
if (!isSupportedReactTuple(reactTuple, rscTuple, react)) {
|
|
79
97
|
return {
|
|
80
98
|
level: 'error',
|
|
81
|
-
message: errorMessage('react', reactVersion, supportedReactRange(react), proVersion),
|
|
99
|
+
message: errorMessage('react', reactVersion, supportedReactRange(rscTuple, react), proVersion),
|
|
82
100
|
};
|
|
83
101
|
}
|
|
84
102
|
}
|
|
85
103
|
if (reactDomVersion) {
|
|
86
104
|
const reactDomTuple = parseTuple(reactDomVersion);
|
|
87
|
-
if (!isSupportedReactTuple(reactDomTuple, react)) {
|
|
105
|
+
if (!isSupportedReactTuple(reactDomTuple, rscTuple, react)) {
|
|
88
106
|
return {
|
|
89
107
|
level: 'error',
|
|
90
|
-
message: errorMessage('react-dom', reactDomVersion, supportedReactRange(react), proVersion),
|
|
108
|
+
message: errorMessage('react-dom', reactDomVersion, supportedReactRange(rscTuple, react), proVersion),
|
|
91
109
|
};
|
|
92
110
|
}
|
|
93
111
|
if (reactTuple && !sameTuple(reactTuple, reactDomTuple)) {
|
|
@@ -25,6 +25,7 @@ export interface Config {
|
|
|
25
25
|
includeTimerPolyfills?: boolean;
|
|
26
26
|
replayServerAsyncOperationLogs: boolean;
|
|
27
27
|
maxVMPoolSize: number;
|
|
28
|
+
enableHealthEndpoints: boolean;
|
|
28
29
|
}
|
|
29
30
|
export declare function getConfig(): Config;
|
|
30
31
|
export declare function logSanitizedConfig(): void;
|
|
@@ -109,6 +109,9 @@ function defaultReplayServerAsyncOperationLogs() {
|
|
|
109
109
|
}
|
|
110
110
|
return env.NODE_ENV?.toLowerCase() === 'development';
|
|
111
111
|
}
|
|
112
|
+
function truthyHealthEndpointFlag(value) {
|
|
113
|
+
return value === '1' || (0, truthy_js_1.default)(value);
|
|
114
|
+
}
|
|
112
115
|
const defaultConfig = {
|
|
113
116
|
// Use env port if we run on Heroku
|
|
114
117
|
port: Number(env.RENDERER_PORT) || DEFAULT_PORT,
|
|
@@ -142,6 +145,8 @@ const defaultConfig = {
|
|
|
142
145
|
// Maximum number of VM contexts to keep in memory. Defaults to 2 since typically only two contexts
|
|
143
146
|
// are needed - one for the server bundle and one for React Server Components (RSC) if enabled.
|
|
144
147
|
maxVMPoolSize: (env.MAX_VM_POOL_SIZE && parseInt(env.MAX_VM_POOL_SIZE, 10)) || 2,
|
|
148
|
+
// Built-in /health and /ready probe endpoints are opt-in.
|
|
149
|
+
enableHealthEndpoints: truthyHealthEndpointFlag(env.RENDERER_ENABLE_HEALTH_ENDPOINTS),
|
|
145
150
|
};
|
|
146
151
|
function envValuesUsed() {
|
|
147
152
|
return {
|
|
@@ -163,6 +168,7 @@ function envValuesUsed() {
|
|
|
163
168
|
INCLUDE_TIMER_POLYFILLS: !('includeTimerPolyfills' in userConfig) && env.INCLUDE_TIMER_POLYFILLS,
|
|
164
169
|
REPLAY_SERVER_ASYNC_OPERATION_LOGS: !userConfig.replayServerAsyncOperationLogs && env.REPLAY_SERVER_ASYNC_OPERATION_LOGS,
|
|
165
170
|
MAX_VM_POOL_SIZE: !userConfig.maxVMPoolSize && env.MAX_VM_POOL_SIZE,
|
|
171
|
+
RENDERER_ENABLE_HEALTH_ENDPOINTS: !('enableHealthEndpoints' in userConfig) && env.RENDERER_ENABLE_HEALTH_ENDPOINTS,
|
|
166
172
|
};
|
|
167
173
|
}
|
|
168
174
|
function sanitizedSettings(aConfig, defaultValue) {
|
|
@@ -248,6 +254,7 @@ function buildConfig(providedUserConfig) {
|
|
|
248
254
|
password: env.RENDERER_PASSWORD,
|
|
249
255
|
// Re-evaluate env-derived defaults at build time in case env vars are set post-import.
|
|
250
256
|
replayServerAsyncOperationLogs: defaultReplayServerAsyncOperationLogs(),
|
|
257
|
+
enableHealthEndpoints: truthyHealthEndpointFlag(env.RENDERER_ENABLE_HEALTH_ENDPOINTS),
|
|
251
258
|
};
|
|
252
259
|
config = { ...runtimeDefaultConfig, ...userConfig };
|
|
253
260
|
if (explicitUndefinedPassword) {
|
|
@@ -269,6 +276,8 @@ function buildConfig(providedUserConfig) {
|
|
|
269
276
|
'Use RENDERER_SERVER_BUNDLE_CACHE_PATH instead.');
|
|
270
277
|
}
|
|
271
278
|
config.supportModules = (0, truthy_js_1.default)(config.supportModules);
|
|
279
|
+
// Coerce in case a user config passes an env-derived string (e.g. "true").
|
|
280
|
+
config.enableHealthEndpoints = truthyHealthEndpointFlag(config.enableHealthEndpoints);
|
|
272
281
|
if (config.maxVMPoolSize <= 0 || !Number.isInteger(config.maxVMPoolSize)) {
|
|
273
282
|
throw new Error('maxVMPoolSize must be a positive integer');
|
|
274
283
|
}
|
|
@@ -5,8 +5,15 @@ export declare const RSC_PEER_SUPPORT: {
|
|
|
5
5
|
};
|
|
6
6
|
readonly react: {
|
|
7
7
|
readonly supportedMajor: 19;
|
|
8
|
-
readonly
|
|
9
|
-
|
|
8
|
+
readonly supportedRanges: readonly [{
|
|
9
|
+
readonly rscMinor: 0;
|
|
10
|
+
readonly minor: 0;
|
|
11
|
+
readonly minPatch: 4;
|
|
12
|
+
}, {
|
|
13
|
+
readonly rscMinor: 2;
|
|
14
|
+
readonly minor: 2;
|
|
15
|
+
readonly minPatch: 7;
|
|
16
|
+
}];
|
|
10
17
|
};
|
|
11
18
|
};
|
|
12
19
|
//# sourceMappingURL=rscPeerSupport.d.ts.map
|
|
@@ -26,6 +26,13 @@ exports.RSC_PEER_SUPPORT = void 0;
|
|
|
26
26
|
// (the stable 19.0.5 ship/pin is tracked by issue #3634).
|
|
27
27
|
exports.RSC_PEER_SUPPORT = {
|
|
28
28
|
reactOnRailsRsc: { recommendedMin: '19.0.2', supportedMajor: 19 },
|
|
29
|
-
react: {
|
|
29
|
+
react: {
|
|
30
|
+
supportedMajor: 19,
|
|
31
|
+
supportedRanges: [
|
|
32
|
+
{ rscMinor: 0, minor: 0, minPatch: 4 },
|
|
33
|
+
// React 19.2.7 is the coordinated floor for react-on-rails-rsc 19.2.x.
|
|
34
|
+
{ rscMinor: 2, minor: 2, minPatch: 7 },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
30
37
|
};
|
|
31
38
|
//# sourceMappingURL=rscPeerSupport.js.map
|
package/lib/shared/utils.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { MoveOptions, CopyOptions } from 'fs-extra';
|
|
|
3
3
|
import { Readable, Writable, PassThrough } from 'stream';
|
|
4
4
|
import type { TracingContext } from './tracing.js';
|
|
5
5
|
import type { RenderResult } from '../worker/vm.js';
|
|
6
|
+
import { remapStackTrace } from '../worker/vmSourceMapSupport.js';
|
|
6
7
|
export declare const TRUNCATION_FILLER = "\n... TRUNCATED ...\n";
|
|
7
8
|
export declare const SHUTDOWN_WORKER_MESSAGE = "NODE_RENDERER_SHUTDOWN_WORKER";
|
|
8
9
|
export declare function workerIdLabel(): number | "NO WORKER ID";
|
|
@@ -10,6 +11,9 @@ export declare function smartTrim(value: unknown, maxLength?: number): string |
|
|
|
10
11
|
export interface ResponseResult {
|
|
11
12
|
headers: {
|
|
12
13
|
'Cache-Control'?: string;
|
|
14
|
+
'Content-Type'?: string;
|
|
15
|
+
'X-Content-Type-Options'?: string;
|
|
16
|
+
[key: string]: string | undefined;
|
|
13
17
|
};
|
|
14
18
|
status: number;
|
|
15
19
|
data?: unknown;
|
|
@@ -27,8 +31,10 @@ export type RequestInfo = {
|
|
|
27
31
|
* @param request Either a rendering request (auto-labeled) or a { label, content } pair
|
|
28
32
|
* @param error The error that was thrown (typed as `unknown` to minimize casts in `catch`)
|
|
29
33
|
* @param context Optional context to include in the error message
|
|
34
|
+
* @param stackRemapper Defaults to scanning registered source-map bundles. VM request paths pass a
|
|
35
|
+
* registration-scoped remapper to avoid rewriting unrelated bundle paths.
|
|
30
36
|
*/
|
|
31
|
-
export declare function formatExceptionMessage(request: RequestInfo, error: unknown, context?: string): string;
|
|
37
|
+
export declare function formatExceptionMessage(request: RequestInfo, error: unknown, context?: string, stackRemapper?: typeof remapStackTrace): string;
|
|
32
38
|
export declare function saveMultipartFile(multipartFile: MultipartFile, destinationPath: string): Promise<void>;
|
|
33
39
|
export interface Asset {
|
|
34
40
|
type: 'asset';
|
package/lib/shared/utils.js
CHANGED
|
@@ -74,6 +74,7 @@ const errorReporter = __importStar(require("./errorReporter.js"));
|
|
|
74
74
|
const configBuilder_js_1 = require("./configBuilder.js");
|
|
75
75
|
const log_js_1 = __importDefault(require("./log.js"));
|
|
76
76
|
const fileExistsAsync_js_1 = __importDefault(require("./fileExistsAsync.js"));
|
|
77
|
+
const vmSourceMapSupport_js_1 = require("../worker/vmSourceMapSupport.js");
|
|
77
78
|
exports.TRUNCATION_FILLER = '\n... TRUNCATED ...\n';
|
|
78
79
|
exports.SHUTDOWN_WORKER_MESSAGE = 'NODE_RENDERER_SHUTDOWN_WORKER';
|
|
79
80
|
function workerIdLabel() {
|
|
@@ -121,10 +122,14 @@ function errorResponseResult(msg, tracingContext) {
|
|
|
121
122
|
* @param request Either a rendering request (auto-labeled) or a { label, content } pair
|
|
122
123
|
* @param error The error that was thrown (typed as `unknown` to minimize casts in `catch`)
|
|
123
124
|
* @param context Optional context to include in the error message
|
|
125
|
+
* @param stackRemapper Defaults to scanning registered source-map bundles. VM request paths pass a
|
|
126
|
+
* registration-scoped remapper to avoid rewriting unrelated bundle paths.
|
|
124
127
|
*/
|
|
125
|
-
function formatExceptionMessage(request, error, context) {
|
|
128
|
+
function formatExceptionMessage(request, error, context, stackRemapper = vmSourceMapSupport_js_1.remapStackTrace) {
|
|
126
129
|
const label = 'renderingRequest' in request ? 'JS code for rendering request was:' : request.label;
|
|
127
130
|
const content = 'renderingRequest' in request ? request.renderingRequest : request.content;
|
|
131
|
+
const rawStack = error.stack;
|
|
132
|
+
const stack = stackRemapper(rawStack) ?? rawStack;
|
|
128
133
|
return `${context ? `\nContext:\n${context}\n` : ''}
|
|
129
134
|
${label}
|
|
130
135
|
${smartTrim(content)}
|
|
@@ -133,7 +138,7 @@ EXCEPTION MESSAGE:
|
|
|
133
138
|
${error.message || error}
|
|
134
139
|
|
|
135
140
|
STACK:
|
|
136
|
-
${
|
|
141
|
+
${stack}`;
|
|
137
142
|
}
|
|
138
143
|
// https://github.com/fastify/fastify-multipart?tab=readme-ov-file#usage
|
|
139
144
|
const pump = (0, util_1.promisify)(stream_1.pipeline);
|
|
@@ -205,13 +210,24 @@ const delay = (milliseconds) => new Promise((resolve) => {
|
|
|
205
210
|
setTimeout(resolve, milliseconds);
|
|
206
211
|
});
|
|
207
212
|
exports.delay = delay;
|
|
213
|
+
// Keep aligned with ReactOnRailsPro::RollingDeploy::SAFE_HASH_PATTERN.
|
|
214
|
+
const BUNDLE_TIMESTAMP_PATH_COMPONENT_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
215
|
+
function bundleTimestampPathComponent(bundleTimestamp) {
|
|
216
|
+
const pathComponent = String(bundleTimestamp);
|
|
217
|
+
if (!BUNDLE_TIMESTAMP_PATH_COMPONENT_PATTERN.test(pathComponent)) {
|
|
218
|
+
throw new Error(`Invalid bundle timestamp path component: ${pathComponent}. ` +
|
|
219
|
+
'Expected only letters, digits, dots, underscores, and hyphens.');
|
|
220
|
+
}
|
|
221
|
+
return pathComponent;
|
|
222
|
+
}
|
|
208
223
|
function getBundleDirectory(bundleTimestamp) {
|
|
209
224
|
const { serverBundleCachePath } = (0, configBuilder_js_1.getConfig)();
|
|
210
|
-
return path_1.default.
|
|
225
|
+
return path_1.default.resolve(serverBundleCachePath, bundleTimestampPathComponent(bundleTimestamp));
|
|
211
226
|
}
|
|
212
227
|
function getRequestBundleFilePath(bundleTimestamp) {
|
|
213
|
-
const
|
|
214
|
-
|
|
228
|
+
const pathComponent = bundleTimestampPathComponent(bundleTimestamp);
|
|
229
|
+
const bundleDirectory = getBundleDirectory(pathComponent);
|
|
230
|
+
return path_1.default.join(bundleDirectory, `${pathComponent}.js`);
|
|
215
231
|
}
|
|
216
232
|
function getAssetPath(bundleTimestamp, filename) {
|
|
217
233
|
const bundleDirectory = getBundleDirectory(bundleTimestamp);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { ResponseResult } from '../shared/utils';
|
|
2
|
+
import type { ExecutionContext } from './vm';
|
|
2
3
|
export type IncrementalRenderSink = {
|
|
3
4
|
/** Called for every subsequent NDJSON object after the first one */
|
|
4
5
|
add: (chunk: unknown) => Promise<void>;
|
|
5
|
-
handleRequestClosed: () => void
|
|
6
|
+
handleRequestClosed: () => Promise<void>;
|
|
7
|
+
executionContext: ExecutionContext;
|
|
6
8
|
};
|
|
7
9
|
export type UpdateChunk = {
|
|
8
10
|
bundleTimestamp: string | number;
|
|
@@ -102,6 +102,7 @@ async function handleIncrementalRenderRequest(initial) {
|
|
|
102
102
|
return {
|
|
103
103
|
response,
|
|
104
104
|
sink: {
|
|
105
|
+
executionContext,
|
|
105
106
|
add: async (chunk) => {
|
|
106
107
|
try {
|
|
107
108
|
assertIsUpdateChunk(chunk);
|
|
@@ -122,20 +123,24 @@ async function handleIncrementalRenderRequest(initial) {
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
},
|
|
125
|
-
handleRequestClosed: () => {
|
|
126
|
+
handleRequestClosed: async () => {
|
|
126
127
|
if (!onRequestClosedUpdateChunk) {
|
|
127
128
|
return;
|
|
128
129
|
}
|
|
129
130
|
const bundlePath = (0, utils_1.getRequestBundleFilePath)(onRequestClosedUpdateChunk.bundleTimestamp);
|
|
130
|
-
|
|
131
|
-
.runInVM(onRequestClosedUpdateChunk.updateChunk, bundlePath)
|
|
132
|
-
|
|
131
|
+
try {
|
|
132
|
+
const result = await executionContext.runInVM(onRequestClosedUpdateChunk.updateChunk, bundlePath);
|
|
133
|
+
if ((0, utils_1.isErrorRenderResult)(result)) {
|
|
134
|
+
throw new Error(result.exceptionMessage);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
133
138
|
log_1.default.error({
|
|
134
139
|
msg: 'Error running onRequestClosedUpdateChunk',
|
|
135
140
|
err,
|
|
136
141
|
onRequestClosedUpdateChunk,
|
|
137
142
|
});
|
|
138
|
-
}
|
|
143
|
+
}
|
|
139
144
|
},
|
|
140
145
|
},
|
|
141
146
|
};
|
package/lib/worker/vm.d.ts
CHANGED
|
@@ -3,9 +3,11 @@ import type { Readable } from 'stream';
|
|
|
3
3
|
import type { ReactOnRails as ROR } from 'react-on-rails' with { 'resolution-mode': 'import' };
|
|
4
4
|
import type { Context } from 'vm';
|
|
5
5
|
import SharedConsoleHistory from '../shared/sharedConsoleHistory.js';
|
|
6
|
+
import { type BundleSourceMapRegistration } from './vmSourceMapSupport.js';
|
|
6
7
|
export interface VMContext {
|
|
7
8
|
context: Context;
|
|
8
9
|
sharedConsoleHistory: SharedConsoleHistory;
|
|
10
|
+
sourceMapRegistration: BundleSourceMapRegistration;
|
|
9
11
|
lastUsed: number;
|
|
10
12
|
}
|
|
11
13
|
/**
|
|
@@ -17,6 +19,19 @@ export declare function hasVMContextForBundle(bundlePath: string): boolean;
|
|
|
17
19
|
* Get a specific VM context by bundle path
|
|
18
20
|
*/
|
|
19
21
|
export declare function getVMContext(bundlePath: string): VMContext | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Whether this worker has at least one bundle compiled into a VM context.
|
|
24
|
+
* Used by the built-in /ready readiness endpoint: a worker with zero loaded
|
|
25
|
+
* bundles cannot serve render requests until a bundle is uploaded.
|
|
26
|
+
*
|
|
27
|
+
* This intentionally stays false while a bundle is still compiling in
|
|
28
|
+
* vmCreationPromises; /ready flips to 200 only after compilation finishes and
|
|
29
|
+
* the compiled context is stored in vmContexts.
|
|
30
|
+
*
|
|
31
|
+
* Pool eviction can remove older bundle contexts, but readiness remains true as
|
|
32
|
+
* long as at least one compiled bundle remains in the pool.
|
|
33
|
+
*/
|
|
34
|
+
export declare function hasAnyVMContext(): boolean;
|
|
20
35
|
/**
|
|
21
36
|
* The type of the result returned by executing the code payload sent in the rendering request.
|
|
22
37
|
*/
|
|
@@ -39,6 +54,7 @@ export declare class VMContextNotFoundError extends Error {
|
|
|
39
54
|
export type ExecutionContext = {
|
|
40
55
|
runInVM: (renderingRequest: string, bundleFilePath: string, vmCluster?: typeof cluster) => Promise<RenderResult>;
|
|
41
56
|
getVMContext: (bundleFilePath: string) => VMContext | undefined;
|
|
57
|
+
release: () => void;
|
|
42
58
|
};
|
|
43
59
|
/**
|
|
44
60
|
* Builds an ExecutionContext that manages VM execution for a set of bundles.
|
package/lib/worker/vm.js
CHANGED
|
@@ -53,6 +53,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
53
53
|
exports.VMContextNotFoundError = void 0;
|
|
54
54
|
exports.hasVMContextForBundle = hasVMContextForBundle;
|
|
55
55
|
exports.getVMContext = getVMContext;
|
|
56
|
+
exports.hasAnyVMContext = hasAnyVMContext;
|
|
56
57
|
exports.buildExecutionContext = buildExecutionContext;
|
|
57
58
|
exports.resetVM = resetVM;
|
|
58
59
|
exports.removeVM = removeVM;
|
|
@@ -72,12 +73,55 @@ const log_js_1 = __importDefault(require("../shared/log.js"));
|
|
|
72
73
|
const configBuilder_js_1 = require("../shared/configBuilder.js");
|
|
73
74
|
const utils_js_1 = require("../shared/utils.js");
|
|
74
75
|
const errorReporter = __importStar(require("../shared/errorReporter.js"));
|
|
76
|
+
const vmSourceMapSupport_js_1 = require("./vmSourceMapSupport.js");
|
|
75
77
|
const readFileAsync = (0, util_1.promisify)(fs_1.default.readFile);
|
|
78
|
+
// Length of the `Module.wrap` prefix that is prepended to the first line of a
|
|
79
|
+
// wrapped bundle. Needed to correct first-line stack-frame columns before
|
|
80
|
+
// source map lookups.
|
|
81
|
+
const MODULE_WRAP_FIRST_LINE_PREFIX_LENGTH = module_1.default.wrap('\n').indexOf('\n');
|
|
76
82
|
const writeFileAsync = (0, util_1.promisify)(fs_1.default.writeFile);
|
|
77
83
|
// Store contexts by their bundle file paths
|
|
78
84
|
const vmContexts = new Map();
|
|
79
85
|
// Track VM creation promises to handle concurrent buildVM requests
|
|
80
86
|
const vmCreationPromises = new Map();
|
|
87
|
+
// Execution contexts can outlive VM pool entries while a request is still
|
|
88
|
+
// running. Keep source maps for those evicted contexts until the request
|
|
89
|
+
// releases them, then drop both the registration and parsed-map cache.
|
|
90
|
+
const activeSourceMapRequestCounts = new Map();
|
|
91
|
+
const evictedSourceMapRegistrations = new Set();
|
|
92
|
+
function retainSourceMapRegistration(sourceMapRegistration) {
|
|
93
|
+
activeSourceMapRequestCounts.set(sourceMapRegistration, (activeSourceMapRequestCounts.get(sourceMapRegistration) ?? 0) + 1);
|
|
94
|
+
}
|
|
95
|
+
function releaseSourceMapRegistration(sourceMapRegistration) {
|
|
96
|
+
const currentCount = activeSourceMapRequestCounts.get(sourceMapRegistration) ?? 0;
|
|
97
|
+
if (currentCount <= 1) {
|
|
98
|
+
activeSourceMapRequestCounts.delete(sourceMapRegistration);
|
|
99
|
+
const wasEvicted = evictedSourceMapRegistrations.delete(sourceMapRegistration);
|
|
100
|
+
// Eviction deletes the VM first today; keep the guard so future callers do
|
|
101
|
+
// not unregister a source map for a live pooled VM.
|
|
102
|
+
let isStillInPool = false;
|
|
103
|
+
for (const vmContext of vmContexts.values()) {
|
|
104
|
+
if (vmContext.sourceMapRegistration === sourceMapRegistration) {
|
|
105
|
+
isStillInPool = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (wasEvicted && !isStillInPool) {
|
|
110
|
+
(0, vmSourceMapSupport_js_1.unregisterBundleForSourceMaps)(sourceMapRegistration);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
activeSourceMapRequestCounts.set(sourceMapRegistration, currentCount - 1);
|
|
115
|
+
}
|
|
116
|
+
function retireSourceMapRegistrationAfterEviction(sourceMapRegistration) {
|
|
117
|
+
(0, vmSourceMapSupport_js_1.retireMissingSourceMapRetry)(sourceMapRegistration);
|
|
118
|
+
if ((activeSourceMapRequestCounts.get(sourceMapRegistration) ?? 0) > 0) {
|
|
119
|
+
evictedSourceMapRegistrations.add(sourceMapRegistration);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
(0, vmSourceMapSupport_js_1.unregisterBundleForSourceMaps)(sourceMapRegistration);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
81
125
|
/**
|
|
82
126
|
* Returns all bundle paths that have a VM context
|
|
83
127
|
* @internal Used in tests
|
|
@@ -91,12 +135,35 @@ function hasVMContextForBundle(bundlePath) {
|
|
|
91
135
|
function getVMContext(bundlePath) {
|
|
92
136
|
return vmContexts.get(bundlePath);
|
|
93
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether this worker has at least one bundle compiled into a VM context.
|
|
140
|
+
* Used by the built-in /ready readiness endpoint: a worker with zero loaded
|
|
141
|
+
* bundles cannot serve render requests until a bundle is uploaded.
|
|
142
|
+
*
|
|
143
|
+
* This intentionally stays false while a bundle is still compiling in
|
|
144
|
+
* vmCreationPromises; /ready flips to 200 only after compilation finishes and
|
|
145
|
+
* the compiled context is stored in vmContexts.
|
|
146
|
+
*
|
|
147
|
+
* Pool eviction can remove older bundle contexts, but readiness remains true as
|
|
148
|
+
* long as at least one compiled bundle remains in the pool.
|
|
149
|
+
*/
|
|
150
|
+
function hasAnyVMContext() {
|
|
151
|
+
return vmContexts.size > 0;
|
|
152
|
+
}
|
|
94
153
|
const extendContext = (contextObject, additionalContext) => {
|
|
95
154
|
if (log_js_1.default.level === 'debug') {
|
|
96
155
|
log_js_1.default.debug(`Adding ${Object.keys(additionalContext).join(', ')} to context object.`);
|
|
97
156
|
}
|
|
98
157
|
Object.assign(contextObject, additionalContext);
|
|
99
158
|
};
|
|
159
|
+
const readPrepareStackTraceHook = (context) => {
|
|
160
|
+
try {
|
|
161
|
+
return vm_1.default.runInContext('typeof Error === "undefined" ? undefined : Error.prepareStackTrace', context);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
100
167
|
// Helper function to manage VM pool size
|
|
101
168
|
function manageVMPoolSize() {
|
|
102
169
|
const { maxVMPoolSize } = (0, configBuilder_js_1.getConfig)();
|
|
@@ -105,9 +172,11 @@ function manageVMPoolSize() {
|
|
|
105
172
|
}
|
|
106
173
|
const sortedEntries = Array.from(vmContexts.entries()).sort(([, a], [, b]) => a.lastUsed - b.lastUsed);
|
|
107
174
|
while (sortedEntries.length > maxVMPoolSize) {
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
175
|
+
const oldestEntry = sortedEntries.shift();
|
|
176
|
+
if (oldestEntry) {
|
|
177
|
+
const [oldestPath, oldestContext] = oldestEntry;
|
|
110
178
|
vmContexts.delete(oldestPath);
|
|
179
|
+
retireSourceMapRegistrationAfterEviction(oldestContext.sourceMapRegistration);
|
|
111
180
|
log_js_1.default.debug(`Removed VM for bundle ${oldestPath} due to pool size limit (max: ${maxVMPoolSize})`);
|
|
112
181
|
}
|
|
113
182
|
}
|
|
@@ -139,12 +208,29 @@ async function buildVM(filePath) {
|
|
|
139
208
|
// than a try/finally inside the IIFE, because an IIFE's finally block can
|
|
140
209
|
// execute synchronously (before `vmCreationPromises.set`) when the code throws
|
|
141
210
|
// before the first `await`, which would leave a stale rejected promise in the map.
|
|
211
|
+
let sourceMapRegistration;
|
|
142
212
|
const vmCreationPromise = (async () => {
|
|
143
213
|
try {
|
|
144
214
|
const { supportModules, stubTimers, additionalContext } = (0, configBuilder_js_1.getConfig)();
|
|
145
215
|
const additionalContextIsObject = additionalContext !== null && additionalContext.constructor === Object;
|
|
146
216
|
const sharedConsoleHistory = new sharedConsoleHistory_js_1.default();
|
|
147
|
-
|
|
217
|
+
// Request-derived bundle paths are built from validated timestamp path components.
|
|
218
|
+
// Direct `buildExecutionContext` callers pass trusted internal bundle paths.
|
|
219
|
+
// codeql[js/path-injection]
|
|
220
|
+
const bundleContents = await readFileAsync(filePath, 'utf8');
|
|
221
|
+
const firstLineColumnOffset = additionalContextIsObject || supportModules ? MODULE_WRAP_FIRST_LINE_PREFIX_LENGTH : 0;
|
|
222
|
+
const preloadedSourceMap = await (0, vmSourceMapSupport_js_1.preloadSourceMapJsonForBundle)(filePath, bundleContents);
|
|
223
|
+
const currentSourceMapRegistration = (0, vmSourceMapSupport_js_1.registerBundleForSourceMaps)(filePath, firstLineColumnOffset, bundleContents, preloadedSourceMap.sourceMapJson, preloadedSourceMap.retryMissingSourceMap);
|
|
224
|
+
sourceMapRegistration = currentSourceMapRegistration;
|
|
225
|
+
// Host callback used by the in-VM `Error.prepareStackTrace` hook (see
|
|
226
|
+
// vmSourceMapSupport.ts) to remap bundle stack frames to original
|
|
227
|
+
// TS/JS sources. Only resolves positions for registered bundle paths.
|
|
228
|
+
const contextObject = {
|
|
229
|
+
sharedConsoleHistory,
|
|
230
|
+
[vmSourceMapSupport_js_1.SOURCE_MAP_LOOKUP_ATTEMPT_CONTEXT_KEY]: vmSourceMapSupport_js_1.createSourceMapLookupAttempt,
|
|
231
|
+
[vmSourceMapSupport_js_1.SOURCE_MAP_RESOLVER_CONTEXT_KEY]: (fileName, lineNumber, columnNumber, lookupAttempt) => (0, vmSourceMapSupport_js_1.resolveOriginalPositionForRegistration)(currentSourceMapRegistration, fileName, lineNumber, columnNumber, lookupAttempt),
|
|
232
|
+
[vmSourceMapSupport_js_1.SOURCE_MAP_STACK_REMAPPER_CONTEXT_KEY]: (stack) => (0, vmSourceMapSupport_js_1.remapStackTrace)(stack, currentSourceMapRegistration),
|
|
233
|
+
};
|
|
148
234
|
if (supportModules) {
|
|
149
235
|
// IMPORTANT: When adding anything to this object, update:
|
|
150
236
|
// 1. docs/oss/building-features/node-renderer/js-configuration.md
|
|
@@ -217,20 +303,29 @@ async function buildVM(filePath) {
|
|
|
217
303
|
vm_1.default.runInContext(`function clearImmediate() {}`, context);
|
|
218
304
|
vm_1.default.runInContext(`function queueMicrotask() {}`, context);
|
|
219
305
|
}
|
|
220
|
-
//
|
|
221
|
-
|
|
306
|
+
// Install lazy source-mapped stack traces for errors created inside the
|
|
307
|
+
// VM. Must run before the bundle is evaluated so the bundle's own error
|
|
308
|
+
// handling (e.g. react-on-rails `handleError`) sees remapped stacks.
|
|
309
|
+
vm_1.default.runInContext(vmSourceMapSupport_js_1.PREPARE_STACK_TRACE_INSTALL_SCRIPT, context);
|
|
310
|
+
const installedPrepareStackTrace = readPrepareStackTraceHook(context);
|
|
222
311
|
// If node-specific code is provided then it must be wrapped into a module wrapper. The bundle
|
|
223
312
|
// may need the `require` function, which is not available when running in vm unless passed in.
|
|
313
|
+
// Pass `filename` so stack frames point at the real bundle path (instead of
|
|
314
|
+
// `evalmachine.<anonymous>`), which also keys lazy source map resolution.
|
|
224
315
|
if (additionalContextIsObject || supportModules) {
|
|
225
|
-
vm_1.default.runInContext(module_1.default.wrap(bundleContents), context)(exports, require, module, filePath, path_1.default.dirname(filePath));
|
|
316
|
+
vm_1.default.runInContext(module_1.default.wrap(bundleContents), context, { filename: filePath })(exports, require, module, filePath, path_1.default.dirname(filePath));
|
|
226
317
|
}
|
|
227
318
|
else {
|
|
228
|
-
vm_1.default.runInContext(bundleContents, context);
|
|
319
|
+
vm_1.default.runInContext(bundleContents, context, { filename: filePath });
|
|
320
|
+
}
|
|
321
|
+
if (readPrepareStackTraceHook(context) !== installedPrepareStackTrace) {
|
|
322
|
+
log_js_1.default.warn('Bundle replaced Error.prepareStackTrace; source-mapped stack traces are disabled for bundle %s', filePath);
|
|
229
323
|
}
|
|
230
324
|
// Only now, after VM is fully initialized, store the context
|
|
231
325
|
const newVmContext = {
|
|
232
326
|
context,
|
|
233
327
|
sharedConsoleHistory,
|
|
328
|
+
sourceMapRegistration: currentSourceMapRegistration,
|
|
234
329
|
lastUsed: Date.now(),
|
|
235
330
|
};
|
|
236
331
|
vmContexts.set(filePath, newVmContext);
|
|
@@ -247,8 +342,18 @@ async function buildVM(filePath) {
|
|
|
247
342
|
return newVmContext;
|
|
248
343
|
}
|
|
249
344
|
catch (error) {
|
|
345
|
+
// Materialize/remap the stack before reporting so failed bundle builds
|
|
346
|
+
// still include original source locations, then retire the registration:
|
|
347
|
+
// failed builds never enter the VM pool and cannot be reused.
|
|
348
|
+
(0, vmSourceMapSupport_js_1.remapErrorStack)(error, sourceMapRegistration);
|
|
250
349
|
log_js_1.default.error({ error }, 'Caught Error when creating context in buildVM');
|
|
251
350
|
errorReporter.error(error);
|
|
351
|
+
if (sourceMapRegistration) {
|
|
352
|
+
(0, vmSourceMapSupport_js_1.unregisterBundleForSourceMaps)(sourceMapRegistration);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
(0, vmSourceMapSupport_js_1.unregisterBundleForSourceMaps)(filePath);
|
|
356
|
+
}
|
|
252
357
|
throw error;
|
|
253
358
|
}
|
|
254
359
|
})();
|
|
@@ -305,23 +410,53 @@ async function getOrBuildVMContext(bundleFilePath, buildVmsIfNeeded) {
|
|
|
305
410
|
* @see handleIncrementalRenderRequest.ts for how update chunks access the same context
|
|
306
411
|
*/
|
|
307
412
|
async function buildExecutionContext(bundlePaths, buildVmsIfNeeded) {
|
|
413
|
+
const retainedSourceMapRegistrations = new Set();
|
|
414
|
+
const retainSourceMapRegistrationOnce = (sourceMapRegistration) => {
|
|
415
|
+
if (retainedSourceMapRegistrations.has(sourceMapRegistration)) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
retainSourceMapRegistration(sourceMapRegistration);
|
|
419
|
+
retainedSourceMapRegistrations.add(sourceMapRegistration);
|
|
420
|
+
};
|
|
308
421
|
const mapBundleFilePathToVMContext = new Map();
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
422
|
+
let buildRejected = false;
|
|
423
|
+
let firstBuildRejection;
|
|
424
|
+
// Wait for every parallel build callback before releasing retained source-map
|
|
425
|
+
// registrations; otherwise a sibling build can retain after an early rejection.
|
|
426
|
+
await Promise.allSettled(bundlePaths.map(async (bundleFilePath) => {
|
|
427
|
+
try {
|
|
428
|
+
const vmContext = await getOrBuildVMContext(bundleFilePath, buildVmsIfNeeded);
|
|
429
|
+
retainSourceMapRegistrationOnce(vmContext.sourceMapRegistration);
|
|
430
|
+
vmContext.lastUsed = Date.now();
|
|
431
|
+
mapBundleFilePathToVMContext.set(bundleFilePath, vmContext);
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
if (!buildRejected) {
|
|
435
|
+
buildRejected = true;
|
|
436
|
+
firstBuildRejection = error;
|
|
437
|
+
}
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
313
440
|
}));
|
|
441
|
+
if (buildRejected) {
|
|
442
|
+
retainedSourceMapRegistrations.forEach(releaseSourceMapRegistration);
|
|
443
|
+
throw firstBuildRejection;
|
|
444
|
+
}
|
|
314
445
|
// This Map persists for the lifetime of this ExecutionContext (one HTTP request).
|
|
315
446
|
// It allows data to be shared between the initial render and subsequent update chunks.
|
|
316
447
|
// Example: asyncPropsManager is stored here during initial render and accessed by update chunks.
|
|
317
448
|
const sharedExecutionContext = new Map();
|
|
449
|
+
let released = false;
|
|
318
450
|
const runInVM = async (renderingRequest, bundleFilePath, vmCluster) => {
|
|
451
|
+
let sourceMapRegistrationForRequest;
|
|
319
452
|
try {
|
|
320
453
|
const { serverBundleCachePath } = (0, configBuilder_js_1.getConfig)();
|
|
321
454
|
const vmContext = mapBundleFilePathToVMContext.get(bundleFilePath);
|
|
322
455
|
if (!vmContext) {
|
|
323
456
|
throw new VMContextNotFoundError(bundleFilePath);
|
|
324
457
|
}
|
|
458
|
+
sourceMapRegistrationForRequest = vmContext.sourceMapRegistration;
|
|
459
|
+
const remapStackTraceForRequest = (stack) => (0, vmSourceMapSupport_js_1.remapStackTrace)(stack, vmContext.sourceMapRegistration);
|
|
325
460
|
// Update last used timestamp
|
|
326
461
|
vmContext.lastUsed = Date.now();
|
|
327
462
|
const { context, sharedConsoleHistory } = vmContext;
|
|
@@ -361,7 +496,7 @@ async function buildExecutionContext(bundlePaths, buildVmsIfNeeded) {
|
|
|
361
496
|
});
|
|
362
497
|
if ((0, utils_js_1.isReadableStream)(result)) {
|
|
363
498
|
const newStreamAfterHandlingError = (0, utils_js_1.handleStreamError)(result, (error) => {
|
|
364
|
-
const msg = (0, utils_js_1.formatExceptionMessage)({ renderingRequest }, error, 'Error in a rendering stream');
|
|
499
|
+
const msg = (0, utils_js_1.formatExceptionMessage)({ renderingRequest }, error, 'Error in a rendering stream', remapStackTraceForRequest);
|
|
365
500
|
errorReporter.message(msg);
|
|
366
501
|
});
|
|
367
502
|
return newStreamAfterHandlingError;
|
|
@@ -382,7 +517,9 @@ async function buildExecutionContext(bundlePaths, buildVmsIfNeeded) {
|
|
|
382
517
|
return result;
|
|
383
518
|
}
|
|
384
519
|
catch (exception) {
|
|
385
|
-
const exceptionMessage = (0, utils_js_1.formatExceptionMessage)({ renderingRequest }, exception
|
|
520
|
+
const exceptionMessage = (0, utils_js_1.formatExceptionMessage)({ renderingRequest }, exception, undefined, sourceMapRegistrationForRequest
|
|
521
|
+
? (stack) => (0, vmSourceMapSupport_js_1.remapStackTrace)(stack, sourceMapRegistrationForRequest)
|
|
522
|
+
: (_stack) => undefined);
|
|
386
523
|
log_js_1.default.debug('Caught exception in rendering request: %s', exceptionMessage);
|
|
387
524
|
return Promise.resolve({ exceptionMessage });
|
|
388
525
|
}
|
|
@@ -390,19 +527,36 @@ async function buildExecutionContext(bundlePaths, buildVmsIfNeeded) {
|
|
|
390
527
|
return {
|
|
391
528
|
getVMContext: (bundleFilePath) => mapBundleFilePathToVMContext.get(bundleFilePath),
|
|
392
529
|
runInVM,
|
|
530
|
+
release: () => {
|
|
531
|
+
if (released) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
released = true;
|
|
535
|
+
retainedSourceMapRegistrations.forEach(releaseSourceMapRegistration);
|
|
536
|
+
},
|
|
393
537
|
};
|
|
394
538
|
}
|
|
395
539
|
/** @internal Used in tests */
|
|
396
540
|
function resetVM() {
|
|
397
541
|
vmContexts.clear();
|
|
398
542
|
vmCreationPromises.clear();
|
|
543
|
+
activeSourceMapRequestCounts.clear();
|
|
544
|
+
evictedSourceMapRegistrations.clear();
|
|
545
|
+
(0, vmSourceMapSupport_js_1.resetSourceMapSupport)();
|
|
399
546
|
}
|
|
400
547
|
// Optional: Add a method to remove a specific VM if needed
|
|
401
548
|
/**
|
|
402
549
|
* @public TODO: Remove the line below when this function is actually used
|
|
403
550
|
*/
|
|
404
551
|
function removeVM(bundlePath) {
|
|
552
|
+
const vmContext = vmContexts.get(bundlePath);
|
|
405
553
|
vmContexts.delete(bundlePath);
|
|
406
554
|
vmCreationPromises.delete(bundlePath);
|
|
555
|
+
if (vmContext) {
|
|
556
|
+
retireSourceMapRegistrationAfterEviction(vmContext.sourceMapRegistration);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
(0, vmSourceMapSupport_js_1.unregisterBundleForSourceMaps)(bundlePath);
|
|
560
|
+
}
|
|
407
561
|
}
|
|
408
562
|
//# sourceMappingURL=vm.js.map
|