@ontrails/cloudflare 0.2.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,280 @@
1
+ /**
2
+ * Cloudflare Workers materializer for Trails HTTP routes.
3
+ *
4
+ * Produces the `{ fetch(request, env, ctx) }` Worker export by delegating to
5
+ * the shared HTTP fetch kernel (`createFetchHandler` from `@ontrails/http`),
6
+ * making Workers the kernel's third consumer after Bun and Hono.
7
+ *
8
+ * The env bridge: bindings arrive per-request on `env`, so the kernel handler
9
+ * is materialized per env identity — a request carrying a new `env` object
10
+ * re-resolves every env-bound resource before it executes. Resource overrides
11
+ * are checked before core's singleton resource cache, so no resource instance
12
+ * can serve a request with a stale env.
13
+ */
14
+
15
+ import {
16
+ renderErrorDiagnostics,
17
+ renderPublicSurfaceError,
18
+ } from '@ontrails/core';
19
+ import type {
20
+ BaseSurfaceOptions,
21
+ Layer,
22
+ ResourceOverrideMap,
23
+ Topo,
24
+ TrailContextInit,
25
+ } from '@ontrails/core';
26
+ import { createFetchHandler } from '@ontrails/http';
27
+ import type { ResolveHttpPermit } from '@ontrails/http';
28
+
29
+ import { buildEnvResourceOverrides } from '../env.js';
30
+ import type { WorkersEnv } from '../env.js';
31
+ import { createQueueHandler } from '../queues/index.js';
32
+ import type { CloudflareQueueBatch } from '../queues/index.js';
33
+
34
+ export {
35
+ buildEnvResourceOverrides,
36
+ getEnvBinding,
37
+ registerEnvBinding,
38
+ } from '../env.js';
39
+ export type {
40
+ BuildEnvResourceOverridesOptions,
41
+ EnvBindingSpec,
42
+ WorkersEnv,
43
+ } from '../env.js';
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Options
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Resource overrides for the Workers surface.
51
+ *
52
+ * A static map is applied as-is. A function receives the per-request Worker
53
+ * env and is re-invoked whenever a new env object arrives, so overrides that
54
+ * read bindings stay as fresh as the env bridge itself.
55
+ */
56
+ export type WorkersResourceOverrides =
57
+ | ResourceOverrideMap
58
+ | ((env: WorkersEnv) => ResourceOverrideMap);
59
+
60
+ /**
61
+ * Options for building a Trails Worker handler.
62
+ */
63
+ export interface CreateWorkersHandlerOptions extends BaseSurfaceOptions {
64
+ readonly basePath?: string | undefined;
65
+ readonly createContext?:
66
+ | (() => TrailContextInit | Promise<TrailContextInit>)
67
+ | undefined;
68
+ readonly layers?: readonly Layer[] | undefined;
69
+ /** Maximum JSON request body size in bytes. Defaults to 1 MiB. */
70
+ readonly maxJsonBodyBytes?: number | undefined;
71
+ readonly resolvePermit?: ResolveHttpPermit | undefined;
72
+ readonly resources?: WorkersResourceOverrides | undefined;
73
+ }
74
+
75
+ /**
76
+ * The `ExecutionContext` shape the Workers runtime passes as the third
77
+ * `fetch` argument. Declared structurally so the adapter does not require
78
+ * `@cloudflare/workers-types` at runtime.
79
+ */
80
+ export interface WorkersExecutionContext {
81
+ passThroughOnException(): void;
82
+ waitUntil(promise: Promise<unknown>): void;
83
+ }
84
+
85
+ /**
86
+ * The Worker module export produced by {@link createWorkersHandler}.
87
+ */
88
+ export interface CloudflareWorker {
89
+ fetch(
90
+ request: Request,
91
+ env?: WorkersEnv | undefined,
92
+ executionCtx?: WorkersExecutionContext | undefined
93
+ ): Promise<Response>;
94
+ queue(
95
+ batch: CloudflareQueueBatch,
96
+ env?: WorkersEnv | undefined,
97
+ executionCtx?: WorkersExecutionContext | undefined
98
+ ): Promise<void>;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Error mapping
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Map a materialization failure (route derivation, env bridge resolution) to
107
+ * a rendered HTTP error response. Route execution errors never reach this
108
+ * path — the fetch kernel maps those itself.
109
+ */
110
+ const mapCaughtError = (error: unknown): Response => {
111
+ const err = error instanceof Error ? error : new Error(String(error));
112
+ // Materialization failures are host bootstrap problems; surface their
113
+ // diagnostics to the Worker log while the response stays redacted.
114
+ console.error(
115
+ '[ontrails:cloudflare/workers] Failed to materialize request handler',
116
+ renderErrorDiagnostics(err)
117
+ );
118
+ const rendering = renderPublicSurfaceError('http', err);
119
+ return Response.json(
120
+ {
121
+ error: {
122
+ category: rendering.category,
123
+ code: rendering.name,
124
+ message: rendering.message,
125
+ },
126
+ },
127
+ { status: rendering.code }
128
+ );
129
+ };
130
+
131
+ const mapCaughtQueueError = (
132
+ error: unknown,
133
+ batch: CloudflareQueueBatch
134
+ ): void => {
135
+ const err = error instanceof Error ? error : new Error(String(error));
136
+ console.error(
137
+ '[ontrails:cloudflare/workers] Failed to materialize queue handler',
138
+ renderErrorDiagnostics(err)
139
+ );
140
+ batch.retryAll();
141
+ };
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // createWorkersHandler
145
+ // ---------------------------------------------------------------------------
146
+
147
+ const resolveResourceOverrides = (
148
+ graph: Topo,
149
+ env: WorkersEnv,
150
+ options: CreateWorkersHandlerOptions,
151
+ entrypoint: 'fetch' | 'queue',
152
+ queueName?: string
153
+ ): ResourceOverrideMap => {
154
+ // Explicit overrides are the documented escape hatch, so they resolve
155
+ // first: an overridden resource never requires its env binding. Surface
156
+ // filters are forwarded so trails the handler does not expose never
157
+ // require theirs either.
158
+ const userOverrides =
159
+ typeof options.resources === 'function'
160
+ ? options.resources(env)
161
+ : (options.resources ?? {});
162
+ const envOverrides = buildEnvResourceOverrides(graph, env, {
163
+ entrypoint,
164
+ except: Object.keys(userOverrides),
165
+ exclude: options.exclude,
166
+ include: options.include,
167
+ intent: options.intent,
168
+ queue: queueName,
169
+ });
170
+ if (envOverrides.isErr()) {
171
+ throw envOverrides.error;
172
+ }
173
+ return { ...envOverrides.value, ...userOverrides };
174
+ };
175
+
176
+ interface MaterializedHandler {
177
+ readonly env: WorkersEnv | undefined;
178
+ readonly handle: (request: Request) => Promise<Response>;
179
+ }
180
+
181
+ interface MaterializedQueueHandler {
182
+ readonly env: WorkersEnv | undefined;
183
+ readonly handle: (batch: CloudflareQueueBatch) => Promise<void>;
184
+ readonly queue: string;
185
+ }
186
+
187
+ /**
188
+ * Build the `{ fetch }` Worker export for a topo.
189
+ *
190
+ * @remarks The kernel fetch handler is materialized lazily per env identity.
191
+ * The Workers runtime keeps `env` stable within an isolate, so steady-state
192
+ * requests reuse one materialization; any request carrying a different env
193
+ * object triggers a fresh resolution of every env-bound resource.
194
+ *
195
+ * @example
196
+ * ```ts
197
+ * import { createWorkersHandler } from '@ontrails/cloudflare/workers';
198
+ * import { graph } from './app.js';
199
+ *
200
+ * export default createWorkersHandler(graph, { basePath: '/api' });
201
+ * ```
202
+ */
203
+ export const createWorkersHandler = (
204
+ graph: Topo,
205
+ options: CreateWorkersHandlerOptions = {}
206
+ ): CloudflareWorker => {
207
+ let materialized: MaterializedHandler | undefined;
208
+ let materializedQueue: MaterializedQueueHandler | undefined;
209
+
210
+ const handlerFor = (
211
+ env: WorkersEnv | undefined
212
+ ): ((request: Request) => Promise<Response>) => {
213
+ if (materialized !== undefined && materialized.env === env) {
214
+ return materialized.handle;
215
+ }
216
+ const handle = createFetchHandler(graph, {
217
+ basePath: options.basePath,
218
+ configValues: options.configValues,
219
+ createContext: options.createContext,
220
+ exclude: options.exclude,
221
+ include: options.include,
222
+ intent: options.intent,
223
+ layers: options.layers,
224
+ maxJsonBodyBytes: options.maxJsonBodyBytes,
225
+ resolvePermit: options.resolvePermit,
226
+ resources: resolveResourceOverrides(graph, env ?? {}, options, 'fetch'),
227
+ validate: options.validate,
228
+ });
229
+ materialized = { env, handle };
230
+ return handle;
231
+ };
232
+
233
+ const queueHandlerFor = (
234
+ env: WorkersEnv | undefined,
235
+ queueName: string
236
+ ): ((batch: CloudflareQueueBatch) => Promise<void>) => {
237
+ if (
238
+ materializedQueue !== undefined &&
239
+ materializedQueue.env === env &&
240
+ materializedQueue.queue === queueName
241
+ ) {
242
+ return materializedQueue.handle;
243
+ }
244
+ const handle = createQueueHandler(graph, {
245
+ configValues: options.configValues,
246
+ createContext: options.createContext,
247
+ exclude: options.exclude,
248
+ include: options.include,
249
+ intent: options.intent,
250
+ layers: options.layers,
251
+ resources: resolveResourceOverrides(
252
+ graph,
253
+ env ?? {},
254
+ options,
255
+ 'queue',
256
+ queueName
257
+ ),
258
+ validate: options.validate,
259
+ });
260
+ materializedQueue = { env, handle, queue: queueName };
261
+ return handle;
262
+ };
263
+
264
+ return {
265
+ fetch: async (request, env, _executionCtx) => {
266
+ try {
267
+ return await handlerFor(env)(request);
268
+ } catch (error: unknown) {
269
+ return mapCaughtError(error);
270
+ }
271
+ },
272
+ queue: async (batch, env, _executionCtx) => {
273
+ try {
274
+ await queueHandlerFor(env, batch.queue)(batch);
275
+ } catch (error: unknown) {
276
+ mapCaughtQueueError(error, batch);
277
+ }
278
+ },
279
+ };
280
+ };