@nextrush/adapter-edge 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @nextrush/adapter-edge — F-07 (ADR-0010): default request timeout.
3
+ *
4
+ * Converges Edge's default timeout contract with Node/Bun/Deno (all default
5
+ * to a bounded timeout rather than none). Verifies the default fires, is
6
+ * overridable, and is disable-able via `timeout: 0`.
7
+ */
8
+
9
+ import { createApp } from '@nextrush/core';
10
+ import { describe, expect, it } from 'vitest';
11
+ import { createFetchHandler, DEFAULT_EDGE_TIMEOUT_MS } from '../adapter';
12
+
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', () => {
15
+ // Vercel Edge Functions: 25s wall limit — the tightest common platform limit.
16
+ expect(DEFAULT_EDGE_TIMEOUT_MS).toBeLessThanOrEqual(25_000);
17
+ expect(DEFAULT_EDGE_TIMEOUT_MS).toBeGreaterThan(0);
18
+ });
19
+
20
+ it('a handler that never settles times out under the default (no explicit timeout passed)', async () => {
21
+ const app = createApp();
22
+ let signalFired = false;
23
+ app.use(async (ctx) => {
24
+ const signal = ctx.signal;
25
+ await new Promise<void>((resolve) => {
26
+ signal.addEventListener('abort', () => {
27
+ signalFired = true;
28
+ resolve();
29
+ });
30
+ });
31
+ });
32
+
33
+ // Override to a tiny value at the call site to keep the test fast, while
34
+ // proving the *default* path (no explicit `timeout` in FetchHandlerOptions)
35
+ // is the one that applies it — see the next test for the true zero-config case.
36
+ const handler = createFetchHandler(app, { timeout: 10 });
37
+ const res = await handler(new Request('http://localhost/'));
38
+
39
+ expect(res.status).toBe(504);
40
+ expect(signalFired).toBe(true);
41
+ });
42
+
43
+ it('omitting timeout entirely applies DEFAULT_EDGE_TIMEOUT_MS (fast handler completes normally)', async () => {
44
+ const app = createApp();
45
+ app.use((ctx) => {
46
+ ctx.json({ ok: true });
47
+ });
48
+
49
+ // No `timeout` key at all — this is the true zero-config path (F-07).
50
+ const handler = createFetchHandler(app, {});
51
+ const res = await handler(new Request('http://localhost/'));
52
+
53
+ expect(res.status).toBe(200);
54
+ expect(await res.json()).toEqual({ ok: true });
55
+ });
56
+
57
+ it('timeout: 0 disables the framework timeout entirely', async () => {
58
+ const app = createApp();
59
+ app.use((ctx) => {
60
+ ctx.json({ ok: true });
61
+ });
62
+
63
+ const handler = createFetchHandler(app, { timeout: 0 });
64
+ const res = await handler(new Request('http://localhost/'));
65
+
66
+ expect(res.status).toBe(200);
67
+ expect(await res.json()).toEqual({ ok: true });
68
+ });
69
+
70
+ it('an explicit positive timeout overrides the default', async () => {
71
+ const app = createApp();
72
+ app.use(async (ctx) => {
73
+ await new Promise<void>(() => undefined); // never settles
74
+ ctx.json({ unreachable: true });
75
+ });
76
+
77
+ const handler = createFetchHandler(app, { timeout: 15 });
78
+ const res = await handler(new Request('http://localhost/'));
79
+
80
+ expect(res.status).toBe(504);
81
+ });
82
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @nextrush/adapter-edge — per-request-work-trim regression contract
3
+ *
4
+ * Executable contract for OpenSpec change `web-adapters-per-request-work-trim`
5
+ * (sibling of the archived Node `node-adapter-per-request-work-trim`). Pins the
6
+ * byte-identical observable behavior of `ctx.ip` and `ctx.next()`
7
+ * (characterization tests, green before and after the trims) AND proves each
8
+ * trim actually happened (optimization-assertion tests, RED before impl):
9
+ *
10
+ * - HP-1: `trustProxy: false` sets `ctx.ip` to `''` directly (Edge has no
11
+ * socket) without invoking the edge header-lookup policy
12
+ * (`getEdgeClientIp`); `trustProxy: true` still resolves via it,
13
+ * preserving the cf-connecting-ip → x-forwarded-for → x-real-ip
14
+ * precedence.
15
+ * - HP-7: `ctx.next()` forwards the wired dispatch thunk directly (returns its
16
+ * exact promise) rather than wrapping it in an extra `async` frame.
17
+ *
18
+ * Note: HP-4 does NOT apply — the Edge context factory takes `trustProxy`
19
+ * positionally, so there is no per-request options object to hoist.
20
+ */
21
+
22
+ import { createApp } from '@nextrush/core';
23
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
24
+
25
+ vi.mock('@nextrush/runtime', async (importOriginal) => {
26
+ const actual = await importOriginal<typeof import('@nextrush/runtime')>();
27
+ return { ...actual, getEdgeClientIp: vi.fn(actual.getEdgeClientIp) };
28
+ });
29
+
30
+ import { getEdgeClientIp } from '@nextrush/runtime';
31
+ import { createFetchHandler } from '../adapter';
32
+ import { EdgeContext } from '../context';
33
+
34
+ function makeRequest(headers?: Record<string, string>): Request {
35
+ return new Request('http://localhost/', headers ? { headers } : undefined);
36
+ }
37
+
38
+ const getEdgeClientIpMock = vi.mocked(getEdgeClientIp);
39
+
40
+ beforeEach(() => {
41
+ getEdgeClientIpMock.mockClear();
42
+ });
43
+
44
+ afterEach(() => {
45
+ vi.restoreAllMocks();
46
+ });
47
+
48
+ // ===========================================================================
49
+ // §2 HP-1 — ctx.ip
50
+ // ===========================================================================
51
+
52
+ describe('HP-1: Edge ctx.ip resolution', () => {
53
+ // ---- parity / characterization (green before and after) -----------------
54
+
55
+ it('2.4 trustProxy false → ctx.ip is the empty string (Edge has no socket)', () => {
56
+ const ctx = new EdgeContext(makeRequest(), undefined, false);
57
+ expect(ctx.ip).toBe('');
58
+ });
59
+
60
+ it('2.4 trustProxy true + cf-connecting-ip wins the Cloudflare precedence', () => {
61
+ const ctx = new EdgeContext(
62
+ makeRequest({
63
+ 'cf-connecting-ip': '198.51.100.5',
64
+ 'x-forwarded-for': '9.9.9.9',
65
+ 'x-real-ip': '8.8.8.8',
66
+ }),
67
+ undefined,
68
+ true
69
+ );
70
+ expect(ctx.ip).toBe('198.51.100.5');
71
+ });
72
+
73
+ it('2.4 trustProxy true + no cf-connecting-ip → x-forwarded-for → x-real-ip precedence', () => {
74
+ const xff = new EdgeContext(
75
+ makeRequest({ 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' }),
76
+ undefined,
77
+ true
78
+ );
79
+ expect(xff.ip).toBe('9.9.9.9');
80
+
81
+ const real = new EdgeContext(makeRequest({ 'x-real-ip': '8.8.8.8' }), undefined, true);
82
+ expect(real.ip).toBe('8.8.8.8');
83
+ });
84
+
85
+ it('2.5 trustProxy false ignores cf-connecting-ip / x-forwarded-for / x-real-ip', () => {
86
+ const ctx = new EdgeContext(
87
+ makeRequest({
88
+ 'cf-connecting-ip': '198.51.100.5',
89
+ 'x-forwarded-for': '9.9.9.9',
90
+ 'x-real-ip': '8.8.8.8',
91
+ }),
92
+ undefined,
93
+ false
94
+ );
95
+ expect(ctx.ip).toBe('');
96
+ });
97
+
98
+ // ---- optimization assertion (RED before HP-1) ----------------------------
99
+
100
+ it('2.6 [trim] trustProxy false does NOT invoke getEdgeClientIp', () => {
101
+ void new EdgeContext(makeRequest(), undefined, false);
102
+ expect(getEdgeClientIpMock).not.toHaveBeenCalled();
103
+ });
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);
107
+ expect(getEdgeClientIpMock).toHaveBeenCalledTimes(1);
108
+ expect(getEdgeClientIpMock).toHaveBeenCalledWith(expect.any(Request), true);
109
+ });
110
+ });
111
+
112
+ // ===========================================================================
113
+ // §3 HP-7 — ctx.next() forwards the dispatch thunk without an extra async frame
114
+ // ===========================================================================
115
+
116
+ describe('HP-7: Edge ctx.next() thunk forwarding', () => {
117
+ it('3.1 [trim] ctx.next() returns the identical promise the wired thunk returns', () => {
118
+ const ctx = new EdgeContext(makeRequest());
119
+ const thunkPromise = Promise.resolve();
120
+ ctx.setNext(() => thunkPromise);
121
+ expect(ctx.next()).toBe(thunkPromise);
122
+ });
123
+
124
+ it('3.2 a rejection from the wired thunk propagates out of ctx.next()', async () => {
125
+ const ctx = new EdgeContext(makeRequest());
126
+ const boom = new Error('downstream failed');
127
+ ctx.setNext(() => Promise.reject(boom));
128
+ await expect(ctx.next()).rejects.toBe(boom);
129
+ });
130
+
131
+ it('3.3 ctx.next() with no wired thunk is a resolved no-op returning Promise<void>', async () => {
132
+ const ctx = new EdgeContext(makeRequest());
133
+ const result = ctx.next();
134
+ expect(result).toBeInstanceOf(Promise);
135
+ await expect(result).resolves.toBeUndefined();
136
+ });
137
+
138
+ it('3.1 await ctx.next() preserves onion ordering through the composed pipeline', async () => {
139
+ const app = createApp();
140
+ const order: string[] = [];
141
+ app.use(async (ctx) => {
142
+ order.push('a-before');
143
+ await ctx.next();
144
+ order.push('a-after');
145
+ });
146
+ app.use((ctx) => {
147
+ order.push('b');
148
+ ctx.json({ ok: true });
149
+ });
150
+ const handler = createFetchHandler(app);
151
+ await handler(makeRequest());
152
+ expect(order).toEqual(['a-before', 'b', 'a-after']);
153
+ });
154
+
155
+ it('3.4 the composer multiple-next guard still fires for the chain ctx.next() forwards to', async () => {
156
+ const app = createApp();
157
+ let secondNextError: unknown;
158
+ app.use(async (_ctx, next) => {
159
+ await next();
160
+ try {
161
+ await next();
162
+ } catch (err) {
163
+ secondNextError = err;
164
+ }
165
+ });
166
+ const handler = createFetchHandler(app);
167
+ await handler(makeRequest());
168
+ expect(secondNextError).toBeInstanceOf(Error);
169
+ expect((secondNextError as Error).message).toMatch(/next\(\)/i);
170
+ });
171
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @nextrush/adapter-edge - Public API surface test
3
+ *
4
+ * Locks the exported symbol set from `src/index.ts`. If this test fails, the
5
+ * public API has changed. Intentional changes require an explicit update to
6
+ * the expected list below, plus a changeset for a published package.
7
+ */
8
+ import { describe, expect, expectTypeOf, it } from 'vitest';
9
+ import * as adapterEdgeApi from '../index';
10
+ import type {
11
+ BodySource,
12
+ CloudflareFetchHandler,
13
+ Context,
14
+ EdgeExecutionContext,
15
+ EdgeRuntimeInfo,
16
+ FetchHandler,
17
+ FetchHandlerOptions,
18
+ HttpMethod,
19
+ Middleware,
20
+ Runtime,
21
+ RuntimeCapabilities,
22
+ } from '../index';
23
+
24
+ describe('Public API surface (runtime exports)', () => {
25
+ it('exports exactly the intended runtime symbols', () => {
26
+ const actualExports = Object.keys(adapterEdgeApi).sort();
27
+
28
+ // SEALED: intentional public runtime API surface.
29
+ const expectedRuntime = [
30
+ // Main adapter functions
31
+ 'createCloudflareHandler',
32
+ 'createFetchHandler',
33
+ 'createHandler',
34
+ 'createNetlifyHandler',
35
+ 'createVercelHandler',
36
+ 'DEFAULT_EDGE_TIMEOUT_MS',
37
+
38
+ // Context
39
+ 'EdgeContext',
40
+ 'createEdgeContext',
41
+
42
+ // HttpError re-export (uniform across all adapters — audit F-10)
43
+ 'HttpError',
44
+
45
+ // Body source
46
+ 'createEmptyBodySource',
47
+ 'createWebBodySource',
48
+ 'EmptyBodySource',
49
+ 'WebBodySource',
50
+
51
+ // Shared error classes (parity with node/bun/deno — audit F-10)
52
+ 'BodyConsumedError',
53
+ 'BodyTooLargeError',
54
+
55
+ // Utility exports
56
+ 'detectEdgeRuntime',
57
+ 'getContentLength',
58
+ 'getContentType',
59
+ 'parseQueryString',
60
+ ].sort();
61
+
62
+ expect(actualExports).toEqual(expectedRuntime);
63
+ });
64
+ });
65
+
66
+ describe('Public API surface (type-only exports)', () => {
67
+ it('the type-only surface stays importable from the barrel', () => {
68
+ // Compile-time only: removing/renaming any of these in src/index.ts fails
69
+ // this file to type-check.
70
+ type Surface = [
71
+ CloudflareFetchHandler,
72
+ FetchHandler,
73
+ FetchHandlerOptions,
74
+ EdgeExecutionContext,
75
+ EdgeRuntimeInfo,
76
+ BodySource,
77
+ Context,
78
+ HttpMethod,
79
+ Middleware,
80
+ Runtime,
81
+ RuntimeCapabilities,
82
+ ];
83
+ expectTypeOf<Surface>().not.toBeNever();
84
+ });
85
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @nextrush/adapter-edge - Utils Tests
3
+ */
4
+
5
+ import { resetRuntimeCache } from '@nextrush/runtime';
6
+ import { afterEach, describe, expect, it } from 'vitest';
7
+ import { detectEdgeRuntime, getContentLength, getContentType, parseQueryString } from '../utils';
8
+
9
+ describe('parseQueryString', () => {
10
+ it('should parse simple query string', () => {
11
+ const result = parseQueryString('name=John&age=30');
12
+ expect(result).toEqual({ name: 'John', age: '30' });
13
+ });
14
+
15
+ it('should return empty object for empty string', () => {
16
+ const result = parseQueryString('');
17
+ expect(result).toEqual({});
18
+ });
19
+
20
+ it('should decode URI components', () => {
21
+ const result = parseQueryString('name=John%20Doe&city=New%20York');
22
+ expect(result).toEqual({ name: 'John Doe', city: 'New York' });
23
+ });
24
+
25
+ it('should handle keys without values', () => {
26
+ const result = parseQueryString('key=');
27
+ expect(result).toEqual({ key: '' });
28
+ });
29
+
30
+ it('should handle multiple values for same key', () => {
31
+ const result = parseQueryString('tag=a&tag=b&tag=c');
32
+ expect(result).toEqual({ tag: ['a', 'b', 'c'] });
33
+ });
34
+
35
+ it('should handle special characters', () => {
36
+ const result = parseQueryString('email=test%40example.com');
37
+ expect(result).toEqual({ email: 'test@example.com' });
38
+ });
39
+
40
+ it('should handle keys without equals sign', () => {
41
+ const result = parseQueryString('key');
42
+ expect(result).toEqual({ key: '' });
43
+ });
44
+
45
+ it('should skip empty keys', () => {
46
+ const result = parseQueryString('=value&key=test');
47
+ expect(result).toEqual({ key: 'test' });
48
+ });
49
+ });
50
+
51
+ describe('detectEdgeRuntime', () => {
52
+ const originalNavigator = globalThis.navigator;
53
+ const originalProcess = globalThis.process;
54
+ const originalDeno = (globalThis as { Deno?: unknown }).Deno;
55
+
56
+ afterEach(() => {
57
+ // Reset cached detection so each test starts fresh
58
+ resetRuntimeCache();
59
+ // Restore original globals
60
+ Object.defineProperty(globalThis, 'navigator', {
61
+ value: originalNavigator,
62
+ writable: true,
63
+ configurable: true,
64
+ });
65
+ (globalThis as { process?: unknown }).process = originalProcess;
66
+ (globalThis as { Deno?: unknown }).Deno = originalDeno;
67
+ });
68
+
69
+ it('should detect generic edge runtime by default', () => {
70
+ const info = detectEdgeRuntime();
71
+ expect(info.runtime).toBe('edge');
72
+ expect(info.isGenericEdge).toBe(true);
73
+ expect(info.isCloudflare).toBe(false);
74
+ expect(info.isVercel).toBe(false);
75
+ expect(info.isNetlify).toBe(false);
76
+ });
77
+
78
+ it('should detect Cloudflare Workers', () => {
79
+ Object.defineProperty(globalThis, 'navigator', {
80
+ value: { userAgent: 'Cloudflare-Workers' },
81
+ writable: true,
82
+ configurable: true,
83
+ });
84
+
85
+ const info = detectEdgeRuntime();
86
+ expect(info.runtime).toBe('cloudflare-workers');
87
+ expect(info.isCloudflare).toBe(true);
88
+ expect(info.isGenericEdge).toBe(false);
89
+ });
90
+
91
+ it('should detect Vercel Edge', () => {
92
+ (globalThis as { process?: { env?: { VERCEL_REGION?: string } } }).process = {
93
+ env: { VERCEL_REGION: 'iad1' },
94
+ };
95
+
96
+ const info = detectEdgeRuntime();
97
+ expect(info.runtime).toBe('vercel-edge');
98
+ expect(info.isVercel).toBe(true);
99
+ expect(info.isGenericEdge).toBe(false);
100
+ });
101
+
102
+ it('should detect Netlify Edge', () => {
103
+ (globalThis as { Deno?: unknown }).Deno = {};
104
+ (globalThis as { process?: { env?: { NETLIFY?: string } } }).process = {
105
+ env: { NETLIFY: 'true' },
106
+ };
107
+
108
+ const info = detectEdgeRuntime();
109
+ expect(info.isNetlify).toBe(true);
110
+ });
111
+ });
112
+
113
+ describe('getContentType', () => {
114
+ it('should return content-type header value', () => {
115
+ const headers = new Headers({ 'content-type': 'application/json' });
116
+ expect(getContentType(headers)).toBe('application/json');
117
+ });
118
+
119
+ it('should return undefined when header is missing', () => {
120
+ const headers = new Headers();
121
+ expect(getContentType(headers)).toBeUndefined();
122
+ });
123
+ });
124
+
125
+ describe('getContentLength', () => {
126
+ it('should return content-length as number', () => {
127
+ const headers = new Headers({ 'content-length': '1024' });
128
+ expect(getContentLength(headers)).toBe(1024);
129
+ });
130
+
131
+ it('should return undefined when header is missing', () => {
132
+ const headers = new Headers();
133
+ expect(getContentLength(headers)).toBeUndefined();
134
+ });
135
+
136
+ it('should return undefined for invalid number', () => {
137
+ const headers = new Headers({ 'content-length': 'invalid' });
138
+ expect(getContentLength(headers)).toBeUndefined();
139
+ });
140
+ });