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.
@@ -17,10 +17,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.init = init;
18
18
  const api_js_1 = require("./api.js");
19
19
  const DEFAULT_SERVICE_NAME = 'react-on-rails-pro-node-renderer';
20
- const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000;
20
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
21
21
  // Leave 1s of headroom under the worker's hard cap so the shutdown hook can
22
22
  // resolve cleanly even when provider.shutdown() runs right at its limit.
23
- const MAX_SHUTDOWN_TIMEOUT_MS = api_js_1.WORKER_SHUTDOWN_HOOKS_TIMEOUT_MS - 1000;
23
+ const MAX_SHUTDOWN_TIMEOUT_MS = api_js_1.WORKER_SHUTDOWN_HOOKS_TIMEOUT_MS - 1_000;
24
24
  function isProduction() {
25
25
  return process.env.NODE_ENV === 'production' || process.env.RAILS_ENV === 'production';
26
26
  }
@@ -265,7 +265,7 @@ function init(opts = {}) {
265
265
  }
266
266
  let shutdownOpenTelemetryPromise;
267
267
  const shutdownOpenTelemetry = () => {
268
- shutdownOpenTelemetryPromise ?? (shutdownOpenTelemetryPromise = (async () => {
268
+ shutdownOpenTelemetryPromise ??= (async () => {
269
269
  try {
270
270
  await shutdownProviderWithTimeout(provider, shutdownTimeoutMs);
271
271
  if ((0, api_js_1.getOpenTelemetryTracerProvider)() === provider) {
@@ -279,7 +279,7 @@ function init(opts = {}) {
279
279
  unregisterFastifyConfig?.();
280
280
  unregisterWorkerShutdownHook?.();
281
281
  }
282
- })());
282
+ })();
283
283
  return shutdownOpenTelemetryPromise;
284
284
  };
285
285
  // Register these last so failed init paths do not leave partial shutdown hooks
@@ -25,39 +25,93 @@ exports.default = restartWorkers;
25
25
  const cluster_1 = __importDefault(require("cluster"));
26
26
  const log_js_1 = __importDefault(require("../shared/log.js"));
27
27
  const utils_js_1 = require("../shared/utils.js");
28
+ const MILLISECONDS_IN_SECOND = 1000;
28
29
  const MILLISECONDS_IN_MINUTE = 60000;
30
+ function currentWorker(workerId) {
31
+ const worker = cluster_1.default.workers?.[workerId];
32
+ if (!worker) {
33
+ log_js_1.default.debug('Worker #%s is no longer available for scheduled restart', workerId);
34
+ return undefined;
35
+ }
36
+ if (worker.isDead()) {
37
+ log_js_1.default.debug('Worker #%d is already dead before scheduled restart', worker.id);
38
+ return undefined;
39
+ }
40
+ return worker;
41
+ }
29
42
  async function restartWorkers(delayBetweenIndividualWorkerRestarts, gracefulWorkerRestartTimeout) {
30
43
  log_js_1.default.info('Started scheduled restart of workers');
31
- if (!cluster_1.default.workers) {
32
- throw new Error('No workers to restart');
44
+ const workerIds = Object.keys(cluster_1.default.workers ?? {});
45
+ if (workerIds.length === 0) {
46
+ log_js_1.default.warn('No workers to restart');
47
+ return;
33
48
  }
34
- for (const worker of Object.values(cluster_1.default.workers).filter((w) => !!w)) {
35
- log_js_1.default.debug('Kill worker #%d', worker.id);
36
- worker.isScheduledRestart = true;
37
- worker.send(utils_js_1.SHUTDOWN_WORKER_MESSAGE);
38
- // It's inteded to restart worker in sequence, it shouldn't happens in parallel
39
- // eslint-disable-next-line no-await-in-loop
40
- await new Promise((resolve) => {
41
- let timeout;
42
- const onExit = () => {
43
- clearTimeout(timeout);
44
- resolve();
45
- };
46
- worker.on('exit', onExit);
47
- // Zero means no timeout
48
- if (gracefulWorkerRestartTimeout) {
49
- timeout = setTimeout(() => {
50
- log_js_1.default.debug('Worker #%d timed out, forcing kill it', worker.id);
51
- worker.destroy();
52
- worker.off('exit', onExit);
49
+ for (const workerId of workerIds) {
50
+ const worker = currentWorker(workerId);
51
+ if (worker) {
52
+ const workerToRestart = worker;
53
+ log_js_1.default.debug('Kill worker #%d', workerToRestart.id);
54
+ workerToRestart.isScheduledRestart = true;
55
+ // It's intended to restart worker in sequence, it shouldn't happen in parallel
56
+ // eslint-disable-next-line no-await-in-loop
57
+ await new Promise((resolve) => {
58
+ let timeout;
59
+ let isResolved = false;
60
+ let finish = () => { };
61
+ const onExit = () => {
62
+ finish();
63
+ };
64
+ const onError = (err) => {
65
+ log_js_1.default.warn({ msg: 'Error while waiting for scheduled worker restart', err });
66
+ if (!gracefulWorkerRestartTimeout) {
67
+ workerToRestart.isScheduledRestart = false;
68
+ finish();
69
+ }
70
+ };
71
+ const onSendError = (err) => {
72
+ if (!err || isResolved)
73
+ return;
74
+ workerToRestart.isScheduledRestart = false;
75
+ log_js_1.default.warn({ msg: 'Error sending scheduled graceful shutdown message to worker', err });
76
+ finish();
77
+ };
78
+ finish = () => {
79
+ if (isResolved)
80
+ return;
81
+ isResolved = true;
82
+ if (timeout)
83
+ clearTimeout(timeout);
84
+ workerToRestart.off('exit', onExit);
85
+ workerToRestart.off('error', onError);
53
86
  resolve();
54
- }, gracefulWorkerRestartTimeout);
55
- }
56
- });
57
- // eslint-disable-next-line no-await-in-loop
58
- await new Promise((resolve) => {
59
- setTimeout(resolve, delayBetweenIndividualWorkerRestarts * MILLISECONDS_IN_MINUTE);
60
- });
87
+ };
88
+ workerToRestart.on('exit', onExit);
89
+ workerToRestart.on('error', onError);
90
+ try {
91
+ workerToRestart.send(utils_js_1.SHUTDOWN_WORKER_MESSAGE, onSendError);
92
+ }
93
+ catch (err) {
94
+ workerToRestart.isScheduledRestart = false;
95
+ log_js_1.default.warn({ msg: 'Error sending scheduled graceful shutdown message to worker', err });
96
+ finish();
97
+ return;
98
+ }
99
+ if (isResolved)
100
+ return;
101
+ // Zero means no timeout
102
+ if (gracefulWorkerRestartTimeout) {
103
+ timeout = setTimeout(() => {
104
+ log_js_1.default.debug('Worker #%d timed out, forcing kill it', workerToRestart.id);
105
+ workerToRestart.destroy();
106
+ finish();
107
+ }, gracefulWorkerRestartTimeout * MILLISECONDS_IN_SECOND);
108
+ }
109
+ });
110
+ // eslint-disable-next-line no-await-in-loop
111
+ await new Promise((resolve) => {
112
+ setTimeout(resolve, delayBetweenIndividualWorkerRestarts * MILLISECONDS_IN_MINUTE);
113
+ });
114
+ }
61
115
  }
62
116
  log_js_1.default.info('Finished scheduled restart of workers');
63
117
  }
package/lib/master.js CHANGED
@@ -148,6 +148,7 @@ function masterRun(runningConfig) {
148
148
  let isAbortingForStartupFailure = false;
149
149
  let hasReportedStartupFailure = false;
150
150
  let fatalStartupFailure = null;
151
+ const gracefulShutdownAcknowledgedWorkerIds = new Set();
151
152
  // Set as soon as any shutdown path (external signal or startup-failure abort)
152
153
  // begins. Read by the `cluster.on('exit')` handler to suppress re-forking
153
154
  // workers that exit because we are intentionally tearing the cluster down.
@@ -163,13 +164,15 @@ function masterRun(runningConfig) {
163
164
  // exits. A hard-deadline timer guarantees the master still exits if a worker
164
165
  // is stuck.
165
166
  const currentWorkers = () => Object.values(cluster_1.default.workers ?? {}).filter((worker) => Boolean(worker));
166
- const forceKillSurvivingWorkers = (workers) => {
167
+ const forceKillSurvivingWorkers = (workers, { skipAcknowledgedWorkers = false } = {}) => {
167
168
  workers.forEach((worker) => {
168
169
  const workerProcess = worker.process;
169
170
  // isDead() only: ChildProcess.killed means a signal was sent, not that
170
171
  // the process died — e.g. a blocked worker surviving destroy()'s SIGTERM.
171
172
  if (worker.isDead())
172
173
  return;
174
+ if (skipAcknowledgedWorkers && gracefulShutdownAcknowledgedWorkerIds.has(worker.id))
175
+ return;
173
176
  try {
174
177
  workerProcess.kill('SIGKILL');
175
178
  }
@@ -227,8 +230,13 @@ function masterRun(runningConfig) {
227
230
  // Early force-kill of workers that cannot drain (blocked event loop).
228
231
  // Their SIGKILL-induced exits complete the disconnect, which lets the
229
232
  // normal waitForWorkerExits path below finish the shutdown promptly.
233
+ // ACK means the worker accepted shutdown and disconnected itself from new
234
+ // requests; it may still be draining an active render. Keep ACKed workers
235
+ // alive until the hard deadline so graceful drain can finish. Deployments
236
+ // with shorter supervisor grace windows can still terminate the master
237
+ // before that hard deadline and should tune the supervisor window.
230
238
  const forceKillTimer = setTimeout(() => {
231
- forceKillSurvivingWorkers(workersAtShutdown);
239
+ forceKillSurvivingWorkers(workersAtShutdown, { skipAcknowledgedWorkers: true });
232
240
  }, SHUTDOWN_WORKER_FORCE_KILL_TIMEOUT_MS);
233
241
  if (typeof forceKillTimer.unref === 'function')
234
242
  forceKillTimer.unref();
@@ -274,6 +282,10 @@ function masterRun(runningConfig) {
274
282
  return true;
275
283
  };
276
284
  cluster_1.default.on('message', (worker, message) => {
285
+ if (message === utils_js_1.SHUTDOWN_WORKER_ACK_MESSAGE) {
286
+ gracefulShutdownAcknowledgedWorkerIds.add(worker.id);
287
+ return;
288
+ }
277
289
  // Check the abort flag first to short-circuit the type-guard on every
278
290
  // ordinary IPC message once we are already aborting.
279
291
  if (isAbortingForStartupFailure || !(0, workerMessages_js_1.isWorkerStartupFailureMessage)(message))
@@ -283,6 +295,7 @@ function masterRun(runningConfig) {
283
295
  });
284
296
  // Listen for dying workers:
285
297
  cluster_1.default.on('exit', (worker) => {
298
+ gracefulShutdownAcknowledgedWorkerIds.delete(worker.id);
286
299
  // Once a startup failure has been detected, abort regardless of whether
287
300
  // this particular exit was from the failing worker, a scheduled restart,
288
301
  // or an unrelated crash. Don't fork any more workers. If an external signal
@@ -336,7 +349,11 @@ function masterRun(runningConfig) {
336
349
  log_js_1.default.info('Scheduled workers restarts every %d minutes (%d minutes btw each)', allWorkersRestartInterval, delayBetweenIndividualWorkerRestarts);
337
350
  const allWorkersRestartIntervalMS = allWorkersRestartInterval * MILLISECONDS_IN_MINUTE;
338
351
  const scheduleWorkersRestart = () => {
339
- void (0, restartWorkers_js_1.default)(delayBetweenIndividualWorkerRestarts, gracefulWorkerRestartTimeout).finally(() => {
352
+ void (0, restartWorkers_js_1.default)(delayBetweenIndividualWorkerRestarts, gracefulWorkerRestartTimeout)
353
+ .catch((err) => {
354
+ log_js_1.default.error({ msg: 'Scheduled worker restart failed', err });
355
+ })
356
+ .finally(() => {
340
357
  setTimeout(scheduleWorkersRestart, allWorkersRestartIntervalMS);
341
358
  });
342
359
  };
@@ -1,9 +1,8 @@
1
- export type RscPeerCheckLevel = 'ok' | 'warn' | 'error';
2
1
  export type RscPeerCheckResult = {
3
2
  level: 'ok';
4
3
  message?: undefined;
5
4
  } | {
6
- level: Exclude<RscPeerCheckLevel, 'ok'>;
5
+ level: 'error';
7
6
  message: string;
8
7
  };
9
8
  export interface RscPeerCheckInput {
@@ -16,35 +16,83 @@
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.checkRscPeerCompatibility = checkRscPeerCompatibility;
18
18
  const rscPeerSupport_js_1 = require("./rscPeerSupport.js");
19
- // Strip build metadata (`+...`) and prerelease (`-...`) so a coordinated RC such as
20
- // `19.0.5-rc.7` compares as `19.0.5`. We only need major/minor/patch ordering, so this
21
- // avoids semver's prerelease rules (and a `semver` dependency) entirely.
22
- const parseTuple = (version) => {
19
+ const parseVersion = (version) => {
23
20
  // `resolveVersion` is a public injection point, so tolerate a leading `v`/`=` (e.g. `v19.0.4`).
24
21
  // Malformed versions intentionally coerce to 0 segments so the major mismatch
25
22
  // branch reports the original string instead of hiding it behind a parse error.
26
23
  const normalized = version.replace(/^[v=]+/, '');
27
- const [withoutBuild = ''] = normalized.split('+', 1);
28
- const [core = ''] = withoutBuild.split('-', 1);
24
+ const buildIndex = normalized.indexOf('+');
25
+ const withoutBuild = buildIndex === -1 ? normalized : normalized.slice(0, buildIndex);
26
+ const prereleaseIndex = withoutBuild.indexOf('-');
27
+ const core = prereleaseIndex === -1 ? withoutBuild : withoutBuild.slice(0, prereleaseIndex);
28
+ const prerelease = prereleaseIndex === -1 ? null : withoutBuild.slice(prereleaseIndex + 1) || null;
29
29
  const parts = core.split('.');
30
- return [Number(parts[0]) || 0, Number(parts[1]) || 0, Number(parts[2]) || 0];
30
+ return {
31
+ tuple: [Number(parts[0]) || 0, Number(parts[1]) || 0, Number(parts[2]) || 0],
32
+ prerelease: prerelease || null,
33
+ };
31
34
  };
32
- const isAtLeast = (actual, floor) => {
35
+ const parseTuple = (version) => parseVersion(version).tuple;
36
+ const comparePrereleasePart = (left, right) => {
37
+ const leftNumeric = /^\d+$/.test(left);
38
+ const rightNumeric = /^\d+$/.test(right);
39
+ if (leftNumeric && rightNumeric)
40
+ return Number(left) - Number(right);
41
+ if (leftNumeric)
42
+ return -1;
43
+ if (rightNumeric)
44
+ return 1;
45
+ if (left < right)
46
+ return -1;
47
+ if (left > right)
48
+ return 1;
49
+ return 0;
50
+ };
51
+ const comparePrerelease = (left, right) => {
52
+ if (left === right)
53
+ return 0;
54
+ if (!left)
55
+ return 1;
56
+ if (!right)
57
+ return -1;
58
+ const leftParts = left.split('.');
59
+ const rightParts = right.split('.');
60
+ const maxParts = Math.max(leftParts.length, rightParts.length);
61
+ for (let i = 0; i < maxParts; i += 1) {
62
+ const leftPart = leftParts[i];
63
+ const rightPart = rightParts[i];
64
+ if (leftPart === undefined)
65
+ return -1;
66
+ if (rightPart === undefined)
67
+ return 1;
68
+ const comparison = comparePrereleasePart(leftPart, rightPart);
69
+ if (comparison !== 0)
70
+ return comparison;
71
+ }
72
+ return 0;
73
+ };
74
+ const compareVersions = (actual, floor) => {
75
+ const actualVersion = parseVersion(actual);
76
+ const floorVersion = parseVersion(floor);
33
77
  for (let i = 0; i < 3; i += 1) {
34
- const a = actual[i] ?? 0;
35
- const f = floor[i] ?? 0;
78
+ const a = actualVersion.tuple[i] ?? 0;
79
+ const f = floorVersion.tuple[i] ?? 0;
36
80
  if (a > f)
37
- return true;
81
+ return 1;
38
82
  if (a < f)
39
- return false;
83
+ return -1;
40
84
  }
41
- return true;
85
+ return comparePrerelease(actualVersion.prerelease, floorVersion.prerelease);
42
86
  };
87
+ const isAtLeastVersion = (actual, floor) => compareVersions(actual, floor) >= 0;
43
88
  const sameTuple = (left, right) => left.every((value, index) => value === right[index]);
44
89
  const supportedRscRange = ({ supportedMajor }, { supportedRanges }) => {
45
90
  const supportedMinors = [...new Set(supportedRanges.map((range) => range.rscMinor))].sort((left, right) => left - right);
46
91
  return supportedMinors.map((minor) => `${supportedMajor}.${minor}.x`).join(' or ');
47
92
  };
93
+ const rscFloorRange = ({ minimumVersion, minimumPrereleaseVersion, }) => minimumPrereleaseVersion
94
+ ? `>= ${minimumVersion} (or ${minimumPrereleaseVersion} during the RC soak)`
95
+ : `>= ${minimumVersion}`;
48
96
  const supportedReactRange = (rscTuple, { supportedMajor, supportedRanges }) => {
49
97
  const rscMinor = rscTuple[1];
50
98
  const matchingRanges = supportedRanges.filter((range) => range.rscMinor === rscMinor);
@@ -62,11 +110,6 @@ const errorMessage = (pkg, found, want, proVersion) => [
62
110
  ` Upgrade or downgrade ${pkg} to a compatible release. See https://www.shakacode.com/react-on-rails-pro/docs/.`,
63
111
  ` (Set REACT_ON_RAILS_PRO_DISABLE_VERSION_CHECK=1 to downgrade this error to a warning.)`,
64
112
  ].join('\n');
65
- const warnMessage = (found, recommendedMin, proVersion) => [
66
- `[ReactOnRails] react-on-rails-rsc ${found} is older than the recommended minimum ${recommendedMin}.`,
67
- ` ${proLabel(proVersion)} may behave incorrectly (missing coordinated RSC fixes).`,
68
- ` Upgrade react-on-rails-rsc to ${recommendedMin} or newer.`,
69
- ].join('\n');
70
113
  function checkRscPeerCompatibility(input) {
71
114
  const { rscVersion, reactVersion, reactDomVersion, proVersion } = input;
72
115
  // react-on-rails-rsc is an optional peer. Absent => the consumer is not on the RSC
@@ -74,7 +117,8 @@ function checkRscPeerCompatibility(input) {
74
117
  if (!rscVersion)
75
118
  return { level: 'ok' };
76
119
  const { reactOnRailsRsc, react } = rscPeerSupport_js_1.RSC_PEER_SUPPORT;
77
- const rscTuple = parseTuple(rscVersion);
120
+ const rscParsedVersion = parseVersion(rscVersion);
121
+ const rscTuple = rscParsedVersion.tuple;
78
122
  const [rscMajor] = rscTuple;
79
123
  if (rscMajor !== reactOnRailsRsc.supportedMajor) {
80
124
  return {
@@ -82,6 +126,20 @@ function checkRscPeerCompatibility(input) {
82
126
  message: errorMessage('react-on-rails-rsc', rscVersion, `${reactOnRailsRsc.supportedMajor}.x`, proVersion),
83
127
  };
84
128
  }
129
+ const meetsStableFloor = !rscParsedVersion.prerelease && isAtLeastVersion(rscVersion, reactOnRailsRsc.minimumVersion);
130
+ const minimumPrereleaseTuple = reactOnRailsRsc.minimumPrereleaseVersion
131
+ ? parseTuple(reactOnRailsRsc.minimumPrereleaseVersion)
132
+ : null;
133
+ const meetsPrereleaseFloor = reactOnRailsRsc.minimumPrereleaseVersion &&
134
+ minimumPrereleaseTuple &&
135
+ sameTuple(rscTuple, minimumPrereleaseTuple) &&
136
+ isAtLeastVersion(rscVersion, reactOnRailsRsc.minimumPrereleaseVersion);
137
+ if (!meetsStableFloor && !meetsPrereleaseFloor) {
138
+ return {
139
+ level: 'error',
140
+ message: errorMessage('react-on-rails-rsc', rscVersion, rscFloorRange(reactOnRailsRsc), proVersion),
141
+ };
142
+ }
85
143
  if (!isSupportedRscMinor(rscTuple, react)) {
86
144
  return {
87
145
  level: 'error',
@@ -115,9 +173,6 @@ function checkRscPeerCompatibility(input) {
115
173
  };
116
174
  }
117
175
  }
118
- if (!isAtLeast(rscTuple, parseTuple(reactOnRailsRsc.recommendedMin))) {
119
- return { level: 'warn', message: warnMessage(rscVersion, reactOnRailsRsc.recommendedMin, proVersion) };
120
- }
121
176
  return { level: 'ok' };
122
177
  }
123
178
  //# sourceMappingURL=checkRscPeerCompatibility.js.map
@@ -92,13 +92,20 @@ function normalizedRuntimeEnvs() {
92
92
  .filter((value) => Boolean(value))
93
93
  .map((value) => value.toLowerCase());
94
94
  }
95
- function runtimeEnvsAllowDevelopmentDefaults() {
96
- const runtimeEnvs = normalizedRuntimeEnvs();
95
+ function runtimeEnvsAllowDevelopmentDefaults(runtimeEnvs = normalizedRuntimeEnvs()) {
97
96
  // Fail closed: every present runtime env must be development/test before we allow
98
97
  // missing-password defaults. Any production-like value, or no env at all, still
99
98
  // requires an explicit password.
100
99
  return runtimeEnvs.length > 0 && runtimeEnvs.every((value) => value === 'development' || value === 'test');
101
100
  }
101
+ function unsetRuntimeEnvPasswordGuidance(runtimeEnvs) {
102
+ if (runtimeEnvs.length > 0) {
103
+ return '';
104
+ }
105
+ return ('\n\nBoth RAILS_ENV and NODE_ENV are unset. For a local Rails development shell, either set them explicitly:\n\n' +
106
+ ' export RAILS_ENV=development NODE_ENV=development\n\n' +
107
+ 'or configure RENDERER_PASSWORD. Deployed/shared environments should set explicit envs and RENDERER_PASSWORD.');
108
+ }
102
109
  // Intentionally checks only NODE_ENV, not both NODE_ENV and RAILS_ENV like
103
110
  // runtimeEnvsAllowDevelopmentDefaults(). Async operation log replay is a JS
104
111
  // debugging concern, not a security boundary — it should key off the JS
@@ -204,24 +211,25 @@ function logSanitizedConfig() {
204
211
  const KNOWN_WEAK_PASSWORDS = new Set(['devPassword', 'myPassword1', 'password', 'changeme', 'admin', 'secret', 'test', 'renderer'].map((p) => p.toLowerCase()));
205
212
  const MIN_PASSWORD_LENGTH = 16;
206
213
  function validatePasswordForProduction(aConfig) {
207
- const isProductionLike = !runtimeEnvsAllowDevelopmentDefaults();
214
+ const runtimeEnvs = normalizedRuntimeEnvs();
215
+ const isProductionLike = !runtimeEnvsAllowDevelopmentDefaults(runtimeEnvs);
208
216
  if (!aConfig.password || aConfig.password.trim() === '') {
209
217
  if (isProductionLike) {
210
- return ('RENDERER_PASSWORD must be set in production-like environments ' +
218
+ return (`RENDERER_PASSWORD must be set in production-like environments ` +
211
219
  `(NODE_ENV: "${env.NODE_ENV ?? '(not set)'}", RAILS_ENV: "${env.RAILS_ENV ?? '(not set)'}").` +
212
- '\n\n' +
213
- 'In development and test environments, the renderer password is optional and no authentication\n' +
214
- 'is required. In all other environments, you must explicitly configure a password to secure\n' +
215
- 'communication between Rails and the Node Renderer.\n\n' +
216
- 'To fix this, set the RENDERER_PASSWORD environment variable:\n\n' +
217
- ' export RENDERER_PASSWORD="your-secure-password"\n\n' +
218
- 'Or pass it in the config object:\n\n' +
219
- ' reactOnRailsProNodeRenderer({ password: process.env.RENDERER_PASSWORD });\n\n' +
220
- 'Environment matrix:\n' +
221
- ' development — password optional (no authentication)\n' +
222
- ' test — password optional (no authentication)\n' +
223
- ' (neither set) — treated as production-like; RENDERER_PASSWORD required\n' +
224
- ' all other environments (staging, production, qa, preview, etc.) — RENDERER_PASSWORD required');
220
+ `\n\n` +
221
+ `In development and test environments, the renderer password is optional and no authentication\n` +
222
+ `is required. In all other environments, you must explicitly configure a password to secure\n` +
223
+ `communication between Rails and the Node Renderer.${unsetRuntimeEnvPasswordGuidance(runtimeEnvs)}\n\n` +
224
+ `To secure the renderer, set the RENDERER_PASSWORD environment variable:\n\n` +
225
+ ` export RENDERER_PASSWORD="your-secure-password"\n\n` +
226
+ `Or pass it in the config object:\n\n` +
227
+ ` reactOnRailsProNodeRenderer({ password: process.env.RENDERER_PASSWORD });\n\n` +
228
+ `Environment matrix:\n` +
229
+ ` development — password optional (no authentication)\n` +
230
+ ` test — password optional (no authentication)\n` +
231
+ ` (both unset) — treated as production-like; RENDERER_PASSWORD required\n` +
232
+ ` all other environments (staging, production, qa, preview, etc.) — RENDERER_PASSWORD required`);
225
233
  }
226
234
  return null;
227
235
  }
@@ -57,6 +57,7 @@ const licensePublicKey_js_1 = require("./licensePublicKey.js");
57
57
  * Valid license plan types.
58
58
  * Must match VALID_PLANS in react_on_rails_pro/lib/react_on_rails_pro/license_validator.rb
59
59
  */
60
+ // MIRROR VALUES OF: react_on_rails_pro/lib/react_on_rails_pro/license_validator.rb
60
61
  const VALID_PLANS = ['paid', 'startup', 'nonprofit', 'education', 'oss', 'partner'];
61
62
  // Module-level state for caching license validation results.
62
63
  //
@@ -1,15 +1,12 @@
1
1
  export declare const RSC_PEER_SUPPORT: {
2
2
  readonly reactOnRailsRsc: {
3
- readonly recommendedMin: "19.0.2";
3
+ readonly minimumVersion: "19.2.1";
4
+ readonly minimumPrereleaseVersion: "19.2.1-rc.0";
4
5
  readonly supportedMajor: 19;
5
6
  };
6
7
  readonly react: {
7
8
  readonly supportedMajor: 19;
8
9
  readonly supportedRanges: readonly [{
9
- readonly rscMinor: 0;
10
- readonly minor: 0;
11
- readonly minPatch: 4;
12
- }, {
13
10
  readonly rscMinor: 2;
14
11
  readonly minor: 2;
15
12
  readonly minPatch: 7;
@@ -15,21 +15,21 @@
15
15
  */
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.RSC_PEER_SUPPORT = void 0;
18
- // Single source of truth for react-on-rails-pro's RSC peer compatibility window.
19
- // This is the only value to bump when compatibility changes.
18
+ // Node-renderer source of truth for react-on-rails-pro's RSC peer compatibility window.
19
+ // Ruby Doctor mirrors these values and has a parity spec to catch cross-language drift.
20
20
  //
21
- // `recommendedMin` deliberately starts at the stable floor we currently recommend.
22
- // Raise it to the stable `react-on-rails-rsc` release (expected 19.0.5) once
23
- // 19.0.5-rc.7 is promoted, which activates the warn tier for anyone still on an older
24
- // 19.x build.
25
- // Bump tracked by https://github.com/shakacode/react_on_rails/issues/3632
26
- // (the stable 19.0.5 ship/pin is tracked by issue #3634).
21
+ // `minimumVersion` is the React on Rails 17 RSC floor. Keep it in sync with the
22
+ // Ruby Doctor/generator constants that install and diagnose the same Pro RSC
23
+ // package line. The 19.2.1 line pairs with React/React DOM 19.2.7 and carries
24
+ // the coordinated RSC fixes required by the Pro RSC renderer path.
25
+ // `minimumPrereleaseVersion` keeps the 17.0 RC soak installable until the stable
26
+ // 19.2.1 package is published without accepting older prereleases on the same
27
+ // tuple. When it is set, keep its core tuple equal to `minimumVersion`.
27
28
  exports.RSC_PEER_SUPPORT = {
28
- reactOnRailsRsc: { recommendedMin: '19.0.2', supportedMajor: 19 },
29
+ reactOnRailsRsc: { minimumVersion: '19.2.1', minimumPrereleaseVersion: '19.2.1-rc.0', supportedMajor: 19 },
29
30
  react: {
30
31
  supportedMajor: 19,
31
32
  supportedRanges: [
32
- { rscMinor: 0, minor: 0, minPatch: 4 },
33
33
  // React 19.2.7 is the coordinated floor for react-on-rails-rsc 19.2.x.
34
34
  { rscMinor: 2, minor: 2, minPatch: 7 },
35
35
  ],
@@ -106,11 +106,6 @@ function runRscPeerCompatibilityCheck(options = {}) {
106
106
  });
107
107
  if (result.level === 'ok')
108
108
  return;
109
- if (result.level === 'warn') {
110
- // DISABLE_ENV only downgrades hard errors; warnings still fire so operators see the degraded-version signal.
111
- log_js_1.default.warn(result.message);
112
- return;
113
- }
114
109
  if (env[DISABLE_ENV] === '1') {
115
110
  log_js_1.default.warn(`${result.message}\n(Version check downgraded to a warning via ${DISABLE_ENV}.)`);
116
111
  return;
@@ -41,6 +41,9 @@ function replayConsoleOnRenderer(consoleHistory) {
41
41
  // AsyncLocalStorage is available in Node.js 12.17.0 and later versions
42
42
  const canUseAsyncLocalStorage = () => typeof async_hooks_1.AsyncLocalStorage !== 'undefined' && (0, configBuilder_js_1.getConfig)().replayServerAsyncOperationLogs;
43
43
  class SharedConsoleHistory {
44
+ asyncLocalStorageIfEnabled;
45
+ isRunningSyncOperation;
46
+ syncHistory;
44
47
  constructor() {
45
48
  if (canUseAsyncLocalStorage()) {
46
49
  this.asyncLocalStorageIfEnabled = new async_hooks_1.AsyncLocalStorage();
@@ -6,6 +6,7 @@ import type { RenderResult } from '../worker/vm.js';
6
6
  import { remapStackTrace } from '../worker/vmSourceMapSupport.js';
7
7
  export declare const TRUNCATION_FILLER = "\n... TRUNCATED ...\n";
8
8
  export declare const SHUTDOWN_WORKER_MESSAGE = "NODE_RENDERER_SHUTDOWN_WORKER";
9
+ export declare const SHUTDOWN_WORKER_ACK_MESSAGE = "NODE_RENDERER_SHUTDOWN_WORKER_ACK";
9
10
  export declare function workerIdLabel(): number | "NO WORKER ID";
10
11
  export declare function smartTrim(value: unknown, maxLength?: number): string | null;
11
12
  export interface ResponseResult {
@@ -35,6 +36,7 @@ export type RequestInfo = {
35
36
  * registration-scoped remapper to avoid rewriting unrelated bundle paths.
36
37
  */
37
38
  export declare function formatExceptionMessage(request: RequestInfo, error: unknown, context?: string, stackRemapper?: typeof remapStackTrace): string;
39
+ export declare function validateAssetFilename(filename: unknown): string;
38
40
  export declare function saveMultipartFile(multipartFile: MultipartFile, destinationPath: string): Promise<void>;
39
41
  export interface Asset {
40
42
  type: 'asset';