react-on-rails-pro-node-renderer 16.5.0 → 16.6.0-rc.0
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/configBuilder.d.ts +3 -1
- package/lib/shared/configBuilder.d.ts.map +1 -1
- package/lib/shared/configBuilder.js +83 -8
- package/lib/shared/configBuilder.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/worker/handleRenderRequest.d.ts +1 -0
- package/lib/worker/handleRenderRequest.d.ts.map +1 -1
- package/lib/worker/handleRenderRequest.js +13 -9
- package/lib/worker/handleRenderRequest.js.map +1 -1
- package/lib/worker.d.ts.map +1 -1
- package/lib/worker.js +57 -70
- package/lib/worker.js.map +1 -1
- package/package.json +3 -3
- package/src/shared/configBuilder.ts +100 -11
- package/src/worker/handleRenderRequest.ts +14 -11
- package/src/worker.ts +70 -85
- package/tests/configBuilder.test.ts +323 -8
- package/tests/uploadRaceCondition.test.ts +9 -9
- package/tests/worker.test.ts +27 -2
package/src/worker.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import cluster from 'cluster';
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
|
-
import {
|
|
9
|
+
import { rm } from 'fs/promises';
|
|
10
10
|
import fastify from 'fastify';
|
|
11
11
|
import fastifyFormbody from '@fastify/formbody';
|
|
12
12
|
import fastifyMultipart from '@fastify/multipart';
|
|
@@ -17,21 +17,20 @@ import fileExistsAsync from './shared/fileExistsAsync.js';
|
|
|
17
17
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from './worker/types.js';
|
|
18
18
|
import checkProtocolVersion from './worker/checkProtocolVersionHandler.js';
|
|
19
19
|
import authenticate from './worker/authHandler.js';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
handleRenderRequest,
|
|
22
|
+
handleNewBundlesProvided,
|
|
23
|
+
type ProvidedNewBundle,
|
|
24
|
+
} from './worker/handleRenderRequest.js';
|
|
21
25
|
import handleGracefulShutdown from './worker/handleGracefulShutdown.js';
|
|
22
26
|
import {
|
|
23
27
|
errorResponseResult,
|
|
24
28
|
formatExceptionMessage,
|
|
25
|
-
copyUploadedAssets,
|
|
26
29
|
ResponseResult,
|
|
27
|
-
workerIdLabel,
|
|
28
30
|
saveMultipartFile,
|
|
29
31
|
Asset,
|
|
30
32
|
getAssetPath,
|
|
31
|
-
getBundleDirectory,
|
|
32
|
-
getRequestBundleFilePath,
|
|
33
33
|
} from './shared/utils.js';
|
|
34
|
-
import { lock, unlock } from './shared/locks.js';
|
|
35
34
|
import { startSsrRequestOptions, trace } from './shared/tracing.js';
|
|
36
35
|
|
|
37
36
|
// Uncomment the below for testing timeouts:
|
|
@@ -96,6 +95,42 @@ function assertAsset(value: unknown, key: string): asserts value is Asset {
|
|
|
96
95
|
}
|
|
97
96
|
}
|
|
98
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Parses the multipart form body to separate bundle files from shared assets.
|
|
100
|
+
* Used by both the render and /upload-assets endpoints to avoid duplicating
|
|
101
|
+
* bundle-vs-asset classification logic.
|
|
102
|
+
*
|
|
103
|
+
* @param body The parsed multipart request body.
|
|
104
|
+
* @param primaryBundleTimestamp If provided, a field with key `"bundle"` is
|
|
105
|
+
* treated as a bundle for this timestamp (render endpoint convention).
|
|
106
|
+
*/
|
|
107
|
+
function extractBundlesAndAssets(
|
|
108
|
+
body: Record<string, unknown>,
|
|
109
|
+
primaryBundleTimestamp?: string | number,
|
|
110
|
+
): { providedNewBundles: ProvidedNewBundle[]; assetsToCopy: Asset[] } {
|
|
111
|
+
const providedNewBundles: ProvidedNewBundle[] = [];
|
|
112
|
+
const assetsToCopy: Asset[] = [];
|
|
113
|
+
Object.entries(body).forEach(([key, value]) => {
|
|
114
|
+
if (key === 'bundle' && primaryBundleTimestamp != null) {
|
|
115
|
+
assertAsset(value, key);
|
|
116
|
+
providedNewBundles.push({ timestamp: primaryBundleTimestamp, bundle: value });
|
|
117
|
+
} else if (key.startsWith('bundle_')) {
|
|
118
|
+
const timestamp = key.slice('bundle_'.length);
|
|
119
|
+
if (!timestamp) {
|
|
120
|
+
log.warn(
|
|
121
|
+
'Received form field with key "bundle_" but no hash suffix — possible bug in the Ruby client',
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
assertAsset(value, key);
|
|
125
|
+
providedNewBundles.push({ timestamp, bundle: value });
|
|
126
|
+
}
|
|
127
|
+
} else if (isAsset(value)) {
|
|
128
|
+
assetsToCopy.push(value);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return { providedNewBundles, assetsToCopy };
|
|
132
|
+
}
|
|
133
|
+
|
|
99
134
|
// Remove after this issue is resolved: https://github.com/fastify/light-my-request/issues/315
|
|
100
135
|
let useHttp2 = true;
|
|
101
136
|
|
|
@@ -127,6 +162,9 @@ export default function run(config: Partial<Config>) {
|
|
|
127
162
|
|
|
128
163
|
const { serverBundleCachePath, logHttpLevel, port, host, fastifyServerOptions, workersCount } = getConfig();
|
|
129
164
|
|
|
165
|
+
// The renderer uses cleartext HTTP/2 (h2c). Node's `allowHTTP1` option only
|
|
166
|
+
// applies to TLS servers (http2.createSecureServer), so it cannot enable
|
|
167
|
+
// HTTP/1.1 Kubernetes httpGet probes on this listener.
|
|
130
168
|
const app = fastify({
|
|
131
169
|
http2: useHttp2 as true,
|
|
132
170
|
bodyLimit: 104857600, // 100 MB
|
|
@@ -271,19 +309,7 @@ export default function run(config: Partial<Config>) {
|
|
|
271
309
|
|
|
272
310
|
const { renderingRequest } = req.body;
|
|
273
311
|
const { bundleTimestamp } = req.params;
|
|
274
|
-
const providedNewBundles
|
|
275
|
-
const assetsToCopy: Asset[] = [];
|
|
276
|
-
Object.entries(req.body).forEach(([key, value]) => {
|
|
277
|
-
if (key === 'bundle') {
|
|
278
|
-
assertAsset(value, key);
|
|
279
|
-
providedNewBundles.push({ timestamp: bundleTimestamp, bundle: value });
|
|
280
|
-
} else if (key.startsWith('bundle_')) {
|
|
281
|
-
assertAsset(value, key);
|
|
282
|
-
providedNewBundles.push({ timestamp: key.replace('bundle_', ''), bundle: value });
|
|
283
|
-
} else if (isAsset(value)) {
|
|
284
|
-
assetsToCopy.push(value);
|
|
285
|
-
}
|
|
286
|
-
});
|
|
312
|
+
const { providedNewBundles, assetsToCopy } = extractBundlesAndAssets(req.body, bundleTimestamp);
|
|
287
313
|
|
|
288
314
|
try {
|
|
289
315
|
const dependencyBundleTimestamps = extractBodyArrayField(req.body, 'dependencyBundleTimestamps');
|
|
@@ -315,88 +341,47 @@ export default function run(config: Partial<Config>) {
|
|
|
315
341
|
|
|
316
342
|
// There can be additional files that might be required at the runtime.
|
|
317
343
|
// Since the remote renderer doesn't contain any assets, they must be uploaded manually.
|
|
344
|
+
// Bundle files use the form key convention "bundle_<hash>" and are placed in
|
|
345
|
+
// their own directory; remaining assets are copied to every bundle directory.
|
|
318
346
|
app.post<{
|
|
319
|
-
Body:
|
|
347
|
+
Body: Record<string, unknown>;
|
|
320
348
|
}>('/upload-assets', async (req, res) => {
|
|
321
349
|
if (!(await requestPrechecks(req, res))) {
|
|
322
350
|
return;
|
|
323
351
|
}
|
|
324
|
-
const assets: Asset[] = Object.values(req.body).filter(isAsset);
|
|
325
352
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (
|
|
329
|
-
const errorMsg =
|
|
353
|
+
const { providedNewBundles, assetsToCopy } = extractBundlesAndAssets(req.body);
|
|
354
|
+
|
|
355
|
+
if (providedNewBundles.length === 0) {
|
|
356
|
+
const errorMsg =
|
|
357
|
+
'No bundle_<hash> fields provided. ' +
|
|
358
|
+
'The /upload-assets endpoint requires at least one bundle file with a "bundle_<hash>" form key.';
|
|
330
359
|
log.error(errorMsg);
|
|
331
360
|
await setResponse(errorResponseResult(errorMsg), res);
|
|
332
361
|
return;
|
|
333
362
|
}
|
|
334
363
|
|
|
335
|
-
const
|
|
336
|
-
const
|
|
337
|
-
|
|
364
|
+
const bundleNames = providedNewBundles.map((b) => b.bundle.filename);
|
|
365
|
+
const assetNames = assetsToCopy.map((a) => a.filename);
|
|
366
|
+
const taskDescription = `Uploading bundles [${bundleNames.join(', ')}] with assets [${assetNames.join(', ')}]`;
|
|
338
367
|
log.info(taskDescription);
|
|
339
|
-
try {
|
|
340
|
-
// Use per-bundle locks (same lock key as handleRenderRequest) so that
|
|
341
|
-
// asset copies and render-request bundle writes to the same directory
|
|
342
|
-
// are mutually exclusive. See https://github.com/shakacode/react_on_rails/issues/2463
|
|
343
|
-
//
|
|
344
|
-
// Use allSettled (not Promise.all) to ensure every in-flight copy
|
|
345
|
-
// finishes before the handler returns. Otherwise the onResponse hook
|
|
346
|
-
// can delete req.uploadDir while background copies still read from it.
|
|
347
|
-
const copyPromises = targetBundles.map(async (bundleTimestamp) => {
|
|
348
|
-
const bundleDirectory = getBundleDirectory(bundleTimestamp);
|
|
349
|
-
await mkdir(bundleDirectory, { recursive: true });
|
|
350
|
-
|
|
351
|
-
const bundleFilePath = getRequestBundleFilePath(bundleTimestamp);
|
|
352
|
-
const { lockfileName, wasLockAcquired, errorMessage } = await lock(bundleFilePath);
|
|
353
|
-
|
|
354
|
-
if (!wasLockAcquired) {
|
|
355
|
-
const msg = formatExceptionMessage(
|
|
356
|
-
taskDescription,
|
|
357
|
-
errorMessage,
|
|
358
|
-
`Failed to acquire lock ${lockfileName}. Worker: ${workerIdLabel()}.`,
|
|
359
|
-
);
|
|
360
|
-
throw new Error(msg);
|
|
361
|
-
}
|
|
362
368
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
err: error,
|
|
373
|
-
task: taskDescription,
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
const results = await Promise.allSettled(copyPromises);
|
|
380
|
-
const firstFailure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected');
|
|
381
|
-
if (firstFailure) {
|
|
382
|
-
throw firstFailure.reason;
|
|
369
|
+
try {
|
|
370
|
+
// Reuses the same per-bundle lock + move/copy logic as the render
|
|
371
|
+
// endpoint so that concurrent /upload-assets and render requests
|
|
372
|
+
// targeting the same bundle directory are mutually exclusive.
|
|
373
|
+
// See https://github.com/shakacode/react_on_rails/issues/2463
|
|
374
|
+
const result = await handleNewBundlesProvided(taskDescription, providedNewBundles, assetsToCopy);
|
|
375
|
+
if (result) {
|
|
376
|
+
await setResponse(result, res);
|
|
377
|
+
return;
|
|
383
378
|
}
|
|
384
379
|
|
|
385
|
-
await setResponse(
|
|
386
|
-
{
|
|
387
|
-
status: 200,
|
|
388
|
-
headers: {},
|
|
389
|
-
},
|
|
390
|
-
res,
|
|
391
|
-
);
|
|
380
|
+
await setResponse({ status: 200, headers: {} }, res);
|
|
392
381
|
} catch (err) {
|
|
393
|
-
const msg = 'ERROR when trying to
|
|
382
|
+
const msg = 'ERROR when trying to upload bundles and assets';
|
|
394
383
|
const message = `${msg}. ${err}. Task: ${taskDescription}`;
|
|
395
|
-
log.error({
|
|
396
|
-
msg,
|
|
397
|
-
err,
|
|
398
|
-
task: taskDescription,
|
|
399
|
-
});
|
|
384
|
+
log.error({ msg, err, task: taskDescription });
|
|
400
385
|
await setResponse(errorResponseResult(message), res);
|
|
401
386
|
}
|
|
402
387
|
});
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
describe('configBuilder', () => {
|
|
2
|
-
const
|
|
2
|
+
const envVarsToRestore = [
|
|
3
|
+
'RENDERER_HOST',
|
|
4
|
+
'NODE_ENV',
|
|
5
|
+
'RENDERER_PASSWORD',
|
|
6
|
+
'RAILS_ENV',
|
|
7
|
+
'REPLAY_SERVER_ASYNC_OPERATION_LOGS',
|
|
8
|
+
'RENDERER_WORKERS_COUNT',
|
|
9
|
+
] as const;
|
|
10
|
+
const savedEnvValues = Object.fromEntries(envVarsToRestore.map((key) => [key, process.env[key]]));
|
|
3
11
|
|
|
4
12
|
afterEach(() => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
13
|
+
for (const key of envVarsToRestore) {
|
|
14
|
+
if (savedEnvValues[key] === undefined) {
|
|
15
|
+
delete process.env[key];
|
|
16
|
+
} else {
|
|
17
|
+
process.env[key] = savedEnvValues[key];
|
|
18
|
+
}
|
|
9
19
|
}
|
|
10
20
|
jest.restoreAllMocks();
|
|
11
21
|
jest.resetModules();
|
|
@@ -13,17 +23,25 @@ describe('configBuilder', () => {
|
|
|
13
23
|
|
|
14
24
|
function loadConfigBuilderWithMockedLogger() {
|
|
15
25
|
const info = jest.fn();
|
|
26
|
+
const error = jest.fn();
|
|
27
|
+
const warn = jest.fn();
|
|
16
28
|
jest.doMock('../src/shared/log', () => ({
|
|
17
29
|
__esModule: true,
|
|
18
30
|
default: {
|
|
19
31
|
info,
|
|
20
|
-
error
|
|
21
|
-
warn
|
|
32
|
+
error,
|
|
33
|
+
warn,
|
|
22
34
|
fatal: jest.fn(),
|
|
23
35
|
},
|
|
24
36
|
}));
|
|
25
37
|
const { buildConfig, logSanitizedConfig } = jest.requireActual('../src/shared/configBuilder');
|
|
26
|
-
return { buildConfig, logSanitizedConfig, info };
|
|
38
|
+
return { buildConfig, logSanitizedConfig, info, error, warn };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mockProcessExit() {
|
|
42
|
+
return jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
43
|
+
throw new Error(`process.exit: ${code ?? 0}`);
|
|
44
|
+
}) as never);
|
|
27
45
|
}
|
|
28
46
|
|
|
29
47
|
function envValuesUsedForRenderedConfig(userConfig: { host?: string }) {
|
|
@@ -51,4 +69,301 @@ describe('configBuilder', () => {
|
|
|
51
69
|
|
|
52
70
|
expect(envValues.RENDERER_HOST).toBe(false);
|
|
53
71
|
});
|
|
72
|
+
|
|
73
|
+
it('does not mark RENDERER_PASSWORD as env-provided when password is explicitly overridden', () => {
|
|
74
|
+
process.env.RENDERER_PASSWORD = 'env-password';
|
|
75
|
+
const { buildConfig, logSanitizedConfig, info } = loadConfigBuilderWithMockedLogger();
|
|
76
|
+
|
|
77
|
+
buildConfig({ password: '' });
|
|
78
|
+
logSanitizedConfig();
|
|
79
|
+
|
|
80
|
+
const logPayload = info.mock.calls[0][0] as Record<string, unknown>;
|
|
81
|
+
const envValues = logPayload['ENV values used for settings (use "RENDERER_" prefix)'] as Record<
|
|
82
|
+
string,
|
|
83
|
+
unknown
|
|
84
|
+
>;
|
|
85
|
+
|
|
86
|
+
expect(envValues.RENDERER_PASSWORD).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('masks module-load password defaults in sanitized logs', () => {
|
|
90
|
+
process.env.RENDERER_PASSWORD = 'env-password';
|
|
91
|
+
const { buildConfig, logSanitizedConfig, info } = loadConfigBuilderWithMockedLogger();
|
|
92
|
+
|
|
93
|
+
buildConfig();
|
|
94
|
+
logSanitizedConfig();
|
|
95
|
+
|
|
96
|
+
const logPayload = info.mock.calls[0][0] as Record<string, unknown>;
|
|
97
|
+
const defaultSettings = logPayload[
|
|
98
|
+
'Default settings at module load (env-backed values may lag current runtime)'
|
|
99
|
+
] as Record<string, unknown>;
|
|
100
|
+
|
|
101
|
+
expect(defaultSettings.password).toBe('<MASKED>');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('labels an empty-string password override explicitly in sanitized logs', () => {
|
|
105
|
+
const { buildConfig, logSanitizedConfig, info } = loadConfigBuilderWithMockedLogger();
|
|
106
|
+
|
|
107
|
+
buildConfig({ password: '' });
|
|
108
|
+
logSanitizedConfig();
|
|
109
|
+
|
|
110
|
+
const logPayload = info.mock.calls[0][0] as Record<string, unknown>;
|
|
111
|
+
const finalSettings = logPayload['Final renderer settings'] as Record<string, unknown>;
|
|
112
|
+
|
|
113
|
+
expect(finalSettings.password).toBe('<EMPTY STRING>');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('password validation in production-like environments', () => {
|
|
117
|
+
it('throws when no password is set in production', () => {
|
|
118
|
+
process.env.NODE_ENV = 'production';
|
|
119
|
+
delete process.env.RENDERER_PASSWORD;
|
|
120
|
+
const processExit = mockProcessExit();
|
|
121
|
+
|
|
122
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
123
|
+
|
|
124
|
+
expect(() => buildConfig()).toThrow('process.exit: 1');
|
|
125
|
+
expect(processExit).toHaveBeenCalledWith(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('throws when no password is set in staging', () => {
|
|
129
|
+
process.env.NODE_ENV = 'staging';
|
|
130
|
+
delete process.env.RENDERER_PASSWORD;
|
|
131
|
+
const processExit = mockProcessExit();
|
|
132
|
+
|
|
133
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
134
|
+
|
|
135
|
+
expect(() => buildConfig()).toThrow('process.exit: 1');
|
|
136
|
+
expect(processExit).toHaveBeenCalledWith(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('does not throw when password is set via env in production', () => {
|
|
140
|
+
process.env.NODE_ENV = 'production';
|
|
141
|
+
process.env.RENDERER_PASSWORD = 'secure-password';
|
|
142
|
+
|
|
143
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
144
|
+
|
|
145
|
+
expect(() => buildConfig()).not.toThrow();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does not throw when password is set via config in production', () => {
|
|
149
|
+
process.env.NODE_ENV = 'production';
|
|
150
|
+
delete process.env.RENDERER_PASSWORD;
|
|
151
|
+
|
|
152
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
153
|
+
|
|
154
|
+
expect(() => buildConfig({ password: 'secure-password' })).not.toThrow();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('does not throw in development without a password', () => {
|
|
158
|
+
process.env.NODE_ENV = 'development';
|
|
159
|
+
delete process.env.RENDERER_PASSWORD;
|
|
160
|
+
|
|
161
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
162
|
+
|
|
163
|
+
expect(() => buildConfig()).not.toThrow();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('does not throw in test without a password', () => {
|
|
167
|
+
process.env.NODE_ENV = 'test';
|
|
168
|
+
delete process.env.RENDERER_PASSWORD;
|
|
169
|
+
|
|
170
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
171
|
+
|
|
172
|
+
expect(() => buildConfig()).not.toThrow();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('throws when RAILS_ENV is production even if NODE_ENV is development', () => {
|
|
176
|
+
process.env.NODE_ENV = 'development';
|
|
177
|
+
process.env.RAILS_ENV = 'production';
|
|
178
|
+
delete process.env.RENDERER_PASSWORD;
|
|
179
|
+
const processExit = mockProcessExit();
|
|
180
|
+
|
|
181
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
182
|
+
|
|
183
|
+
expect(() => buildConfig()).toThrow('process.exit: 1');
|
|
184
|
+
expect(processExit).toHaveBeenCalledWith(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('throws when NODE_ENV is production even if RAILS_ENV is development', () => {
|
|
188
|
+
process.env.NODE_ENV = 'production';
|
|
189
|
+
process.env.RAILS_ENV = 'development';
|
|
190
|
+
delete process.env.RENDERER_PASSWORD;
|
|
191
|
+
const processExit = mockProcessExit();
|
|
192
|
+
|
|
193
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
194
|
+
|
|
195
|
+
expect(() => buildConfig()).toThrow('process.exit: 1');
|
|
196
|
+
expect(processExit).toHaveBeenCalledWith(1);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('throws when RAILS_ENV is production and NODE_ENV is unset', () => {
|
|
200
|
+
delete process.env.NODE_ENV;
|
|
201
|
+
process.env.RAILS_ENV = 'production';
|
|
202
|
+
delete process.env.RENDERER_PASSWORD;
|
|
203
|
+
const processExit = mockProcessExit();
|
|
204
|
+
|
|
205
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
206
|
+
|
|
207
|
+
expect(() => buildConfig()).toThrow('process.exit: 1');
|
|
208
|
+
expect(processExit).toHaveBeenCalledWith(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('throws when NODE_ENV is staging even if RAILS_ENV is development', () => {
|
|
212
|
+
process.env.NODE_ENV = 'staging';
|
|
213
|
+
process.env.RAILS_ENV = 'development';
|
|
214
|
+
delete process.env.RENDERER_PASSWORD;
|
|
215
|
+
const processExit = mockProcessExit();
|
|
216
|
+
|
|
217
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
218
|
+
|
|
219
|
+
expect(() => buildConfig()).toThrow('process.exit: 1');
|
|
220
|
+
expect(processExit).toHaveBeenCalledWith(1);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('throws when RAILS_ENV is production even if NODE_ENV is test', () => {
|
|
224
|
+
process.env.NODE_ENV = 'test';
|
|
225
|
+
process.env.RAILS_ENV = 'production';
|
|
226
|
+
delete process.env.RENDERER_PASSWORD;
|
|
227
|
+
const processExit = mockProcessExit();
|
|
228
|
+
|
|
229
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
230
|
+
|
|
231
|
+
expect(() => buildConfig()).toThrow('process.exit: 1');
|
|
232
|
+
expect(processExit).toHaveBeenCalledWith(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('does not throw when RAILS_ENV is development and NODE_ENV is development', () => {
|
|
236
|
+
process.env.NODE_ENV = 'development';
|
|
237
|
+
process.env.RAILS_ENV = 'development';
|
|
238
|
+
delete process.env.RENDERER_PASSWORD;
|
|
239
|
+
|
|
240
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
241
|
+
|
|
242
|
+
expect(() => buildConfig()).not.toThrow();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('does not throw when NODE_ENV uses mixed-case development value', () => {
|
|
246
|
+
process.env.NODE_ENV = 'Development';
|
|
247
|
+
process.env.RAILS_ENV = 'development';
|
|
248
|
+
delete process.env.RENDERER_PASSWORD;
|
|
249
|
+
|
|
250
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
251
|
+
|
|
252
|
+
expect(() => buildConfig()).not.toThrow();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('does not throw when only RAILS_ENV is development and NODE_ENV is unset', () => {
|
|
256
|
+
delete process.env.NODE_ENV;
|
|
257
|
+
process.env.RAILS_ENV = 'development';
|
|
258
|
+
delete process.env.RENDERER_PASSWORD;
|
|
259
|
+
|
|
260
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
261
|
+
|
|
262
|
+
expect(() => buildConfig()).not.toThrow();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('throws when neither NODE_ENV nor RAILS_ENV is set (fail-closed)', () => {
|
|
266
|
+
delete process.env.NODE_ENV;
|
|
267
|
+
delete process.env.RAILS_ENV;
|
|
268
|
+
delete process.env.RENDERER_PASSWORD;
|
|
269
|
+
const processExit = mockProcessExit();
|
|
270
|
+
|
|
271
|
+
const { buildConfig, error } = loadConfigBuilderWithMockedLogger();
|
|
272
|
+
|
|
273
|
+
expect(() => buildConfig()).toThrow('process.exit: 1');
|
|
274
|
+
expect(processExit).toHaveBeenCalledWith(1);
|
|
275
|
+
expect(error).toHaveBeenCalledWith(
|
|
276
|
+
expect.stringContaining('(neither set) — treated as production-like; RENDERER_PASSWORD required'),
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('does not throw when password is set after module import', () => {
|
|
281
|
+
process.env.NODE_ENV = 'production';
|
|
282
|
+
delete process.env.RENDERER_PASSWORD;
|
|
283
|
+
|
|
284
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
285
|
+
|
|
286
|
+
process.env.RENDERER_PASSWORD = 'late-loaded-password';
|
|
287
|
+
|
|
288
|
+
expect(() => buildConfig()).not.toThrow();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('does not treat undefined user password as override when env password exists', () => {
|
|
292
|
+
process.env.NODE_ENV = 'production';
|
|
293
|
+
process.env.RENDERER_PASSWORD = 'late-loaded-password';
|
|
294
|
+
|
|
295
|
+
const { buildConfig, warn } = loadConfigBuilderWithMockedLogger();
|
|
296
|
+
|
|
297
|
+
expect(() => buildConfig({ password: undefined })).not.toThrow();
|
|
298
|
+
expect(warn).toHaveBeenCalledWith(
|
|
299
|
+
expect.stringContaining('buildConfig({ password: undefined }) preserves the env/default password'),
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('does not warn about undefined password in development environments', () => {
|
|
304
|
+
process.env.NODE_ENV = 'development';
|
|
305
|
+
process.env.RENDERER_PASSWORD = 'dev-password';
|
|
306
|
+
|
|
307
|
+
const { buildConfig, warn } = loadConfigBuilderWithMockedLogger();
|
|
308
|
+
|
|
309
|
+
buildConfig({ password: undefined });
|
|
310
|
+
expect(warn).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('keeps normal spread semantics for non-password undefined overrides', () => {
|
|
314
|
+
process.env.NODE_ENV = 'production';
|
|
315
|
+
process.env.RENDERER_PASSWORD = 'late-loaded-password';
|
|
316
|
+
process.env.RENDERER_WORKERS_COUNT = '7';
|
|
317
|
+
|
|
318
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
319
|
+
|
|
320
|
+
expect(buildConfig({ workersCount: undefined }).workersCount).toBeUndefined();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('replayServerAsyncOperationLogs defaults', () => {
|
|
325
|
+
it('defaults to true when NODE_ENV is development', () => {
|
|
326
|
+
process.env.NODE_ENV = 'development';
|
|
327
|
+
delete process.env.RAILS_ENV;
|
|
328
|
+
delete process.env.REPLAY_SERVER_ASYNC_OPERATION_LOGS;
|
|
329
|
+
|
|
330
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
331
|
+
const config = buildConfig();
|
|
332
|
+
|
|
333
|
+
expect(config.replayServerAsyncOperationLogs).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('defaults to true when NODE_ENV is development even if RAILS_ENV is production', () => {
|
|
337
|
+
process.env.NODE_ENV = 'development';
|
|
338
|
+
process.env.RAILS_ENV = 'production';
|
|
339
|
+
delete process.env.REPLAY_SERVER_ASYNC_OPERATION_LOGS;
|
|
340
|
+
|
|
341
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
342
|
+
const config = buildConfig({ password: 'secure-password' });
|
|
343
|
+
|
|
344
|
+
expect(config.replayServerAsyncOperationLogs).toBe(true);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('defaults to false in test when no explicit override is provided', () => {
|
|
348
|
+
process.env.NODE_ENV = 'test';
|
|
349
|
+
delete process.env.RAILS_ENV;
|
|
350
|
+
delete process.env.REPLAY_SERVER_ASYNC_OPERATION_LOGS;
|
|
351
|
+
|
|
352
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
353
|
+
const config = buildConfig();
|
|
354
|
+
|
|
355
|
+
expect(config.replayServerAsyncOperationLogs).toBe(false);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('treats mixed-case NODE_ENV development values as development', () => {
|
|
359
|
+
process.env.NODE_ENV = 'Development';
|
|
360
|
+
delete process.env.RAILS_ENV;
|
|
361
|
+
delete process.env.REPLAY_SERVER_ASYNC_OPERATION_LOGS;
|
|
362
|
+
|
|
363
|
+
const { buildConfig } = loadConfigBuilderWithMockedLogger();
|
|
364
|
+
const config = buildConfig();
|
|
365
|
+
|
|
366
|
+
expect(config.replayServerAsyncOperationLogs).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
54
369
|
});
|
|
@@ -23,7 +23,7 @@ import formAutoContent from 'form-auto-content';
|
|
|
23
23
|
// eslint-disable-next-line import/no-relative-packages
|
|
24
24
|
import packageJson from '../package.json';
|
|
25
25
|
import worker, { disableHttp2 } from '../src/worker';
|
|
26
|
-
import { resetForTest, serverBundleCachePath, getFixtureBundle } from './helper';
|
|
26
|
+
import { resetForTest, serverBundleCachePath, getFixtureBundle, getFixtureSecondaryBundle } from './helper';
|
|
27
27
|
|
|
28
28
|
const testName = 'uploadRaceCondition';
|
|
29
29
|
const serverBundleCachePathForTest = () => serverBundleCachePath(testName);
|
|
@@ -176,14 +176,14 @@ describe('concurrent upload isolation (issue #2449)', () => {
|
|
|
176
176
|
gemVersion,
|
|
177
177
|
protocolVersion,
|
|
178
178
|
railsEnv,
|
|
179
|
-
|
|
179
|
+
[`bundle_${bundleHashA}`]: fs.createReadStream(getFixtureBundle()),
|
|
180
180
|
asset1: fs.createReadStream(path.join(tmpDirA, 'loadable-stats.json')),
|
|
181
181
|
});
|
|
182
182
|
const formB = formAutoContent({
|
|
183
183
|
gemVersion,
|
|
184
184
|
protocolVersion,
|
|
185
185
|
railsEnv,
|
|
186
|
-
|
|
186
|
+
[`bundle_${bundleHashB}`]: fs.createReadStream(getFixtureBundle()),
|
|
187
187
|
asset1: fs.createReadStream(path.join(tmpDirB, 'loadable-stats.json')),
|
|
188
188
|
});
|
|
189
189
|
|
|
@@ -282,7 +282,7 @@ describe('concurrent upload isolation (issue #2449)', () => {
|
|
|
282
282
|
gemVersion,
|
|
283
283
|
protocolVersion,
|
|
284
284
|
railsEnv,
|
|
285
|
-
|
|
285
|
+
[`bundle_${bundleHashA}`]: fs.createReadStream(getFixtureBundle()),
|
|
286
286
|
asset1: fs.createReadStream(path.join(tmpDirA, 'loadable-stats.json')),
|
|
287
287
|
asset2: fs.createReadStream(path.join(tmpDirA, 'manifest.json')),
|
|
288
288
|
});
|
|
@@ -290,7 +290,7 @@ describe('concurrent upload isolation (issue #2449)', () => {
|
|
|
290
290
|
gemVersion,
|
|
291
291
|
protocolVersion,
|
|
292
292
|
railsEnv,
|
|
293
|
-
|
|
293
|
+
[`bundle_${bundleHashB}`]: fs.createReadStream(getFixtureBundle()),
|
|
294
294
|
asset1: fs.createReadStream(path.join(tmpDirB, 'loadable-stats.json')),
|
|
295
295
|
asset2: fs.createReadStream(path.join(tmpDirB, 'manifest.json')),
|
|
296
296
|
});
|
|
@@ -349,14 +349,14 @@ describe('concurrent upload isolation (issue #2449)', () => {
|
|
|
349
349
|
gemVersion,
|
|
350
350
|
protocolVersion,
|
|
351
351
|
railsEnv,
|
|
352
|
-
|
|
352
|
+
[`bundle_${sharedBundleHash}`]: fs.createReadStream(getFixtureBundle()),
|
|
353
353
|
asset1: fs.createReadStream(path.join(tmpDirA, 'loadable-stats.json')),
|
|
354
354
|
});
|
|
355
355
|
const formB = formAutoContent({
|
|
356
356
|
gemVersion,
|
|
357
357
|
protocolVersion,
|
|
358
358
|
railsEnv,
|
|
359
|
-
|
|
359
|
+
[`bundle_${sharedBundleHash}`]: fs.createReadStream(getFixtureBundle()),
|
|
360
360
|
asset1: fs.createReadStream(path.join(tmpDirB, 'loadable-stats.json')),
|
|
361
361
|
});
|
|
362
362
|
|
|
@@ -528,12 +528,12 @@ describe('concurrent upload isolation (issue #2449)', () => {
|
|
|
528
528
|
asset1: fs.createReadStream(path.join(tmpDirA, 'loadable-stats.json')),
|
|
529
529
|
});
|
|
530
530
|
|
|
531
|
-
// Upload-assets request: sends the same-named asset to the same bundle
|
|
531
|
+
// Upload-assets request: sends bundle + the same-named asset to the same bundle
|
|
532
532
|
const uploadForm = formAutoContent({
|
|
533
533
|
gemVersion,
|
|
534
534
|
protocolVersion,
|
|
535
535
|
railsEnv,
|
|
536
|
-
|
|
536
|
+
[`bundle_${bundleTimestamp}`]: fs.createReadStream(getFixtureBundle()),
|
|
537
537
|
asset1: fs.createReadStream(path.join(tmpDirB, 'loadable-stats.json')),
|
|
538
538
|
});
|
|
539
539
|
|