@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.
- package/CHANGELOG.md +160 -0
- package/README.md +269 -0
- package/package.json +57 -0
- package/src/d1/index.ts +1087 -0
- package/src/env.ts +309 -0
- package/src/facts.ts +112 -0
- package/src/index.ts +96 -0
- package/src/kv/index.ts +275 -0
- package/src/queues/index.ts +746 -0
- package/src/r2/index.ts +736 -0
- package/src/workers/index.ts +280 -0
|
@@ -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
|
+
};
|