@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 +21 -0
- package/README.md +117 -0
- package/package.json +43 -0
- package/src/env.ts +275 -0
- package/src/facts.ts +112 -0
- package/src/index.ts +41 -0
- package/src/kv/index.ts +275 -0
- package/src/workers/index.ts +212 -0
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';
|
package/src/kv/index.ts
ADDED
|
@@ -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
|
+
};
|