@nextrush/adapter-edge 1.0.0-beta.0 → 1.0.0-beta.2
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/README.md +67 -4
- package/dist/index.d.ts +72 -19
- package/dist/index.js +63 -9
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/boot-reuse-warning.test.ts +95 -0
- package/src/__tests__/context-ip-warning.test.ts +67 -0
- package/src/__tests__/context-platform.test.ts +41 -0
- package/src/__tests__/context.test.ts +40 -5
- package/src/__tests__/default-timeout.test.ts +5 -2
- package/src/__tests__/per-request-work-trim.test.ts +14 -14
- package/src/__tests__/timeout-attribution.test.ts +80 -0
- package/src/adapter.ts +84 -14
- package/src/context.ts +86 -10
- package/src/index.ts +1 -0
- package/src/utils.ts +4 -7
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1-3a (report/dx-review-serverless-edge-adapters.md): warn, once per
|
|
3
|
+
* process/isolate, when a SECOND distinct `Application` instance boots
|
|
4
|
+
* through this module's request runner — the mechanical signature of the
|
|
5
|
+
* #1 documented cold-start mistake (calling `createApp()`/`createFetchHandler()`
|
|
6
|
+
* inside the exported handler instead of at module scope, which rebuilds and
|
|
7
|
+
* reboots the app on every invocation).
|
|
8
|
+
*
|
|
9
|
+
* Uses `vi.resetModules()` + dynamic re-import per test so each test gets a
|
|
10
|
+
* fresh copy of `adapter.ts`'s module-level tracking state, since the whole
|
|
11
|
+
* point of this feature is that the state persists ACROSS `createFetchHandler`
|
|
12
|
+
* calls within one module instance.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createApp } from '@nextrush/core';
|
|
16
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
|
+
import type { createFetchHandler as CreateFetchHandler } from '../adapter';
|
|
18
|
+
|
|
19
|
+
async function freshAdapterModule(): Promise<{ createFetchHandler: typeof CreateFetchHandler }> {
|
|
20
|
+
vi.resetModules();
|
|
21
|
+
return import('../adapter');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function devApp(): ReturnType<typeof createApp> {
|
|
25
|
+
const app = createApp({ env: 'development' });
|
|
26
|
+
app.use(async (ctx) => ctx.json({ ok: true }));
|
|
27
|
+
return app;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('boot-reuse warning (P1-3a)', () => {
|
|
31
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not warn when the same Application boots once', async () => {
|
|
38
|
+
const { createFetchHandler } = await freshAdapterModule();
|
|
39
|
+
const app = devApp();
|
|
40
|
+
const handler = createFetchHandler(app);
|
|
41
|
+
await handler(new Request('http://localhost/'));
|
|
42
|
+
|
|
43
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
44
|
+
warnSpy.mockRestore();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('does not warn when the same Application is passed to a second createFetchHandler call (rebuilding the handler, not the app, is not the mistake)', async () => {
|
|
48
|
+
const { createFetchHandler } = await freshAdapterModule();
|
|
49
|
+
const app = devApp();
|
|
50
|
+
await createFetchHandler(app)(new Request('http://localhost/'));
|
|
51
|
+
await createFetchHandler(app)(new Request('http://localhost/'));
|
|
52
|
+
|
|
53
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
54
|
+
warnSpy.mockRestore();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('warns once when a second, distinct Application boots in the same module instance', async () => {
|
|
58
|
+
const { createFetchHandler } = await freshAdapterModule();
|
|
59
|
+
const appOne = devApp();
|
|
60
|
+
const appTwo = devApp();
|
|
61
|
+
|
|
62
|
+
await createFetchHandler(appOne)(new Request('http://localhost/'));
|
|
63
|
+
await createFetchHandler(appTwo)(new Request('http://localhost/'));
|
|
64
|
+
|
|
65
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(warnSpy.mock.calls[0]?.[0]).toMatch(/\[nextrush\/edge\].*module scope/i);
|
|
67
|
+
warnSpy.mockRestore();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not warn in production even with two distinct Applications', async () => {
|
|
71
|
+
const { createFetchHandler } = await freshAdapterModule();
|
|
72
|
+
const appOne = createApp({ env: 'production' });
|
|
73
|
+
appOne.use(async (ctx) => ctx.json({ ok: true }));
|
|
74
|
+
const appTwo = createApp({ env: 'production' });
|
|
75
|
+
appTwo.use(async (ctx) => ctx.json({ ok: true }));
|
|
76
|
+
|
|
77
|
+
await createFetchHandler(appOne)(new Request('http://localhost/'));
|
|
78
|
+
await createFetchHandler(appTwo)(new Request('http://localhost/'));
|
|
79
|
+
|
|
80
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
81
|
+
warnSpy.mockRestore();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('warns only once even across a third and fourth distinct Application', async () => {
|
|
85
|
+
const { createFetchHandler } = await freshAdapterModule();
|
|
86
|
+
for (let i = 0; i < 4; i++) {
|
|
87
|
+
const app = devApp();
|
|
88
|
+
// eslint-disable-next-line no-await-in-loop -- sequential boots are the point of this test
|
|
89
|
+
await createFetchHandler(app)(new Request('http://localhost/'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
93
|
+
warnSpy.mockRestore();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/adapter-edge - ctx.ip empty-string dev-mode warning (P2-3).
|
|
3
|
+
*
|
|
4
|
+
* When `proxy` is `false` (default), `ctx.ip` is `''` on Edge (no
|
|
5
|
+
* socket to fall back to). Outside production, the first *read* of `ctx.ip`
|
|
6
|
+
* in that state warns once per context — a silent empty string is otherwise
|
|
7
|
+
* indistinguishable from "IP resolution succeeded and it's empty."
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { EdgeContext } from '../context';
|
|
12
|
+
|
|
13
|
+
function mockRequest(headers: Record<string, string> = {}): Request {
|
|
14
|
+
return new Request('http://localhost/', { headers });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('EdgeContext ctx.ip dev-mode warning (P2-3)', () => {
|
|
18
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
warnSpy.mockRestore();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('warns once, in development, on the first read of ctx.ip when proxy is false', () => {
|
|
29
|
+
const ctx = new EdgeContext(mockRequest(), undefined, false, undefined, false);
|
|
30
|
+
|
|
31
|
+
expect(ctx.ip).toBe('');
|
|
32
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
33
|
+
expect(warnSpy.mock.calls[0]?.[0]).toContain('[nextrush/edge]');
|
|
34
|
+
expect(warnSpy.mock.calls[0]?.[0]).toContain('ctx.ip');
|
|
35
|
+
expect(warnSpy.mock.calls[0]?.[0]).toContain('proxy');
|
|
36
|
+
|
|
37
|
+
// Reading it again on the same context does not warn a second time.
|
|
38
|
+
expect(ctx.ip).toBe('');
|
|
39
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does not warn in production, even though ctx.ip is still empty', () => {
|
|
43
|
+
const ctx = new EdgeContext(mockRequest(), undefined, false, undefined, true);
|
|
44
|
+
|
|
45
|
+
expect(ctx.ip).toBe('');
|
|
46
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not warn when proxy is a hop count, even in development', () => {
|
|
50
|
+
const ctx = new EdgeContext(
|
|
51
|
+
mockRequest({ 'cf-connecting-ip': '1.2.3.4' }),
|
|
52
|
+
undefined,
|
|
53
|
+
1,
|
|
54
|
+
undefined,
|
|
55
|
+
false
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(ctx.ip).toBe('1.2.3.4');
|
|
59
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('4.3: rejects a peer-CIDR list at construction — Edge has no peer address to validate against', () => {
|
|
63
|
+
expect(() => new EdgeContext(mockRequest(), undefined, ['10.0.0.0/8'])).toThrow(
|
|
64
|
+
/no socket peer address/
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/adapter-edge - ctx.platform Tests (RFC-026 P1)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resetRuntimeCache } from '@nextrush/runtime';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
import { EdgeContext } from '../context';
|
|
8
|
+
|
|
9
|
+
function createMockRequest(url = 'http://localhost/'): Request {
|
|
10
|
+
return new Request(url);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('EdgeContext ctx.platform', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetRuntimeCache();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
resetRuntimeCache();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('defaults to undefined when no explicit platform is given and no named edge platform is detected', () => {
|
|
23
|
+
const ctx = new EdgeContext(createMockRequest());
|
|
24
|
+
|
|
25
|
+
expect(ctx.platform).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('uses the explicitly-supplied platform over detection', () => {
|
|
29
|
+
const ctx = new EdgeContext(createMockRequest(), undefined, false, undefined, true, 'lambda');
|
|
30
|
+
|
|
31
|
+
expect(ctx.platform).toBe('lambda');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('accepts each serverless PlatformId explicitly', () => {
|
|
35
|
+
const gcf = new EdgeContext(createMockRequest(), undefined, false, undefined, true, 'gcf');
|
|
36
|
+
const azure = new EdgeContext(createMockRequest(), undefined, false, undefined, true, 'azure');
|
|
37
|
+
|
|
38
|
+
expect(gcf.platform).toBe('gcf');
|
|
39
|
+
expect(azure.platform).toBe('azure');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -98,7 +98,7 @@ describe('EdgeContext', () => {
|
|
|
98
98
|
request = createMockRequest('http://localhost/', {
|
|
99
99
|
headers: { 'cf-connecting-ip': '192.168.1.100' },
|
|
100
100
|
});
|
|
101
|
-
ctx = new EdgeContext(request, undefined,
|
|
101
|
+
ctx = new EdgeContext(request, undefined, 1);
|
|
102
102
|
|
|
103
103
|
expect(ctx.ip).toBe('192.168.1.100');
|
|
104
104
|
});
|
|
@@ -107,21 +107,21 @@ describe('EdgeContext', () => {
|
|
|
107
107
|
request = createMockRequest('http://localhost/', {
|
|
108
108
|
headers: { 'x-forwarded-for': '10.0.0.1, 10.0.0.2' },
|
|
109
109
|
});
|
|
110
|
-
ctx = new EdgeContext(request, undefined,
|
|
110
|
+
ctx = new EdgeContext(request, undefined, 1);
|
|
111
111
|
|
|
112
|
-
expect(ctx.ip).toBe('10.0.0.
|
|
112
|
+
expect(ctx.ip).toBe('10.0.0.2');
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
it('should extract IP from x-real-ip header', () => {
|
|
116
116
|
request = createMockRequest('http://localhost/', {
|
|
117
117
|
headers: { 'x-real-ip': '172.16.0.1' },
|
|
118
118
|
});
|
|
119
|
-
ctx = new EdgeContext(request, undefined,
|
|
119
|
+
ctx = new EdgeContext(request, undefined, 1);
|
|
120
120
|
|
|
121
121
|
expect(ctx.ip).toBe('172.16.0.1');
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
-
it('should not trust proxy headers when
|
|
124
|
+
it('should not trust proxy headers when proxy is false', () => {
|
|
125
125
|
request = createMockRequest('http://localhost/', {
|
|
126
126
|
headers: { 'x-forwarded-for': '10.0.0.1', 'cf-connecting-ip': '192.168.1.100' },
|
|
127
127
|
});
|
|
@@ -409,6 +409,41 @@ describe('EdgeContext', () => {
|
|
|
409
409
|
expect(() => ctx.waitUntil(promise)).not.toThrow();
|
|
410
410
|
});
|
|
411
411
|
});
|
|
412
|
+
|
|
413
|
+
describe('waitUntil() dev-mode warning (P2-4)', () => {
|
|
414
|
+
it('warns once via console.warn when isProduction is explicitly false and no execution context is available', () => {
|
|
415
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
416
|
+
ctx = new EdgeContext(request, undefined, false, undefined, false);
|
|
417
|
+
|
|
418
|
+
ctx.waitUntil(Promise.resolve());
|
|
419
|
+
ctx.waitUntil(Promise.resolve());
|
|
420
|
+
|
|
421
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
422
|
+
expect(warnSpy.mock.calls[0]?.[0]).toMatch(/\[nextrush\/edge\].*waitUntil/i);
|
|
423
|
+
warnSpy.mockRestore();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('does not warn when an execution context IS available, even outside production', () => {
|
|
427
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
428
|
+
const execCtx = createMockExecutionContext();
|
|
429
|
+
ctx = new EdgeContext(request, execCtx, false, undefined, false);
|
|
430
|
+
|
|
431
|
+
ctx.waitUntil(Promise.resolve());
|
|
432
|
+
|
|
433
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
434
|
+
warnSpy.mockRestore();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('does not warn by default (isProduction defaults to true — safe/silent unless explicitly told otherwise)', () => {
|
|
438
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
439
|
+
ctx = new EdgeContext(request); // no isProduction arg — matches every existing direct-construction call site
|
|
440
|
+
|
|
441
|
+
ctx.waitUntil(Promise.resolve());
|
|
442
|
+
|
|
443
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
444
|
+
warnSpy.mockRestore();
|
|
445
|
+
});
|
|
446
|
+
});
|
|
412
447
|
});
|
|
413
448
|
|
|
414
449
|
describe('HttpError', () => {
|
|
@@ -11,9 +11,12 @@ import { describe, expect, it } from 'vitest';
|
|
|
11
11
|
import { createFetchHandler, DEFAULT_EDGE_TIMEOUT_MS } from '../adapter';
|
|
12
12
|
|
|
13
13
|
describe('F-07: Edge default request timeout', () => {
|
|
14
|
-
it('DEFAULT_EDGE_TIMEOUT_MS is a documented constant under the tightest common edge wall limit', () => {
|
|
14
|
+
it('DEFAULT_EDGE_TIMEOUT_MS is a documented constant strictly under the tightest common edge wall limit', () => {
|
|
15
15
|
// Vercel Edge Functions: 25s wall limit — the tightest common platform limit.
|
|
16
|
-
|
|
16
|
+
// Strictly below (not at) the limit (P2-2): equal to it means the framework's
|
|
17
|
+
// clean 504 races the platform's own kill with no margin, non-deterministically
|
|
18
|
+
// losing on Vercel.
|
|
19
|
+
expect(DEFAULT_EDGE_TIMEOUT_MS).toBeLessThan(25_000);
|
|
17
20
|
expect(DEFAULT_EDGE_TIMEOUT_MS).toBeGreaterThan(0);
|
|
18
21
|
});
|
|
19
22
|
|
|
@@ -7,15 +7,15 @@
|
|
|
7
7
|
* (characterization tests, green before and after the trims) AND proves each
|
|
8
8
|
* trim actually happened (optimization-assertion tests, RED before impl):
|
|
9
9
|
*
|
|
10
|
-
* - HP-1: `
|
|
10
|
+
* - HP-1: `proxy: false` sets `ctx.ip` to `''` directly (Edge has no
|
|
11
11
|
* socket) without invoking the edge header-lookup policy
|
|
12
|
-
* (`getEdgeClientIp`);
|
|
12
|
+
* (`getEdgeClientIp`); a hop-count `proxy` still resolves via it,
|
|
13
13
|
* preserving the cf-connecting-ip → x-forwarded-for → x-real-ip
|
|
14
14
|
* precedence.
|
|
15
15
|
* - HP-7: `ctx.next()` forwards the wired dispatch thunk directly (returns its
|
|
16
16
|
* exact promise) rather than wrapping it in an extra `async` frame.
|
|
17
17
|
*
|
|
18
|
-
* Note: HP-4 does NOT apply — the Edge context factory takes `
|
|
18
|
+
* Note: HP-4 does NOT apply — the Edge context factory takes `proxy`
|
|
19
19
|
* positionally, so there is no per-request options object to hoist.
|
|
20
20
|
*/
|
|
21
21
|
|
|
@@ -52,12 +52,12 @@ afterEach(() => {
|
|
|
52
52
|
describe('HP-1: Edge ctx.ip resolution', () => {
|
|
53
53
|
// ---- parity / characterization (green before and after) -----------------
|
|
54
54
|
|
|
55
|
-
it('2.4
|
|
55
|
+
it('2.4 proxy: false → ctx.ip is the empty string (Edge has no socket)', () => {
|
|
56
56
|
const ctx = new EdgeContext(makeRequest(), undefined, false);
|
|
57
57
|
expect(ctx.ip).toBe('');
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
it('2.4
|
|
60
|
+
it('2.4 proxy: 1 (hop count) + cf-connecting-ip wins the Cloudflare precedence', () => {
|
|
61
61
|
const ctx = new EdgeContext(
|
|
62
62
|
makeRequest({
|
|
63
63
|
'cf-connecting-ip': '198.51.100.5',
|
|
@@ -65,24 +65,24 @@ describe('HP-1: Edge ctx.ip resolution', () => {
|
|
|
65
65
|
'x-real-ip': '8.8.8.8',
|
|
66
66
|
}),
|
|
67
67
|
undefined,
|
|
68
|
-
|
|
68
|
+
1
|
|
69
69
|
);
|
|
70
70
|
expect(ctx.ip).toBe('198.51.100.5');
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
it('2.4
|
|
73
|
+
it('2.4 proxy: 1 (hop count) + no cf-connecting-ip → x-forwarded-for → x-real-ip precedence', () => {
|
|
74
74
|
const xff = new EdgeContext(
|
|
75
75
|
makeRequest({ 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' }),
|
|
76
76
|
undefined,
|
|
77
|
-
|
|
77
|
+
1
|
|
78
78
|
);
|
|
79
79
|
expect(xff.ip).toBe('9.9.9.9');
|
|
80
80
|
|
|
81
|
-
const real = new EdgeContext(makeRequest({ 'x-real-ip': '8.8.8.8' }), undefined,
|
|
81
|
+
const real = new EdgeContext(makeRequest({ 'x-real-ip': '8.8.8.8' }), undefined, 1);
|
|
82
82
|
expect(real.ip).toBe('8.8.8.8');
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
it('2.5
|
|
85
|
+
it('2.5 proxy: false ignores cf-connecting-ip / x-forwarded-for / x-real-ip', () => {
|
|
86
86
|
const ctx = new EdgeContext(
|
|
87
87
|
makeRequest({
|
|
88
88
|
'cf-connecting-ip': '198.51.100.5',
|
|
@@ -97,15 +97,15 @@ describe('HP-1: Edge ctx.ip resolution', () => {
|
|
|
97
97
|
|
|
98
98
|
// ---- optimization assertion (RED before HP-1) ----------------------------
|
|
99
99
|
|
|
100
|
-
it('2.6 [trim]
|
|
100
|
+
it('2.6 [trim] proxy: false does NOT invoke getEdgeClientIp', () => {
|
|
101
101
|
void new EdgeContext(makeRequest(), undefined, false);
|
|
102
102
|
expect(getEdgeClientIpMock).not.toHaveBeenCalled();
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
it('2.6 [trim]
|
|
106
|
-
void new EdgeContext(makeRequest({ 'cf-connecting-ip': '198.51.100.5' }), undefined,
|
|
105
|
+
it('2.6 [trim] proxy: 1 (hop count) still resolves via getEdgeClientIp', () => {
|
|
106
|
+
void new EdgeContext(makeRequest({ 'cf-connecting-ip': '198.51.100.5' }), undefined, 1);
|
|
107
107
|
expect(getEdgeClientIpMock).toHaveBeenCalledTimes(1);
|
|
108
|
-
expect(getEdgeClientIpMock).toHaveBeenCalledWith(expect.any(Request),
|
|
108
|
+
expect(getEdgeClientIpMock).toHaveBeenCalledWith(expect.any(Request), 1);
|
|
109
109
|
});
|
|
110
110
|
});
|
|
111
111
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1-3b (report/dx-review-serverless-edge-adapters.md): when the framework's
|
|
3
|
+
* own timeout fires (a 504), the error log should name the effective timeout
|
|
4
|
+
* value AND its source (an explicit `options.timeout` vs. the
|
|
5
|
+
* `DEFAULT_EDGE_TIMEOUT_MS` fallback) — today a 504 gives no indication of
|
|
6
|
+
* which timeout fired or why, forcing a developer to go read the adapter's
|
|
7
|
+
* source to find out.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createApp } from '@nextrush/core';
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { createFetchHandler, DEFAULT_EDGE_TIMEOUT_MS } from '../adapter';
|
|
13
|
+
|
|
14
|
+
function neverSettlingApp(): ReturnType<typeof createApp> {
|
|
15
|
+
const app = createApp();
|
|
16
|
+
app.use(async (ctx) => {
|
|
17
|
+
await new Promise<void>((resolve) => {
|
|
18
|
+
ctx.signal.addEventListener('abort', () => resolve());
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
return app;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('timeout attribution on the 504 path (P1-3b)', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.useFakeTimers();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.useRealTimers();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('logs the effective value and "default" as the source when no explicit timeout was passed', async () => {
|
|
34
|
+
const app = neverSettlingApp();
|
|
35
|
+
const warnSpy = vi.spyOn(app.logger, 'warn').mockImplementation(() => undefined);
|
|
36
|
+
const handler = createFetchHandler(app, {}); // no explicit timeout -> DEFAULT_EDGE_TIMEOUT_MS
|
|
37
|
+
|
|
38
|
+
const resPromise = handler(new Request('http://localhost/'));
|
|
39
|
+
await vi.advanceTimersByTimeAsync(DEFAULT_EDGE_TIMEOUT_MS);
|
|
40
|
+
const res = await resPromise;
|
|
41
|
+
|
|
42
|
+
expect(res.status).toBe(504);
|
|
43
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
44
|
+
const message = warnSpy.mock.calls[0]?.[0] as string;
|
|
45
|
+
expect(message).toMatch(/\[nextrush\/edge\]/);
|
|
46
|
+
expect(message).toMatch(new RegExp(`${DEFAULT_EDGE_TIMEOUT_MS}ms`));
|
|
47
|
+
expect(message).toMatch(/default/i);
|
|
48
|
+
warnSpy.mockRestore();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('logs the effective value and "explicit options.timeout" as the source when a timeout is passed', async () => {
|
|
52
|
+
const app = neverSettlingApp();
|
|
53
|
+
const warnSpy = vi.spyOn(app.logger, 'warn').mockImplementation(() => undefined);
|
|
54
|
+
const handler = createFetchHandler(app, { timeout: 12 });
|
|
55
|
+
|
|
56
|
+
const resPromise = handler(new Request('http://localhost/'));
|
|
57
|
+
await vi.advanceTimersByTimeAsync(12);
|
|
58
|
+
const res = await resPromise;
|
|
59
|
+
|
|
60
|
+
expect(res.status).toBe(504);
|
|
61
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
62
|
+
const message = warnSpy.mock.calls[0]?.[0] as string;
|
|
63
|
+
expect(message).toMatch(/\[nextrush\/edge\]/);
|
|
64
|
+
expect(message).toMatch(/12ms/);
|
|
65
|
+
expect(message).toMatch(/explicit/i);
|
|
66
|
+
warnSpy.mockRestore();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('does not log a timeout warning when the handler completes before the timeout', async () => {
|
|
70
|
+
const app = createApp();
|
|
71
|
+
app.use((ctx) => ctx.json({ ok: true }));
|
|
72
|
+
const warnSpy = vi.spyOn(app.logger, 'warn').mockImplementation(() => undefined);
|
|
73
|
+
const handler = createFetchHandler(app, { timeout: 5000 });
|
|
74
|
+
|
|
75
|
+
await handler(new Request('http://localhost/'));
|
|
76
|
+
|
|
77
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
78
|
+
warnSpy.mockRestore();
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/adapter.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import type { Application } from '@nextrush/core';
|
|
20
20
|
import { jsonErrorResponse } from '@nextrush/runtime';
|
|
21
|
-
import type { AdapterContextFactory, FetchAdapter } from '@nextrush/types';
|
|
21
|
+
import type { AdapterContextFactory, FetchAdapter, PlatformId, ProxyTrust } from '@nextrush/types';
|
|
22
22
|
import { createEdgeContext, EdgeContext, type EdgeExecutionContext } from './context';
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -27,13 +27,35 @@ import { createEdgeContext, EdgeContext, type EdgeExecutionContext } from './con
|
|
|
27
27
|
* default to a bounded timeout rather than none).
|
|
28
28
|
*
|
|
29
29
|
* @remarks
|
|
30
|
-
*
|
|
31
|
-
* Functions: 25 s), comfortably above typical handler
|
|
32
|
-
* framework's clean `504` fires before the
|
|
30
|
+
* 24 000 ms — strictly below the tightest common edge-platform wall limit
|
|
31
|
+
* (Vercel Edge Functions: 25 s exactly), comfortably above typical handler
|
|
32
|
+
* durations, so the framework's clean `504` reliably fires before the
|
|
33
|
+
* platform terminates the isolate. A prior value of 25 000 ms sat exactly
|
|
34
|
+
* *at* that limit rather than below it, racing the platform's own kill with
|
|
35
|
+
* no margin — this constant is deliberately 1 000 ms under the tightest
|
|
36
|
+
* limit rather than per-platform-branched, since one shared constant is
|
|
37
|
+
* simpler to reason about than a platform switch inside a package whose
|
|
38
|
+
* whole point is one runner for every edge platform.
|
|
33
39
|
* Pass `timeout: 0` to disable the framework timeout entirely (platform limit
|
|
34
40
|
* alone applies) or a positive value to override the default.
|
|
35
41
|
*/
|
|
36
|
-
export const DEFAULT_EDGE_TIMEOUT_MS =
|
|
42
|
+
export const DEFAULT_EDGE_TIMEOUT_MS = 24_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Module-level tracking for the boot-reuse warning (P1-3a).
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* `bootedApps` records every distinct `Application` that has completed at
|
|
49
|
+
* least one request through this module instance; `warnedBootReuse` ensures
|
|
50
|
+
* the warning fires at most once per module instance, not once per extra app.
|
|
51
|
+
* Module-level (not per-closure) is deliberate: the mistake this catches
|
|
52
|
+
* (calling `createApp()`/`createFetchHandler()` inside the exported handler)
|
|
53
|
+
* produces a NEW `createRequestRunner` closure on every invocation, so
|
|
54
|
+
* per-closure state could never observe a second app.
|
|
55
|
+
*/
|
|
56
|
+
const bootedApps = new WeakSet<Application>();
|
|
57
|
+
let hasBootedAnyApp = false;
|
|
58
|
+
let warnedBootReuse = false;
|
|
37
59
|
|
|
38
60
|
/**
|
|
39
61
|
* Options for the fetch handler
|
|
@@ -50,19 +72,34 @@ export interface FetchHandlerOptions {
|
|
|
50
72
|
* first, cancelling the still-running handler via `ctx.signal`.
|
|
51
73
|
*
|
|
52
74
|
* @remarks
|
|
53
|
-
* Defaults to {@link DEFAULT_EDGE_TIMEOUT_MS} (
|
|
54
|
-
* (F-07/ADR-0010) — below the tightest common edge-platform wall
|
|
55
|
-
* (Vercel Edge: 25 s), so the framework's clean `504` fires before
|
|
56
|
-
* platform terminates the isolate. Pass `0` to disable the framework
|
|
75
|
+
* Defaults to {@link DEFAULT_EDGE_TIMEOUT_MS} (24 000 ms) when omitted
|
|
76
|
+
* (F-07/ADR-0010) — strictly below the tightest common edge-platform wall
|
|
77
|
+
* limit (Vercel Edge: 25 s), so the framework's clean `504` fires before
|
|
78
|
+
* the platform terminates the isolate. Pass `0` to disable the framework
|
|
57
79
|
* timeout entirely (the platform's own limit still applies).
|
|
58
80
|
*
|
|
59
81
|
* Per-platform CPU/wall limits for context when overriding:
|
|
60
82
|
* - Cloudflare Workers: 30 000 (30 s CPU limit)
|
|
61
83
|
* - Vercel Edge: 25 000 (25 s wall limit)
|
|
62
84
|
*
|
|
63
|
-
* @default DEFAULT_EDGE_TIMEOUT_MS (
|
|
85
|
+
* @default DEFAULT_EDGE_TIMEOUT_MS (24000)
|
|
64
86
|
*/
|
|
65
87
|
timeout?: number;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Explicit named platform, overriding detection (RFC-026).
|
|
91
|
+
*
|
|
92
|
+
* @remarks
|
|
93
|
+
* Internal — set by `@nextrush/adapter-serverless`'s Tier-1 handlers
|
|
94
|
+
* (`createLambdaHandler`, `createGoogleHandler`, `createAzureHandler`),
|
|
95
|
+
* which already know their own platform identity unambiguously and pass it
|
|
96
|
+
* through rather than relying on detection. Application code calling
|
|
97
|
+
* `createFetchHandler`/`createCloudflareHandler`/etc. directly should not
|
|
98
|
+
* normally need to set this — the three named edge platforms
|
|
99
|
+
* (Cloudflare Workers, Vercel Edge, Netlify Edge) are still auto-detected
|
|
100
|
+
* when omitted.
|
|
101
|
+
*/
|
|
102
|
+
platform?: PlatformId;
|
|
66
103
|
}
|
|
67
104
|
|
|
68
105
|
/**
|
|
@@ -130,7 +167,8 @@ function createRequestRunner(
|
|
|
130
167
|
// F-07/ADR-0010: default to DEFAULT_EDGE_TIMEOUT_MS when the caller specifies
|
|
131
168
|
// none (`undefined`); `0` remains an explicit opt-out (no framework timeout).
|
|
132
169
|
const timeout = options.timeout ?? DEFAULT_EDGE_TIMEOUT_MS;
|
|
133
|
-
const
|
|
170
|
+
const timeoutSource = options.timeout === undefined ? 'default' : 'explicit options.timeout';
|
|
171
|
+
const proxy = app.options.proxy ?? false;
|
|
134
172
|
|
|
135
173
|
/** Sentinel value returned by the timeout racer */
|
|
136
174
|
const TIMEOUT_SENTINEL = Symbol('timeout');
|
|
@@ -141,6 +179,24 @@ function createRequestRunner(
|
|
|
141
179
|
let bootPromise: Promise<ReturnType<Application['callback']>> | null = null;
|
|
142
180
|
const ensureBooted = (): Promise<ReturnType<Application['callback']>> => {
|
|
143
181
|
bootPromise ??= app.ready().then(() => {
|
|
182
|
+
// P1-3a: outside production, warn once per module instance if a
|
|
183
|
+
// DIFFERENT Application already booted here — the mechanical signature
|
|
184
|
+
// of building the app inside the exported handler instead of at module
|
|
185
|
+
// scope (rebuilding the app on every invocation defeats warm reuse).
|
|
186
|
+
if (!app.isProduction && !warnedBootReuse && hasBootedAnyApp && !bootedApps.has(app)) {
|
|
187
|
+
warnedBootReuse = true;
|
|
188
|
+
console.warn(
|
|
189
|
+
'[nextrush/edge] A different Application booted in this process/isolate than the one ' +
|
|
190
|
+
'that booted first. This usually means createApp() (or createFetchHandler/createCloudflareHandler/' +
|
|
191
|
+
'createVercelHandler/createNetlifyHandler) is being called inside the exported handler ' +
|
|
192
|
+
'instead of at module scope — rebuilding the app on every invocation defeats warm-instance ' +
|
|
193
|
+
'reuse and increases cold-start-like latency on every request. Build the app once, at module ' +
|
|
194
|
+
'scope, above the export. (This message appears in development only.)'
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
bootedApps.add(app);
|
|
198
|
+
hasBootedAnyApp = true;
|
|
199
|
+
|
|
144
200
|
const handler = app.callback();
|
|
145
201
|
// F-14: mark the app running so `app.isRunning` is consistent with the
|
|
146
202
|
// server adapters. Edge has no server lifetime, so close()/destroy() are
|
|
@@ -158,7 +214,7 @@ function createRequestRunner(
|
|
|
158
214
|
env?: unknown
|
|
159
215
|
): Promise<Response> => {
|
|
160
216
|
const handler = await ensureBooted();
|
|
161
|
-
const ctx = createEdgeContext(request, executionContext,
|
|
217
|
+
const ctx = createEdgeContext(request, executionContext, proxy, env, app.isProduction, options.platform);
|
|
162
218
|
|
|
163
219
|
try {
|
|
164
220
|
if (timeout > 0) {
|
|
@@ -176,6 +232,12 @@ function createRequestRunner(
|
|
|
176
232
|
if (result === TIMEOUT_SENTINEL) {
|
|
177
233
|
// F-08: cancel the still-running handler cooperatively via ctx.signal.
|
|
178
234
|
ctx.triggerTimeout();
|
|
235
|
+
// P1-3b: name the effective timeout and its source so a 504 is
|
|
236
|
+
// attributable without reading this adapter's source.
|
|
237
|
+
app.logger.warn(
|
|
238
|
+
`[nextrush/edge] Request timed out after ${String(timeout)}ms (${timeoutSource}) — returning 504. ` +
|
|
239
|
+
`${ctx.method} ${ctx.path}`
|
|
240
|
+
);
|
|
179
241
|
return jsonErrorResponse(504, 'Gateway Timeout');
|
|
180
242
|
}
|
|
181
243
|
} finally {
|
|
@@ -315,7 +377,15 @@ export function createNetlifyHandler(
|
|
|
315
377
|
return createFetchHandler(app, options);
|
|
316
378
|
}
|
|
317
379
|
|
|
318
|
-
|
|
380
|
+
/**
|
|
381
|
+
* Alias of {@link createFetchHandler}.
|
|
382
|
+
*
|
|
383
|
+
* @deprecated Use {@link createFetchHandler} directly (P3-1) — two exported
|
|
384
|
+
* names for one function means autocomplete shows both with neither
|
|
385
|
+
* obviously canonical, on a package whose docs stress bundle discipline.
|
|
386
|
+
* `createFetchHandler` is the canonical name; this alias is kept for
|
|
387
|
+
* backwards compatibility and will be removed in a future major.
|
|
388
|
+
*/
|
|
319
389
|
export const createHandler = createFetchHandler;
|
|
320
390
|
|
|
321
391
|
// F-01: compile-time conformance guard. If the exported fetch-adapter shape
|
|
@@ -327,7 +397,7 @@ void _edgeConformance;
|
|
|
327
397
|
// AdapterContext over the shared Context contract. A drift in createEdgeContext's
|
|
328
398
|
// return type stops compiling here.
|
|
329
399
|
const _edgeContextFactory: AdapterContextFactory<
|
|
330
|
-
[Request, EdgeExecutionContext?, boolean?,
|
|
400
|
+
[Request, EdgeExecutionContext?, ProxyTrust?, unknown?, boolean?, PlatformId?],
|
|
331
401
|
EdgeContext
|
|
332
402
|
> = createEdgeContext;
|
|
333
403
|
void _edgeContextFactory;
|