@ontrails/cloudflare 1.0.0-beta.39

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 ADDED
@@ -0,0 +1,21 @@
1
+ # @ontrails/cloudflare
2
+
3
+ ## 1.0.0-beta.39
4
+
5
+ ### Minor Changes
6
+
7
+ - [`cc169e2`](https://github.com/outfitter-dev/trails/commit/cc169e2a9b580036b0c6e4ce77d396db6a34f830): Add `cloudflareOverlay`, the first lock overlay overlay: it derives the app's env-bound resources into `overlays.cloudflare` (wrangler binding name per resource) when the app exports it via `trailsOverlays` and runs `trails compile`.
8
+
9
+ ### Patch Changes
10
+
11
+ - [`6b75a46`](https://github.com/outfitter-dev/trails/commit/6b75a46ab6210237d306cceade833bf9ce6e7431): The core barrel is now execution-portable: no eager `bun:`/`node:` builtin imports remain on its module graph (TRL-1198). `trails-db`, workspace discovery, and path security load `bun:sqlite`, `node:fs`, `node:os`, and `node:path` lazily through `process.getBuiltinModule` at first use, and signal payload summaries plus per-project store keys use a pure SHA-256 (output-identical to `node:crypto`). A Worker bundle no longer needs a `bun:sqlite` stub plugin or the `nodejs_compat` flag to serve trails; the Cloudflare adapter's miniflare lane now bundles without externals and boots workerd without `nodejs_compat` as the structural regression gate, and its README stub instructions are replaced with the portable posture. Tooling helpers throw a clear `InternalError` naming the missing builtin when called on runtimes without it.
12
+
13
+ ## 1.0.0-beta.38
14
+
15
+ ### Minor Changes
16
+
17
+ - [`a105127`](https://github.com/outfitter-dev/trails/commit/a105127e5662ed9a6c245125f791fb0182da3f5e): Add the `@ontrails/cloudflare` adapter collection with its first two service subpaths. `@ontrails/cloudflare/workers` exports `createWorkersHandler`, a materializer producing the `{ fetch(request, env, ctx) }` Worker export on the shared HTTP fetch kernel, with an env bridge that re-resolves env-bound resources whenever a new Worker `env` arrives so no resource instance serves a request with a stale env. `@ontrails/cloudflare/kv` exports `cloudflareKv`, a resource definition wrapping a KV namespace binding (`get`/`put`/`delete`/`list` with TTL options) plus an in-memory `createMemoryKv` mock so `testAll` runs configuration-free.
18
+
19
+ `@ontrails/core` now guards the default trail context fields: `requestId` falls back to `crypto.randomUUID()` when the `Bun` global is absent, and `cwd`/`env` fall back to `'/'`/`{}` when `process` is absent, so trail execution works on runtimes like Cloudflare Workers.
20
+
21
+ `@ontrails/warden` registers the `@ontrails/cloudflare` public barrel in the repo-local `public-export-example-coverage` policy, requiring `@example` TSDoc coverage on `createWorkersHandler` and `cloudflareKv`.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # @ontrails/cloudflare
2
+
3
+ The Cloudflare adapter collection for Trails. One package, one subpath per Cloudflare service, each connecting a service to the Trails primitive it naturally serves:
4
+
5
+ | Subpath | Serves | Status |
6
+ | --- | --- | --- |
7
+ | `@ontrails/cloudflare/workers` | HTTP surface materializer (fetch handler) | ✅ |
8
+ | `@ontrails/cloudflare/kv` | Key-value resource | ✅ |
9
+ | `/d1` | Store driver | Planned |
10
+ | `/queues` | Activation source + outbound delivery | Planned |
11
+ | `/r2` | Blob/object resource | Planned |
12
+
13
+ Adapter composition doctrine applies throughout: subpaths take primitive-authored declarations (a resource definition, a surface config) and never shadow authoring verbs. Bindings arrive ambiently on the Worker `env`, so runtime dependencies are near-zero.
14
+
15
+ ## `/workers` — the fetch-handler materializer
16
+
17
+ `createWorkersHandler(graph, options)` produces the `{ fetch(request, env, ctx) }` Worker export by delegating to the shared HTTP fetch kernel from `@ontrails/http` — the same kernel behind the Bun and Hono surfaces, so routes, validation, error projection, and webhook handling behave identically.
18
+
19
+ ```ts
20
+ // src/worker.ts
21
+ import { createWorkersHandler } from '@ontrails/cloudflare/workers';
22
+ import { graph } from './app.js';
23
+
24
+ export default createWorkersHandler(graph, { basePath: '/api' });
25
+ ```
26
+
27
+ Options mirror the other HTTP surfaces: `basePath`, `createContext`, `layers`, `maxJsonBodyBytes`, `resolvePermit`, plus include/exclude/intent filtering. `resources` accepts either a static override map or a function of the Worker env:
28
+
29
+ ```ts
30
+ export default createWorkersHandler(graph, {
31
+ resources: (env) => ({ audit: createAuditClient(env['AUDIT_URL']) }),
32
+ });
33
+ ```
34
+
35
+ ### The env bridge
36
+
37
+ Worker bindings (KV, D1, R2, queues) live on `env`, which arrives per request — they cannot be captured at module init. The bridge closes that gap once, for every subpath in this collection:
38
+
39
+ 1. A subpath authors an ordinary `resource()` definition and registers an `EnvBindingSpec` for it (`registerEnvBinding(definition, { binding, fromEnv })`).
40
+ 2. `createWorkersHandler` walks the declared resources of the trails the surface actually exposes (honoring `include`/`exclude`/`intent`, and including fork-version resources), and for each env-bound definition resolves `env[binding]` through `fromEnv` into a resource override. Explicitly overridden resource IDs skip env resolution entirely, so an override never requires its binding.
41
+ 3. The kernel handler is materialized per env identity. The Workers runtime keeps `env` stable within an isolate, so steady-state requests reuse one materialization — but any request carrying a different env object re-resolves every env-bound resource before it executes.
42
+
43
+ Because resource overrides are checked before core's singleton resource cache, no resource instance can serve a request with a stale env. This guarantee has a dedicated regression test (`src/workers/__tests__/env-bridge.test.ts`).
44
+
45
+ Missing or mistyped bindings fail the request with a redacted 500 and log full diagnostics to the Worker log, naming the binding and the resource that needed it.
46
+
47
+ ### Runtime notes
48
+
49
+ - The core execution path is runtime-portable (TRL-1198): `@ontrails/core` loads `bun:sqlite` and `node:` builtins lazily at first use, so a Worker bundle needs no stub plugin and no `nodejs_compat` flag to serve trails. The integration lane (`src/__tests__/miniflare.test.ts`) bundles the demo Worker with no externals and boots workerd without `nodejs_compat` as the structural regression gate.
50
+ - Tooling helpers on the core barrel (the trails-db store, workspace discovery) still require a Bun or Node runtime when actually called; on workerd they throw a clear `InternalError` naming the missing builtin instead of poisoning the module graph.
51
+ - Explicit `resources` overrides win over env-bound resolution, which is how tests substitute fakes.
52
+
53
+ ## `/kv` — the key-value resource
54
+
55
+ `cloudflareKv(id, { binding })` authors a resource wrapping a KV namespace binding. Trails declare it with `resources: [...]` and read it with `flags.from(ctx)` — the standard accessor pattern.
56
+
57
+ ```ts
58
+ import { cloudflareKv } from '@ontrails/cloudflare/kv';
59
+ import { trail, Result } from '@ontrails/core';
60
+ import { z } from 'zod';
61
+
62
+ const flags = cloudflareKv('flags', { binding: 'FLAGS' });
63
+
64
+ const showFlag = trail('flag.show', {
65
+ blaze: async (input, ctx) => {
66
+ const value = await flags.from(ctx).get(input.key);
67
+ return Result.ok({ value });
68
+ },
69
+ input: z.object({ key: z.string() }),
70
+ intent: 'read',
71
+ output: z.object({ value: z.string().nullable() }),
72
+ resources: [flags],
73
+ });
74
+ ```
75
+
76
+ The client surface is `get`/`put`/`delete`/`list`, with TTL options on `put` (`expirationTtl` in seconds, or an absolute `expiration` Unix timestamp) and prefix/limit/cursor pagination on `list`. A real `KVNamespace` binding satisfies the shape structurally, so the env bridge passes it through unchanged.
77
+
78
+ Declare the binding in wrangler config:
79
+
80
+ ```toml
81
+ kv_namespaces = [
82
+ { binding = "FLAGS", id = "<namespace-id>" }
83
+ ]
84
+ ```
85
+
86
+ ### Testing with the mock
87
+
88
+ Every `cloudflareKv` resource carries an in-memory mock factory, so `testAll(app)` runs configuration-free — no Cloudflare account, no wrangler:
89
+
90
+ ```ts
91
+ import { testAll } from '@ontrails/testing';
92
+ import { graph } from '../src/app.js';
93
+
94
+ testAll(graph);
95
+ ```
96
+
97
+ `createMemoryKv()` is also exported directly for hand-rolled tests, with an injectable clock for TTL assertions. Two documented divergences from the real binding: the mock does not enforce KV's 60-second minimum TTL, and when both `expiration` and `expirationTtl` are passed the mock prefers `expirationTtl` where the real binding rejects the combination.
98
+
99
+ ## Local integration testing
100
+
101
+ Integration runs are local-first via [miniflare](https://miniflare.dev) (workerd in-process): the test lane bundles a demo Worker with `Bun.build`, boots it with a real KV namespace, and exercises HTTP, webhook, and KV routes. See `src/__tests__/miniflare.test.ts`. Real-account deploys are manual and never CI-required.
102
+
103
+ ## Lock facts
104
+
105
+ `cloudflareOverlay` (root export) is the adapter's lock overlay overlay: an `Overlay` pairing the `cloudflare` namespace with an elevated zod fact schema and a deterministic derive over the app's topo. It records every env-bound resource as `{ binding, resourceId }` so the committed `trails.lock` documents which wrangler bindings the app depends on.
106
+
107
+ An app opts in by exporting the overlay list next to its topo, then compiling:
108
+
109
+ ```ts
110
+ // src/app.ts
111
+ import { cloudflareOverlay } from '@ontrails/cloudflare';
112
+
113
+ export const app = topo('my-worker', { readFlag });
114
+ export const trailsOverlays = [cloudflareOverlay];
115
+ ```
116
+
117
+ `trails compile` validates the derived facts against the schema and embeds them as `overlays.cloudflare`; `trails wayfind --facts cloudflare` reads them back. Toolchains that predate a overlay's namespace preserve it byte-for-byte — adding a new fact family never edits the lock schema or graph type.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@ontrails/cloudflare",
3
+ "version": "1.0.0-beta.39",
4
+ "files": [
5
+ "src/**/*.ts",
6
+ "!src/**/__tests__/**",
7
+ "!src/**/*.test.ts",
8
+ "!src/**/*.test-d.ts",
9
+ "README.md",
10
+ "CHANGELOG.md"
11
+ ],
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./workers": "./src/workers/index.ts",
16
+ "./kv": "./src/kv/index.ts",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -b",
21
+ "test": "bun test",
22
+ "typecheck": "tsc --noEmit",
23
+ "lint": "oxlint ./src",
24
+ "clean": "rm -rf dist *.tsbuildinfo"
25
+ },
26
+ "dependencies": {
27
+ "@ontrails/core": "^1.0.0-beta.39"
28
+ },
29
+ "devDependencies": {
30
+ "@ontrails/adapter-kit": "^1.0.0-beta.39",
31
+ "@ontrails/testing": "^1.0.0-beta.39",
32
+ "miniflare": "^4.20250617.4"
33
+ },
34
+ "peerDependencies": {
35
+ "@ontrails/http": "^1.0.0-beta.39",
36
+ "zod": "^4.3.5"
37
+ },
38
+ "trails": {
39
+ "adapter": {
40
+ "target": "http"
41
+ }
42
+ }
43
+ }
package/src/env.ts ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The Cloudflare env bridge.
3
+ *
4
+ * Worker bindings (KV, D1, R2, queues) arrive per-request on the `env`
5
+ * argument of the Worker `fetch` handler. Trails resources are authored as
6
+ * ordinary `resource()` definitions, so this module provides the seam that
7
+ * connects the two: a subpath registers an {@link EnvBindingSpec} for each
8
+ * resource definition it authors, and the Workers materializer resolves those
9
+ * specs against the live `env` into per-materialization resource overrides.
10
+ *
11
+ * Overrides are re-resolved whenever a new `env` object arrives, and core
12
+ * resolves overrides before its singleton resource cache, so no resource
13
+ * instance can capture a stale env. Every Cloudflare subpath (`/kv` today,
14
+ * `/d1`, `/queues`, `/r2` later) consumes this one seam.
15
+ */
16
+
17
+ import {
18
+ InternalError,
19
+ matchesTrailPattern,
20
+ Result,
21
+ filterSurfaceTrails,
22
+ } from '@ontrails/core';
23
+ import type {
24
+ AnyResource,
25
+ BaseSurfaceOptions,
26
+ ResourceOverrideMap,
27
+ Topo,
28
+ Trail,
29
+ } from '@ontrails/core';
30
+
31
+ /**
32
+ * The ambient Worker environment: bindings keyed by their wrangler-configured
33
+ * names. Values are runtime binding objects (KV namespaces, D1 databases,
34
+ * queues), so they are typed as `unknown` and narrowed by each subpath.
35
+ */
36
+ export type WorkersEnv = Readonly<Record<string, unknown>>;
37
+
38
+ /**
39
+ * How a resource definition materializes from the Worker env.
40
+ *
41
+ * `fromEnv` receives the raw binding value found at `env[binding]` and either
42
+ * narrows it into the resource instance or explains why the binding does not
43
+ * match the resource's expectations.
44
+ */
45
+ export interface EnvBindingSpec {
46
+ /** The wrangler binding name to read from the Worker env. */
47
+ readonly binding: string;
48
+ /** Narrow the raw binding value into the resource instance. */
49
+ readonly fromEnv: (value: unknown) => Result<unknown, Error>;
50
+ }
51
+
52
+ const envBindings = new WeakMap<AnyResource, EnvBindingSpec>();
53
+
54
+ /**
55
+ * Register an env binding for a resource definition.
56
+ *
57
+ * Called by Cloudflare subpaths (and available to apps authoring their own
58
+ * env-bound resources) so the Workers materializer knows how to build the
59
+ * resource instance from the per-request env.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * import { resource, Result } from '@ontrails/core';
64
+ * import { registerEnvBinding } from '@ontrails/cloudflare/workers';
65
+ *
66
+ * const queue = resource<{ send(body: string): Promise<void> }>('outbox', {
67
+ * create: () => Result.err(new Error('outbox is only available on Workers')),
68
+ * mock: () => ({ send: () => Promise.resolve() }),
69
+ * });
70
+ * registerEnvBinding(queue, {
71
+ * binding: 'OUTBOX',
72
+ * fromEnv: (value) => Result.ok(value),
73
+ * });
74
+ * ```
75
+ */
76
+ export const registerEnvBinding = (
77
+ resourceDefinition: AnyResource,
78
+ spec: EnvBindingSpec
79
+ ): void => {
80
+ envBindings.set(resourceDefinition, spec);
81
+ };
82
+
83
+ /**
84
+ * Read the env binding registered for a resource definition, if any.
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * import { getEnvBinding } from '@ontrails/cloudflare/workers';
89
+ * import { cloudflareKv } from '@ontrails/cloudflare/kv';
90
+ *
91
+ * const flags = cloudflareKv('flags', { binding: 'FLAGS' });
92
+ * getEnvBinding(flags)?.binding; // 'FLAGS'
93
+ * ```
94
+ */
95
+ export const getEnvBinding = (
96
+ resourceDefinition: AnyResource
97
+ ): EnvBindingSpec | undefined => envBindings.get(resourceDefinition);
98
+
99
+ /**
100
+ * Options for {@link buildEnvResourceOverrides}.
101
+ *
102
+ * `exclude`/`include`/`intent` mirror the fetch kernel's surface selection so
103
+ * env resolution only considers trails the surface actually exposes — a
104
+ * filtered-out trail's bindings are never required. `except` names resource
105
+ * IDs already provided explicitly, which skip env resolution entirely.
106
+ */
107
+ export interface BuildEnvResourceOverridesOptions extends Pick<
108
+ BaseSurfaceOptions,
109
+ 'exclude' | 'include' | 'intent'
110
+ > {
111
+ /** Resource IDs already provided explicitly; env resolution skips them. */
112
+ readonly except?: readonly string[] | undefined;
113
+ }
114
+
115
+ const isInternalTrail = (
116
+ graphTrail: Trail<unknown, unknown, unknown>
117
+ ): boolean =>
118
+ graphTrail.visibility === 'internal' ||
119
+ graphTrail.meta?.['internal'] === true;
120
+
121
+ const matchesAnyPattern = (
122
+ trailId: string,
123
+ patterns: readonly string[] | undefined
124
+ ): boolean =>
125
+ patterns !== undefined &&
126
+ patterns.some((pattern) => matchesTrailPattern(trailId, pattern));
127
+
128
+ const passesIncludeFilter = (
129
+ trailId: string,
130
+ include: readonly string[] | undefined
131
+ ): boolean =>
132
+ include === undefined ||
133
+ include.length === 0 ||
134
+ matchesAnyPattern(trailId, include);
135
+
136
+ /**
137
+ * Mirror of the fetch kernel's webhook trail eligibility: webhook consumers
138
+ * become HTTP routes even though `filterSurfaceTrails` skips
139
+ * activation-driven trails, so the bridge applies the same rules here.
140
+ */
141
+ const isEligibleWebhookTrail = (
142
+ graphTrail: Trail<unknown, unknown, unknown>,
143
+ options: BuildEnvResourceOverridesOptions
144
+ ): boolean => {
145
+ const hasWebhookSource = graphTrail.activationSources.some(
146
+ (activation) => activation.source.kind === 'webhook'
147
+ );
148
+ if (!hasWebhookSource) {
149
+ return false;
150
+ }
151
+ if (
152
+ isInternalTrail(graphTrail) &&
153
+ !options.include?.includes(graphTrail.id)
154
+ ) {
155
+ return false;
156
+ }
157
+ if (matchesAnyPattern(graphTrail.id, options.exclude)) {
158
+ return false;
159
+ }
160
+ if (!passesIncludeFilter(graphTrail.id, options.include)) {
161
+ return false;
162
+ }
163
+ return (
164
+ options.intent === undefined ||
165
+ options.intent.length === 0 ||
166
+ options.intent.includes(graphTrail.intent)
167
+ );
168
+ };
169
+
170
+ const collectSurfaceEligibleTrails = (
171
+ graph: Topo,
172
+ options: BuildEnvResourceOverridesOptions
173
+ ): readonly Trail<unknown, unknown, unknown>[] => {
174
+ const trails = graph.list();
175
+ const eligible = new Map<string, Trail<unknown, unknown, unknown>>();
176
+ const filtered = filterSurfaceTrails(trails, {
177
+ exclude: options.exclude,
178
+ include: options.include,
179
+ intent: options.intent,
180
+ });
181
+ for (const graphTrail of filtered) {
182
+ eligible.set(graphTrail.id, graphTrail);
183
+ }
184
+ for (const graphTrail of trails) {
185
+ if (
186
+ !eligible.has(graphTrail.id) &&
187
+ isEligibleWebhookTrail(graphTrail, options)
188
+ ) {
189
+ eligible.set(graphTrail.id, graphTrail);
190
+ }
191
+ }
192
+ return [...eligible.values()];
193
+ };
194
+
195
+ /**
196
+ * All resources a trail can execute with: the current contract's declarations
197
+ * plus any fork version entry's own `resources`, since core runs historical
198
+ * forks with the entry's resource set.
199
+ */
200
+ const declaredTrailResources = (
201
+ graphTrail: Trail<unknown, unknown, unknown>
202
+ ): readonly AnyResource[] => [
203
+ ...graphTrail.resources,
204
+ ...Object.values(graphTrail.versions ?? {}).flatMap(
205
+ (entry) => entry.resources ?? []
206
+ ),
207
+ ];
208
+
209
+ const collectEnvBoundResources = (
210
+ graph: Topo,
211
+ options: BuildEnvResourceOverridesOptions
212
+ ): readonly AnyResource[] => {
213
+ const except = new Set(options.except);
214
+ const collected = new Map<string, AnyResource>();
215
+ for (const graphTrail of collectSurfaceEligibleTrails(graph, options)) {
216
+ for (const declared of declaredTrailResources(graphTrail)) {
217
+ if (
218
+ !collected.has(declared.id) &&
219
+ !except.has(declared.id) &&
220
+ envBindings.has(declared)
221
+ ) {
222
+ collected.set(declared.id, declared);
223
+ }
224
+ }
225
+ }
226
+ return [...collected.values()];
227
+ };
228
+
229
+ /**
230
+ * Resolve every env-bound resource declared by the topo's surface-eligible
231
+ * trails (including fork-version resources) into a resource override map for
232
+ * one Worker env.
233
+ *
234
+ * Returns `Result.err` when a required binding is missing from the env or a
235
+ * binding value fails the resource's narrowing check. Trails filtered off the
236
+ * surface by `exclude`/`include`/`intent` never require their bindings, and
237
+ * resource IDs listed in `except` are skipped because the caller already
238
+ * provides them.
239
+ *
240
+ * @example
241
+ * ```ts
242
+ * import { buildEnvResourceOverrides } from '@ontrails/cloudflare/workers';
243
+ *
244
+ * const overrides = buildEnvResourceOverrides(graph, env);
245
+ * if (overrides.isErr()) throw overrides.error;
246
+ * ```
247
+ */
248
+ export const buildEnvResourceOverrides = (
249
+ graph: Topo,
250
+ env: WorkersEnv,
251
+ options: BuildEnvResourceOverridesOptions = {}
252
+ ): Result<ResourceOverrideMap, Error> => {
253
+ const overrides: Record<string, unknown> = {};
254
+ for (const declared of collectEnvBoundResources(graph, options)) {
255
+ const spec = envBindings.get(declared);
256
+ if (spec === undefined) {
257
+ continue;
258
+ }
259
+ const value = env[spec.binding];
260
+ if (value === undefined) {
261
+ return Result.err(
262
+ new InternalError(
263
+ `Worker env is missing binding "${spec.binding}" required by resource "${declared.id}". Declare the binding in your wrangler configuration (for example a kv_namespaces entry) or provide an explicit resource override.`,
264
+ { context: { binding: spec.binding, resourceId: declared.id } }
265
+ )
266
+ );
267
+ }
268
+ const instance = spec.fromEnv(value);
269
+ if (instance.isErr()) {
270
+ return instance;
271
+ }
272
+ overrides[declared.id] = instance.value;
273
+ }
274
+ return Result.ok(overrides);
275
+ };
package/src/facts.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Cloudflare lock facts.
3
+ *
4
+ * The adapter's `trails.lock` overlay overlay: `derive` projects the
5
+ * topo's env-bound resources (resources with a registered
6
+ * {@link EnvBindingSpec | env binding}, such as every `cloudflareKv`
7
+ * definition) into `overlays.cloudflare`, listing the wrangler binding name
8
+ * each resource resolves from. The import from `@ontrails/adapter-kit` is
9
+ * type-only — the adapter never depends on the adapter kit at runtime.
10
+ */
11
+
12
+ import type { Overlay } from '@ontrails/adapter-kit';
13
+ import type { AnyResource, Topo } from '@ontrails/core';
14
+ import { z } from 'zod';
15
+
16
+ import { getEnvBinding } from './env.js';
17
+ import type { EnvBindingSpec } from './env.js';
18
+
19
+ const cloudflareFactsSchema = z
20
+ .object({
21
+ bindings: z.array(
22
+ z
23
+ .object({
24
+ binding: z.string(),
25
+ resourceId: z.string(),
26
+ })
27
+ .strict()
28
+ ),
29
+ })
30
+ .strict();
31
+
32
+ /**
33
+ * The facts embedded at `overlays.cloudflare` in `trails.lock`: one entry
34
+ * per env-bound resource, pairing the resource ID with its wrangler binding
35
+ * name.
36
+ */
37
+ export type CloudflareLockFacts = z.infer<typeof cloudflareFactsSchema>;
38
+
39
+ /**
40
+ * Every resource visible on the topo: module-registered resources plus
41
+ * resources declared on trail contracts (including fork version entries),
42
+ * which core executes with but `topo()` does not auto-register.
43
+ */
44
+ const collectTopoResources = (graph: Topo): readonly AnyResource[] => {
45
+ const collected = new Map<string, AnyResource>();
46
+ for (const definition of graph.listResources()) {
47
+ collected.set(definition.id, definition);
48
+ }
49
+ for (const graphTrail of graph.list()) {
50
+ const declared = [
51
+ ...graphTrail.resources,
52
+ ...Object.values(graphTrail.versions ?? {}).flatMap(
53
+ (entry) => entry.resources ?? []
54
+ ),
55
+ ];
56
+ for (const definition of declared) {
57
+ if (!collected.has(definition.id)) {
58
+ collected.set(definition.id, definition);
59
+ }
60
+ }
61
+ }
62
+ return [...collected.values()];
63
+ };
64
+
65
+ const derive = (graph: Topo): CloudflareLockFacts => {
66
+ const bindings = collectTopoResources(graph)
67
+ .map((definition) => ({
68
+ definition,
69
+ spec: getEnvBinding(definition),
70
+ }))
71
+ .filter(
72
+ (entry): entry is { definition: AnyResource; spec: EnvBindingSpec } =>
73
+ entry.spec !== undefined
74
+ )
75
+ .map((entry) => ({
76
+ binding: entry.spec.binding,
77
+ resourceId: entry.definition.id,
78
+ }))
79
+ .toSorted(
80
+ (a, b) =>
81
+ a.resourceId.localeCompare(b.resourceId) ||
82
+ a.binding.localeCompare(b.binding)
83
+ );
84
+ return { bindings };
85
+ };
86
+
87
+ /**
88
+ * The Cloudflare adapter's `trails.lock` overlay overlay.
89
+ *
90
+ * An app opts in by exporting `trailsOverlays` next to its topo export;
91
+ * `trails compile` then validates `derive(topo)` against the facts schema
92
+ * and embeds the result as `overlays.cloudflare`, listing every env-bound
93
+ * resource's wrangler binding. Derivation is deterministic: the same topo
94
+ * always yields the same facts, sorted by resource ID then binding.
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * import { cloudflareOverlay, cloudflareKv } from '@ontrails/cloudflare';
99
+ * import { topo } from '@ontrails/core';
100
+ *
101
+ * export const flags = cloudflareKv('flags', { binding: 'FLAGS' });
102
+ * export const app = topo('my-worker', { flags });
103
+ * export const trailsOverlays = [cloudflareOverlay];
104
+ * // `trails compile` embeds overlays.cloudflare:
105
+ * // { bindings: [{ binding: 'FLAGS', resourceId: 'flags' }] }
106
+ * ```
107
+ */
108
+ export const cloudflareOverlay = {
109
+ derive,
110
+ namespace: 'cloudflare',
111
+ schema: cloudflareFactsSchema,
112
+ } satisfies Overlay;
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `@ontrails/cloudflare` — the Cloudflare adapter collection.
3
+ *
4
+ * Service subpaths are the primary entry points:
5
+ * - `@ontrails/cloudflare/workers` — HTTP surface materializer (fetch handler)
6
+ * - `@ontrails/cloudflare/kv` — key-value resource
7
+ *
8
+ * The root export re-exports the subpaths for convenience and adapter
9
+ * tooling, and owns the adapter's `trails.lock` overlay overlay
10
+ * (`cloudflareOverlay`).
11
+ */
12
+
13
+ export { cloudflareOverlay } from './facts.js';
14
+ export type { CloudflareLockFacts } from './facts.js';
15
+ export {
16
+ buildEnvResourceOverrides,
17
+ getEnvBinding,
18
+ registerEnvBinding,
19
+ } from './env.js';
20
+ export type {
21
+ BuildEnvResourceOverridesOptions,
22
+ EnvBindingSpec,
23
+ WorkersEnv,
24
+ } from './env.js';
25
+ export { cloudflareKv, createMemoryKv } from './kv/index.js';
26
+ export type {
27
+ CloudflareKv,
28
+ CloudflareKvListKey,
29
+ CloudflareKvListOptions,
30
+ CloudflareKvListResult,
31
+ CloudflareKvOptions,
32
+ CloudflareKvPutOptions,
33
+ CreateMemoryKvOptions,
34
+ } from './kv/index.js';
35
+ export { createWorkersHandler } from './workers/index.js';
36
+ export type {
37
+ CloudflareWorker,
38
+ CreateWorkersHandlerOptions,
39
+ WorkersExecutionContext,
40
+ WorkersResourceOverrides,
41
+ } from './workers/index.js';
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Cloudflare KV resource for Trails.
3
+ *
4
+ * `cloudflareKv` authors an ordinary `resource()` definition wrapping a KV
5
+ * namespace binding. On Workers, the env bridge (see `../env.ts`) resolves
6
+ * the binding per env so trails read live KV through `flags.from(ctx)`. In
7
+ * tests, the in-memory mock keeps `testAll(app)` configuration-free.
8
+ */
9
+
10
+ import { InternalError, Result, resource } from '@ontrails/core';
11
+ import type { Resource } from '@ontrails/core';
12
+
13
+ import { registerEnvBinding } from '../env.js';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Client shape
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Options accepted by {@link CloudflareKv.put}. */
20
+ export interface CloudflareKvPutOptions {
21
+ /** Absolute expiration as a Unix timestamp in seconds. */
22
+ readonly expiration?: number | undefined;
23
+ /** Relative expiration in seconds from now. Wins over `expiration`. */
24
+ readonly expirationTtl?: number | undefined;
25
+ }
26
+
27
+ /** Options accepted by {@link CloudflareKv.list}. */
28
+ export interface CloudflareKvListOptions {
29
+ /** Opaque cursor from a previous page's result. */
30
+ readonly cursor?: string | undefined;
31
+ /** Maximum keys per page. Defaults to 1000, matching the KV binding. */
32
+ readonly limit?: number | undefined;
33
+ /** Restrict results to keys starting with this prefix. */
34
+ readonly prefix?: string | undefined;
35
+ }
36
+
37
+ /** One key entry in a {@link CloudflareKvListResult}. */
38
+ export interface CloudflareKvListKey {
39
+ /** Absolute expiration as a Unix timestamp in seconds, when set. */
40
+ readonly expiration?: number | undefined;
41
+ readonly name: string;
42
+ }
43
+
44
+ /** Result shape of {@link CloudflareKv.list}, matching the KV binding. */
45
+ export interface CloudflareKvListResult {
46
+ readonly cursor?: string | undefined;
47
+ readonly keys: readonly CloudflareKvListKey[];
48
+ readonly list_complete: boolean;
49
+ }
50
+
51
+ /**
52
+ * The KV surface trails consume. A real `KVNamespace` binding satisfies this
53
+ * shape structurally, so the env bridge passes bindings through unchanged.
54
+ */
55
+ export interface CloudflareKv {
56
+ delete(key: string): Promise<void>;
57
+ get(key: string): Promise<string | null>;
58
+ list(options?: CloudflareKvListOptions): Promise<CloudflareKvListResult>;
59
+ put(
60
+ key: string,
61
+ value: string,
62
+ options?: CloudflareKvPutOptions
63
+ ): Promise<void>;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // In-memory mock
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** Options for {@link createMemoryKv}. */
71
+ export interface CreateMemoryKvOptions {
72
+ /** Clock override for TTL tests. Defaults to `Date.now`. */
73
+ readonly now?: (() => number) | undefined;
74
+ }
75
+
76
+ interface MemoryKvEntry {
77
+ readonly expiresAtMs: number | undefined;
78
+ readonly value: string;
79
+ }
80
+
81
+ const DEFAULT_LIST_LIMIT = 1000;
82
+ const MS_PER_SECOND = 1000;
83
+
84
+ const isExpired = (entry: MemoryKvEntry, nowMs: number): boolean =>
85
+ entry.expiresAtMs !== undefined && entry.expiresAtMs <= nowMs;
86
+
87
+ const resolveExpiresAtMs = (
88
+ nowMs: number,
89
+ options: CloudflareKvPutOptions | undefined
90
+ ): number | undefined => {
91
+ if (options?.expirationTtl !== undefined) {
92
+ return nowMs + options.expirationTtl * MS_PER_SECOND;
93
+ }
94
+ if (options?.expiration !== undefined) {
95
+ return options.expiration * MS_PER_SECOND;
96
+ }
97
+ return undefined;
98
+ };
99
+
100
+ /**
101
+ * Create an in-memory {@link CloudflareKv} backed by a `Map`.
102
+ *
103
+ * This is the mock factory behind every `cloudflareKv` resource, exported for
104
+ * direct use in tests. TTL semantics mirror the KV binding (lazy expiry,
105
+ * seconds granularity) but the 60-second minimum TTL is intentionally not
106
+ * enforced so tests can use short expirations.
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * import { createMemoryKv } from '@ontrails/cloudflare/kv';
111
+ *
112
+ * const kv = createMemoryKv();
113
+ * await kv.put('color', 'red', { expirationTtl: 60 });
114
+ * await kv.get('color'); // 'red'
115
+ * ```
116
+ */
117
+ export const createMemoryKv = (
118
+ options: CreateMemoryKvOptions = {}
119
+ ): CloudflareKv => {
120
+ const now = options.now ?? Date.now;
121
+ const entries = new Map<string, MemoryKvEntry>();
122
+
123
+ const liveEntry = (key: string): MemoryKvEntry | undefined => {
124
+ const entry = entries.get(key);
125
+ if (entry === undefined) {
126
+ return undefined;
127
+ }
128
+ if (isExpired(entry, now())) {
129
+ entries.delete(key);
130
+ return undefined;
131
+ }
132
+ return entry;
133
+ };
134
+
135
+ return {
136
+ delete: (key) => {
137
+ entries.delete(key);
138
+ return Promise.resolve();
139
+ },
140
+ get: (key) => Promise.resolve(liveEntry(key)?.value ?? null),
141
+ list: (listOptions) => {
142
+ const limit = listOptions?.limit ?? DEFAULT_LIST_LIMIT;
143
+ const prefix = listOptions?.prefix ?? '';
144
+ const nowMs = now();
145
+ const names = [...entries.keys()]
146
+ .filter((name) => {
147
+ const entry = entries.get(name);
148
+ return (
149
+ entry !== undefined &&
150
+ !isExpired(entry, nowMs) &&
151
+ name.startsWith(prefix)
152
+ );
153
+ })
154
+ .toSorted();
155
+ const startIndex =
156
+ listOptions?.cursor === undefined
157
+ ? 0
158
+ : names.findIndex((name) => name > (listOptions.cursor ?? ''));
159
+ const pageStart = startIndex === -1 ? names.length : startIndex;
160
+ const page = names.slice(pageStart, pageStart + limit);
161
+ const listComplete = pageStart + page.length >= names.length;
162
+ const lastName = page.at(-1);
163
+ return Promise.resolve({
164
+ ...(listComplete || lastName === undefined ? {} : { cursor: lastName }),
165
+ keys: page.map((name) => {
166
+ const entry = entries.get(name);
167
+ const expiresAtMs = entry?.expiresAtMs;
168
+ return {
169
+ ...(expiresAtMs === undefined
170
+ ? {}
171
+ : { expiration: Math.floor(expiresAtMs / MS_PER_SECOND) }),
172
+ name,
173
+ };
174
+ }),
175
+ list_complete: listComplete,
176
+ });
177
+ },
178
+ put: (key, value, putOptions) => {
179
+ entries.set(key, {
180
+ expiresAtMs: resolveExpiresAtMs(now(), putOptions),
181
+ value,
182
+ });
183
+ return Promise.resolve();
184
+ },
185
+ };
186
+ };
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Resource factory
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /** Options for {@link cloudflareKv}. */
193
+ export interface CloudflareKvOptions {
194
+ /** The wrangler binding name (a `kv_namespaces` entry's `binding`). */
195
+ readonly binding: string;
196
+ readonly description?: string | undefined;
197
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
198
+ }
199
+
200
+ const isKvBinding = (value: unknown): value is CloudflareKv => {
201
+ if (typeof value !== 'object' || value === null) {
202
+ return false;
203
+ }
204
+ const candidate = value as Partial<Record<keyof CloudflareKv, unknown>>;
205
+ return (
206
+ typeof candidate.get === 'function' &&
207
+ typeof candidate.put === 'function' &&
208
+ typeof candidate.delete === 'function' &&
209
+ typeof candidate.list === 'function'
210
+ );
211
+ };
212
+
213
+ /**
214
+ * Author a Trails resource wrapping a Cloudflare KV namespace binding.
215
+ *
216
+ * The instance arrives through the Workers env bridge — `create` refuses to
217
+ * run outside a Worker because KV bindings only exist there. The in-memory
218
+ * mock keeps `testAll(app)` configuration-free.
219
+ *
220
+ * @example
221
+ * ```ts
222
+ * import { cloudflareKv } from '@ontrails/cloudflare/kv';
223
+ * import { trail, Result } from '@ontrails/core';
224
+ * import { z } from 'zod';
225
+ *
226
+ * const flags = cloudflareKv('flags', { binding: 'FLAGS' });
227
+ *
228
+ * const showFlag = trail('flag.show', {
229
+ * blaze: async (input, ctx) => {
230
+ * const value = await flags.from(ctx).get(input.key);
231
+ * return Result.ok({ value });
232
+ * },
233
+ * input: z.object({ key: z.string() }),
234
+ * intent: 'read',
235
+ * output: z.object({ value: z.string().nullable() }),
236
+ * resources: [flags],
237
+ * });
238
+ * ```
239
+ */
240
+ export const cloudflareKv = (
241
+ id: string,
242
+ options: CloudflareKvOptions
243
+ ): Resource<CloudflareKv> => {
244
+ const definition = resource<CloudflareKv>(id, {
245
+ create: () =>
246
+ Result.err(
247
+ new InternalError(
248
+ `Resource "${id}" wraps Cloudflare KV binding "${options.binding}", which only exists on a Workers env. Serve the topo with createWorkersHandler from @ontrails/cloudflare/workers, or rely on the in-memory mock in tests.`,
249
+ { context: { binding: options.binding, resourceId: id } }
250
+ )
251
+ ),
252
+ description:
253
+ options.description ??
254
+ `Cloudflare KV namespace bound to "${options.binding}"`,
255
+ meta: {
256
+ ...options.meta,
257
+ 'cloudflare.binding': options.binding,
258
+ 'cloudflare.service': 'kv',
259
+ },
260
+ mock: () => createMemoryKv(),
261
+ });
262
+ registerEnvBinding(definition, {
263
+ binding: options.binding,
264
+ fromEnv: (value) =>
265
+ isKvBinding(value)
266
+ ? Result.ok(value)
267
+ : Result.err(
268
+ new InternalError(
269
+ `Worker env binding "${options.binding}" for resource "${id}" is not a KV namespace. Check the kv_namespaces entry in your wrangler configuration.`,
270
+ { context: { binding: options.binding, resourceId: id } }
271
+ )
272
+ ),
273
+ });
274
+ return definition;
275
+ };
@@ -0,0 +1,212 @@
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
+ projectErrorDiagnostics,
17
+ projectPublicSurfaceError,
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
+
32
+ export {
33
+ buildEnvResourceOverrides,
34
+ getEnvBinding,
35
+ registerEnvBinding,
36
+ } from '../env.js';
37
+ export type {
38
+ BuildEnvResourceOverridesOptions,
39
+ EnvBindingSpec,
40
+ WorkersEnv,
41
+ } from '../env.js';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Options
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Resource overrides for the Workers surface.
49
+ *
50
+ * A static map is applied as-is. A function receives the per-request Worker
51
+ * env and is re-invoked whenever a new env object arrives, so overrides that
52
+ * read bindings stay as fresh as the env bridge itself.
53
+ */
54
+ export type WorkersResourceOverrides =
55
+ | ResourceOverrideMap
56
+ | ((env: WorkersEnv) => ResourceOverrideMap);
57
+
58
+ /**
59
+ * Options for building a Trails Worker handler.
60
+ */
61
+ export interface CreateWorkersHandlerOptions extends BaseSurfaceOptions {
62
+ readonly basePath?: string | undefined;
63
+ readonly createContext?:
64
+ | (() => TrailContextInit | Promise<TrailContextInit>)
65
+ | undefined;
66
+ readonly layers?: readonly Layer[] | undefined;
67
+ /** Maximum JSON request body size in bytes. Defaults to 1 MiB. */
68
+ readonly maxJsonBodyBytes?: number | undefined;
69
+ readonly resolvePermit?: ResolveHttpPermit | undefined;
70
+ readonly resources?: WorkersResourceOverrides | undefined;
71
+ }
72
+
73
+ /**
74
+ * The `ExecutionContext` shape the Workers runtime passes as the third
75
+ * `fetch` argument. Declared structurally so the adapter does not require
76
+ * `@cloudflare/workers-types` at runtime.
77
+ */
78
+ export interface WorkersExecutionContext {
79
+ passThroughOnException(): void;
80
+ waitUntil(promise: Promise<unknown>): void;
81
+ }
82
+
83
+ /**
84
+ * The Worker module export produced by {@link createWorkersHandler}.
85
+ */
86
+ export interface CloudflareWorker {
87
+ fetch(
88
+ request: Request,
89
+ env?: WorkersEnv | undefined,
90
+ executionCtx?: WorkersExecutionContext | undefined
91
+ ): Promise<Response>;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Error mapping
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Map a materialization failure (route derivation, env bridge resolution) to
100
+ * a projected HTTP error response. Route execution errors never reach this
101
+ * path — the fetch kernel maps those itself.
102
+ */
103
+ const mapCaughtError = (error: unknown): Response => {
104
+ const err = error instanceof Error ? error : new Error(String(error));
105
+ // Materialization failures are host bootstrap problems; surface their
106
+ // diagnostics to the Worker log while the response stays redacted.
107
+ console.error(
108
+ '[ontrails:cloudflare/workers] Failed to materialize request handler',
109
+ projectErrorDiagnostics(err)
110
+ );
111
+ const projection = projectPublicSurfaceError('http', err);
112
+ return Response.json(
113
+ {
114
+ error: {
115
+ category: projection.category,
116
+ code: projection.name,
117
+ message: projection.message,
118
+ },
119
+ },
120
+ { status: projection.code }
121
+ );
122
+ };
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // createWorkersHandler
126
+ // ---------------------------------------------------------------------------
127
+
128
+ const resolveResourceOverrides = (
129
+ graph: Topo,
130
+ env: WorkersEnv,
131
+ options: CreateWorkersHandlerOptions
132
+ ): ResourceOverrideMap => {
133
+ // Explicit overrides are the documented escape hatch, so they resolve
134
+ // first: an overridden resource never requires its env binding. Surface
135
+ // filters are forwarded so trails the handler does not expose never
136
+ // require theirs either.
137
+ const userOverrides =
138
+ typeof options.resources === 'function'
139
+ ? options.resources(env)
140
+ : (options.resources ?? {});
141
+ const envOverrides = buildEnvResourceOverrides(graph, env, {
142
+ except: Object.keys(userOverrides),
143
+ exclude: options.exclude,
144
+ include: options.include,
145
+ intent: options.intent,
146
+ });
147
+ if (envOverrides.isErr()) {
148
+ throw envOverrides.error;
149
+ }
150
+ return { ...envOverrides.value, ...userOverrides };
151
+ };
152
+
153
+ interface MaterializedHandler {
154
+ readonly env: WorkersEnv | undefined;
155
+ readonly handle: (request: Request) => Promise<Response>;
156
+ }
157
+
158
+ /**
159
+ * Build the `{ fetch }` Worker export for a topo.
160
+ *
161
+ * @remarks The kernel fetch handler is materialized lazily per env identity.
162
+ * The Workers runtime keeps `env` stable within an isolate, so steady-state
163
+ * requests reuse one materialization; any request carrying a different env
164
+ * object triggers a fresh resolution of every env-bound resource.
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * import { createWorkersHandler } from '@ontrails/cloudflare/workers';
169
+ * import { graph } from './app.js';
170
+ *
171
+ * export default createWorkersHandler(graph, { basePath: '/api' });
172
+ * ```
173
+ */
174
+ export const createWorkersHandler = (
175
+ graph: Topo,
176
+ options: CreateWorkersHandlerOptions = {}
177
+ ): CloudflareWorker => {
178
+ let materialized: MaterializedHandler | undefined;
179
+
180
+ const handlerFor = (
181
+ env: WorkersEnv | undefined
182
+ ): ((request: Request) => Promise<Response>) => {
183
+ if (materialized !== undefined && materialized.env === env) {
184
+ return materialized.handle;
185
+ }
186
+ const handle = createFetchHandler(graph, {
187
+ basePath: options.basePath,
188
+ configValues: options.configValues,
189
+ createContext: options.createContext,
190
+ exclude: options.exclude,
191
+ include: options.include,
192
+ intent: options.intent,
193
+ layers: options.layers,
194
+ maxJsonBodyBytes: options.maxJsonBodyBytes,
195
+ resolvePermit: options.resolvePermit,
196
+ resources: resolveResourceOverrides(graph, env ?? {}, options),
197
+ validate: options.validate,
198
+ });
199
+ materialized = { env, handle };
200
+ return handle;
201
+ };
202
+
203
+ return {
204
+ fetch: async (request, env, _executionCtx) => {
205
+ try {
206
+ return await handlerFor(env)(request);
207
+ } catch (error: unknown) {
208
+ return mapCaughtError(error);
209
+ }
210
+ },
211
+ };
212
+ };