@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.
@@ -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, true);
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, true);
110
+ ctx = new EdgeContext(request, undefined, 1);
111
111
 
112
- expect(ctx.ip).toBe('10.0.0.1');
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, true);
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 trustProxy is false', () => {
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
- expect(DEFAULT_EDGE_TIMEOUT_MS).toBeLessThanOrEqual(25_000);
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: `trustProxy: false` sets `ctx.ip` to `''` directly (Edge has no
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`); `trustProxy: true` still resolves via it,
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 `trustProxy`
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 trustProxy false → ctx.ip is the empty string (Edge has no socket)', () => {
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 trustProxy true + cf-connecting-ip wins the Cloudflare precedence', () => {
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
- true
68
+ 1
69
69
  );
70
70
  expect(ctx.ip).toBe('198.51.100.5');
71
71
  });
72
72
 
73
- it('2.4 trustProxy true + no cf-connecting-ip → x-forwarded-for → x-real-ip precedence', () => {
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
- true
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, true);
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 trustProxy false ignores cf-connecting-ip / x-forwarded-for / x-real-ip', () => {
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] trustProxy false does NOT invoke getEdgeClientIp', () => {
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] trustProxy true still resolves via getEdgeClientIp', () => {
106
- void new EdgeContext(makeRequest({ 'cf-connecting-ip': '198.51.100.5' }), undefined, true);
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), true);
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
- * 25 000 ms — below the tightest common edge-platform wall limit (Vercel Edge
31
- * Functions: 25 s), comfortably above typical handler durations, so the
32
- * framework's clean `504` fires before the platform terminates the isolate.
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 = 25_000;
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} (25 000 ms) when omitted
54
- * (F-07/ADR-0010) — below the tightest common edge-platform wall limit
55
- * (Vercel Edge: 25 s), so the framework's clean `504` fires before the
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 (25000)
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 trustProxy = app.options.proxy ?? false;
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, trustProxy, env);
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
- // Alias for backwards compatibility and consistency
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?, unknown?],
400
+ [Request, EdgeExecutionContext?, ProxyTrust?, unknown?, boolean?, PlatformId?],
331
401
  EdgeContext
332
402
  > = createEdgeContext;
333
403
  void _edgeContextFactory;