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/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 { mkdir, rm } from 'fs/promises';
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 { handleRenderRequest, type ProvidedNewBundle } from './worker/handleRenderRequest.js';
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: ProvidedNewBundle[] = [];
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: WithBodyArrayField<Record<string, Asset>, 'targetBundles'>;
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
- // Handle targetBundles as either a string or an array
327
- const targetBundles = extractBodyArrayField(req.body, 'targetBundles');
328
- if (!targetBundles || targetBundles.length === 0) {
329
- const errorMsg = 'No targetBundles provided. As of protocol version 2.0.0, targetBundles is required.';
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 assetsDescription = JSON.stringify(assets.map((asset) => asset.filename));
336
- const taskDescription = `Uploading files ${assetsDescription} to bundle directories: ${targetBundles.join(', ')}`;
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
- try {
364
- await copyUploadedAssets(assets, bundleDirectory);
365
- log.info(`Copied assets to bundle directory: ${bundleDirectory}`);
366
- } finally {
367
- try {
368
- await unlock(lockfileName);
369
- } catch (error) {
370
- log.warn({
371
- msg: `Error unlocking ${lockfileName} from worker ${workerIdLabel()}`,
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 copy assets';
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 originalRendererHost = process.env.RENDERER_HOST;
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
- if (originalRendererHost === undefined) {
6
- delete process.env.RENDERER_HOST;
7
- } else {
8
- process.env.RENDERER_HOST = originalRendererHost;
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: jest.fn(),
21
- warn: jest.fn(),
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
- targetBundles: [bundleHashA],
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
- targetBundles: [bundleHashB],
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
- targetBundles: [bundleHashA],
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
- targetBundles: [bundleHashB],
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
- targetBundles: [sharedBundleHash],
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
- targetBundles: [sharedBundleHash],
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
- targetBundles: [bundleTimestamp],
536
+ [`bundle_${bundleTimestamp}`]: fs.createReadStream(getFixtureBundle()),
537
537
  asset1: fs.createReadStream(path.join(tmpDirB, 'loadable-stats.json')),
538
538
  });
539
539