@bleedingdev/modern-js-server-runtime-extensions 0.0.0-trusted-publisher-bootstrap
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/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/cjs/contractGateAutopilot.js +162 -0
- package/dist/cjs/contractGateSnapshotStore.js +253 -0
- package/dist/cjs/env.js +58 -0
- package/dist/cjs/index.js +162 -0
- package/dist/cjs/mfCache.js +106 -0
- package/dist/cjs/moduleFederationCss.js +285 -0
- package/dist/cjs/runtimeFallbackSignal.js +311 -0
- package/dist/cjs/telemetry.js +373 -0
- package/dist/cjs/telemetryCore.js +819 -0
- package/dist/esm/contractGateAutopilot.mjs +124 -0
- package/dist/esm/contractGateSnapshotStore.mjs +190 -0
- package/dist/esm/env.mjs +17 -0
- package/dist/esm/index.mjs +6 -0
- package/dist/esm/mfCache.mjs +55 -0
- package/dist/esm/moduleFederationCss.mjs +225 -0
- package/dist/esm/runtimeFallbackSignal.mjs +222 -0
- package/dist/esm/telemetry.mjs +275 -0
- package/dist/esm/telemetryCore.mjs +759 -0
- package/dist/esm-node/contractGateAutopilot.mjs +125 -0
- package/dist/esm-node/contractGateSnapshotStore.mjs +192 -0
- package/dist/esm-node/env.mjs +18 -0
- package/dist/esm-node/index.mjs +7 -0
- package/dist/esm-node/mfCache.mjs +56 -0
- package/dist/esm-node/moduleFederationCss.mjs +226 -0
- package/dist/esm-node/runtimeFallbackSignal.mjs +223 -0
- package/dist/esm-node/telemetry.mjs +276 -0
- package/dist/esm-node/telemetryCore.mjs +760 -0
- package/dist/types/contractGateAutopilot.d.ts +35 -0
- package/dist/types/contractGateSnapshotStore.d.ts +57 -0
- package/dist/types/env.d.ts +40 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/mfCache.d.ts +27 -0
- package/dist/types/moduleFederationCss.d.ts +87 -0
- package/dist/types/runtimeFallbackSignal.d.ts +94 -0
- package/dist/types/telemetry.d.ts +12 -0
- package/dist/types/telemetryCore.d.ts +257 -0
- package/package.json +69 -0
- package/rslib.config.mts +4 -0
- package/rstest.config.mts +7 -0
- package/src/contractGateAutopilot.ts +247 -0
- package/src/contractGateSnapshotStore.ts +420 -0
- package/src/env.ts +63 -0
- package/src/index.ts +84 -0
- package/src/mfCache.ts +119 -0
- package/src/moduleFederationCss.ts +473 -0
- package/src/runtimeFallbackSignal.ts +584 -0
- package/src/telemetry.ts +554 -0
- package/src/telemetryCore.ts +1332 -0
- package/tests/contractGateAutopilot.test.ts +203 -0
- package/tests/contractGateSnapshotStore.test.ts +223 -0
- package/tests/env.test.ts +73 -0
- package/tests/helpers.ts +19 -0
- package/tests/mfCache.test.ts +150 -0
- package/tests/moduleFederationCss.test.ts +392 -0
- package/tests/registration.test.ts +112 -0
- package/tests/telemetry.test.ts +360 -0
- package/tests/telemetryAutopilot.test.ts +993 -0
- package/tests/telemetryCanaryOrchestrator.test.ts +140 -0
- package/tests/telemetryLifecycle.test.ts +168 -0
- package/tests/telemetryTraceparent.test.ts +167 -0
- package/tests/tsconfig.json +11 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { ContractGateAutopilot } from '../src/contractGateAutopilot';
|
|
5
|
+
import {
|
|
6
|
+
TelemetryCanaryOrchestrator,
|
|
7
|
+
TelemetryRegistry,
|
|
8
|
+
} from '../src/telemetry';
|
|
9
|
+
|
|
10
|
+
const makeTempDir = () =>
|
|
11
|
+
fs.mkdtempSync(path.join(os.tmpdir(), 'modern-contract-gate-autopilot-'));
|
|
12
|
+
|
|
13
|
+
describe('contract gate autopilot', () => {
|
|
14
|
+
test('syncs gate snapshot and updates canary decisions automatically', async () => {
|
|
15
|
+
const dir = makeTempDir();
|
|
16
|
+
const snapshotPath = path.join(dir, 'contract-gates.json');
|
|
17
|
+
|
|
18
|
+
const registry = new TelemetryRegistry({
|
|
19
|
+
service: 'svc',
|
|
20
|
+
module: 'server',
|
|
21
|
+
environment: 'test',
|
|
22
|
+
flushIntervalMs: 60_000,
|
|
23
|
+
});
|
|
24
|
+
const orchestrator = new TelemetryCanaryOrchestrator({
|
|
25
|
+
registry,
|
|
26
|
+
rollbackConsecutiveFailures: 1,
|
|
27
|
+
});
|
|
28
|
+
const autopilot = new ContractGateAutopilot({
|
|
29
|
+
orchestrator,
|
|
30
|
+
gateSnapshotPath: snapshotPath,
|
|
31
|
+
pollIntervalMs: 50,
|
|
32
|
+
gateStaleAfterMs: 60_000,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
fs.writeFileSync(
|
|
37
|
+
snapshotPath,
|
|
38
|
+
JSON.stringify(
|
|
39
|
+
{
|
|
40
|
+
schemaVersion: 1,
|
|
41
|
+
updatedAt: Date.now(),
|
|
42
|
+
gates: {
|
|
43
|
+
'release-candidate-contract-gates': {
|
|
44
|
+
passed: true,
|
|
45
|
+
updatedAt: Date.now(),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
null,
|
|
50
|
+
2,
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
await autopilot.start();
|
|
55
|
+
const healthyDecision = orchestrator.evaluate();
|
|
56
|
+
expect(healthyDecision.failures).toHaveLength(0);
|
|
57
|
+
|
|
58
|
+
fs.writeFileSync(
|
|
59
|
+
snapshotPath,
|
|
60
|
+
JSON.stringify(
|
|
61
|
+
{
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
updatedAt: Date.now(),
|
|
64
|
+
gates: {
|
|
65
|
+
'release-candidate-contract-gates': {
|
|
66
|
+
passed: false,
|
|
67
|
+
reason: 'runtime compatibility drift',
|
|
68
|
+
updatedAt: Date.now(),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
null,
|
|
73
|
+
2,
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
await autopilot.syncOnce();
|
|
78
|
+
const rollbackDecision = orchestrator.evaluate();
|
|
79
|
+
expect(rollbackDecision.action).toBe('rollback');
|
|
80
|
+
expect(
|
|
81
|
+
rollbackDecision.failures.some(
|
|
82
|
+
item =>
|
|
83
|
+
item.reason === 'contract_gate_failed' &&
|
|
84
|
+
item.gate === 'release-candidate-contract-gates',
|
|
85
|
+
),
|
|
86
|
+
).toBe(true);
|
|
87
|
+
} finally {
|
|
88
|
+
autopilot.stop();
|
|
89
|
+
await registry.shutdown();
|
|
90
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('fails stale gate snapshots without manual intervention', async () => {
|
|
95
|
+
const dir = makeTempDir();
|
|
96
|
+
const snapshotPath = path.join(dir, 'contract-gates.json');
|
|
97
|
+
|
|
98
|
+
const registry = new TelemetryRegistry({
|
|
99
|
+
service: 'svc',
|
|
100
|
+
module: 'server',
|
|
101
|
+
environment: 'test',
|
|
102
|
+
flushIntervalMs: 60_000,
|
|
103
|
+
});
|
|
104
|
+
const orchestrator = new TelemetryCanaryOrchestrator({
|
|
105
|
+
registry,
|
|
106
|
+
rollbackConsecutiveFailures: 1,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const staleUpdatedAt = Date.now() - 10_000;
|
|
110
|
+
fs.writeFileSync(
|
|
111
|
+
snapshotPath,
|
|
112
|
+
JSON.stringify(
|
|
113
|
+
{
|
|
114
|
+
schemaVersion: 1,
|
|
115
|
+
updatedAt: staleUpdatedAt,
|
|
116
|
+
gates: {
|
|
117
|
+
'module-onboarding-certification-gates': {
|
|
118
|
+
passed: true,
|
|
119
|
+
updatedAt: staleUpdatedAt,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
null,
|
|
124
|
+
2,
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const autopilot = new ContractGateAutopilot({
|
|
129
|
+
orchestrator,
|
|
130
|
+
gateSnapshotPath: snapshotPath,
|
|
131
|
+
gateStaleAfterMs: 1_000,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await autopilot.syncOnce();
|
|
136
|
+
const decision = orchestrator.evaluate();
|
|
137
|
+
expect(decision.action).toBe('rollback');
|
|
138
|
+
expect(
|
|
139
|
+
decision.failures.some(
|
|
140
|
+
item =>
|
|
141
|
+
item.reason === 'contract_gate_failed' &&
|
|
142
|
+
item.gate === 'module-onboarding-certification-gates',
|
|
143
|
+
),
|
|
144
|
+
).toBe(true);
|
|
145
|
+
} finally {
|
|
146
|
+
autopilot.stop();
|
|
147
|
+
await registry.shutdown();
|
|
148
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('auto-recovers runtime fallback gates after expiry window', async () => {
|
|
153
|
+
const dir = makeTempDir();
|
|
154
|
+
const snapshotPath = path.join(dir, 'contract-gates.json');
|
|
155
|
+
|
|
156
|
+
const registry = new TelemetryRegistry({
|
|
157
|
+
service: 'svc',
|
|
158
|
+
module: 'server',
|
|
159
|
+
environment: 'test',
|
|
160
|
+
flushIntervalMs: 60_000,
|
|
161
|
+
});
|
|
162
|
+
const orchestrator = new TelemetryCanaryOrchestrator({
|
|
163
|
+
registry,
|
|
164
|
+
rollbackConsecutiveFailures: 1,
|
|
165
|
+
});
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
fs.writeFileSync(
|
|
168
|
+
snapshotPath,
|
|
169
|
+
JSON.stringify(
|
|
170
|
+
{
|
|
171
|
+
schemaVersion: 1,
|
|
172
|
+
updatedAt: now,
|
|
173
|
+
gates: {
|
|
174
|
+
'runtime-mf-fallback-health': {
|
|
175
|
+
passed: false,
|
|
176
|
+
reason: 'runtime_fallback:remote_load_failed',
|
|
177
|
+
updatedAt: now - 1_000,
|
|
178
|
+
expiresAt: now - 100,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
null,
|
|
183
|
+
2,
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const autopilot = new ContractGateAutopilot({
|
|
188
|
+
orchestrator,
|
|
189
|
+
gateSnapshotPath: snapshotPath,
|
|
190
|
+
gateStaleAfterMs: 60_000,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
await autopilot.syncOnce();
|
|
195
|
+
const decision = orchestrator.evaluate();
|
|
196
|
+
expect(decision.failures).toHaveLength(0);
|
|
197
|
+
} finally {
|
|
198
|
+
autopilot.stop();
|
|
199
|
+
await registry.shutdown();
|
|
200
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import {
|
|
6
|
+
type GateSnapshot,
|
|
7
|
+
resolveContractGateSnapshotStore,
|
|
8
|
+
} from '../src/contractGateSnapshotStore';
|
|
9
|
+
|
|
10
|
+
const makeTempAppDir = () =>
|
|
11
|
+
fs.mkdtempSync(path.join(os.tmpdir(), 'modern-gate-store-app-'));
|
|
12
|
+
|
|
13
|
+
const STORE_MODULE_SOURCE = (storeName: string) => `'use strict';
|
|
14
|
+
exports.createContractGateSnapshotStore = ({ gateSnapshotPath }) => {
|
|
15
|
+
let snapshot;
|
|
16
|
+
return {
|
|
17
|
+
name: ${JSON.stringify(storeName)},
|
|
18
|
+
async readSnapshot() {
|
|
19
|
+
return snapshot;
|
|
20
|
+
},
|
|
21
|
+
async writeSnapshot(next) {
|
|
22
|
+
snapshot = next;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
describe('contract gate snapshot store', () => {
|
|
29
|
+
test('supports built-in http stateStore adapter', async () => {
|
|
30
|
+
let snapshot: GateSnapshot | undefined;
|
|
31
|
+
|
|
32
|
+
const server = createServer((req, res) => {
|
|
33
|
+
if (!req.url || req.url !== '/snapshot') {
|
|
34
|
+
res.statusCode = 404;
|
|
35
|
+
res.end();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (req.method === 'GET') {
|
|
40
|
+
if (!snapshot) {
|
|
41
|
+
res.statusCode = 404;
|
|
42
|
+
res.end();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
res.statusCode = 200;
|
|
47
|
+
res.setHeader('content-type', 'application/json');
|
|
48
|
+
res.end(JSON.stringify(snapshot));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (req.method === 'PUT') {
|
|
53
|
+
let body = '';
|
|
54
|
+
req.on('data', chunk => {
|
|
55
|
+
body += String(chunk);
|
|
56
|
+
});
|
|
57
|
+
req.on('end', () => {
|
|
58
|
+
snapshot = JSON.parse(body);
|
|
59
|
+
res.statusCode = 204;
|
|
60
|
+
res.end();
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
res.statusCode = 405;
|
|
66
|
+
res.end();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await new Promise<void>(resolve => {
|
|
70
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const address = server.address();
|
|
74
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
75
|
+
const endpoint = `http://127.0.0.1:${String(port)}/snapshot`;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const store = await resolveContractGateSnapshotStore({
|
|
79
|
+
appDirectory: process.cwd(),
|
|
80
|
+
gateSnapshotPath: '.modern/contract-gates.json',
|
|
81
|
+
stateStore: {
|
|
82
|
+
module: 'http',
|
|
83
|
+
options: {
|
|
84
|
+
endpoint,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(await store.readSnapshot()).toBeUndefined();
|
|
90
|
+
|
|
91
|
+
await store.writeSnapshot({
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
updatedAt: Date.now(),
|
|
94
|
+
gates: {
|
|
95
|
+
'runtime-mf-fallback-health': {
|
|
96
|
+
passed: false,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const loaded = await store.readSnapshot();
|
|
102
|
+
expect(loaded?.gates?.['runtime-mf-fallback-health']).toEqual({
|
|
103
|
+
passed: false,
|
|
104
|
+
});
|
|
105
|
+
} finally {
|
|
106
|
+
await new Promise<void>((resolve, reject) => {
|
|
107
|
+
server.close(error => {
|
|
108
|
+
if (error) {
|
|
109
|
+
reject(error);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
resolve();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('resolves relative stateStore modules against the app directory', async () => {
|
|
119
|
+
const appDirectory = makeTempAppDir();
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
fs.mkdirSync(path.join(appDirectory, 'stores'), { recursive: true });
|
|
123
|
+
fs.writeFileSync(
|
|
124
|
+
path.join(appDirectory, 'stores', 'gate-store.js'),
|
|
125
|
+
STORE_MODULE_SOURCE('relative-store'),
|
|
126
|
+
'utf8',
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const store = await resolveContractGateSnapshotStore({
|
|
130
|
+
appDirectory,
|
|
131
|
+
gateSnapshotPath: path.join(
|
|
132
|
+
appDirectory,
|
|
133
|
+
'.modern/contract-gates.json',
|
|
134
|
+
),
|
|
135
|
+
stateStore: {
|
|
136
|
+
module: './stores/gate-store.js',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(store.name).toBe('relative-store');
|
|
141
|
+
} finally {
|
|
142
|
+
fs.rmSync(appDirectory, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('resolves bare-specifier stateStore modules from the app node_modules', async () => {
|
|
147
|
+
const appDirectory = makeTempAppDir();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
// Simulate a pnpm-style strict install: the store package exists only
|
|
151
|
+
// in the app's node_modules, never next to the framework package.
|
|
152
|
+
const packageDir = path.join(appDirectory, 'node_modules', 'gate-store');
|
|
153
|
+
fs.mkdirSync(packageDir, { recursive: true });
|
|
154
|
+
fs.writeFileSync(
|
|
155
|
+
path.join(appDirectory, 'package.json'),
|
|
156
|
+
JSON.stringify({ name: 'test-app', private: true }),
|
|
157
|
+
'utf8',
|
|
158
|
+
);
|
|
159
|
+
fs.writeFileSync(
|
|
160
|
+
path.join(packageDir, 'package.json'),
|
|
161
|
+
JSON.stringify({
|
|
162
|
+
name: 'gate-store',
|
|
163
|
+
version: '1.0.0',
|
|
164
|
+
main: 'index.js',
|
|
165
|
+
}),
|
|
166
|
+
'utf8',
|
|
167
|
+
);
|
|
168
|
+
fs.writeFileSync(
|
|
169
|
+
path.join(packageDir, 'index.js'),
|
|
170
|
+
STORE_MODULE_SOURCE('bare-specifier-store'),
|
|
171
|
+
'utf8',
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const store = await resolveContractGateSnapshotStore({
|
|
175
|
+
appDirectory,
|
|
176
|
+
gateSnapshotPath: path.join(
|
|
177
|
+
appDirectory,
|
|
178
|
+
'.modern/contract-gates.json',
|
|
179
|
+
),
|
|
180
|
+
stateStore: {
|
|
181
|
+
module: 'gate-store',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(store.name).toBe('bare-specifier-store');
|
|
186
|
+
|
|
187
|
+
await store.writeSnapshot({
|
|
188
|
+
schemaVersion: 1,
|
|
189
|
+
updatedAt: Date.now(),
|
|
190
|
+
gates: { 'runtime-mf-fallback-health': { passed: false } },
|
|
191
|
+
});
|
|
192
|
+
const loaded = await store.readSnapshot();
|
|
193
|
+
expect(loaded?.gates?.['runtime-mf-fallback-health']).toEqual({
|
|
194
|
+
passed: false,
|
|
195
|
+
});
|
|
196
|
+
} finally {
|
|
197
|
+
fs.rmSync(appDirectory, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('reports a clear error when the stateStore module cannot be resolved', async () => {
|
|
202
|
+
const appDirectory = makeTempAppDir();
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await expect(
|
|
206
|
+
resolveContractGateSnapshotStore({
|
|
207
|
+
appDirectory,
|
|
208
|
+
gateSnapshotPath: path.join(
|
|
209
|
+
appDirectory,
|
|
210
|
+
'.modern/contract-gates.json',
|
|
211
|
+
),
|
|
212
|
+
stateStore: {
|
|
213
|
+
module: 'definitely-missing-gate-store',
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
).rejects.toThrow(
|
|
217
|
+
/Failed to load stateStore\.module "definitely-missing-gate-store"/,
|
|
218
|
+
);
|
|
219
|
+
} finally {
|
|
220
|
+
fs.rmSync(appDirectory, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_ENVIRONMENT_NAME,
|
|
3
|
+
parseServerRuntimeExtensionsEnv,
|
|
4
|
+
} from '../src/env';
|
|
5
|
+
|
|
6
|
+
describe('parseServerRuntimeExtensionsEnv', () => {
|
|
7
|
+
test('applies documented defaults for an empty environment', () => {
|
|
8
|
+
const parsed = parseServerRuntimeExtensionsEnv({});
|
|
9
|
+
|
|
10
|
+
expect(parsed).toEqual({
|
|
11
|
+
modernEnv: undefined,
|
|
12
|
+
nodeEnv: undefined,
|
|
13
|
+
environmentName: DEFAULT_ENVIRONMENT_NAME,
|
|
14
|
+
contractGatesFile: undefined,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('parses configured values and trims whitespace', () => {
|
|
19
|
+
const parsed = parseServerRuntimeExtensionsEnv({
|
|
20
|
+
MODERN_ENV: ' staging ',
|
|
21
|
+
NODE_ENV: 'production',
|
|
22
|
+
MODERN_CONTRACT_GATES_FILE: ' /var/run/gates.json ',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(parsed.modernEnv).toBe('staging');
|
|
26
|
+
expect(parsed.nodeEnv).toBe('production');
|
|
27
|
+
expect(parsed.environmentName).toBe('staging');
|
|
28
|
+
expect(parsed.contractGatesFile).toBe('/var/run/gates.json');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('falls back from MODERN_ENV to NODE_ENV to the default', () => {
|
|
32
|
+
expect(
|
|
33
|
+
parseServerRuntimeExtensionsEnv({ NODE_ENV: 'production' })
|
|
34
|
+
.environmentName,
|
|
35
|
+
).toBe('production');
|
|
36
|
+
expect(
|
|
37
|
+
parseServerRuntimeExtensionsEnv({
|
|
38
|
+
MODERN_ENV: 'preview',
|
|
39
|
+
NODE_ENV: 'production',
|
|
40
|
+
}).environmentName,
|
|
41
|
+
).toBe('preview');
|
|
42
|
+
expect(parseServerRuntimeExtensionsEnv({}).environmentName).toBe(
|
|
43
|
+
DEFAULT_ENVIRONMENT_NAME,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('treats blank strings as unset', () => {
|
|
48
|
+
const parsed = parseServerRuntimeExtensionsEnv({
|
|
49
|
+
MODERN_ENV: ' ',
|
|
50
|
+
MODERN_CONTRACT_GATES_FILE: '',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(parsed.modernEnv).toBeUndefined();
|
|
54
|
+
expect(parsed.contractGatesFile).toBeUndefined();
|
|
55
|
+
expect(parsed.environmentName).toBe(DEFAULT_ENVIRONMENT_NAME);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('reads from process.env by default', () => {
|
|
59
|
+
const previous = process.env.MODERN_CONTRACT_GATES_FILE;
|
|
60
|
+
process.env.MODERN_CONTRACT_GATES_FILE = '/tmp/gates.json';
|
|
61
|
+
try {
|
|
62
|
+
expect(parseServerRuntimeExtensionsEnv().contractGatesFile).toBe(
|
|
63
|
+
'/tmp/gates.json',
|
|
64
|
+
);
|
|
65
|
+
} finally {
|
|
66
|
+
if (previous === undefined) {
|
|
67
|
+
delete process.env.MODERN_CONTRACT_GATES_FILE;
|
|
68
|
+
} else {
|
|
69
|
+
process.env.MODERN_CONTRACT_GATES_FILE = previous;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
package/tests/helpers.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function getDefaultConfig() {
|
|
2
|
+
return {
|
|
3
|
+
html: {},
|
|
4
|
+
output: {},
|
|
5
|
+
source: {},
|
|
6
|
+
tools: {},
|
|
7
|
+
server: {},
|
|
8
|
+
bff: {},
|
|
9
|
+
dev: {},
|
|
10
|
+
security: {},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getDefaultAppContext() {
|
|
15
|
+
return {
|
|
16
|
+
apiDirectory: '',
|
|
17
|
+
lambdaDirectory: '',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDefaultPlugins,
|
|
3
|
+
createServerBase,
|
|
4
|
+
type ServerPlugin,
|
|
5
|
+
} from '@modern-js/server-core';
|
|
6
|
+
import { describe, expect, test } from '@rstest/core';
|
|
7
|
+
import {
|
|
8
|
+
getRequestPathname,
|
|
9
|
+
injectMfAssetCacheHeadersPlugin,
|
|
10
|
+
isMfManifestAsset,
|
|
11
|
+
isMfRemoteEntryAsset,
|
|
12
|
+
resolveMfAssetCacheHeaders,
|
|
13
|
+
} from '../src/mfCache';
|
|
14
|
+
import { getDefaultAppContext, getDefaultConfig } from './helpers';
|
|
15
|
+
|
|
16
|
+
describe('mf cache headers', () => {
|
|
17
|
+
test('detects MF manifest assets', () => {
|
|
18
|
+
expect(isMfManifestAsset('/mf-manifest.json')).toBe(true);
|
|
19
|
+
expect(isMfManifestAsset('/mf-stats.json')).toBe(true);
|
|
20
|
+
expect(isMfManifestAsset('/foo/bar.json')).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('detects remoteEntry assets', () => {
|
|
24
|
+
expect(isMfRemoteEntryAsset('/remoteEntry.js')).toBe(true);
|
|
25
|
+
expect(isMfRemoteEntryAsset('/assets/remoteEntry.abc123.js')).toBe(true);
|
|
26
|
+
expect(isMfRemoteEntryAsset('/assets/index.js')).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('resolves strict no-cache headers for MF manifest endpoints', () => {
|
|
30
|
+
const headers = resolveMfAssetCacheHeaders('/mf-manifest.json');
|
|
31
|
+
expect(headers).toEqual({
|
|
32
|
+
'cache-control': 'no-cache, no-store, must-revalidate',
|
|
33
|
+
pragma: 'no-cache',
|
|
34
|
+
expires: '0',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('resolves revalidation cache policy for non-versioned remoteEntry', () => {
|
|
39
|
+
const headers = resolveMfAssetCacheHeaders('/remoteEntry.js');
|
|
40
|
+
expect(headers).toEqual({
|
|
41
|
+
'cache-control': 'public, max-age=0, must-revalidate',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('resolves immutable cache policy for version-pinned remoteEntry', () => {
|
|
46
|
+
const headers = resolveMfAssetCacheHeaders('/remoteEntry.js', {
|
|
47
|
+
mfv: 'remote-v1',
|
|
48
|
+
});
|
|
49
|
+
expect(headers).toEqual({
|
|
50
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('extracts pathname from full request URL', () => {
|
|
55
|
+
expect(
|
|
56
|
+
getRequestPathname('https://example.com/remoteEntry.js?mfv=remote-v1'),
|
|
57
|
+
).toBe('/remoteEntry.js');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('injectMfAssetCacheHeadersPlugin', () => {
|
|
62
|
+
const createServerWithStubAssets = async () => {
|
|
63
|
+
const stubAssetsPlugin: ServerPlugin = {
|
|
64
|
+
name: 'stub-static-assets',
|
|
65
|
+
setup(api) {
|
|
66
|
+
api.onPrepare(() => {
|
|
67
|
+
const { middlewares } = api.getServerContext();
|
|
68
|
+
middlewares.push({
|
|
69
|
+
name: 'stub-static-assets',
|
|
70
|
+
handler: async (c: any) => {
|
|
71
|
+
const pathname = c.req.path as string;
|
|
72
|
+
if (pathname === '/missing/remoteEntry.js') {
|
|
73
|
+
return c.body('not found', 404);
|
|
74
|
+
}
|
|
75
|
+
if (pathname.endsWith('.json') || pathname.endsWith('.js')) {
|
|
76
|
+
return c.body('asset-body', 200);
|
|
77
|
+
}
|
|
78
|
+
return c.json({ ok: true });
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const server = createServerBase({
|
|
86
|
+
config: getDefaultConfig(),
|
|
87
|
+
pwd: process.cwd(),
|
|
88
|
+
appContext: getDefaultAppContext(),
|
|
89
|
+
});
|
|
90
|
+
server.addPlugins([
|
|
91
|
+
...createDefaultPlugins({ logger: false }),
|
|
92
|
+
injectMfAssetCacheHeadersPlugin(),
|
|
93
|
+
stubAssetsPlugin,
|
|
94
|
+
]);
|
|
95
|
+
await server.init();
|
|
96
|
+
return server;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
test('applies no-store policy to served MF manifests', async () => {
|
|
100
|
+
const server = await createServerWithStubAssets();
|
|
101
|
+
|
|
102
|
+
const response = await server.request('/mf-manifest.json', {}, {});
|
|
103
|
+
expect(response.status).toBe(200);
|
|
104
|
+
expect(response.headers.get('cache-control')).toBe(
|
|
105
|
+
'no-cache, no-store, must-revalidate',
|
|
106
|
+
);
|
|
107
|
+
expect(response.headers.get('pragma')).toBe('no-cache');
|
|
108
|
+
expect(response.headers.get('expires')).toBe('0');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('applies revalidation policy to non-pinned remoteEntry assets', async () => {
|
|
112
|
+
const server = await createServerWithStubAssets();
|
|
113
|
+
|
|
114
|
+
const response = await server.request('/static/remoteEntry.js', {}, {});
|
|
115
|
+
expect(response.status).toBe(200);
|
|
116
|
+
expect(response.headers.get('cache-control')).toBe(
|
|
117
|
+
'public, max-age=0, must-revalidate',
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('applies immutable policy to version-pinned remoteEntry assets', async () => {
|
|
122
|
+
const server = await createServerWithStubAssets();
|
|
123
|
+
|
|
124
|
+
const response = await server.request(
|
|
125
|
+
'/static/remoteEntry.js?mfv=remote-v1',
|
|
126
|
+
{},
|
|
127
|
+
{},
|
|
128
|
+
);
|
|
129
|
+
expect(response.status).toBe(200);
|
|
130
|
+
expect(response.headers.get('cache-control')).toBe(
|
|
131
|
+
'public, max-age=31536000, immutable',
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('does not attach cache policies to error responses', async () => {
|
|
136
|
+
const server = await createServerWithStubAssets();
|
|
137
|
+
|
|
138
|
+
const response = await server.request('/missing/remoteEntry.js', {}, {});
|
|
139
|
+
expect(response.status).toBe(404);
|
|
140
|
+
expect(response.headers.get('cache-control')).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('leaves non-MF assets untouched', async () => {
|
|
144
|
+
const server = await createServerWithStubAssets();
|
|
145
|
+
|
|
146
|
+
const response = await server.request('/static/js/app.js', {}, {});
|
|
147
|
+
expect(response.status).toBe(200);
|
|
148
|
+
expect(response.headers.get('cache-control')).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
});
|