@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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/cjs/contractGateAutopilot.js +162 -0
  4. package/dist/cjs/contractGateSnapshotStore.js +253 -0
  5. package/dist/cjs/env.js +58 -0
  6. package/dist/cjs/index.js +162 -0
  7. package/dist/cjs/mfCache.js +106 -0
  8. package/dist/cjs/moduleFederationCss.js +285 -0
  9. package/dist/cjs/runtimeFallbackSignal.js +311 -0
  10. package/dist/cjs/telemetry.js +373 -0
  11. package/dist/cjs/telemetryCore.js +819 -0
  12. package/dist/esm/contractGateAutopilot.mjs +124 -0
  13. package/dist/esm/contractGateSnapshotStore.mjs +190 -0
  14. package/dist/esm/env.mjs +17 -0
  15. package/dist/esm/index.mjs +6 -0
  16. package/dist/esm/mfCache.mjs +55 -0
  17. package/dist/esm/moduleFederationCss.mjs +225 -0
  18. package/dist/esm/runtimeFallbackSignal.mjs +222 -0
  19. package/dist/esm/telemetry.mjs +275 -0
  20. package/dist/esm/telemetryCore.mjs +759 -0
  21. package/dist/esm-node/contractGateAutopilot.mjs +125 -0
  22. package/dist/esm-node/contractGateSnapshotStore.mjs +192 -0
  23. package/dist/esm-node/env.mjs +18 -0
  24. package/dist/esm-node/index.mjs +7 -0
  25. package/dist/esm-node/mfCache.mjs +56 -0
  26. package/dist/esm-node/moduleFederationCss.mjs +226 -0
  27. package/dist/esm-node/runtimeFallbackSignal.mjs +223 -0
  28. package/dist/esm-node/telemetry.mjs +276 -0
  29. package/dist/esm-node/telemetryCore.mjs +760 -0
  30. package/dist/types/contractGateAutopilot.d.ts +35 -0
  31. package/dist/types/contractGateSnapshotStore.d.ts +57 -0
  32. package/dist/types/env.d.ts +40 -0
  33. package/dist/types/index.d.ts +6 -0
  34. package/dist/types/mfCache.d.ts +27 -0
  35. package/dist/types/moduleFederationCss.d.ts +87 -0
  36. package/dist/types/runtimeFallbackSignal.d.ts +94 -0
  37. package/dist/types/telemetry.d.ts +12 -0
  38. package/dist/types/telemetryCore.d.ts +257 -0
  39. package/package.json +69 -0
  40. package/rslib.config.mts +4 -0
  41. package/rstest.config.mts +7 -0
  42. package/src/contractGateAutopilot.ts +247 -0
  43. package/src/contractGateSnapshotStore.ts +420 -0
  44. package/src/env.ts +63 -0
  45. package/src/index.ts +84 -0
  46. package/src/mfCache.ts +119 -0
  47. package/src/moduleFederationCss.ts +473 -0
  48. package/src/runtimeFallbackSignal.ts +584 -0
  49. package/src/telemetry.ts +554 -0
  50. package/src/telemetryCore.ts +1332 -0
  51. package/tests/contractGateAutopilot.test.ts +203 -0
  52. package/tests/contractGateSnapshotStore.test.ts +223 -0
  53. package/tests/env.test.ts +73 -0
  54. package/tests/helpers.ts +19 -0
  55. package/tests/mfCache.test.ts +150 -0
  56. package/tests/moduleFederationCss.test.ts +392 -0
  57. package/tests/registration.test.ts +112 -0
  58. package/tests/telemetry.test.ts +360 -0
  59. package/tests/telemetryAutopilot.test.ts +993 -0
  60. package/tests/telemetryCanaryOrchestrator.test.ts +140 -0
  61. package/tests/telemetryLifecycle.test.ts +168 -0
  62. package/tests/telemetryTraceparent.test.ts +167 -0
  63. package/tests/tsconfig.json +11 -0
  64. package/tsconfig.json +10 -0
@@ -0,0 +1,140 @@
1
+ import {
2
+ TelemetryCanaryOrchestrator,
3
+ type TelemetryEnvelope,
4
+ TelemetryRegistry,
5
+ } from '../src/telemetry';
6
+
7
+ const createEnvelope = (
8
+ partial: Partial<TelemetryEnvelope> = {},
9
+ ): TelemetryEnvelope => ({
10
+ timestamp: Date.now(),
11
+ service: 'svc',
12
+ module: 'server',
13
+ environment: 'test',
14
+ signalType: 'metric',
15
+ name: 'server.handle.request',
16
+ value: 10,
17
+ unit: 'ms',
18
+ ...partial,
19
+ });
20
+
21
+ describe('telemetry canary orchestrator', () => {
22
+ test('supports dynamic required contract gate registration', async () => {
23
+ const registry = new TelemetryRegistry({
24
+ service: 'svc',
25
+ module: 'server',
26
+ environment: 'test',
27
+ flushIntervalMs: 60_000,
28
+ });
29
+
30
+ const orchestrator = new TelemetryCanaryOrchestrator({
31
+ registry,
32
+ rollbackConsecutiveFailures: 1,
33
+ });
34
+
35
+ orchestrator.addRequiredContractGate('runtime-contracts');
36
+ const missingDecision = orchestrator.evaluate();
37
+ expect(missingDecision.action).toBe('rollback');
38
+ expect(
39
+ missingDecision.failures.some(
40
+ item =>
41
+ item.reason === 'contract_gate_missing' &&
42
+ item.gate === 'runtime-contracts',
43
+ ),
44
+ ).toBe(true);
45
+
46
+ orchestrator.resetToCanary();
47
+ orchestrator.setContractGate('runtime-contracts', true);
48
+ const passingDecision = orchestrator.evaluate();
49
+ expect(passingDecision.failures).toHaveLength(0);
50
+
51
+ await registry.shutdown();
52
+ });
53
+
54
+ test('promotes when telemetry and contract gates stay healthy', async () => {
55
+ const registry = new TelemetryRegistry({
56
+ service: 'svc',
57
+ module: 'server',
58
+ environment: 'test',
59
+ flushIntervalMs: 60_000,
60
+ });
61
+ await registry.register({
62
+ name: 'memory',
63
+ async emit() {
64
+ // noop
65
+ },
66
+ });
67
+
68
+ const onPromote = rs.fn();
69
+ const orchestrator = new TelemetryCanaryOrchestrator({
70
+ registry,
71
+ minConsecutiveHealthyEvaluations: 2,
72
+ requiredContractGates: ['contracts'],
73
+ onPromote,
74
+ });
75
+ orchestrator.setContractGate('contracts', true);
76
+
77
+ expect(orchestrator.evaluate().action).toBe('hold');
78
+ const decision = orchestrator.evaluate();
79
+ expect(decision.action).toBe('promote');
80
+ expect(decision.state).toBe('promoted');
81
+ expect(onPromote).toHaveBeenCalledTimes(1);
82
+
83
+ await registry.shutdown();
84
+ });
85
+
86
+ test('rolls back after consecutive contract gate failures', async () => {
87
+ const registry = new TelemetryRegistry({
88
+ service: 'svc',
89
+ module: 'server',
90
+ environment: 'test',
91
+ flushIntervalMs: 60_000,
92
+ });
93
+
94
+ const onRollback = rs.fn();
95
+ const orchestrator = new TelemetryCanaryOrchestrator({
96
+ registry,
97
+ requiredContractGates: ['contracts'],
98
+ rollbackConsecutiveFailures: 2,
99
+ onRollback,
100
+ });
101
+ orchestrator.setContractGate('contracts', false, 'schema drift');
102
+
103
+ expect(orchestrator.evaluate().action).toBe('hold');
104
+ const decision = orchestrator.evaluate();
105
+ expect(decision.action).toBe('rollback');
106
+ expect(decision.state).toBe('rolled_back');
107
+ expect(
108
+ decision.failures.some(item => item.reason === 'contract_gate_failed'),
109
+ ).toBe(true);
110
+ expect(onRollback).toHaveBeenCalledTimes(1);
111
+
112
+ await registry.shutdown();
113
+ });
114
+
115
+ test('rolls back immediately when queue dropped budget is exceeded', async () => {
116
+ const registry = new TelemetryRegistry({
117
+ service: 'svc',
118
+ module: 'server',
119
+ environment: 'test',
120
+ maxQueueSize: 2,
121
+ flushIntervalMs: 60_000,
122
+ });
123
+ registry.enqueue(createEnvelope({ name: 'a' }));
124
+ registry.enqueue(createEnvelope({ name: 'b' }));
125
+ registry.enqueue(createEnvelope({ name: 'c' }));
126
+
127
+ const orchestrator = new TelemetryCanaryOrchestrator({
128
+ registry,
129
+ maxTotalDropped: 0,
130
+ rollbackConsecutiveFailures: 1,
131
+ });
132
+ const decision = orchestrator.evaluate();
133
+ expect(decision.action).toBe('rollback');
134
+ expect(
135
+ decision.failures.some(item => item.reason === 'queue_dropped'),
136
+ ).toBe(true);
137
+
138
+ await registry.shutdown();
139
+ });
140
+ });
@@ -0,0 +1,168 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import {
3
+ createDefaultPlugins,
4
+ createServerBase,
5
+ type ServerPlugin,
6
+ } from '@modern-js/server-core';
7
+ import { logger } from '@modern-js/utils';
8
+ import { injectTelemetryPlugin } from '../src/telemetry';
9
+ import { getDefaultAppContext, getDefaultConfig } from './helpers';
10
+
11
+ const emitMonitorEventsPlugin = (input: {
12
+ path: string;
13
+ message: string;
14
+ count: number;
15
+ }): ServerPlugin => ({
16
+ name: 'emit-monitor-events',
17
+ setup(api) {
18
+ api.onPrepare(() => {
19
+ const { middlewares } = api.getServerContext();
20
+ middlewares.push({
21
+ name: 'emit-monitor-events',
22
+ path: input.path,
23
+ handler: async (c: any) => {
24
+ const monitors = c.get('monitors');
25
+ for (let index = 0; index < input.count; index++) {
26
+ monitors?.info(`${input.message}-${index}`);
27
+ }
28
+ return c.json({ ok: true });
29
+ },
30
+ });
31
+ });
32
+ },
33
+ });
34
+
35
+ const waitFor = async (predicate: () => boolean, timeoutMs = 2_000) => {
36
+ const startedAt = Date.now();
37
+ while (!predicate()) {
38
+ if (Date.now() - startedAt > timeoutMs) {
39
+ throw new Error('waitFor timed out');
40
+ }
41
+ await new Promise(resolve => setTimeout(resolve, 10));
42
+ }
43
+ };
44
+
45
+ describe('telemetry plugin lifecycle', () => {
46
+ test('wires server.telemetry.slo through to alert emission', async () => {
47
+ const warnSpy = rs.spyOn(logger, 'warn').mockImplementation(() => {});
48
+
49
+ try {
50
+ const config = getDefaultConfig();
51
+ config.server = {
52
+ telemetry: {
53
+ enabled: true,
54
+ maxQueueSize: 2,
55
+ maxBatchSize: 500,
56
+ flushIntervalMs: 60_000,
57
+ slo: {
58
+ queueUtilizationWarnThreshold: 0.5,
59
+ queueDroppedWarnThreshold: 1,
60
+ alertCooldownMs: 0,
61
+ },
62
+ },
63
+ } as any;
64
+
65
+ const server = createServerBase({
66
+ config,
67
+ pwd: process.cwd(),
68
+ appContext: getDefaultAppContext(),
69
+ });
70
+ server.addPlugins([
71
+ ...createDefaultPlugins({ logger: false }),
72
+ injectTelemetryPlugin(),
73
+ emitMonitorEventsPlugin({
74
+ path: '/emit',
75
+ message: 'slo-probe',
76
+ count: 6,
77
+ }),
78
+ ]);
79
+ await server.init();
80
+
81
+ const response = await server.request('/emit', {}, {});
82
+ expect(response.status).toBe(200);
83
+
84
+ const sloWarnings = warnSpy.mock.calls.filter(call =>
85
+ String(call[0]).includes('[telemetry.slo]'),
86
+ );
87
+ expect(sloWarnings.length).toBeGreaterThan(0);
88
+ expect(
89
+ sloWarnings.some(call => String(call[0]).includes('queue.drop')),
90
+ ).toBe(true);
91
+ } finally {
92
+ warnSpy.mockRestore();
93
+ }
94
+ });
95
+
96
+ test('flushes pending envelopes when the node server closes', async () => {
97
+ const fetchCalls: Array<{ url: string; body: string }> = [];
98
+ const fetchMock = rs.fn(async (url: any, init?: any) => {
99
+ fetchCalls.push({ url: String(url), body: String(init?.body ?? '') });
100
+ return new Response('{}', { status: 200 });
101
+ });
102
+ const originalFetch = globalThis.fetch;
103
+ globalThis.fetch = fetchMock as any;
104
+
105
+ const nodeServerStub = new EventEmitter();
106
+ const stubNodeServerPlugin: ServerPlugin = {
107
+ name: 'stub-node-server',
108
+ setup(api) {
109
+ api.updateServerContext({ nodeServer: nodeServerStub } as any);
110
+ },
111
+ };
112
+
113
+ try {
114
+ const config = getDefaultConfig();
115
+ config.server = {
116
+ telemetry: {
117
+ enabled: true,
118
+ // make sure nothing flushes before the close event:
119
+ flushIntervalMs: 60_000,
120
+ maxBatchSize: 500,
121
+ exporters: {
122
+ otlp: {
123
+ enabled: true,
124
+ endpoint: 'http://127.0.0.1:9/v1/logs',
125
+ },
126
+ },
127
+ },
128
+ } as any;
129
+
130
+ const server = createServerBase({
131
+ config,
132
+ pwd: process.cwd(),
133
+ appContext: getDefaultAppContext(),
134
+ });
135
+ server.addPlugins([
136
+ ...createDefaultPlugins({ logger: false }),
137
+ stubNodeServerPlugin,
138
+ injectTelemetryPlugin(),
139
+ emitMonitorEventsPlugin({
140
+ path: '/emit',
141
+ message: 'close-flush-probe',
142
+ count: 1,
143
+ }),
144
+ ]);
145
+ await server.init();
146
+
147
+ // init performed the startup health probe only.
148
+ const callsAfterInit = fetchCalls.length;
149
+ expect(callsAfterInit).toBeGreaterThan(0);
150
+
151
+ const response = await server.request('/emit', {}, {});
152
+ expect(response.status).toBe(200);
153
+
154
+ // The envelope is queued but not flushed (long flush interval).
155
+ expect(
156
+ fetchCalls.some(call => call.body.includes('close-flush-probe')),
157
+ ).toBe(false);
158
+
159
+ nodeServerStub.emit('close');
160
+
161
+ await waitFor(() =>
162
+ fetchCalls.some(call => call.body.includes('close-flush-probe')),
163
+ );
164
+ } finally {
165
+ globalThis.fetch = originalFetch;
166
+ }
167
+ });
168
+ });
@@ -0,0 +1,167 @@
1
+ import {
2
+ createDefaultPlugins,
3
+ createServerBase,
4
+ type ServerPlugin,
5
+ } from '@modern-js/server-core';
6
+ import { createServer } from 'http';
7
+ import type { AddressInfo } from 'net';
8
+ import { injectTelemetryPlugin } from '../src/telemetry';
9
+ import { getDefaultAppContext, getDefaultConfig } from './helpers';
10
+
11
+ type CapturedEnvelope = {
12
+ name?: string;
13
+ traceId?: string;
14
+ spanId?: string;
15
+ };
16
+
17
+ const createCaptureServer = async () => {
18
+ const envelopes: CapturedEnvelope[] = [];
19
+
20
+ const server = createServer((req, res) => {
21
+ const chunks: string[] = [];
22
+ req.on('data', chunk => {
23
+ chunks.push(String(chunk));
24
+ });
25
+ req.on('end', () => {
26
+ try {
27
+ const body = JSON.parse(chunks.join('')) as {
28
+ events?: CapturedEnvelope[];
29
+ };
30
+ envelopes.push(...(body.events || []));
31
+ } catch {
32
+ // ignore malformed payloads
33
+ }
34
+ res.statusCode = 200;
35
+ res.end('ok');
36
+ });
37
+ });
38
+
39
+ await new Promise<void>(resolve => server.listen(0, resolve));
40
+ const { port } = server.address() as AddressInfo;
41
+
42
+ return {
43
+ endpoint: `http://127.0.0.1:${port}/v1/logs`,
44
+ envelopes,
45
+ close: () =>
46
+ new Promise<void>(resolve => {
47
+ server.close(() => resolve());
48
+ }),
49
+ };
50
+ };
51
+
52
+ const waitForEnvelope = async (
53
+ envelopes: CapturedEnvelope[],
54
+ name: string,
55
+ timeoutMs = 5_000,
56
+ ) => {
57
+ const startedAt = Date.now();
58
+ for (;;) {
59
+ const found = envelopes.find(item => item.name === name);
60
+ if (found) {
61
+ return found;
62
+ }
63
+ if (Date.now() - startedAt > timeoutMs) {
64
+ throw new Error(`Timed out waiting for telemetry envelope "${name}"`);
65
+ }
66
+ await new Promise(resolve => setTimeout(resolve, 25));
67
+ }
68
+ };
69
+
70
+ describe('telemetry envelope traceparent tagging (W3C strict)', () => {
71
+ test('propagates valid traceparent ids and drops all-zero ids', async () => {
72
+ const capture = await createCaptureServer();
73
+
74
+ try {
75
+ const config = getDefaultConfig();
76
+ (config as Record<string, any>).server = {
77
+ telemetry: {
78
+ enabled: true,
79
+ // flush on every enqueue so the capture server sees envelopes
80
+ // without waiting for the interval timer
81
+ maxBatchSize: 1,
82
+ flushIntervalMs: 60_000,
83
+ exporters: {
84
+ otlp: {
85
+ enabled: true,
86
+ endpoint: capture.endpoint,
87
+ },
88
+ },
89
+ },
90
+ };
91
+
92
+ const probePlugin: ServerPlugin = {
93
+ name: 'emit-traceparent-probe',
94
+ setup(api) {
95
+ api.onPrepare(() => {
96
+ const { middlewares } = api.getServerContext();
97
+ middlewares.push({
98
+ name: 'emit-traceparent-probe',
99
+ handler: async (c: any) => {
100
+ const probeName = c.req.header('x-probe-name') || 'probe';
101
+ c.get('monitors')?.info(probeName);
102
+ return c.json({ ok: true });
103
+ },
104
+ });
105
+ });
106
+ },
107
+ };
108
+
109
+ const server = createServerBase({
110
+ config,
111
+ pwd: process.cwd(),
112
+ appContext: getDefaultAppContext(),
113
+ });
114
+ server.addPlugins([
115
+ ...createDefaultPlugins({ logger: false }),
116
+ injectTelemetryPlugin(),
117
+ probePlugin,
118
+ ]);
119
+ await server.init();
120
+
121
+ const validResponse = await server.request(
122
+ '/',
123
+ {
124
+ headers: new Headers({
125
+ 'x-probe-name': 'probe-valid',
126
+ traceparent:
127
+ '00-0AF7651916CD43DD8448EB211C80319C-B7AD6B7169203331-01',
128
+ }),
129
+ },
130
+ {},
131
+ );
132
+ expect(validResponse.status).toBe(200);
133
+
134
+ const allZeroResponse = await server.request(
135
+ '/',
136
+ {
137
+ headers: new Headers({
138
+ 'x-probe-name': 'probe-all-zero',
139
+ traceparent:
140
+ '00-00000000000000000000000000000000-0000000000000000-01',
141
+ }),
142
+ },
143
+ {},
144
+ );
145
+ expect(allZeroResponse.status).toBe(200);
146
+
147
+ const validEnvelope = await waitForEnvelope(
148
+ capture.envelopes,
149
+ 'probe-valid',
150
+ );
151
+ // valid traceparent ids propagate (normalized to lowercase)
152
+ expect(validEnvelope.traceId).toBe('0af7651916cd43dd8448eb211c80319c');
153
+ expect(validEnvelope.spanId).toBe('b7ad6b7169203331');
154
+
155
+ const allZeroEnvelope = await waitForEnvelope(
156
+ capture.envelopes,
157
+ 'probe-all-zero',
158
+ );
159
+ // all-zero trace/span ids are invalid per the W3C trace-context spec
160
+ // and must not be tagged onto envelopes
161
+ expect(allZeroEnvelope.traceId).toBeUndefined();
162
+ expect(allZeroEnvelope.spanId).toBeUndefined();
163
+ } finally {
164
+ await capture.close();
165
+ }
166
+ });
167
+ });
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "@modern-js/tsconfig/base",
3
+ "compilerOptions": {
4
+ "declaration": false,
5
+ "jsx": "preserve",
6
+ "isolatedModules": true,
7
+ "types": ["node", "@rstest/core/globals"],
8
+ "rootDir": "..",
9
+ "outDir": "./dist"
10
+ }
11
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "@modern-js/tsconfig/base",
3
+ "compilerOptions": {
4
+ "declaration": false,
5
+ "jsx": "preserve",
6
+ "isolatedModules": true,
7
+ "rootDir": "./src"
8
+ },
9
+ "include": ["src"]
10
+ }