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

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.
@@ -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 supportedReactRange = ({ supportedMajor, supportedMinor, minPatch, }) => `${supportedMajor}.${supportedMinor}.x with patch >= ${supportedMajor}.${supportedMinor}.${minPatch}`;
45
- const isSupportedReactTuple = ([major, minor, patch], { supportedMajor, supportedMinor, minPatch }) => major === supportedMajor && minor === supportedMinor && patch >= minPatch;
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 supportedMinor: 0;
9
- readonly minPatch: 4;
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: { supportedMajor: 19, supportedMinor: 0, minPatch: 4 },
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
@@ -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';
@@ -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
- ${error.stack}`;
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.join(serverBundleCachePath, `${bundleTimestamp}`);
225
+ return path_1.default.resolve(serverBundleCachePath, bundleTimestampPathComponent(bundleTimestamp));
211
226
  }
212
227
  function getRequestBundleFilePath(bundleTimestamp) {
213
- const bundleDirectory = getBundleDirectory(bundleTimestamp);
214
- return path_1.default.join(bundleDirectory, `${bundleTimestamp}.js`);
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
- executionContext
131
- .runInVM(onRequestClosedUpdateChunk.updateChunk, bundlePath)
132
- .catch((err) => {
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
  };
@@ -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 oldestPath = sortedEntries.shift()?.[0];
109
- if (oldestPath) {
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
- const contextObject = { sharedConsoleHistory };
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
- // Run bundle code in created context:
221
- const bundleContents = await readFileAsync(filePath, 'utf8');
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
- await Promise.all(bundlePaths.map(async (bundleFilePath) => {
310
- const vmContext = await getOrBuildVMContext(bundleFilePath, buildVmsIfNeeded);
311
- vmContext.lastUsed = Date.now();
312
- mapBundleFilePathToVMContext.set(bundleFilePath, vmContext);
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