@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.
package/src/adapter.ts ADDED
@@ -0,0 +1,333 @@
1
+ /**
2
+ * @nextrush/adapter-edge - Edge Runtime Adapter
3
+ *
4
+ * Connects NextRush Application to Edge runtimes via fetch handlers.
5
+ *
6
+ * @remarks
7
+ * **Size optimization**: Edge runtimes have strict bundle-size constraints
8
+ * (e.g. Cloudflare Workers 1 MB limit). Import only the packages you need:
9
+ *
10
+ * - Import `@nextrush/core` and `@nextrush/adapter-edge` only.
11
+ * - Avoid `@nextrush/di` unless you need DI (adds `reflect-metadata`).
12
+ * - Tree-shake unused middleware — each middleware is a separate package.
13
+ * - Use `@nextrush/router` only if you have dynamic routes; for simple
14
+ * handlers, use the application directly.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ import type { Application } from '@nextrush/core';
20
+ import { jsonErrorResponse } from '@nextrush/runtime';
21
+ import type { AdapterContextFactory, FetchAdapter } from '@nextrush/types';
22
+ import { createEdgeContext, EdgeContext, type EdgeExecutionContext } from './context';
23
+
24
+ /**
25
+ * Default request timeout applied when the caller specifies none (F-07,
26
+ * ADR-0010), converging Edge's default contract with Node/Bun/Deno (which all
27
+ * default to a bounded timeout rather than none).
28
+ *
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.
33
+ * Pass `timeout: 0` to disable the framework timeout entirely (platform limit
34
+ * alone applies) or a positive value to override the default.
35
+ */
36
+ export const DEFAULT_EDGE_TIMEOUT_MS = 25_000;
37
+
38
+ /**
39
+ * Options for the fetch handler
40
+ */
41
+ export interface FetchHandlerOptions {
42
+ /**
43
+ * Custom error handler
44
+ */
45
+ onError?: (error: Error, ctx: EdgeContext) => Response | Promise<Response>;
46
+
47
+ /**
48
+ * Request timeout in milliseconds. The handler races the application logic
49
+ * against a timer and returns a 504 Gateway Timeout if the timer fires
50
+ * first, cancelling the still-running handler via `ctx.signal`.
51
+ *
52
+ * @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
57
+ * timeout entirely (the platform's own limit still applies).
58
+ *
59
+ * Per-platform CPU/wall limits for context when overriding:
60
+ * - Cloudflare Workers: 30 000 (30 s CPU limit)
61
+ * - Vercel Edge: 25 000 (25 s wall limit)
62
+ *
63
+ * @default DEFAULT_EDGE_TIMEOUT_MS (25000)
64
+ */
65
+ timeout?: number;
66
+ }
67
+
68
+ /**
69
+ * Standard edge fetch handler type
70
+ */
71
+ export type FetchHandler = (
72
+ request: Request,
73
+ ctx?: EdgeExecutionContext
74
+ ) => Response | Promise<Response>;
75
+
76
+ /**
77
+ * Create a fetch handler for Edge runtimes
78
+ *
79
+ * @param app - NextRush Application instance
80
+ * @param options - Handler options
81
+ * @returns Fetch handler function
82
+ *
83
+ * @example
84
+ * ```typescript
85
+ * // Cloudflare Workers
86
+ * import { createApp } from '@nextrush/core';
87
+ * import { createFetchHandler } from '@nextrush/adapter-edge';
88
+ *
89
+ * const app = createApp();
90
+ * const handler = createFetchHandler(app);
91
+ *
92
+ * export default {
93
+ * fetch: handler
94
+ * };
95
+ * ```
96
+ *
97
+ * @example
98
+ * ```typescript
99
+ * // Vercel Edge Functions
100
+ * import { createApp } from '@nextrush/core';
101
+ * import { createFetchHandler } from '@nextrush/adapter-edge';
102
+ *
103
+ * const app = createApp();
104
+ * export const config = { runtime: 'edge' };
105
+ * export default createFetchHandler(app);
106
+ * ```
107
+ */
108
+ export function createFetchHandler(
109
+ app: Application,
110
+ options: FetchHandlerOptions = {}
111
+ ): FetchHandler {
112
+ const run = createRequestRunner(app, options);
113
+ return (request: Request, executionContext?: EdgeExecutionContext): Promise<Response> =>
114
+ run(request, executionContext);
115
+ }
116
+
117
+ /**
118
+ * Internal request runner shared by every edge entry point.
119
+ *
120
+ * @remarks
121
+ * Centralizes booting, context creation (threading Cloudflare `env` — F-03),
122
+ * timeout racing with cooperative cancellation (F-08), the header-preserving
123
+ * finalize path (F-02), and error handling — so Cloudflare/Vercel/Netlify
124
+ * handlers cannot drift.
125
+ */
126
+ function createRequestRunner(
127
+ app: Application,
128
+ options: FetchHandlerOptions
129
+ ): (request: Request, executionContext?: EdgeExecutionContext, env?: unknown) => Promise<Response> {
130
+ // F-07/ADR-0010: default to DEFAULT_EDGE_TIMEOUT_MS when the caller specifies
131
+ // none (`undefined`); `0` remains an explicit opt-out (no framework timeout).
132
+ const timeout = options.timeout ?? DEFAULT_EDGE_TIMEOUT_MS;
133
+ const trustProxy = app.options.proxy ?? false;
134
+
135
+ /** Sentinel value returned by the timeout racer */
136
+ const TIMEOUT_SENTINEL = Symbol('timeout');
137
+
138
+ // Edge has no serve()/start() phase, so the deferred boot barrier runs lazily
139
+ // on the first request (idempotent). The boot promise caches the snapshotted
140
+ // request handler, so every request awaits and reuses the same one.
141
+ let bootPromise: Promise<ReturnType<Application['callback']>> | null = null;
142
+ const ensureBooted = (): Promise<ReturnType<Application['callback']>> => {
143
+ bootPromise ??= app.ready().then(() => {
144
+ const handler = app.callback();
145
+ // F-14: mark the app running so `app.isRunning` is consistent with the
146
+ // server adapters. Edge has no server lifetime, so close()/destroy() are
147
+ // intentionally never called — that no-teardown contract is documented
148
+ // on createFetchHandler.
149
+ app.start();
150
+ return handler;
151
+ });
152
+ return bootPromise;
153
+ };
154
+
155
+ return async (
156
+ request: Request,
157
+ executionContext?: EdgeExecutionContext,
158
+ env?: unknown
159
+ ): Promise<Response> => {
160
+ const handler = await ensureBooted();
161
+ const ctx = createEdgeContext(request, executionContext, trustProxy, env);
162
+
163
+ try {
164
+ if (timeout > 0) {
165
+ let timerId: ReturnType<typeof setTimeout> | undefined;
166
+ try {
167
+ const result = await Promise.race([
168
+ handler(ctx).then(() => undefined),
169
+ new Promise<typeof TIMEOUT_SENTINEL>((resolve) => {
170
+ timerId = setTimeout(() => {
171
+ resolve(TIMEOUT_SENTINEL);
172
+ }, timeout);
173
+ }),
174
+ ]);
175
+
176
+ if (result === TIMEOUT_SENTINEL) {
177
+ // F-08: cancel the still-running handler cooperatively via ctx.signal.
178
+ ctx.triggerTimeout();
179
+ return jsonErrorResponse(504, 'Gateway Timeout');
180
+ }
181
+ } finally {
182
+ // F-08: always clear the timer, on both handler-wins and timeout paths.
183
+ if (timerId !== undefined) clearTimeout(timerId);
184
+ }
185
+ } else {
186
+ await handler(ctx);
187
+ }
188
+
189
+ // F-02: finalize through the context so headers set via ctx.set() survive
190
+ // an implicit/empty response. The 404 default body is written through the
191
+ // same builder, preserving those headers too.
192
+ if (!ctx.responded && ctx.status === 404) {
193
+ ctx.json({ error: 'Not Found' });
194
+ }
195
+ return ctx.getResponse();
196
+ } catch (error) {
197
+ // Custom error handler
198
+ if (options.onError) {
199
+ return options.onError(error as Error, ctx);
200
+ }
201
+
202
+ // Default error handling
203
+ app.logger.error('Request error:', error);
204
+
205
+ return jsonErrorResponse(500, 'Internal Server Error');
206
+ }
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Cloudflare Workers fetch handler type
212
+ *
213
+ * Cloudflare's module format passes `(request, env, ctx)` where:
214
+ * - `env` contains bindings (KV, D1, R2, secrets, etc.)
215
+ * - `ctx` provides `waitUntil()` and `passThroughOnException()`
216
+ *
217
+ * @typeParam Env - The shape of the Worker's bindings.
218
+ */
219
+ export type CloudflareFetchHandler<Env = Record<string, unknown>> = (
220
+ request: Request,
221
+ env: Env,
222
+ ctx: EdgeExecutionContext
223
+ ) => Response | Promise<Response>;
224
+
225
+ /**
226
+ * Create Cloudflare Workers module export
227
+ *
228
+ * @param app - NextRush Application instance
229
+ * @param options - Handler options
230
+ * @returns Cloudflare Workers module export object with correct `(request, env, ctx)` signature
231
+ *
232
+ * @remarks
233
+ * The Cloudflare `env` argument (KV, D1, R2, Durable Objects, Queues, secrets)
234
+ * is threaded onto the context as `ctx.env` (audit F-03). Pass a binding type
235
+ * as the generic to make `ctx.env` fully typed inside handlers.
236
+ *
237
+ * @example
238
+ * ```typescript
239
+ * interface Env { MY_KV: KVNamespace }
240
+ * export default createCloudflareHandler<Env>(app);
241
+ * // inside a handler: ctx.env.MY_KV.get('key')
242
+ * ```
243
+ */
244
+ export function createCloudflareHandler<Env = Record<string, unknown>>(
245
+ app: Application,
246
+ options: FetchHandlerOptions = {}
247
+ ): { fetch: CloudflareFetchHandler<Env> } {
248
+ const run = createRequestRunner(app, options);
249
+
250
+ return {
251
+ fetch: (request: Request, env: Env, ctx: EdgeExecutionContext): Promise<Response> =>
252
+ run(request, ctx, env),
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Create Vercel Edge Function handler
258
+ *
259
+ * @param app - NextRush Application instance
260
+ * @param options - Handler options
261
+ * @returns Vercel Edge Function handler
262
+ *
263
+ * @example
264
+ * ```typescript
265
+ * // api/hello.ts
266
+ * import { createApp } from '@nextrush/core';
267
+ * import { createVercelHandler } from '@nextrush/adapter-edge';
268
+ *
269
+ * const app = createApp();
270
+ *
271
+ * app.use(async (ctx) => {
272
+ * ctx.json({
273
+ * message: 'Hello from Vercel Edge!',
274
+ * region: process.env.VERCEL_REGION
275
+ * });
276
+ * });
277
+ *
278
+ * export const config = { runtime: 'edge' };
279
+ * export default createVercelHandler(app);
280
+ * ```
281
+ */
282
+ export function createVercelHandler(
283
+ app: Application,
284
+ options: FetchHandlerOptions = {}
285
+ ): FetchHandler {
286
+ return createFetchHandler(app, options);
287
+ }
288
+
289
+ /**
290
+ * Create Netlify Edge Function handler
291
+ *
292
+ * @param app - NextRush Application instance
293
+ * @param options - Handler options
294
+ * @returns Netlify Edge Function handler
295
+ *
296
+ * @example
297
+ * ```typescript
298
+ * // netlify/edge-functions/api.ts
299
+ * import { createApp } from '@nextrush/core';
300
+ * import { createNetlifyHandler } from '@nextrush/adapter-edge';
301
+ *
302
+ * const app = createApp();
303
+ *
304
+ * app.use(async (ctx) => {
305
+ * ctx.json({ message: 'Hello from Netlify Edge!' });
306
+ * });
307
+ *
308
+ * export default createNetlifyHandler(app);
309
+ * ```
310
+ */
311
+ export function createNetlifyHandler(
312
+ app: Application,
313
+ options: FetchHandlerOptions = {}
314
+ ): FetchHandler {
315
+ return createFetchHandler(app, options);
316
+ }
317
+
318
+ // Alias for backwards compatibility and consistency
319
+ export const createHandler = createFetchHandler;
320
+
321
+ // F-01: compile-time conformance guard. If the exported fetch-adapter shape
322
+ // drifts from the shared `FetchAdapter` contract, this stops compiling.
323
+ const _edgeConformance: FetchAdapter<Application, EdgeExecutionContext> = { createFetchHandler };
324
+ void _edgeConformance;
325
+
326
+ // RFC-NEXTRUSH-ADAPTER-CONTRACT: prove the context factory produces an
327
+ // AdapterContext over the shared Context contract. A drift in createEdgeContext's
328
+ // return type stops compiling here.
329
+ const _edgeContextFactory: AdapterContextFactory<
330
+ [Request, EdgeExecutionContext?, boolean?, unknown?],
331
+ EdgeContext
332
+ > = createEdgeContext;
333
+ void _edgeContextFactory;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @nextrush/adapter-edge - Body Source
3
+ *
4
+ * The bespoke `EdgeBodySource`/`EmptyBodySource`/`concatUint8Arrays` have been
5
+ * collapsed onto the shared cross-runtime {@link WebBodySource} in
6
+ * `@nextrush/runtime` (audit F-04a). Body-reading behavior is now identical
7
+ * across the Web adapters (Bun/Deno/Edge) and fixed in one place.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+
12
+ import {
13
+ createEmptyBodySource,
14
+ createWebBodySource,
15
+ EmptyBodySource,
16
+ WebBodySource,
17
+ } from '@nextrush/runtime';
18
+
19
+ export { createEmptyBodySource, createWebBodySource, EmptyBodySource, WebBodySource };
package/src/context.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @nextrush/adapter-edge - Context Implementation
3
+ *
4
+ * Edge-specific Context implementation for Cloudflare Workers, Vercel Edge, etc.
5
+ *
6
+ * @remarks
7
+ * Extends the shared {@link WebContextBase} (F-08, ADR-0010), which owns the
8
+ * response-building logic (json/send/html/redirect/set/getResponse and body
9
+ * suppression, composed from {@link WebResponseBuilder}), the lazy `raw`/
10
+ * `signal`/`triggerTimeout`, the streaming methods, and
11
+ * `get`/`next`/`throw`/`assert` — defined once across the Web adapters rather
12
+ * than copy-pasted per runtime. This file supplies only what is genuinely
13
+ * Edge-specific: `cf-connecting-ip`-aware IP resolution, platform bindings
14
+ * (`env`), and `waitUntil`/`executionContext`.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ import { detectEdgeRuntime, getEdgeClientIp, WebContextBase } from '@nextrush/runtime';
20
+ import { runNDJSONStream, runSSEStream, runTextStream } from '@nextrush/stream';
21
+
22
+ /**
23
+ * Edge execution context interface
24
+ *
25
+ * @remarks
26
+ * Provides access to edge-specific features like `waitUntil` and `passThroughOnException`.
27
+ */
28
+ export interface EdgeExecutionContext {
29
+ /** Extend request lifetime for async operations */
30
+ waitUntil(promise: Promise<unknown>): void;
31
+
32
+ /** Pass through to origin on exception */
33
+ passThroughOnException?(): void;
34
+ }
35
+
36
+ /**
37
+ * Edge Context implementation
38
+ *
39
+ * @remarks
40
+ * Works with any edge runtime that implements the Web Fetch API:
41
+ * - Cloudflare Workers
42
+ * - Vercel Edge Functions
43
+ * - Netlify Edge Functions
44
+ *
45
+ * The response is built internally (via the inherited {@link WebResponseBuilder}
46
+ * composition) and returned via `getResponse()`.
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * const ctx = new EdgeContext(request);
51
+ * ctx.json({ message: 'Hello from Edge!' });
52
+ * const response = ctx.getResponse();
53
+ * ```
54
+ */
55
+ export class EdgeContext<Env = unknown> extends WebContextBase {
56
+ /** Edge execution context (for waitUntil, etc.) */
57
+ readonly executionContext?: EdgeExecutionContext;
58
+
59
+ /**
60
+ * Platform bindings passed by the runtime (audit F-03).
61
+ *
62
+ * @remarks
63
+ * On Cloudflare Workers this is the `env` argument of
64
+ * `fetch(request, env, ctx)` — KV, D1, R2, Durable Objects, Queues, secrets.
65
+ * `undefined` on runtimes that do not supply bindings (e.g. Vercel Edge).
66
+ */
67
+ readonly env?: Env;
68
+
69
+ constructor(
70
+ request: Request,
71
+ executionContext?: EdgeExecutionContext,
72
+ trustProxy = false,
73
+ env?: Env
74
+ ) {
75
+ // Get client IP from CF headers or standard headers.
76
+ //
77
+ // HP-1 trim: Edge has no socket, so when `trustProxy` is false (default) the
78
+ // client IP is `''` — returned directly, with no per-request header-lookup
79
+ // closure and no `getEdgeClientIp` policy call (byte-identical to
80
+ // `getEdgeClientIp(request, false)`, whose `directIp` is `''`). When true,
81
+ // resolution still goes through `getEdgeClientIp`, preserving the Cloudflare
82
+ // `cf-connecting-ip` → `x-forwarded-for` → `x-real-ip` precedence.
83
+ const ip = trustProxy ? getEdgeClientIp(request, true) : '';
84
+
85
+ // Detect specific edge runtime
86
+ const runtime = detectEdgeRuntime().runtime;
87
+
88
+ super(request, ip, runtime, { runTextStream, runSSEStream, runNDJSONStream });
89
+
90
+ this.executionContext = executionContext;
91
+ this.env = env;
92
+ }
93
+
94
+ // ===========================================================================
95
+ // Edge-Specific Methods
96
+ // ===========================================================================
97
+
98
+ /**
99
+ * Extend request lifetime for async operations
100
+ *
101
+ * @remarks
102
+ * Use this for fire-and-forget operations that should complete
103
+ * after the response is sent (logging, analytics, etc.)
104
+ */
105
+ waitUntil(promise: Promise<unknown>): void {
106
+ if (this.executionContext?.waitUntil) {
107
+ this.executionContext.waitUntil(promise);
108
+ }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Create a new EdgeContext
114
+ */
115
+ export function createEdgeContext<Env = unknown>(
116
+ request: Request,
117
+ executionContext?: EdgeExecutionContext,
118
+ trustProxy = false,
119
+ env?: Env
120
+ ): EdgeContext<Env> {
121
+ return new EdgeContext<Env>(request, executionContext, trustProxy, env);
122
+ }
package/src/index.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @nextrush/adapter-edge - Edge Runtime Adapter for NextRush
3
+ *
4
+ * Provides universal Edge runtime support for:
5
+ * - Cloudflare Workers
6
+ * - Vercel Edge Functions
7
+ * - Netlify Edge Functions
8
+ * - Any runtime supporting the Fetch API
9
+ *
10
+ * @packageDocumentation
11
+ * @module @nextrush/adapter-edge
12
+ */
13
+
14
+ // Main adapter functions
15
+ export {
16
+ createCloudflareHandler,
17
+ createFetchHandler,
18
+ createHandler,
19
+ createNetlifyHandler,
20
+ createVercelHandler,
21
+ DEFAULT_EDGE_TIMEOUT_MS,
22
+ type CloudflareFetchHandler, // Alias
23
+ type FetchHandler,
24
+ type FetchHandlerOptions,
25
+ } from './adapter';
26
+
27
+ // Context exports
28
+ export { EdgeContext, createEdgeContext, type EdgeExecutionContext } from './context';
29
+
30
+ // HttpError re-export (uniform across all adapters — audit F-10)
31
+ export { HttpError } from '@nextrush/errors';
32
+
33
+ // Body source exports (F-10: previously only `EdgeBodySource` was exported;
34
+ // the rest were dead. Now the full shared surface is wired up.)
35
+ export {
36
+ createEmptyBodySource,
37
+ createWebBodySource,
38
+ EmptyBodySource,
39
+ WebBodySource,
40
+ } from './body-source';
41
+
42
+ // Shared error classes (parity with node/bun/deno — audit F-10)
43
+ export { BodyConsumedError, BodyTooLargeError } from '@nextrush/runtime';
44
+
45
+ // Utility exports
46
+ /* eslint-disable @typescript-eslint/no-deprecated -- F-09: intentional compat re-export, not a usage site */
47
+ export {
48
+ detectEdgeRuntime,
49
+ getContentLength,
50
+ getContentType,
51
+ parseQueryString,
52
+ type EdgeRuntimeInfo,
53
+ } from './utils';
54
+ /* eslint-enable @typescript-eslint/no-deprecated */
55
+
56
+ // Re-export types for convenience (parity with node/bun/deno — audit F-10)
57
+ export type {
58
+ BodySource,
59
+ Context,
60
+ HttpMethod,
61
+ Middleware,
62
+ Runtime,
63
+ RuntimeCapabilities,
64
+ } from '@nextrush/types';
package/src/utils.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @nextrush/adapter-edge - Utility Functions
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ export { detectEdgeRuntime, parseQueryString } from '@nextrush/runtime';
8
+ export type { EdgeRuntimeInfo } from '@nextrush/runtime';
9
+
10
+ /**
11
+ * Get Content-Type header value
12
+ *
13
+ * @deprecated F-09: unused internally (superseded by `@nextrush/body-parser`'s
14
+ * own content-type parsing). Kept for backward compatibility per the public-API
15
+ * contract (deprecate-before-remove); will be removed in a future major.
16
+ */
17
+ export function getContentType(headers: Headers): string | undefined {
18
+ return headers.get('content-type') ?? undefined;
19
+ }
20
+
21
+ /**
22
+ * Get Content-Length header as number
23
+ *
24
+ * @deprecated F-09: unused internally (superseded by `@nextrush/body-parser`'s
25
+ * own content-length handling). Kept for backward compatibility per the
26
+ * public-API contract (deprecate-before-remove); will be removed in a future
27
+ * major.
28
+ */
29
+ export function getContentLength(headers: Headers): number | undefined {
30
+ const value = headers.get('content-length');
31
+ if (value === null) return undefined;
32
+ const num = parseInt(value, 10);
33
+ return Number.isNaN(num) ? undefined : num;
34
+ }