From 3a319b13a826a4226ec798621d5db6ddc4fa12b8 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Sat, 11 Jul 2026 00:07:50 -0700 Subject: [PATCH 01/13] feat(action): local bindings + Output accessors in Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actions can now bind resources with current credentials and read resource Outputs, like a Cloudflare Worker. - ActionRuntimeContext: `yield* db.databaseId` inside an Action init captures the Output (→ dependency edge) and resolves it against the tracker at apply time. Works in both the inline and tagged `.make` forms. - Cloudflare.D1.QueryDatabaseLocal: a `*Local` layer (third variant alongside `*Binding`/`*Http`) that queries D1 over HTTP with the current credentials — no Worker host, no `host.bind`. Co-Authored-By: Claude Opus 4.8 --- packages/alchemy/src/Action.ts | 45 ++++- packages/alchemy/src/ActionRuntimeContext.ts | 68 +++++++ packages/alchemy/src/Apply.ts | 35 +++- .../src/Cloudflare/D1/QueryDatabaseLocal.ts | 185 ++++++++++++++++++ packages/alchemy/src/Cloudflare/D1/index.ts | 1 + packages/alchemy/src/Plan.ts | 17 +- .../Cloudflare/D1/QueryDatabaseLocal.test.ts | 87 ++++++++ packages/alchemy/test/action.test.ts | 65 ++++++ 8 files changed, 490 insertions(+), 13 deletions(-) create mode 100644 packages/alchemy/src/ActionRuntimeContext.ts create mode 100644 packages/alchemy/src/Cloudflare/D1/QueryDatabaseLocal.ts create mode 100644 packages/alchemy/test/Cloudflare/D1/QueryDatabaseLocal.test.ts diff --git a/packages/alchemy/src/Action.ts b/packages/alchemy/src/Action.ts index 1d68773f7..6e6ed7d00 100644 --- a/packages/alchemy/src/Action.ts +++ b/packages/alchemy/src/Action.ts @@ -2,10 +2,12 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type { Pipeable } from "effect/Pipeable"; +import { makeCaptureContext } from "./ActionRuntimeContext.ts"; import { toFqn } from "./FQN.ts"; import type { Input } from "./Input.ts"; import { CurrentNamespace, type NamespaceNode } from "./Namespace.ts"; import * as Output from "./Output.ts"; +import { RuntimeContext } from "./RuntimeContext.ts"; import { Stack } from "./Stack.ts"; /** @@ -35,6 +37,14 @@ export interface ActionLike< readonly Type: Type; readonly LogicalId: string; readonly Input: In; + /** + * Resource Outputs referenced via `yield* output` inside the init Effect, + * keyed by the Output's sanitized key. They become dependency edges (the + * Action waits for these upstreams) and are resolved against the tracker at + * apply time, then exposed to the body through the resolve + * {@link RuntimeContext}. Empty when the Action captures nothing. + */ + readonly Captures: Record; /** Resolved runner — populated by the init effect (if any). */ readonly Run: (input: In) => Effect.Effect; /** @internal phantom */ @@ -83,9 +93,14 @@ export type ActionInit = Effect.Effect< // const rows = yield* Sync({ table: bucket.name }); // // rows: Output<{ rows: number }, never> // -// Tagged form for split contract/implementation: +// Tagged form for split contract/implementation — declare the type with an +// interface, build the value with the no-arg overload, then supply the runner +// via `.make` (add the returned Layer to the stack's `providers`, or provide it +// locally with `Effect.provide`). The init passed to `.make` runs under the +// same capture context as the inline form, so `yield* output` accessors work: // -// class Sync extends Action()("Sync") {} +// interface Sync extends Action<"Sync", { table: string }, { rows: number }> {} +// const Sync = Action()("Sync"); // const SyncLive = Sync.make(Effect.gen(function* () { /* ... */ })); export function Action< @@ -187,14 +202,23 @@ const makeActionClass = ( `alchemy/Action<${type}>`, ); + // Outputs referenced via `yield* output` inside the init Effect land here, + // recorded by the capture RuntimeContext. Shared per definition — the init + // runs at most once (Effect.cached), so captures are inherently + // per-definition, matching how the init's `Req` bubbles per definition. + const captures: Record = {}; + const captureContext = makeCaptureContext(captures); + const constructor = (...args: [any] | [string, any]) => { const [id, input] = args.length === 1 ? [type, args[0]] : (args as [string, any]); return Effect.gen(function* () { const run = resolveRunner - ? yield* resolveRunner + ? yield* resolveRunner.pipe( + Effect.provideService(RuntimeContext, captureContext), + ) : ((yield* SelfTag!) as ActionRunner); - return yield* registerAction(type, id, input, run); + return yield* registerAction(type, id, input, run, captures); }); }; @@ -203,12 +227,19 @@ const makeActionClass = ( extra.Self = SelfTag; // `.make(initOrRun)` — accepts either a direct runner or an init Effect. // For init form we use `Layer.effect` so the init's Req surfaces on the - // Layer; for runners we use `Layer.succeed`. + // Layer, and run it under the capture RuntimeContext so `yield* output` + // accessors are recorded just like the inline form; for runners we use + // `Layer.succeed` (nothing to capture). extra.make = ( initOrRun: ActionRunner | ActionInit, ) => isRunnerEffect(initOrRun) - ? Layer.effect(SelfTag, initOrRun as any) + ? Layer.effect( + SelfTag, + (initOrRun as ActionInit).pipe( + Effect.provideService(RuntimeContext, captureContext), + ), + ) : Layer.succeed(SelfTag, initOrRun as ActionRunner); } return Object.assign(constructor, extra); @@ -223,6 +254,7 @@ const registerAction = < id: string, input: any, run: ActionRunner, + captures: Record, ): Effect.Effect, never, Stack> => Effect.gen(function* () { const stack = yield* Stack; @@ -255,6 +287,7 @@ const registerAction = < FQN: fqn, LogicalId: id, Input: input, + Captures: captures, Run: run, Output: undefined as any, }; diff --git a/packages/alchemy/src/ActionRuntimeContext.ts b/packages/alchemy/src/ActionRuntimeContext.ts new file mode 100644 index 000000000..1a3ce1955 --- /dev/null +++ b/packages/alchemy/src/ActionRuntimeContext.ts @@ -0,0 +1,68 @@ +import * as Effect from "effect/Effect"; +import type { Output } from "./Output.ts"; +import { type BaseRuntimeContext, RuntimeContext } from "./RuntimeContext.ts"; + +/** + * Runtime context bridge that lets an {@link Action} read Resource Outputs. + * + * Actions are peculiar: their init Effect runs at plan/stack-eval time (before + * any resource exists) while their body runs at apply time (after upstreams are + * materialized). To make `yield* db.databaseId` work inside an Action we split + * the {@link RuntimeContext} into two cooperating halves that share nothing but + * a sanitized key: + * + * - {@link makeCaptureContext} is provided while the init Effect runs. Its + * `set(key, output)` records the referenced {@link Output} (so the engine can + * add a dependency edge and later resolve it) and its `get(key)` hands back a + * *deferred* accessor — an Effect that re-reads whatever `RuntimeContext` is + * ambient when it actually runs. + * - {@link makeResolveContext} is provided around the Action body at apply + * time. Its `get(key)` returns the already-resolved value for that key. + * + * Because the accessor produced at init re-reads `RuntimeContext`, yielding it + * inside the body resolves against the apply-time context — no shared mutable + * state, no infinite recursion (the resolve context's `get` is terminal). + */ + +const base = (id: string): Omit => ({ + Type: "Action", + id, + env: {}, +}); + +/** + * Capture context — provided while an Action's init Effect runs. Records every + * Output referenced via `yield* output` into `captures`, keyed by the Output's + * sanitized key, and returns deferred accessors. + */ +export const makeCaptureContext = ( + captures: Record, +): BaseRuntimeContext => ({ + ...base("capture"), + set: (key, output) => + Effect.sync(() => { + captures[key] = output as unknown as Output; + return key; + }), + // Deferred: re-read the ambient RuntimeContext at run time. At apply this is + // the resolve context below; its `get` returns the materialized value. The + // `RuntimeContext` requirement is erased at the Action boundary (`Run` is + // typed `Effect`) and satisfied by the resolve context, so + // the accessor presents as `Effect`. + get: ((key: string) => + Effect.flatMap(RuntimeContext, (ctx) => + ctx.get(key), + )) as BaseRuntimeContext["get"], +}); + +/** + * Resolve context — provided around an Action body at apply time. `resolved` + * maps each captured key to its value (already evaluated against the tracker). + */ +export const makeResolveContext = ( + resolved: Record, +): BaseRuntimeContext => ({ + ...base("resolve"), + set: (key) => Effect.succeed(key), + get: (key: string) => Effect.succeed(resolved[key] as T | undefined), +}); diff --git a/packages/alchemy/src/Apply.ts b/packages/alchemy/src/Apply.ts index 641e42fe4..cb8b9159d 100644 --- a/packages/alchemy/src/Apply.ts +++ b/packages/alchemy/src/Apply.ts @@ -3,6 +3,9 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type { Simplify } from "effect/Types"; +import type { ActionLike } from "./Action.ts"; +import { makeResolveContext } from "./ActionRuntimeContext.ts"; +import { RuntimeContext } from "./RuntimeContext.ts"; import { Artifacts, ArtifactStore, @@ -1117,6 +1120,25 @@ const executeNode = ( // `--force` is set). The output value is written to `tracker[fqn].output` // so downstream Output evaluation works identically to resource attrs. +/** + * Run an Action body, resolving any Outputs it captured via `yield* output` + * during init against the current tracker and exposing them to the body through + * the resolve {@link RuntimeContext}. See {@link makeCaptureContext}. + */ +const runAction = Effect.fn("apply.runAction")(function* ( + task: ActionLike, + input: any, + outputs: Record, +) { + const resolved: Record = {}; + for (const [key, output] of Object.entries(task.Captures)) { + resolved[key] = yield* Output.evaluate(output, outputs); + } + return yield* task + .Run(input) + .pipe(Effect.provideService(RuntimeContext, makeResolveContext(resolved))); +}); + const executeActionNode = ( fqn: string, node: ActionApply, @@ -1195,9 +1217,12 @@ const executeActionNode = ( // until its run actually starts. yield* report("pending"); yield* waitForDeps( - Object.keys(Output.resolveUpstream(node.input)).filter( - (f) => f in readyStable, - ), + [ + ...new Set([ + ...Object.keys(Output.resolveUpstream(node.input)), + ...Object.keys(Output.upstreamAny(task.Captures)), + ]), + ].filter((f) => f in readyStable), ); const outputs = getOutputs(); @@ -1216,7 +1241,7 @@ const executeActionNode = ( }); yield* report("running"); - const result = yield* task.Run(resolvedInput); + const result = yield* runAction(task, resolvedInput, outputs); yield* commit({ kind: "action", @@ -1440,7 +1465,7 @@ const converge = Effect.fn(function* ( } satisfies RunningActionState, }); - const result = yield* node.def.Run(newInput); + const result = yield* runAction(node.def, newInput, outputs); yield* state.set({ stack: stackName, diff --git a/packages/alchemy/src/Cloudflare/D1/QueryDatabaseLocal.ts b/packages/alchemy/src/Cloudflare/D1/QueryDatabaseLocal.ts new file mode 100644 index 000000000..af8c89fcf --- /dev/null +++ b/packages/alchemy/src/Cloudflare/D1/QueryDatabaseLocal.ts @@ -0,0 +1,185 @@ +import type * as runtime from "@cloudflare/workers-types"; +import * as d1 from "@distilled.cloud/cloudflare/d1"; +import type * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; +import type { Database } from "./Database.ts"; +import { + type QueryDatabaseClient, + PreparedStatement, + QueryDatabase, +} from "./QueryDatabase.ts"; + +/** + * Local implementation of the {@link QueryDatabase} binding — queries D1 over + * the Cloudflare HTTP API using the **current credentials** instead of a native + * Worker binding (`QueryDatabaseBinding`) or a scoped API token. + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can talk to + * a D1 database with the same `prepare`/`exec`/`batch` client you'd use inside a + * Worker: + * + * @example Seeding a database from an Action + * ```typescript + * const Seed = Alchemy.Action( + * "Seed", + * Effect.gen(function* () { + * const db = yield* Cloudflare.D1.QueryDatabase(database); + * return Effect.fn(function* () { + * yield* db.exec("CREATE TABLE IF NOT EXISTS users (id TEXT, name TEXT)"); + * yield* db + * .prepare("INSERT INTO users (id, name) VALUES (?, ?)") + * .bind("1", "Ada") + * .run(); + * }); + * }).pipe(Effect.provide(Cloudflare.D1.QueryDatabaseLocal)), + * ); + * ``` + * + * The database id is resolved at apply time through the ambient + * {@link RuntimeContext} (in an Action, that's the resolve context the engine + * provides around the body), so `QueryDatabase(database)` works even though the + * database is created in the same deploy. + */ +export const QueryDatabaseLocal = Layer.effect( + QueryDatabase, + Effect.gen(function* () { + // Account + credentials are ambient during stack-eval (the stack's + // providers layer). Capture the full context so the HTTP query effect can + // be run from the Promise-based D1 shim below. + const { accountId } = yield* yield* CloudflareEnvironment; + const context = yield* Effect.context< + Credentials | HttpClient.HttpClient + >(); + + return Effect.fn(function* (database: Database) { + // Deferred accessor — resolves the databaseId against the tracker at + // apply time. No `host.bind`: the local variant registers no binding. + const databaseId = yield* database.databaseId; + + const rawEff = Effect.map(databaseId, (id) => + makeHttpD1Database({ accountId, databaseId: id, context }), + ); + + return { + raw: rawEff, + prepare: (query: string) => new PreparedStatement(query, [], rawEff), + exec: (query: string) => + Effect.flatMap(rawEff, (raw) => + Effect.promise(() => raw.exec(query)), + ), + batch: (statements: PreparedStatement[]) => + Effect.flatMap(rawEff, (raw) => + Effect.promise(() => + raw.batch(statements.map((s) => s._build(raw))), + ), + ), + } satisfies QueryDatabaseClient; + }); + }), +); + +// ── HTTP-backed D1Database shim ────────────────────────────────────────────── +// +// PreparedStatement (shared with QueryDatabaseBinding) drives a +// `runtime.D1Database` whose executors return Promises. This shim implements the +// slice PreparedStatement uses (prepare/exec/batch + stmt bind/all/first/run/raw) +// by running `d1.queryDatabase` over HTTP with the captured credentials context. + +interface QueryContext { + accountId: string; + databaseId: string; + context: Context.Context; +} + +const runQuery = ( + ctx: QueryContext, + body: + | { sql: string; params?: unknown[] } + | { batch: { sql: string; params?: unknown[] }[] }, +): Promise => + d1 + .queryDatabase({ + accountId: ctx.accountId, + databaseId: ctx.databaseId, + ...(body as any), + }) + .pipe(Effect.provideContext(ctx.context), Effect.runPromise); + +const toResult = ( + r: d1.QueryDatabaseResponse["result"][number] | undefined, +): runtime.D1Result => + ({ + results: (r?.results ?? []) as T[], + success: r?.success ?? true, + meta: (r?.meta ?? {}) as any, + }) as runtime.D1Result; + +const makeHttpD1Database = (ctx: QueryContext): runtime.D1Database => { + const makeStatement = ( + query: string, + binds: ReadonlyArray, + ): runtime.D1PreparedStatement => { + const exec = async () => { + const res = await runQuery(ctx, { + sql: query, + params: binds.length ? [...binds] : undefined, + }); + return res.result[0]; + }; + return { + bind: (...values: unknown[]) => makeStatement(query, values), + first: (async (column?: string) => { + const first = (await exec())?.results?.[0] as + | Record + | undefined; + if (first == null) return null; + return column !== undefined ? (first[column] ?? null) : first; + }) as runtime.D1PreparedStatement["first"], + all: (async () => + toResult(await exec())) as runtime.D1PreparedStatement["all"], + run: (async () => + toResult(await exec())) as runtime.D1PreparedStatement["run"], + raw: (async (options?: { columnNames?: boolean }) => { + const rows = ((await exec())?.results ?? []) as Record< + string, + unknown + >[]; + const arrays = rows.map((row) => Object.values(row)); + if (options?.columnNames && rows[0]) { + return [Object.keys(rows[0]), ...arrays]; + } + return arrays; + }) as runtime.D1PreparedStatement["raw"], + // Carry query + params so `batch` can reconstruct the request. + __query: query, + __params: binds, + } as unknown as runtime.D1PreparedStatement; + }; + + return { + prepare: (query: string) => makeStatement(query, []), + exec: async (query: string) => { + const res = await runQuery(ctx, { sql: query }); + const meta = res.result[res.result.length - 1]?.meta; + return { + count: res.result.length, + duration: meta?.duration ?? 0, + } as runtime.D1ExecResult; + }, + batch: async (statements: runtime.D1PreparedStatement[]) => { + const res = await runQuery(ctx, { + batch: statements.map((s) => ({ + sql: (s as any).__query as string, + params: ((s as any).__params as unknown[]).length + ? ((s as any).__params as unknown[]) + : undefined, + })), + }); + return res.result.map((r) => toResult(r)); + }, + } as unknown as runtime.D1Database; +}; diff --git a/packages/alchemy/src/Cloudflare/D1/index.ts b/packages/alchemy/src/Cloudflare/D1/index.ts index 045934df4..0b08cf011 100644 --- a/packages/alchemy/src/Cloudflare/D1/index.ts +++ b/packages/alchemy/src/Cloudflare/D1/index.ts @@ -1,3 +1,4 @@ export * from "./Database.ts"; export * from "./QueryDatabase.ts"; export * from "./QueryDatabaseBinding.ts"; +export * from "./QueryDatabaseLocal.ts"; diff --git a/packages/alchemy/src/Plan.ts b/packages/alchemy/src/Plan.ts index 60a5d7345..27b24959f 100644 --- a/packages/alchemy/src/Plan.ts +++ b/packages/alchemy/src/Plan.ts @@ -543,7 +543,18 @@ export const make = ( (action) => [ action.FQN, - Object.values(Output.upstreamAny(action.Input)).map((r) => r.FQN), + // An Action depends on the resources referenced by its Input *and* + // any Output captured via `yield* output` inside its init Effect. + Array.from( + new Set([ + ...Object.values(Output.upstreamAny(action.Input)).map( + (r) => r.FQN, + ), + ...Object.values(Output.upstreamAny(action.Captures)).map( + (r) => r.FQN, + ), + ]), + ), ] as const, ), ]); @@ -572,7 +583,8 @@ export const make = ( const bindDeps = bindingUpstreamDependencies[fqn] ?? []; return [fqn, [...new Set([...propDeps, ...bindDeps])]]; }), - // Actions have no bindings — their upstream is purely their input. + // Actions have no bindings — their upstream is input + init captures, + // both already folded into newUpstreamDependencies above. ...actions.map((action): [string, string[]] => { const fqn = action.FQN; return [fqn, newUpstreamDependencies[fqn] ?? []]; @@ -1212,6 +1224,7 @@ export const make = ( LogicalId: logicalId, Type: persisted.actionType, Input: persisted.input, + Captures: {}, Run: () => undefined as any, Output: undefined as any, } satisfies ActionLike, diff --git a/packages/alchemy/test/Cloudflare/D1/QueryDatabaseLocal.test.ts b/packages/alchemy/test/Cloudflare/D1/QueryDatabaseLocal.test.ts new file mode 100644 index 000000000..82c25b047 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/D1/QueryDatabaseLocal.test.ts @@ -0,0 +1,87 @@ +import { Action } from "@/Action"; +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +interface User { + id: string; + name: string; +} + +// Binding a D1 database inside an Action via `QueryDatabaseLocal` — the local +// (current-credentials) implementation of the `QueryDatabase` binding. Exercises +// both the binding client (exec/prepare/run/all) and the accessor mechanism +// (`yield* database.databaseId` resolved at apply time). +test.provider( + "QueryDatabaseLocal: seed and query a database from an Action", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const out = yield* stack.deploy( + Effect.gen(function* () { + const database = yield* Cloudflare.D1.Database("SeedDatabase"); + + const Seed = Action( + "Seed", + Effect.gen(function* () { + const db = yield* Cloudflare.D1.QueryDatabase(database); + // Accessor — resolved at apply time against the tracker. + const databaseId = yield* database.databaseId; + + return Effect.fn(function* () { + yield* db.exec( + "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT)", + ); + yield* db.prepare("DELETE FROM users").run(); + yield* db + .prepare("INSERT INTO users (id, name) VALUES (?, ?)") + .bind("1", "Ada") + .run(); + yield* db + .prepare("INSERT INTO users (id, name) VALUES (?, ?)") + .bind("2", "Grace") + .run(); + + const rows = yield* db + .prepare("SELECT id, name FROM users ORDER BY id") + .all(); + + const first = yield* db + .prepare("SELECT name FROM users WHERE id = ?") + .bind("1") + .first("name"); + + return { + databaseId: yield* databaseId, + users: rows.results, + first, + }; + }); + }).pipe(Effect.provide(Cloudflare.D1.QueryDatabaseLocal)), + ); + + return yield* Seed({}); + }), + ); + + expect(out.databaseId).toBeTruthy(); + expect(out.users).toEqual([ + { id: "1", name: "Ada" }, + { id: "2", name: "Grace" }, + ]); + expect(out.first).toBe("Ada"); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/action.test.ts b/packages/alchemy/test/action.test.ts index 9628b58c7..66f6426e1 100644 --- a/packages/alchemy/test/action.test.ts +++ b/packages/alchemy/test/action.test.ts @@ -438,6 +438,39 @@ describe("Apply", () => { }), ); + test.provider( + "init captures a resource Output; body resolves it at apply", + (stack) => + Effect.gen(function* () { + const out = yield* stack.deploy( + Effect.gen(function* () { + const bucket = yield* Bucket("Cap", { name: "cap-bucket" }); + + const Seed = Action( + "Seed", + Effect.gen(function* () { + // Capture the resource Output at init — before the bucket + // exists. `arn` is a deferred accessor. + const arn = yield* bucket.bucketArn; + const name = yield* bucket.name; + return Effect.fn(function* () { + // Resolve at apply, after the bucket is materialized. + return { arn: yield* arn, name: yield* name }; + }); + }), + ); + + return yield* Seed({}); + }), + ); + + expect(out).toEqual({ + arn: "arn:test:bucket:us-east-1:123456789:Cap", + name: "cap-bucket", + }); + }), + ); + test.provider("init-effect form: deps satisfied at apply", (stack) => Effect.gen(function* () { class Multiplier extends Context.Service()( @@ -464,4 +497,36 @@ describe("Apply", () => { expect(out).toEqual({ result: 63 }); }), ); + + test.provider( + "tagged .make form: init captures a resource Output; body resolves it", + (stack) => + Effect.gen(function* () { + interface SeedAction extends Action<"Seed", {}, { arn: string }> {} + const Seed = Action()("Seed"); + + const out = yield* stack.deploy( + Effect.gen(function* () { + const bucket = yield* Bucket("Cap", { name: "cap-bucket" }); + + // `.make` called inside the builder with `bucket` in scope, then + // provided locally — its init runs under the capture context. + const SeedLive = Seed.make( + Effect.gen(function* () { + const arn = yield* bucket.bucketArn; + return Effect.fn(function* () { + return { arn: yield* arn }; + }); + }), + ); + + return yield* Seed({}).pipe(Effect.provide(SeedLive)); + }), + ); + + expect(out).toEqual({ + arn: "arn:test:bucket:us-east-1:123456789:Cap", + }); + }), + ); }); From ca6eb42558e75094c6ce75ecb6947c0095cfd377 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Sat, 11 Jul 2026 00:22:14 -0700 Subject: [PATCH 02/13] feat(cloudflare): Local bindings for KV, R2, Queues *Local layers (current-credentials, over HTTP) for the KV, R2, and Queues bindings so they can be used inside an Action. Each refactors its shared *Http client builder to accept an injectable auth ({ authorize, accountId }) reused by both the token-scoped Http variant and the current-creds Local variant. - KV: ReadNamespaceLocal / WriteNamespaceLocal / ReadWriteNamespaceLocal - R2: ReadBucketLocal / WriteBucketLocal / ReadWriteBucketLocal - Queues: WriteQueueLocal Co-Authored-By: Claude Opus 4.8 --- .../src/Cloudflare/KV/NamespaceHttp.ts | 27 +++- .../src/Cloudflare/KV/NamespaceLocal.ts | 42 ++++++ .../src/Cloudflare/KV/ReadNamespaceHttp.ts | 13 +- .../src/Cloudflare/KV/ReadNamespaceLocal.ts | 35 +++++ .../Cloudflare/KV/ReadWriteNamespaceHttp.ts | 17 ++- .../Cloudflare/KV/ReadWriteNamespaceLocal.ts | 42 ++++++ .../src/Cloudflare/KV/WriteNamespaceHttp.ts | 13 +- .../src/Cloudflare/KV/WriteNamespaceLocal.ts | 35 +++++ packages/alchemy/src/Cloudflare/KV/index.ts | 3 + .../src/Cloudflare/Queues/QueueHttp.ts | 26 +++- .../src/Cloudflare/Queues/WriteQueueHttp.ts | 23 +++- .../src/Cloudflare/Queues/WriteQueueLocal.ts | 64 +++++++++ .../alchemy/src/Cloudflare/Queues/index.ts | 1 + .../alchemy/src/Cloudflare/R2/BucketHttp.ts | 23 +++- .../alchemy/src/Cloudflare/R2/BucketLocal.ts | 47 +++++++ .../src/Cloudflare/R2/ReadBucketHttp.ts | 15 ++- .../src/Cloudflare/R2/ReadBucketLocal.ts | 33 +++++ .../src/Cloudflare/R2/ReadWriteBucketHttp.ts | 16 ++- .../src/Cloudflare/R2/ReadWriteBucketLocal.ts | 36 +++++ .../src/Cloudflare/R2/WriteBucketHttp.ts | 15 ++- .../src/Cloudflare/R2/WriteBucketLocal.ts | 33 +++++ packages/alchemy/src/Cloudflare/R2/index.ts | 3 + .../test/Cloudflare/KV/NamespaceLocal.test.ts | 95 ++++++++++++++ .../test/Cloudflare/Queue/QueueLocal.test.ts | 123 ++++++++++++++++++ .../test/Cloudflare/R2/BucketLocal.test.ts | 100 ++++++++++++++ 25 files changed, 833 insertions(+), 47 deletions(-) create mode 100644 packages/alchemy/src/Cloudflare/KV/NamespaceLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/KV/ReadNamespaceLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/KV/ReadWriteNamespaceLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/KV/WriteNamespaceLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/Queues/WriteQueueLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/R2/BucketLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/R2/ReadBucketLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/R2/ReadWriteBucketLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/R2/WriteBucketLocal.ts create mode 100644 packages/alchemy/test/Cloudflare/KV/NamespaceLocal.test.ts create mode 100644 packages/alchemy/test/Cloudflare/Queue/QueueLocal.test.ts create mode 100644 packages/alchemy/test/Cloudflare/R2/BucketLocal.test.ts diff --git a/packages/alchemy/src/Cloudflare/KV/NamespaceHttp.ts b/packages/alchemy/src/Cloudflare/KV/NamespaceHttp.ts index 9a35e0bed..76e6a203e 100644 --- a/packages/alchemy/src/Cloudflare/KV/NamespaceHttp.ts +++ b/packages/alchemy/src/Cloudflare/KV/NamespaceHttp.ts @@ -1,9 +1,13 @@ import * as Effect from "effect/Effect"; import type * as Redacted from "effect/Redacted"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; import { Self } from "../../Self.ts"; import { AccountApiToken } from "../ApiToken/AccountApiToken.ts"; import type { PermissionGroupRef } from "../ApiToken/Common.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; +import { authorizeWith } from "../HttpClientUtils.ts"; import type { Namespace } from "./Namespace.ts"; import { NamespaceError } from "./NamespaceTypes.ts"; @@ -58,6 +62,25 @@ export interface HttpScope { namespaceId: string; } +/** + * Injectable auth for the KV HTTP client builders. Both the scoped-token + * (`*Http`) and current-credentials (`*Local`) variants supply an `authorize` + * (which provides `Credentials` + `HttpClient` to a raw SDK op) and an + * `accountId`, so the client builders are agnostic to how creds are obtained. + */ +export interface KVAuth { + authorize: ( + eff: Effect.Effect, + ) => Effect.Effect; + accountId: Effect.Effect; +} + +/** Build a scoped-token {@link KVAuth} from a bound {@link HttpToken}. */ +export const makeKVAuth = (token: HttpToken): KVAuth => ({ + authorize: authorizeWith(token), + accountId: token.accountId, +}); + const KV_HTTP_PERMISSION_GROUPS: PermissionGroupRef[] = [ "Workers KV Storage Read", "Workers KV Storage Write", @@ -67,11 +90,11 @@ type PermissionGroup = (typeof KV_HTTP_PERMISSION_GROUPS)[number]; /** Resolve the account and namespace id once per operation. */ export const makeKVHttpScope = ( - token: HttpToken, + auth: KVAuth, namespaceId: Effect.Effect, ): Effect.Effect => Effect.gen(function* () { - const accountId = yield* token.accountId; + const accountId = yield* auth.accountId; const id = yield* namespaceId; return { accountId, namespaceId: id }; }); diff --git a/packages/alchemy/src/Cloudflare/KV/NamespaceLocal.ts b/packages/alchemy/src/Cloudflare/KV/NamespaceLocal.ts new file mode 100644 index 000000000..3dc3d7bf0 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/KV/NamespaceLocal.ts @@ -0,0 +1,42 @@ +import * as Effect from "effect/Effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; +import type { Namespace } from "./Namespace.ts"; +import type { KVAuth } from "./NamespaceHttp.ts"; + +/** + * Shared scaffolding for the `*Local` KV services. + * + * Instead of minting a scoped {@link AccountApiToken} (the `*Http` path) or + * resolving a native Worker binding (the `*Binding` path), it captures the + * ambient current-credentials context available during stack-eval and builds + * a {@link KVAuth} that provides those credentials directly to the KV HTTP + * ops. It then delegates to the same client builders the `*Http` variant uses. + * + * NOT exported from `index.ts` — this is internal scaffolding shared by the + * three access-level Local layers. + */ +export const makeLocalKVNamespaceBinding = (options: { + makeClient: (auth: KVAuth, namespaceId: Effect.Effect) => Client; +}) => + Effect.gen(function* () { + // Account + credentials are ambient during stack-eval (the stack's + // providers layer). Capture the full context so KV HTTP ops can run with + // the current credentials — no `host.bind`, no minted token. + const { accountId } = yield* yield* CloudflareEnvironment; + const context = yield* Effect.context< + Credentials | HttpClient.HttpClient + >(); + + return Effect.fn(function* (namespace: Namespace) { + // Deferred accessor — resolves the namespaceId against the tracker at + // apply time (in an Action, that's the engine's resolve context). + const namespaceId = yield* namespace.namespaceId; + const auth: KVAuth = { + authorize: (eff) => eff.pipe(Effect.provideContext(context)), + accountId: Effect.succeed(accountId), + }; + return options.makeClient(auth, namespaceId); + }); + }); diff --git a/packages/alchemy/src/Cloudflare/KV/ReadNamespaceHttp.ts b/packages/alchemy/src/Cloudflare/KV/ReadNamespaceHttp.ts index bb00c08d0..30d285614 100644 --- a/packages/alchemy/src/Cloudflare/KV/ReadNamespaceHttp.ts +++ b/packages/alchemy/src/Cloudflare/KV/ReadNamespaceHttp.ts @@ -1,12 +1,12 @@ import * as kv from "@distilled.cloud/cloudflare/kv"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { authorizeWith } from "../HttpClientUtils.ts"; import { makeHttpKVNamespaceBinding, + makeKVAuth, makeKVHttpScope, toKVNamespaceError, - type HttpToken, + type KVAuth, } from "./NamespaceHttp.ts"; import { ReadNamespace, type ReadNamespaceClient } from "./ReadNamespace.ts"; @@ -21,17 +21,18 @@ export const ReadNamespaceHttp = Layer.effect( Effect.suspend(() => makeHttpKVNamespaceBinding({ permissionGroups: ["Workers KV Storage Read"], - makeClient: makeReadKVHttpClient, + makeClient: (token, namespaceId) => + makeReadKVHttpClient(makeKVAuth(token), namespaceId), }), ), ); export const makeReadKVHttpClient = ( - token: HttpToken, + auth: KVAuth, namespaceId: Effect.Effect, ): ReadNamespaceClient => { - const authorize = authorizeWith(token); - const scope = makeKVHttpScope(token, namespaceId); + const { authorize } = auth; + const scope = makeKVHttpScope(auth, namespaceId); const getOne = (key: string, type: string) => scope.pipe( diff --git a/packages/alchemy/src/Cloudflare/KV/ReadNamespaceLocal.ts b/packages/alchemy/src/Cloudflare/KV/ReadNamespaceLocal.ts new file mode 100644 index 000000000..8d2ef97d8 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/KV/ReadNamespaceLocal.ts @@ -0,0 +1,35 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { makeLocalKVNamespaceBinding } from "./NamespaceLocal.ts"; +import { ReadNamespace } from "./ReadNamespace.ts"; +import { makeReadKVHttpClient } from "./ReadNamespaceHttp.ts"; + +/** + * Local implementation of the {@link ReadNamespace} binding — reads KV values + * over the Cloudflare HTTP API using the **current credentials** instead of a + * native Worker binding (`ReadNamespaceBinding`) or a scoped API token + * (`ReadNamespaceHttp`). + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can read + * from a namespace with the same `get`/`getWithMetadata`/`list` client you'd + * use inside a Worker: + * + * @example Reading a value from an Action + * ```typescript + * const Check = Alchemy.Action( + * "Check", + * Effect.gen(function* () { + * const kv = yield* Cloudflare.KV.ReadNamespace(namespace); + * return Effect.fn(function* () { + * return yield* kv.get("my-key"); + * }); + * }).pipe(Effect.provide(Cloudflare.KV.ReadNamespaceLocal)), + * ); + * ``` + */ +export const ReadNamespaceLocal = Layer.effect( + ReadNamespace, + Effect.suspend(() => + makeLocalKVNamespaceBinding({ makeClient: makeReadKVHttpClient }), + ), +); diff --git a/packages/alchemy/src/Cloudflare/KV/ReadWriteNamespaceHttp.ts b/packages/alchemy/src/Cloudflare/KV/ReadWriteNamespaceHttp.ts index 7c74fb364..13537de6f 100644 --- a/packages/alchemy/src/Cloudflare/KV/ReadWriteNamespaceHttp.ts +++ b/packages/alchemy/src/Cloudflare/KV/ReadWriteNamespaceHttp.ts @@ -1,6 +1,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { makeHttpKVNamespaceBinding, type HttpToken } from "./NamespaceHttp.ts"; +import { + makeHttpKVNamespaceBinding, + makeKVAuth, + type KVAuth, +} from "./NamespaceHttp.ts"; import { makeReadKVHttpClient } from "./ReadNamespaceHttp.ts"; import { ReadWriteNamespace, @@ -19,17 +23,18 @@ export const ReadWriteNamespaceHttp = Layer.effect( Effect.suspend(() => makeHttpKVNamespaceBinding({ permissionGroups: ["Workers KV Storage Read", "Workers KV Storage Write"], - makeClient: makeReadWriteKVHttpClient, + makeClient: (token, namespaceId) => + makeReadWriteKVHttpClient(makeKVAuth(token), namespaceId), }), ), ); -/** Build the HTTP-backed read-write client over a bound token + namespace. */ +/** Build the HTTP-backed read-write client over an auth + namespace. */ export const makeReadWriteKVHttpClient = ( - token: HttpToken, + auth: KVAuth, namespaceId: Effect.Effect, ): ReadWriteNamespaceClient => ({ - ...makeReadKVHttpClient(token, namespaceId), - ...makeWriteKVHttpClient(token, namespaceId), + ...makeReadKVHttpClient(auth, namespaceId), + ...makeWriteKVHttpClient(auth, namespaceId), }) as ReadWriteNamespaceClient; diff --git a/packages/alchemy/src/Cloudflare/KV/ReadWriteNamespaceLocal.ts b/packages/alchemy/src/Cloudflare/KV/ReadWriteNamespaceLocal.ts new file mode 100644 index 000000000..51601e3d7 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/KV/ReadWriteNamespaceLocal.ts @@ -0,0 +1,42 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { makeLocalKVNamespaceBinding } from "./NamespaceLocal.ts"; +import { ReadWriteNamespace } from "./ReadWriteNamespace.ts"; +import { makeReadWriteKVHttpClient } from "./ReadWriteNamespaceHttp.ts"; + +/** + * Local implementation of the {@link ReadWriteNamespace} binding — reads and + * writes KV values over the Cloudflare HTTP API using the **current + * credentials** instead of a native Worker binding + * (`ReadWriteNamespaceBinding`) or a scoped API token + * (`ReadWriteNamespaceHttp`). + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can talk + * to a namespace with the same `get`/`put`/`list`/`delete` client you'd use + * inside a Worker: + * + * @example Seeding a namespace from an Action + * ```typescript + * const Seed = Alchemy.Action( + * "Seed", + * Effect.gen(function* () { + * const kv = yield* Cloudflare.KV.ReadWriteNamespace(namespace); + * return Effect.fn(function* () { + * yield* kv.put("greeting", "hello world"); + * return yield* kv.get("greeting"); + * }); + * }).pipe(Effect.provide(Cloudflare.KV.ReadWriteNamespaceLocal)), + * ); + * ``` + * + * The namespace id is resolved at apply time through the ambient + * {@link RuntimeContext} (in an Action, that's the resolve context the engine + * provides around the body), so `ReadWriteNamespace(namespace)` works even + * though the namespace is created in the same deploy. + */ +export const ReadWriteNamespaceLocal = Layer.effect( + ReadWriteNamespace, + Effect.suspend(() => + makeLocalKVNamespaceBinding({ makeClient: makeReadWriteKVHttpClient }), + ), +); diff --git a/packages/alchemy/src/Cloudflare/KV/WriteNamespaceHttp.ts b/packages/alchemy/src/Cloudflare/KV/WriteNamespaceHttp.ts index 1e1dfc1ed..7816cf095 100644 --- a/packages/alchemy/src/Cloudflare/KV/WriteNamespaceHttp.ts +++ b/packages/alchemy/src/Cloudflare/KV/WriteNamespaceHttp.ts @@ -1,12 +1,12 @@ import * as kv from "@distilled.cloud/cloudflare/kv"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { authorizeWith } from "../HttpClientUtils.ts"; import { makeHttpKVNamespaceBinding, + makeKVAuth, makeKVHttpScope, toKVNamespaceError, - type HttpToken, + type KVAuth, } from "./NamespaceHttp.ts"; import { NamespaceError } from "./NamespaceTypes.ts"; import { WriteNamespace, type WriteNamespaceClient } from "./WriteNamespace.ts"; @@ -22,17 +22,18 @@ export const WriteNamespaceHttp = Layer.effect( Effect.suspend(() => makeHttpKVNamespaceBinding({ permissionGroups: ["Workers KV Storage Write"], - makeClient: makeWriteKVHttpClient, + makeClient: (token, namespaceId) => + makeWriteKVHttpClient(makeKVAuth(token), namespaceId), }), ), ); export const makeWriteKVHttpClient = ( - token: HttpToken, + auth: KVAuth, namespaceId: Effect.Effect, ): WriteNamespaceClient => { - const authorize = authorizeWith(token); - const scope = makeKVHttpScope(token, namespaceId); + const { authorize } = auth; + const scope = makeKVHttpScope(auth, namespaceId); return { put: (( diff --git a/packages/alchemy/src/Cloudflare/KV/WriteNamespaceLocal.ts b/packages/alchemy/src/Cloudflare/KV/WriteNamespaceLocal.ts new file mode 100644 index 000000000..2fd6fccc7 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/KV/WriteNamespaceLocal.ts @@ -0,0 +1,35 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { makeLocalKVNamespaceBinding } from "./NamespaceLocal.ts"; +import { WriteNamespace } from "./WriteNamespace.ts"; +import { makeWriteKVHttpClient } from "./WriteNamespaceHttp.ts"; + +/** + * Local implementation of the {@link WriteNamespace} binding — writes KV + * values over the Cloudflare HTTP API using the **current credentials** + * instead of a native Worker binding (`WriteNamespaceBinding`) or a scoped API + * token (`WriteNamespaceHttp`). + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can write + * to a namespace with the same `put`/`delete` client you'd use inside a + * Worker: + * + * @example Writing a value from an Action + * ```typescript + * const Seed = Alchemy.Action( + * "Seed", + * Effect.gen(function* () { + * const kv = yield* Cloudflare.KV.WriteNamespace(namespace); + * return Effect.fn(function* () { + * yield* kv.put("my-key", "hello world"); + * }); + * }).pipe(Effect.provide(Cloudflare.KV.WriteNamespaceLocal)), + * ); + * ``` + */ +export const WriteNamespaceLocal = Layer.effect( + WriteNamespace, + Effect.suspend(() => + makeLocalKVNamespaceBinding({ makeClient: makeWriteKVHttpClient }), + ), +); diff --git a/packages/alchemy/src/Cloudflare/KV/index.ts b/packages/alchemy/src/Cloudflare/KV/index.ts index 0f0a4afc2..5ffce21cf 100644 --- a/packages/alchemy/src/Cloudflare/KV/index.ts +++ b/packages/alchemy/src/Cloudflare/KV/index.ts @@ -3,9 +3,12 @@ export * from "./NamespaceTypes.ts"; export * from "./ReadNamespace.ts"; export * from "./ReadNamespaceBinding.ts"; export * from "./ReadNamespaceHttp.ts"; +export * from "./ReadNamespaceLocal.ts"; export * from "./ReadWriteNamespace.ts"; export * from "./ReadWriteNamespaceBinding.ts"; export * from "./ReadWriteNamespaceHttp.ts"; +export * from "./ReadWriteNamespaceLocal.ts"; export * from "./WriteNamespace.ts"; export * from "./WriteNamespaceBinding.ts"; export * from "./WriteNamespaceHttp.ts"; +export * from "./WriteNamespaceLocal.ts"; diff --git a/packages/alchemy/src/Cloudflare/Queues/QueueHttp.ts b/packages/alchemy/src/Cloudflare/Queues/QueueHttp.ts index 86ef25653..1c129e198 100644 --- a/packages/alchemy/src/Cloudflare/Queues/QueueHttp.ts +++ b/packages/alchemy/src/Cloudflare/Queues/QueueHttp.ts @@ -1,12 +1,34 @@ import * as Effect from "effect/Effect"; import type * as Redacted from "effect/Redacted"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; import { Self } from "../../Self.ts"; import { AccountApiToken } from "../ApiToken/AccountApiToken.ts"; import type { PermissionGroupRef } from "../ApiToken/Common.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; import type { Queue } from "./Queue.ts"; import { SendError } from "./QueueTypes.ts"; +/** + * Injectable auth used by the Queue HTTP client builder. Both the + * scoped-token HTTP variant ({@link makeWriteQueueHttpClient}) and the + * current-credentials Local variant build this so they share the exact + * same request path — only the way credentials reach the SDK op differs. + * + * - `authorize` runs a raw distilled op (which needs + * `Credentials | HttpClient`) and discharges those requirements down to + * {@link RuntimeContext}. The HTTP variant provides a minted token; the + * Local variant provides the ambient current-credentials context. + * - `accountId` resolves the Cloudflare account the queue lives in. + */ +export interface QueueAuth { + authorize: ( + eff: Effect.Effect, + ) => Effect.Effect; + accountId: Effect.Effect; +} + /** * Shared scaffolding for the HTTP-backed Queue services. * @@ -50,11 +72,11 @@ export const makeHttpQueueBinding = (options: { /** Resolve the account and queue id once per operation. */ export const makeQueueHttpScope = ( - token: HttpToken, + auth: QueueAuth, queueId: Effect.Effect, ): Effect.Effect => Effect.gen(function* () { - const accountId = yield* token.accountId; + const accountId = yield* auth.accountId; const id = yield* queueId; return { accountId, queueId: id }; }); diff --git a/packages/alchemy/src/Cloudflare/Queues/WriteQueueHttp.ts b/packages/alchemy/src/Cloudflare/Queues/WriteQueueHttp.ts index 06d87ebfe..88425494e 100644 --- a/packages/alchemy/src/Cloudflare/Queues/WriteQueueHttp.ts +++ b/packages/alchemy/src/Cloudflare/Queues/WriteQueueHttp.ts @@ -6,7 +6,7 @@ import { makeHttpQueueBinding, makeQueueHttpScope, toQueueSendError, - type HttpToken, + type QueueAuth, } from "./QueueHttp.ts"; import { SendError, type SendMessage } from "./QueueTypes.ts"; import { WriteQueue, type WriteQueueClient } from "./WriteQueue.ts"; @@ -27,7 +27,11 @@ export const WriteQueueHttp = Layer.effect( Effect.suspend(() => makeHttpQueueBinding({ permissionGroups: ["Queues Write"], - makeClient: makeWriteQueueHttpClient, + makeClient: (token, queueId) => + makeWriteQueueHttpClient( + { authorize: authorizeWith(token), accountId: token.accountId }, + queueId, + ), }), ), ); @@ -44,18 +48,23 @@ const toMessage = (message: SendMessage) => } : { body: message.body, contentType: "json" as const }; -/** Build the producer client over the Queues bulk-push HTTP API. */ +/** + * Build the producer client over the Queues bulk-push HTTP API. + * + * Shared by {@link WriteQueueHttp} (scoped token auth) and + * `WriteQueueLocal` (current-credentials auth) — they differ only in the + * {@link QueueAuth} they inject. + */ export const makeWriteQueueHttpClient = ( - token: HttpToken, + auth: QueueAuth, queueId: Effect.Effect, ): WriteQueueClient => { - const authorize = authorizeWith(token); - const scope = makeQueueHttpScope(token, queueId); + const scope = makeQueueHttpScope(auth, queueId); const push = (messages: ReadonlyArray) => scope.pipe( Effect.flatMap(({ accountId, queueId }) => - authorize( + auth.authorize( queues.bulkPushMessages({ accountId, queueId, diff --git a/packages/alchemy/src/Cloudflare/Queues/WriteQueueLocal.ts b/packages/alchemy/src/Cloudflare/Queues/WriteQueueLocal.ts new file mode 100644 index 000000000..88e2b9b49 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Queues/WriteQueueLocal.ts @@ -0,0 +1,64 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; +import type { Queue } from "./Queue.ts"; +import { makeWriteQueueHttpClient } from "./WriteQueueHttp.ts"; +import { WriteQueue } from "./WriteQueue.ts"; + +/** + * Local implementation of the {@link WriteQueue} binding — pushes messages + * to a Cloudflare Queue over the bulk-push HTTP API using the **current + * credentials** instead of a native Worker binding + * ({@link WriteQueueBinding}) or a scoped API token ({@link WriteQueueHttp}). + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can send + * messages to a queue with the same `send`/`sendBatch` client you'd use inside + * a Worker — no Worker host and no minted token. + * + * @example Seeding a queue from an Action + * ```typescript + * const Seed = Alchemy.Action( + * "Seed", + * Effect.gen(function* () { + * const q = yield* Cloudflare.Queues.WriteQueue(queue); + * return Effect.fn(function* () { + * yield* q.send({ text: "hi", sentAt: Date.now() }); + * yield* q.sendBatch([{ body: { event: "click", id: 1 } }]); + * }); + * }).pipe(Effect.provide(Cloudflare.Queues.WriteQueueLocal)), + * ); + * ``` + * + * The queue id is resolved at apply time through the ambient + * {@link RuntimeContext} (in an Action, that's the resolve context the engine + * provides around the body), so `WriteQueue(queue)` works even though the + * queue is created in the same deploy. + */ +export const WriteQueueLocal = Layer.effect( + WriteQueue, + Effect.gen(function* () { + // Account + credentials are ambient during stack-eval (the stack's + // providers layer). Capture the full context so the bulk-push effect can + // run with the current credentials instead of a scoped token. + const { accountId } = yield* yield* CloudflareEnvironment; + const context = yield* Effect.context< + Credentials | HttpClient.HttpClient + >(); + + return Effect.fn(function* (queue: Queue) { + // Deferred accessor — resolves the queueId against the tracker at apply + // time. No `host.bind`: the local variant registers no binding. + const queueId = yield* queue.queueId; + + return makeWriteQueueHttpClient( + { + authorize: (eff) => eff.pipe(Effect.provideContext(context)), + accountId: Effect.succeed(accountId), + }, + queueId, + ); + }); + }), +); diff --git a/packages/alchemy/src/Cloudflare/Queues/index.ts b/packages/alchemy/src/Cloudflare/Queues/index.ts index bb46bc25d..07f96e007 100644 --- a/packages/alchemy/src/Cloudflare/Queues/index.ts +++ b/packages/alchemy/src/Cloudflare/Queues/index.ts @@ -5,4 +5,5 @@ export * from "./QueueTypes.ts"; export * from "./WriteQueue.ts"; export * from "./WriteQueueBinding.ts"; export * from "./WriteQueueHttp.ts"; +export * from "./WriteQueueLocal.ts"; export * from "./Subscription.ts"; diff --git a/packages/alchemy/src/Cloudflare/R2/BucketHttp.ts b/packages/alchemy/src/Cloudflare/R2/BucketHttp.ts index 28df75cff..033c8bb0c 100644 --- a/packages/alchemy/src/Cloudflare/R2/BucketHttp.ts +++ b/packages/alchemy/src/Cloudflare/R2/BucketHttp.ts @@ -1,13 +1,30 @@ import * as Effect from "effect/Effect"; import type * as Redacted from "effect/Redacted"; import * as Stream from "effect/Stream"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; import { Self } from "../../Self.ts"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; import { AccountApiToken } from "../ApiToken/AccountApiToken.ts"; import type { PermissionGroupRef } from "../ApiToken/Common.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; import type { Bucket } from "./Bucket.ts"; import { R2Error, type R2Object } from "./BucketTypes.ts"; +/** + * Injectable auth used by the R2 HTTP client builders. Both the token-scoped + * `*Http` layers and the current-credentials `*Local` layers build one of + * these so they can share the exact same client implementation. + */ +export interface R2Auth { + /** Provide credentials + HTTP client to a raw distilled R2 op. */ + authorize: ( + eff: Effect.Effect, + ) => Effect.Effect; + /** Resolve the Cloudflare account id. */ + accountId: Effect.Effect; +} + export const makeHttpBucketBinding = (options: { permissionGroups: PermissionGroup[]; makeClient: ( @@ -66,16 +83,16 @@ type PermissionGroup = (typeof R2_HTTP_PERMISSION_GROUPS)[number]; /** Resolve the account, bucket, and jurisdiction once per operation. */ export const makeR2HttpScope = ( - token: HttpToken, + accountId: Effect.Effect, bucketName: Effect.Effect, jurisdiction: Effect.Effect, ): Effect.Effect => Effect.gen(function* () { - const accountId = yield* token.accountId; + const accountId_ = yield* accountId; const bucket = yield* bucketName; const j = yield* jurisdiction; return { - accountId, + accountId: accountId_, bucketName: bucket, cfR2Jurisdiction: j === "default" ? undefined : j, }; diff --git a/packages/alchemy/src/Cloudflare/R2/BucketLocal.ts b/packages/alchemy/src/Cloudflare/R2/BucketLocal.ts new file mode 100644 index 000000000..82c52d1f0 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/R2/BucketLocal.ts @@ -0,0 +1,47 @@ +import * as Effect from "effect/Effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; +import type { Bucket } from "./Bucket.ts"; +import type { R2Auth } from "./BucketHttp.ts"; + +/** + * Shared scaffolding for the R2 `*Local` binding layers. + * + * Resolves the account + captures the ambient current-credentials context at + * layer construction, then returns the deferred binding callable. The callable + * reads the bucket name/jurisdiction as deferred accessors (resolved at apply + * time) and builds the same HTTP-backed client the `*Http` variant uses, but + * authorized with the current CLI credentials instead of a minted token. + * + * NOT exported from `index.ts`. + */ +export const makeLocalBucketBinding = (makeClient: { + ( + auth: R2Auth, + bucketName: Effect.Effect, + jurisdiction: Effect.Effect, + ): Client; +}) => + Effect.gen(function* () { + // Account + credentials are ambient during stack-eval (the stack's + // providers layer). Capture the full context so each op can be run with the + // current credentials. + const { accountId } = yield* yield* CloudflareEnvironment; + const context = yield* Effect.context< + Credentials | HttpClient.HttpClient + >(); + + const auth: R2Auth = { + authorize: (eff) => eff.pipe(Effect.provideContext(context)), + accountId: Effect.succeed(accountId), + }; + + return Effect.fn(function* (bucket: Bucket) { + // Deferred accessors — resolved against the tracker at apply time. No + // `host.bind`: the local variant registers no binding. + const bucketName = yield* bucket.bucketName; + const jurisdiction = yield* bucket.jurisdiction; + return makeClient(auth, bucketName, jurisdiction); + }); + }); diff --git a/packages/alchemy/src/Cloudflare/R2/ReadBucketHttp.ts b/packages/alchemy/src/Cloudflare/R2/ReadBucketHttp.ts index 510c20f35..e49bf68eb 100644 --- a/packages/alchemy/src/Cloudflare/R2/ReadBucketHttp.ts +++ b/packages/alchemy/src/Cloudflare/R2/ReadBucketHttp.ts @@ -9,7 +9,7 @@ import { makeR2HttpScope, toR2Error, type HttpMetadata, - type HttpToken, + type R2Auth, } from "./BucketHttp.ts"; import { ReadBucket, type ReadBucketClient } from "./ReadBucket.ts"; import { @@ -29,18 +29,23 @@ export const ReadBucketHttp = Layer.effect( Effect.suspend(() => makeHttpBucketBinding({ permissionGroups: ["Workers R2 Storage Read"], - makeClient: makeReadR2HttpClient, + makeClient: (token, bucketName, jurisdiction) => + makeReadR2HttpClient( + { authorize: authorizeWith(token), accountId: token.accountId }, + bucketName, + jurisdiction, + ), }), ), ); export const makeReadR2HttpClient = ( - token: HttpToken, + auth: R2Auth, bucketName: Effect.Effect, jurisdiction: Effect.Effect, ): ReadBucketClient => { - const authorize = authorizeWith(token); - const scope = makeR2HttpScope(token, bucketName, jurisdiction); + const authorize = auth.authorize; + const scope = makeR2HttpScope(auth.accountId, bucketName, jurisdiction); return { raw: Effect.die( diff --git a/packages/alchemy/src/Cloudflare/R2/ReadBucketLocal.ts b/packages/alchemy/src/Cloudflare/R2/ReadBucketLocal.ts new file mode 100644 index 000000000..5cc0c5e9f --- /dev/null +++ b/packages/alchemy/src/Cloudflare/R2/ReadBucketLocal.ts @@ -0,0 +1,33 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { makeLocalBucketBinding } from "./BucketLocal.ts"; +import { ReadBucket } from "./ReadBucket.ts"; +import { makeReadR2HttpClient } from "./ReadBucketHttp.ts"; + +/** + * Local implementation of the {@link ReadBucket} binding — reads R2 objects + * over the Cloudflare HTTP API using the **current credentials** instead of a + * native Worker binding (`ReadBucketBinding`) or a scoped API token + * (`ReadBucketHttp`). + * + * Provide it on an {@link Action} (or any deploy-time Effect) to read a bucket + * with the same `head`/`get`/`list` client you'd use inside a Worker. + * + * @example Reading an object from an Action + * ```typescript + * const Read = Alchemy.Action( + * "Read", + * Effect.gen(function* () { + * const r2 = yield* Cloudflare.R2.ReadBucket(bucket); + * return Effect.fn(function* () { + * const object = yield* r2.get("hello.txt"); + * return object ? yield* object.text() : null; + * }); + * }).pipe(Effect.provide(Cloudflare.R2.ReadBucketLocal)), + * ); + * ``` + */ +export const ReadBucketLocal = Layer.effect( + ReadBucket, + Effect.suspend(() => makeLocalBucketBinding(makeReadR2HttpClient)), +); diff --git a/packages/alchemy/src/Cloudflare/R2/ReadWriteBucketHttp.ts b/packages/alchemy/src/Cloudflare/R2/ReadWriteBucketHttp.ts index b0ed4ac9c..ac41c469f 100644 --- a/packages/alchemy/src/Cloudflare/R2/ReadWriteBucketHttp.ts +++ b/packages/alchemy/src/Cloudflare/R2/ReadWriteBucketHttp.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { makeHttpBucketBinding, type HttpToken } from "./BucketHttp.ts"; +import { authorizeWith } from "../HttpClientUtils.ts"; +import { makeHttpBucketBinding, type R2Auth } from "./BucketHttp.ts"; import { makeReadR2HttpClient } from "./ReadBucketHttp.ts"; import { ReadWriteBucket, @@ -18,18 +19,23 @@ export const ReadWriteBucketHttp = Layer.effect( Effect.suspend(() => makeHttpBucketBinding({ permissionGroups: ["Workers R2 Storage Read", "Workers R2 Storage Write"], - makeClient: makeReadWriteR2HttpClient, + makeClient: (token, bucketName, jurisdiction) => + makeReadWriteR2HttpClient( + { authorize: authorizeWith(token), accountId: token.accountId }, + bucketName, + jurisdiction, + ), }), ), ); /** Build the HTTP-backed {@link ReadWrite} over a bound token + bucket. */ export const makeReadWriteR2HttpClient = ( - token: HttpToken, + auth: R2Auth, bucketName: Effect.Effect, jurisdiction: Effect.Effect, ): ReadWriteBucketClient => ({ - ...makeReadR2HttpClient(token, bucketName, jurisdiction), - ...makeWriteR2HttpClient(token, bucketName, jurisdiction), + ...makeReadR2HttpClient(auth, bucketName, jurisdiction), + ...makeWriteR2HttpClient(auth, bucketName, jurisdiction), }) satisfies ReadWriteBucketClient; diff --git a/packages/alchemy/src/Cloudflare/R2/ReadWriteBucketLocal.ts b/packages/alchemy/src/Cloudflare/R2/ReadWriteBucketLocal.ts new file mode 100644 index 000000000..970f3ff9d --- /dev/null +++ b/packages/alchemy/src/Cloudflare/R2/ReadWriteBucketLocal.ts @@ -0,0 +1,36 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { makeLocalBucketBinding } from "./BucketLocal.ts"; +import { ReadWriteBucket } from "./ReadWriteBucket.ts"; +import { makeReadWriteR2HttpClient } from "./ReadWriteBucketHttp.ts"; + +/** + * Local implementation of the {@link ReadWriteBucket} binding — reads and + * writes R2 objects over the Cloudflare HTTP API using the **current + * credentials** instead of a native Worker binding (`ReadWriteBucketBinding`) + * or a scoped API token (`ReadWriteBucketHttp`). + * + * Provide it on an {@link Action} (or any deploy-time Effect) to use the same + * `head`/`get`/`list`/`put`/`delete` client you'd use inside a Worker. + * Multipart uploads are unsupported over the HTTP API (mirrors + * `ReadWriteBucketHttp`). + * + * @example Seeding then reading a bucket from an Action + * ```typescript + * const Seed = Alchemy.Action( + * "Seed", + * Effect.gen(function* () { + * const r2 = yield* Cloudflare.R2.ReadWriteBucket(bucket); + * return Effect.fn(function* () { + * yield* r2.put("hello.txt", "Hello, World!"); + * const object = yield* r2.get("hello.txt"); + * return object ? yield* object.text() : null; + * }); + * }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketLocal)), + * ); + * ``` + */ +export const ReadWriteBucketLocal = Layer.effect( + ReadWriteBucket, + Effect.suspend(() => makeLocalBucketBinding(makeReadWriteR2HttpClient)), +); diff --git a/packages/alchemy/src/Cloudflare/R2/WriteBucketHttp.ts b/packages/alchemy/src/Cloudflare/R2/WriteBucketHttp.ts index c092b483a..0d2659656 100644 --- a/packages/alchemy/src/Cloudflare/R2/WriteBucketHttp.ts +++ b/packages/alchemy/src/Cloudflare/R2/WriteBucketHttp.ts @@ -10,7 +10,7 @@ import { readHttpMetadata, toBody, toR2Error, - type HttpToken, + type R2Auth, } from "./BucketHttp.ts"; import { R2Error, type PutOptions } from "./BucketTypes.ts"; import { WriteBucket, type WriteBucketClient } from "./WriteBucket.ts"; @@ -25,19 +25,24 @@ export const WriteBucketHttp = Layer.effect( Effect.suspend(() => makeHttpBucketBinding({ permissionGroups: ["Workers R2 Storage Write"], - makeClient: makeWriteR2HttpClient, + makeClient: (token, bucketName, jurisdiction) => + makeWriteR2HttpClient( + { authorize: authorizeWith(token), accountId: token.accountId }, + bucketName, + jurisdiction, + ), }), ), ); /** Build the write half of the HTTP-backed {@link ReadWrite} client. */ export const makeWriteR2HttpClient = ( - token: HttpToken, + auth: R2Auth, bucketName: Effect.Effect, jurisdiction: Effect.Effect, ): WriteBucketClient => { - const authorize = authorizeWith(token); - const scope = makeR2HttpScope(token, bucketName, jurisdiction); + const authorize = auth.authorize; + const scope = makeR2HttpScope(auth.accountId, bucketName, jurisdiction); return { put: (( diff --git a/packages/alchemy/src/Cloudflare/R2/WriteBucketLocal.ts b/packages/alchemy/src/Cloudflare/R2/WriteBucketLocal.ts new file mode 100644 index 000000000..ae833b4fc --- /dev/null +++ b/packages/alchemy/src/Cloudflare/R2/WriteBucketLocal.ts @@ -0,0 +1,33 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { makeLocalBucketBinding } from "./BucketLocal.ts"; +import { WriteBucket } from "./WriteBucket.ts"; +import { makeWriteR2HttpClient } from "./WriteBucketHttp.ts"; + +/** + * Local implementation of the {@link WriteBucket} binding — writes R2 objects + * over the Cloudflare HTTP API using the **current credentials** instead of a + * native Worker binding (`WriteBucketBinding`) or a scoped API token + * (`WriteBucketHttp`). + * + * Provide it on an {@link Action} (or any deploy-time Effect) to write a bucket + * with the same `put`/`delete` client you'd use inside a Worker. Multipart + * uploads are unsupported over the HTTP API (mirrors `WriteBucketHttp`). + * + * @example Seeding a bucket from an Action + * ```typescript + * const Seed = Alchemy.Action( + * "Seed", + * Effect.gen(function* () { + * const r2 = yield* Cloudflare.R2.WriteBucket(bucket); + * return Effect.fn(function* () { + * yield* r2.put("hello.txt", "Hello, World!"); + * }); + * }).pipe(Effect.provide(Cloudflare.R2.WriteBucketLocal)), + * ); + * ``` + */ +export const WriteBucketLocal = Layer.effect( + WriteBucket, + Effect.suspend(() => makeLocalBucketBinding(makeWriteR2HttpClient)), +); diff --git a/packages/alchemy/src/Cloudflare/R2/index.ts b/packages/alchemy/src/Cloudflare/R2/index.ts index 52ec11937..0ffe3d430 100644 --- a/packages/alchemy/src/Cloudflare/R2/index.ts +++ b/packages/alchemy/src/Cloudflare/R2/index.ts @@ -5,9 +5,12 @@ export * from "./DataCatalog.ts"; export * from "./ReadBucket.ts"; export * from "./ReadBucketBinding.ts"; export * from "./ReadBucketHttp.ts"; +export * from "./ReadBucketLocal.ts"; export * from "./ReadWriteBucket.ts"; export * from "./ReadWriteBucketBinding.ts"; export * from "./ReadWriteBucketHttp.ts"; +export * from "./ReadWriteBucketLocal.ts"; export * from "./WriteBucket.ts"; export * from "./WriteBucketBinding.ts"; export * from "./WriteBucketHttp.ts"; +export * from "./WriteBucketLocal.ts"; diff --git a/packages/alchemy/test/Cloudflare/KV/NamespaceLocal.test.ts b/packages/alchemy/test/Cloudflare/KV/NamespaceLocal.test.ts new file mode 100644 index 000000000..732b9cfa0 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/KV/NamespaceLocal.test.ts @@ -0,0 +1,95 @@ +import { Action } from "@/Action"; +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Binding a KV namespace inside an Action via `ReadWriteNamespaceLocal` — the +// local (current-credentials) implementation of the `ReadWriteNamespace` +// binding. Exercises the client (put/get/list/delete) and the accessor +// mechanism (`yield* namespace.namespaceId` resolved at apply time). +test.provider( + "ReadWriteNamespaceLocal: seed and read a namespace from an Action", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const out = yield* stack.deploy( + Effect.gen(function* () { + const namespace = yield* Cloudflare.KV.Namespace("SeedNamespace"); + + const Seed = Action( + "Seed", + Effect.gen(function* () { + const kv = yield* Cloudflare.KV.ReadWriteNamespace(namespace); + // Accessor — resolved at apply time against the tracker. + const namespaceId = yield* namespace.namespaceId; + + return Effect.fn(function* () { + yield* kv.put("greeting", "hello world"); + + // KV is eventually consistent — retry the read-back until the + // value propagates (bounded so the test fails fast). + const value = yield* kv.get("greeting").pipe( + Effect.flatMap((v) => + v === "hello world" + ? Effect.succeed(v) + : Effect.fail("not yet propagated" as const), + ), + Effect.retry({ + schedule: Schedule.spaced("1 second"), + times: 10, + }), + Effect.orElseSucceed(() => null), + ); + + const listed = yield* kv.list(); + const names = listed.keys.map((k) => k.name); + + yield* kv.delete("greeting"); + + const afterDelete = yield* kv.get("greeting").pipe( + Effect.flatMap((v) => + v === null + ? Effect.succeed(v) + : Effect.fail("not yet deleted" as const), + ), + Effect.retry({ + schedule: Schedule.spaced("1 second"), + times: 10, + }), + Effect.orElseSucceed(() => "still present" as string | null), + ); + + return { + namespaceId: yield* namespaceId, + value, + names, + afterDelete, + }; + }); + }).pipe(Effect.provide(Cloudflare.KV.ReadWriteNamespaceLocal)), + ); + + return yield* Seed({}); + }), + ); + + expect(out.namespaceId).toBeTruthy(); + expect(out.value).toBe("hello world"); + expect(out.names).toContain("greeting"); + expect(out.afterDelete).toBeNull(); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/Queue/QueueLocal.test.ts b/packages/alchemy/test/Cloudflare/Queue/QueueLocal.test.ts new file mode 100644 index 000000000..9abddd48d --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Queue/QueueLocal.test.ts @@ -0,0 +1,123 @@ +import { Action } from "@/Action"; +import * as Cloudflare from "@/Cloudflare"; +import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; +import * as Test from "@/Test/Vitest"; +import { poll } from "@/Util/poll.ts"; +import * as queues from "@distilled.cloud/cloudflare/queues"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Sending to a Queue inside an Action via `WriteQueueLocal` — the local +// (current-credentials) implementation of the `WriteQueue` binding. Exercises +// both the binding client (send/sendBatch) and the accessor mechanism +// (`yield* queue.queueId` resolved at apply time). The producer binding is +// send-only, so we verify out-of-band by pulling the messages back off the +// queue through an HTTP-pull consumer. +test.provider( + "WriteQueueLocal: send and sendBatch to a queue from an Action", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const marker = yield* Effect.sync(() => crypto.randomUUID()); + + const out = yield* stack.deploy( + Effect.gen(function* () { + const queue = yield* Cloudflare.Queues.Queue("LocalWriteQueue"); + + const Seed = Action( + "Seed", + Effect.gen(function* () { + const q = yield* Cloudflare.Queues.WriteQueue(queue); + // Accessor — resolved at apply time against the tracker. + const queueId = yield* queue.queueId; + + return Effect.fn(function* () { + const id = yield* queueId; + + // Register an HTTP-pull consumer so the test can pull the + // messages back out-of-band. A brand-new queue can briefly + // 404 the consumer create under load — ride out the lag. + yield* queues + .createConsumer({ + accountId, + queueId: id, + type: "http_pull", + }) + .pipe( + Effect.retry({ + while: (e) => e._tag === "QueueNotFound", + schedule: Schedule.exponential("500 millis"), + times: 8, + }), + Effect.catchTag("ConsumerAlreadyExists", () => Effect.void), + ); + + yield* q.send({ marker, seq: 1 }); + yield* q.sendBatch([ + { body: { marker, seq: 2 } }, + { body: { marker, seq: 3 } }, + ]); + + return { queueId: id }; + }); + }).pipe(Effect.provide(Cloudflare.Queues.WriteQueueLocal)), + ); + + return yield* Seed({}); + }), + ); + + expect(out.queueId).toBeTruthy(); + + // Out-of-band verification: pull the messages back off the queue and + // assert all three payloads (carrying our unique marker) arrive. Pulls + // lease a subset per call, so accumulate distinct bodies across polls. + const collected = new Set(); + yield* poll({ + description: "pull sent messages back off the queue", + effect: Effect.gen(function* () { + const res = yield* queues.pullMessage({ + accountId, + queueId: out.queueId, + batchSize: 10, + visibilityTimeoutMs: 2_000, + }); + for (const m of res.messages ?? []) { + if (m.body) collected.add(m.body); + } + const acks = (res.messages ?? []) + .filter((m): m is typeof m & { leaseId: string } => !!m.leaseId) + .map((m) => ({ leaseId: m.leaseId })); + if (acks.length > 0) { + yield* queues.ackMessage({ accountId, queueId: out.queueId, acks }); + } + return collected.size; + }), + predicate: (size) => size >= 3, + schedule: Schedule.max([ + Schedule.spaced("2 seconds"), + Schedule.recurs(30), + ]), + }); + + expect(collected.size).toBeGreaterThanOrEqual(3); + for (const body of collected) { + expect(body).toContain(marker); + } + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/R2/BucketLocal.test.ts b/packages/alchemy/test/Cloudflare/R2/BucketLocal.test.ts new file mode 100644 index 000000000..4f9a4ee98 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/R2/BucketLocal.test.ts @@ -0,0 +1,100 @@ +import { Action } from "@/Action"; +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// R2 is eventually consistent — a freshly written object can read back `null` +// for a moment. Bounded spaced retry so a real failure surfaces fast. +const readBack = Schedule.max([ + Schedule.spaced("1 second"), + Schedule.recurs(15), +]); + +// Binding an R2 bucket inside an Action via `ReadWriteBucketLocal` — the local +// (current-credentials) implementation of the `ReadWriteBucket` binding. +// Exercises the client (put/get/list/delete) and the accessor mechanism +// (`yield* bucket.bucketName` resolved at apply time). +test.provider( + "ReadWriteBucketLocal: seed and read a bucket from an Action", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const out = yield* stack.deploy( + Effect.gen(function* () { + const bucket = yield* Cloudflare.R2.Bucket("SeedBucket"); + + const Seed = Action( + "Seed", + Effect.gen(function* () { + const r2 = yield* Cloudflare.R2.ReadWriteBucket(bucket); + // Accessor — resolved at apply time against the tracker. + const bucketName = yield* bucket.bucketName; + + return Effect.fn(function* () { + yield* r2.put("hello.txt", "Hello, World!").pipe(Effect.orDie); + yield* r2.put("greeting/hi.txt", "hi").pipe(Effect.orDie); + + // Read back with bounded retry (eventual consistency). + const value = yield* r2.get("hello.txt").pipe( + Effect.flatMap((o) => + o ? o.text() : Effect.succeed(null), + ), + Effect.orDie, + Effect.repeat({ + schedule: readBack, + until: (v) => v !== null, + }), + ); + + const listed = yield* r2.list().pipe(Effect.orDie); + const keys = listed.objects.map((o) => o.key).sort(); + + const head = yield* r2.head("hello.txt").pipe(Effect.orDie); + + yield* r2.delete("hello.txt").pipe(Effect.orDie); + yield* r2.delete("greeting/hi.txt").pipe(Effect.orDie); + + const afterDelete = yield* r2.head("hello.txt").pipe( + Effect.orDie, + Effect.repeat({ + schedule: readBack, + until: (o) => o === null, + }), + ); + + return { + bucketName: yield* bucketName, + value, + keys, + headExists: head !== null, + deleted: afterDelete === null, + }; + }); + }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketLocal)), + ); + + return yield* Seed({}); + }), + ); + + expect(out.bucketName).toBeTruthy(); + expect(out.value).toBe("Hello, World!"); + expect(out.keys).toEqual(["greeting/hi.txt", "hello.txt"]); + expect(out.headExists).toBe(true); + expect(out.deleted).toBe(true); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); From dff130223058e0e93b1bcb31b4df162991e38d91 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Sat, 11 Jul 2026 01:02:25 -0700 Subject: [PATCH 03/13] feat(cloudflare): Local bindings for DNS, Vectorize, Tunnel, AI Search, Flagship, Browser Extends the *Local (current-credentials, HTTP) binding pattern to every Cloudflare capability with an HTTP data-plane, so they can be used inside an Action: - DNS: ReadDnsLocal / WriteDnsLocal / ReadWriteDnsLocal - Vectorize: SearchIndexLocal - Tunnel: ReadTunnelLocal / WriteTunnelLocal / ReadWriteTunnelLocal - AI Search: QuerySearchLocal / QuerySearchNamespaceLocal - Flagship: ReadFlagsLocal - Workers: BrowserLocal (Browser Rendering REST API) Each reuses/refactors its *Http client builder via an injectable { authorize, accountId } auth (shared by token-scoped Http and current-creds Local), or a direct HTTP client where no *Http existed. Every layer is live-tested against real Cloudflare. Worker-runtime-only bindings have no Local variant and are intentionally excluded: SecretsStore (write-only values), AnalyticsEngine (write-only ingestion), Images (in-Worker transforms), Hyperdrive (pooler), Email (send_email), Addressing, Artifacts, WorkersForPlatforms dispatch, AI QueryGateway, RateLimit, VersionMetadata, service bindings. Co-Authored-By: Claude Opus 4.8 --- .../src/Cloudflare/AI/QuerySearchLocal.ts | 66 +++ .../AI/QuerySearchNamespaceLocal.ts | 60 +++ .../src/Cloudflare/AI/SearchHttpClient.ts | 423 ++++++++++++++++++ packages/alchemy/src/Cloudflare/AI/index.ts | 2 + .../alchemy/src/Cloudflare/DNS/DnsHttp.ts | 26 +- .../alchemy/src/Cloudflare/DNS/DnsLocal.ts | 41 ++ .../alchemy/src/Cloudflare/DNS/ReadDnsHttp.ts | 9 +- .../src/Cloudflare/DNS/ReadDnsLocal.ts | 38 ++ .../src/Cloudflare/DNS/ReadWriteDnsHttp.ts | 10 +- .../src/Cloudflare/DNS/ReadWriteDnsLocal.ts | 46 ++ .../src/Cloudflare/DNS/WriteDnsHttp.ts | 9 +- .../src/Cloudflare/DNS/WriteDnsLocal.ts | 44 ++ packages/alchemy/src/Cloudflare/DNS/index.ts | 3 + .../src/Cloudflare/Flagship/ReadFlagsLocal.ts | 224 ++++++++++ .../alchemy/src/Cloudflare/Flagship/index.ts | 1 + .../src/Cloudflare/Tunnel/ReadTunnel.ts | 17 +- .../src/Cloudflare/Tunnel/ReadTunnelLocal.ts | 33 ++ .../src/Cloudflare/Tunnel/ReadWriteTunnel.ts | 10 +- .../Cloudflare/Tunnel/ReadWriteTunnelLocal.ts | 39 ++ .../src/Cloudflare/Tunnel/TunnelBinding.ts | 28 +- .../Cloudflare/Tunnel/TunnelLocalBinding.ts | 38 ++ .../src/Cloudflare/Tunnel/WriteTunnel.ts | 17 +- .../src/Cloudflare/Tunnel/WriteTunnelLocal.ts | 33 ++ .../alchemy/src/Cloudflare/Tunnel/index.ts | 3 + .../Cloudflare/Vectorize/SearchIndexLocal.ts | 236 ++++++++++ .../alchemy/src/Cloudflare/Vectorize/index.ts | 1 + .../src/Cloudflare/Workers/BrowserLocal.ts | 236 ++++++++++ .../alchemy/src/Cloudflare/Workers/index.ts | 1 + .../Cloudflare/AI/QuerySearchLocal.test.ts | 106 +++++ .../AI/QuerySearchNamespaceLocal.test.ts | 112 +++++ .../test/Cloudflare/Dns/DnsLocal.test.ts | 151 +++++++ .../Flagship/ReadFlagsLocal.test.ts | 87 ++++ .../Tunnel/ReadWriteTunnelLocal.test.ts | 87 ++++ .../Vectorize/SearchIndexLocal.test.ts | 102 +++++ .../Cloudflare/Workers/BrowserLocal.test.ts | 55 +++ 35 files changed, 2352 insertions(+), 42 deletions(-) create mode 100644 packages/alchemy/src/Cloudflare/AI/QuerySearchLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/AI/QuerySearchNamespaceLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/AI/SearchHttpClient.ts create mode 100644 packages/alchemy/src/Cloudflare/DNS/DnsLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/DNS/ReadDnsLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/DNS/ReadWriteDnsLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/DNS/WriteDnsLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/Flagship/ReadFlagsLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/Tunnel/ReadTunnelLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/Tunnel/ReadWriteTunnelLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/Tunnel/TunnelLocalBinding.ts create mode 100644 packages/alchemy/src/Cloudflare/Tunnel/WriteTunnelLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts create mode 100644 packages/alchemy/src/Cloudflare/Workers/BrowserLocal.ts create mode 100644 packages/alchemy/test/Cloudflare/AI/QuerySearchLocal.test.ts create mode 100644 packages/alchemy/test/Cloudflare/AI/QuerySearchNamespaceLocal.test.ts create mode 100644 packages/alchemy/test/Cloudflare/Dns/DnsLocal.test.ts create mode 100644 packages/alchemy/test/Cloudflare/Flagship/ReadFlagsLocal.test.ts create mode 100644 packages/alchemy/test/Cloudflare/Tunnel/ReadWriteTunnelLocal.test.ts create mode 100644 packages/alchemy/test/Cloudflare/Vectorize/SearchIndexLocal.test.ts create mode 100644 packages/alchemy/test/Cloudflare/Workers/BrowserLocal.test.ts diff --git a/packages/alchemy/src/Cloudflare/AI/QuerySearchLocal.ts b/packages/alchemy/src/Cloudflare/AI/QuerySearchLocal.ts new file mode 100644 index 000000000..1ba88a350 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/AI/QuerySearchLocal.ts @@ -0,0 +1,66 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; +import { QuerySearch } from "./QuerySearch.ts"; +import { makeLocalSearchClient, type SearchAuth } from "./SearchHttpClient.ts"; +import type { SearchInstance } from "./SearchInstance.ts"; + +/** + * Local implementation of the {@link QuerySearch} binding — queries an AI + * Search instance over the Cloudflare HTTP API using the **current + * credentials** instead of a native Worker binding (`QuerySearchBinding`). + * + * Provide it on an {@link Action} (or any deploy-time Effect) to run + * `search` / `chatCompletions` / `info` / `stats` against an instance with the + * same client you'd use inside a Worker. The instance id and namespace are + * resolved at apply time through the ambient RuntimeContext, so + * `QuerySearch(instance)` works even when the instance is created in the same + * deploy. + * + * `raw` (the native `AiSearchInstance` runtime handle) is unavailable outside a + * deployed Worker and dies if forced. + * + * @example Read an instance's status from an Action + * ```typescript + * const Probe = Alchemy.Action( + * "Probe", + * Effect.gen(function* () { + * const search = yield* Cloudflare.AI.QuerySearch(instance); + * return Effect.fn(function* () { + * const info = yield* search.info(); + * const stats = yield* search.stats(); + * return { status: info.status, indexed: stats.completed }; + * }); + * }).pipe(Effect.provide(Cloudflare.AI.QuerySearchLocal)), + * ); + * ``` + */ +export const QuerySearchLocal = Layer.effect( + QuerySearch, + Effect.gen(function* () { + // Account + credentials are ambient during stack-eval (the stack's + // providers layer). Capture the full context so each HTTP op can be run + // with the current credentials; no `host.bind`, no minted token. + const { accountId } = yield* yield* CloudflareEnvironment; + const context = yield* Effect.context< + Credentials | HttpClient.HttpClient + >(); + + const auth: SearchAuth = { + authorize: (eff) => eff.pipe(Effect.provideContext(context)), + accountId, + }; + + return Effect.fn(function* (instance: SearchInstance) { + // Deferred accessors — resolved against the tracker at apply time. + const instanceId = yield* instance.instanceId; + const namespace = yield* instance.namespace; + return makeLocalSearchClient( + auth, + Effect.all({ id: instanceId, name: namespace }), + ); + }); + }), +); diff --git a/packages/alchemy/src/Cloudflare/AI/QuerySearchNamespaceLocal.ts b/packages/alchemy/src/Cloudflare/AI/QuerySearchNamespaceLocal.ts new file mode 100644 index 000000000..c557e2261 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/AI/QuerySearchNamespaceLocal.ts @@ -0,0 +1,60 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; +import { QuerySearchNamespace } from "./QuerySearchNamespace.ts"; +import { + makeLocalSearchNamespaceClient, + type SearchAuth, +} from "./SearchHttpClient.ts"; +import type { SearchNamespace } from "./SearchNamespace.ts"; + +/** + * Local implementation of the {@link QuerySearchNamespace} binding — queries an + * AI Search namespace over the Cloudflare HTTP API using the **current + * credentials** instead of a native Worker binding + * (`QuerySearchNamespaceBinding`). + * + * Provide it on an {@link Action} (or any deploy-time Effect) to `list` + * instances, run a multi-instance `search`, or `.get(instanceName)` a + * single-instance client — the same client you'd use inside a Worker. The + * namespace name is resolved at apply time through the ambient RuntimeContext. + * + * `raw` (the native `AiSearchNamespace` runtime handle) is unavailable outside a + * deployed Worker and dies if forced. + * + * @example List a namespace's instances from an Action + * ```typescript + * const Probe = Alchemy.Action( + * "Probe", + * Effect.gen(function* () { + * const ns = yield* Cloudflare.AI.QuerySearchNamespace(namespace); + * return Effect.fn(function* () { + * const { result } = yield* ns.list(); + * return result.map((i) => i.id); + * }); + * }).pipe(Effect.provide(Cloudflare.AI.QuerySearchNamespaceLocal)), + * ); + * ``` + */ +export const QuerySearchNamespaceLocal = Layer.effect( + QuerySearchNamespace, + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + const context = yield* Effect.context< + Credentials | HttpClient.HttpClient + >(); + + const auth: SearchAuth = { + authorize: (eff) => eff.pipe(Effect.provideContext(context)), + accountId, + }; + + return Effect.fn(function* (namespace: SearchNamespace) { + // Deferred accessor — resolved against the tracker at apply time. + const name = yield* namespace.name; + return makeLocalSearchNamespaceClient(auth, name); + }); + }), +); diff --git a/packages/alchemy/src/Cloudflare/AI/SearchHttpClient.ts b/packages/alchemy/src/Cloudflare/AI/SearchHttpClient.ts new file mode 100644 index 000000000..6cc96d6be --- /dev/null +++ b/packages/alchemy/src/Cloudflare/AI/SearchHttpClient.ts @@ -0,0 +1,423 @@ +import type * as runtime from "@cloudflare/workers-types"; +import * as aisearch from "@distilled.cloud/cloudflare/aisearch"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import type { Credentials } from "../Credentials.ts"; +import { SearchError, type QuerySearchClient } from "./QuerySearch.ts"; +import type { QuerySearchNamespaceClient } from "./QuerySearchNamespace.ts"; + +// Shared HTTP scaffolding for the AI Search `*Local` binding layers. NOT +// re-exported from `index.ts` — only the per-level `*Local` layers are public. +// +// The `ai_search` / `ai_search_namespace` Worker bindings proxy the same AI +// Search REST API that distilled wraps, so the whole data plane +// (`search` / `chatCompletions` / `info` / `stats` / `list`) is reachable over +// HTTP with the current credentials — no Worker host, no native binding. +// +// The one wrinkle: distilled decodes responses to **camelCase**, while the +// binding client contract types come from `@cloudflare/workers-types` and are +// the **snake_case** wire shape. These adapters translate both directions +// (mirroring the D1 `*Local` shim), passing user-controlled maps +// (`item.metadata`, retrieval `filters`) through untouched. + +/** + * Injectable auth shared by a future Http (scoped-token) impl and the Local + * (current-credentials) impl. `authorize` discharges the + * `Credentials | HttpClient` requirement of a distilled op; `accountId` is the + * Cloudflare account the ops run against. + */ +export interface SearchAuth { + authorize: ( + eff: Effect.Effect, + ) => Effect.Effect; + accountId: string; +} + +const u = (v: T | null | undefined): T | undefined => + v == null ? undefined : v; + +const run = ( + auth: SearchAuth, + eff: Effect.Effect, +): Effect.Effect => + auth.authorize(eff).pipe( + Effect.mapError((cause) => { + const message = + cause instanceof Error + ? cause.message + : typeof (cause as { message?: unknown } | undefined)?.message === + "string" + ? (cause as { message: string }).message + : "AI Search HTTP error"; + return new SearchError({ message, cause }); + }), + ); + +// ── response mappers (distilled camelCase -> runtime snake_case) ───────────── + +type ChunkIn = aisearch.SearchNamespaceInstanceResponse["chunks"][number]; + +const mapChunk = ( + c: ChunkIn, +): runtime.AiSearchSearchResponse["chunks"][number] => ({ + id: c.id, + type: c.type, + score: c.score, + text: c.text, + item: { + key: c.item?.key ?? "", + timestamp: u(c.item?.timestamp), + metadata: u(c.item?.metadata) as Record | undefined, + }, + scoring_details: c.scoringDetails + ? { + keyword_score: u(c.scoringDetails.keywordScore), + vector_score: u(c.scoringDetails.vectorScore), + keyword_rank: u(c.scoringDetails.keywordRank), + vector_rank: u(c.scoringDetails.vectorRank), + reranking_score: u(c.scoringDetails.rerankingScore), + fusion_method: u(c.scoringDetails.fusionMethod) as + | "rrf" + | "max" + | undefined, + } + : undefined, +}); + +const mapSearch = ( + r: aisearch.SearchNamespaceInstanceResponse, +): runtime.AiSearchSearchResponse => ({ + search_query: r.searchQuery ?? "", + chunks: r.chunks.map(mapChunk), +}); + +const mapChat = ( + r: aisearch.ChatCompletionsNamespaceInstanceResponse, +): runtime.AiSearchChatCompletionsResponse => ({ + id: u(r.id), + object: u(r.object), + model: u(r.model), + choices: r.choices.map((choice) => ({ + index: u(choice.index), + message: { + role: choice.message.role as + | "system" + | "developer" + | "user" + | "assistant" + | "tool", + content: + typeof choice.message.content === "string" + ? choice.message.content + : choice.message.content == null + ? null + : JSON.stringify(choice.message.content), + }, + })), + chunks: r.chunks.map(mapChunk), +}); + +const mapStats = ( + r: aisearch.StatsNamespaceInstanceResponse, +): runtime.AiSearchStatsResponse => ({ + queued: u(r.queued), + running: u(r.running), + completed: u(r.completed), + error: u(r.error), + skipped: u(r.skipped), + outdated: u(r.outdated), + last_activity: u(r.lastActivity), + engine: r.engine + ? { + vectorize: r.engine.vectorize + ? { + vectorsCount: r.engine.vectorize.vectorsCount, + dimensions: r.engine.vectorize.dimensions, + } + : undefined, + r2: r.engine.r2 + ? { + payloadSizeBytes: r.engine.r2.payloadSizeBytes, + metadataSizeBytes: r.engine.r2.metadataSizeBytes, + objectCount: r.engine.r2.objectCount, + } + : undefined, + } + : undefined, +}); + +const mapInfo = ( + r: aisearch.ReadNamespaceInstanceResponse, +): runtime.AiSearchInstanceInfo => ({ + id: r.id, + type: u(r.type) as runtime.AiSearchInstanceInfo["type"], + source: u(r.source), + source_params: u(r.sourceParams), + paused: u(r.paused), + status: u(r.status), + namespace: u(r.namespace), + created_at: u(r.createdAt), + modified_at: u(r.modifiedAt), + token_id: u(r.tokenId), + ai_gateway_id: u(r.aiGatewayId), + rewrite_query: u(r.rewriteQuery), + reranking: u(r.reranking), + embedding_model: u(r.embeddingModel), + ai_search_model: u(r.aiSearchModel), + rewrite_model: u(r.rewriteModel), + reranking_model: u(r.rerankingModel), + hybrid_search_enabled: u(r.hybridSearchEnabled), + index_method: r.indexMethod + ? { vector: r.indexMethod.vector, keyword: r.indexMethod.keyword } + : undefined, + fusion_method: u(r.fusionMethod) as "max" | "rrf" | undefined, + indexing_options: r.indexingOptions + ? { + keyword_tokenizer: u(r.indexingOptions.keywordTokenizer) as + | "porter" + | "trigram" + | undefined, + } + : undefined, + retrieval_options: r.retrievalOptions + ? { + keyword_match_mode: u(r.retrievalOptions.keywordMatchMode) as + | "and" + | "or" + | undefined, + boost_by: u(r.retrievalOptions.boostBy)?.map((b) => ({ + field: b.field, + direction: u(b.direction) as + | "asc" + | "desc" + | "exists" + | "not_exists" + | undefined, + })), + } + : undefined, + chunk_size: u(r.chunkSize), + chunk_overlap: u(r.chunkOverlap), + score_threshold: u(r.scoreThreshold), + max_num_results: u(r.maxNumResults), + cache: u(r.cache), + cache_threshold: u( + r.cacheThreshold, + ) as runtime.AiSearchInstanceInfo["cache_threshold"], + custom_metadata: u(r.customMetadata)?.map((m) => ({ + field_name: m.fieldName, + data_type: m.dataType as "text" | "number" | "boolean" | "datetime", + })), + sync_interval: u( + r.syncInterval, + ) as runtime.AiSearchInstanceInfo["sync_interval"], + metadata: u(r.metadata) as Record | undefined, +}); + +const mapList = ( + result: aisearch.ReadNamespaceInstanceResponse[], +): runtime.AiSearchListResponse => ({ + result: result.map(mapInfo), +}); + +const mapMulti = ( + r: aisearch.SearchNamespaceResponse, +): runtime.AiSearchMultiSearchResponse => ({ + search_query: r.searchQuery ?? "", + chunks: r.chunks.map((c) => ({ ...mapChunk(c), instance_id: c.instanceId })), + errors: u(r.errors)?.map((e) => ({ + instance_id: e.instanceId, + message: e.message, + })), +}); + +// ── request mappers (runtime snake_case -> distilled camelCase) ────────────── + +const mapMessages = (messages: runtime.AiSearchMessage[]) => + messages.map((m) => ({ + role: m.role as "system" | "developer" | "user" | "assistant" | "tool", + content: + typeof m.content === "string" || m.content == null + ? m.content + : (m.content as unknown[]).map((part) => { + const p = part as Record; + if ("image_url" in p || "imageUrl" in p) { + const img = (p.image_url ?? p.imageUrl) as { url: string }; + return { type: "image_url" as const, imageUrl: { url: img.url } }; + } + return { type: "text" as const, text: p.text as string }; + }), + })); + +const mapOptions = (o: runtime.AiSearchOptions | undefined) => { + if (!o) return undefined; + return { + cache: o.cache + ? { enabled: o.cache.enabled, cacheThreshold: o.cache.cache_threshold } + : undefined, + queryRewrite: o.query_rewrite + ? { + enabled: o.query_rewrite.enabled, + model: o.query_rewrite.model, + rewritePrompt: o.query_rewrite.rewrite_prompt, + } + : undefined, + reranking: o.reranking + ? { + enabled: o.reranking.enabled, + model: o.reranking.model, + matchThreshold: o.reranking.match_threshold, + } + : undefined, + retrieval: o.retrieval + ? { + retrievalType: o.retrieval.retrieval_type, + fusionMethod: o.retrieval.fusion_method, + keywordMatchMode: o.retrieval.keyword_match_mode, + matchThreshold: o.retrieval.match_threshold, + maxNumResults: o.retrieval.max_num_results, + contextExpansion: o.retrieval.context_expansion, + returnOnFailure: o.retrieval.return_on_failure, + // `filters` is a Vectorize metadata filter over user-defined fields — + // pass through untouched. + filters: o.retrieval.filters as Record | undefined, + boostBy: o.retrieval.boost_by, + } + : undefined, + }; +}; + +// ── client builders ────────────────────────────────────────────────────────── + +/** Effect resolving `{ name (namespace), id (instanceId) }` at apply time. */ +export type InstanceRef = Effect.Effect<{ name: string; id: string }>; + +/** Effect resolving the namespace `name` at apply time. */ +export type NamespaceRef = Effect.Effect; + +const dieRaw = (kind: string): Effect.Effect => + Effect.die( + new Error( + `The AI Search ${kind} *Local binding runs over the HTTP API; the raw native runtime binding is only available inside a deployed Worker.`, + ), + ); + +/** + * Build a single-instance {@link QuerySearchClient} over the HTTP API. `ref` + * resolves the `{ namespace, instanceId }` at apply time. + */ +export const makeLocalSearchClient = ( + auth: SearchAuth, + ref: InstanceRef, +): QuerySearchClient => { + const withRef = ( + fn: (r: { + name: string; + id: string; + }) => Effect.Effect, + ) => Effect.flatMap(ref, (r) => run(auth, fn(r))); + + return { + raw: dieRaw("instance"), + search: (params) => + withRef((r) => + aisearch.searchNamespaceInstance({ + accountId: auth.accountId, + name: r.name, + id: r.id, + ...("query" in params && params.query !== undefined + ? { query: params.query } + : { messages: mapMessages(params.messages ?? []) }), + aiSearchOptions: mapOptions(params.ai_search_options), + } as aisearch.SearchNamespaceInstanceRequest), + ).pipe(Effect.map(mapSearch)), + chatCompletions: (params) => + withRef((r) => + aisearch.chatCompletionsNamespaceInstance({ + accountId: auth.accountId, + name: r.name, + id: r.id, + messages: mapMessages(params.messages), + aiSearchOptions: mapOptions(params.ai_search_options), + } as aisearch.ChatCompletionsNamespaceInstanceRequest), + ).pipe(Effect.map(mapChat)), + info: () => + withRef((r) => + aisearch.readNamespaceInstance({ + accountId: auth.accountId, + name: r.name, + id: r.id, + }), + ).pipe(Effect.map(mapInfo)), + stats: () => + withRef((r) => + aisearch.statsNamespaceInstance({ + accountId: auth.accountId, + name: r.name, + id: r.id, + }), + ).pipe(Effect.map(mapStats)), + } satisfies QuerySearchClient; +}; + +/** + * Build a namespace {@link QuerySearchNamespaceClient} over the HTTP API. + * `.get(instanceName)` scopes a single-instance client to `(namespace, + * instanceName)`. + */ +export const makeLocalSearchNamespaceClient = ( + auth: SearchAuth, + ref: NamespaceRef, +): QuerySearchNamespaceClient => ({ + raw: dieRaw("namespace"), + get: (instanceName) => + makeLocalSearchClient( + auth, + Effect.map(ref, (name) => ({ name, id: instanceName })), + ), + list: (params) => + Effect.flatMap(ref, (name) => + run( + auth, + Stream.runCollect( + aisearch.listNamespaceInstances.pages({ + accountId: auth.accountId, + name, + perPage: params?.per_page, + orderBy: params?.order_by, + orderByDirection: params?.order_by_direction, + search: params?.search, + }), + ), + ), + ).pipe( + Effect.map((chunk) => + mapList( + Array.from(chunk).flatMap( + (page) => + (page.result ?? + []) as unknown as aisearch.ReadNamespaceInstanceResponse[], + ), + ), + ), + ), + search: (params) => + Effect.flatMap(ref, (name) => + run( + auth, + aisearch.searchNamespace({ + accountId: auth.accountId, + name, + aiSearchOptions: { + instanceIds: params.ai_search_options.instance_ids, + ...mapOptions(params.ai_search_options), + }, + ...("query" in params && params.query !== undefined + ? { query: params.query } + : { messages: mapMessages(params.messages ?? []) }), + } as aisearch.SearchNamespaceRequest), + ), + ).pipe(Effect.map(mapMulti)), +}); diff --git a/packages/alchemy/src/Cloudflare/AI/index.ts b/packages/alchemy/src/Cloudflare/AI/index.ts index 8e459c994..ba54ba0aa 100644 --- a/packages/alchemy/src/Cloudflare/AI/index.ts +++ b/packages/alchemy/src/Cloudflare/AI/index.ts @@ -11,8 +11,10 @@ export * from "./QueryGateway.ts"; export * from "./QueryGatewayBinding.ts"; export * from "./QuerySearch.ts"; export * from "./QuerySearchBinding.ts"; +export * from "./QuerySearchLocal.ts"; export * from "./QuerySearchNamespace.ts"; export * from "./QuerySearchNamespaceBinding.ts"; +export * from "./QuerySearchNamespaceLocal.ts"; export * from "./Search.ts"; export * from "./SearchInstance.ts"; export * from "./SearchNamespace.ts"; diff --git a/packages/alchemy/src/Cloudflare/DNS/DnsHttp.ts b/packages/alchemy/src/Cloudflare/DNS/DnsHttp.ts index e9d149a76..5b7330471 100644 --- a/packages/alchemy/src/Cloudflare/DNS/DnsHttp.ts +++ b/packages/alchemy/src/Cloudflare/DNS/DnsHttp.ts @@ -1,9 +1,13 @@ import * as Effect from "effect/Effect"; import type * as Redacted from "effect/Redacted"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; import * as Output from "../../Output.ts"; import { Self } from "../../Self.ts"; import { AccountApiToken } from "../ApiToken/AccountApiToken.ts"; import type { PermissionGroupRef } from "../ApiToken/Common.ts"; +import type { Credentials } from "../Credentials.ts"; +import { authorizeWith } from "../HttpClientUtils.ts"; import type { Zone } from "../Zone/Zone.ts"; /** @@ -21,7 +25,7 @@ import type { Zone } from "../Zone/Zone.ts"; */ export const makeHttpDnsBinding = (options: { permissionGroups: PermissionGroupRef[]; - makeClient: (token: Token, zoneId: Effect.Effect) => Client; + makeClient: (auth: DnsAuth, zoneId: Effect.Effect) => Client; }) => Effect.gen(function* () { const Token = yield* AccountApiToken; @@ -52,10 +56,28 @@ export const makeHttpDnsBinding = (options: { value: yield* token.value, } satisfies Token; const zoneId = yield* zone.zoneId; - return options.makeClient(bound, zoneId); + return options.makeClient(makeDnsAuth(bound), zoneId); }); }); +/** + * Injectable auth for the DNS HTTP client builders. Both the scoped-token + * (`*Http`) and current-credentials (`*Local`) variants supply an `authorize` + * that provides `Credentials` + `HttpClient` to a raw SDK op, so the client + * builders are agnostic to how creds are obtained. DNS record ops are + * zone-scoped (the `zoneId` is passed alongside), so no `accountId` is needed. + */ +export interface DnsAuth { + authorize: ( + eff: Effect.Effect, + ) => Effect.Effect; +} + +/** Build a scoped-token {@link DnsAuth} from a bound {@link Token}. */ +export const makeDnsAuth = (token: Token): DnsAuth => ({ + authorize: authorizeWith(token), +}); + /** * Runtime accessor for a DNS binding's token, obtained by binding the * {@link AccountApiToken}'s `value` output in the Worker's Init phase. Reads the diff --git a/packages/alchemy/src/Cloudflare/DNS/DnsLocal.ts b/packages/alchemy/src/Cloudflare/DNS/DnsLocal.ts new file mode 100644 index 000000000..02a18a72f --- /dev/null +++ b/packages/alchemy/src/Cloudflare/DNS/DnsLocal.ts @@ -0,0 +1,41 @@ +import * as Effect from "effect/Effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { Credentials } from "../Credentials.ts"; +import type { Zone } from "../Zone/Zone.ts"; +import type { DnsAuth } from "./DnsHttp.ts"; + +/** + * Shared scaffolding for the `*Local` DNS services. + * + * Instead of minting a scoped {@link AccountApiToken} (the `*Http` path), it + * captures the ambient current-credentials context available during stack-eval + * and builds a {@link DnsAuth} that provides those credentials directly to the + * DNS HTTP ops. It then delegates to the same client builders the `*Http` + * variant uses. DNS is a zone-management capability with no native Worker + * binding, so there is no `*Binding` counterpart. + * + * NOT exported from `index.ts` — this is internal scaffolding shared by the + * three access-level Local layers. + */ +export const makeLocalDnsBinding = (options: { + makeClient: (auth: DnsAuth, zoneId: Effect.Effect) => Client; +}) => + Effect.gen(function* () { + // Credentials are ambient during stack-eval (the stack's providers layer). + // Capture the full context so DNS HTTP ops can run with the current + // credentials — no `host.bind`, no minted token. DNS record ops are + // zone-scoped, so no accountId is needed. + const context = yield* Effect.context< + Credentials | HttpClient.HttpClient + >(); + + return Effect.fn(function* (zone: Zone) { + // Deferred accessor — resolves the zoneId against the tracker at apply + // time (in an Action, that's the engine's resolve context). + const zoneId = yield* zone.zoneId; + const auth: DnsAuth = { + authorize: (eff) => eff.pipe(Effect.provideContext(context)), + }; + return options.makeClient(auth, zoneId); + }); + }); diff --git a/packages/alchemy/src/Cloudflare/DNS/ReadDnsHttp.ts b/packages/alchemy/src/Cloudflare/DNS/ReadDnsHttp.ts index fd01ece17..30b3f2656 100644 --- a/packages/alchemy/src/Cloudflare/DNS/ReadDnsHttp.ts +++ b/packages/alchemy/src/Cloudflare/DNS/ReadDnsHttp.ts @@ -1,8 +1,7 @@ import * as dns from "@distilled.cloud/cloudflare/dns"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { authorizeWith } from "../HttpClientUtils.ts"; -import { makeHttpDnsBinding, type Token } from "./DnsHttp.ts"; +import { type DnsAuth, makeHttpDnsBinding } from "./DnsHttp.ts"; import { ReadDns, type ReadDnsClient } from "./ReadDns.ts"; /** Runtime layer for {@link ReadDns}. */ @@ -16,12 +15,12 @@ export const ReadDnsHttp = Layer.effect( ), ); -/** Build the read-only client over a bound token and zone id. */ +/** Build the read-only client over an injectable auth and zone id. */ export const dnsReadClient = ( - token: Token, + auth: DnsAuth, zoneId: Effect.Effect, ): ReadDnsClient => { - const authorize = authorizeWith(token); + const authorize = auth.authorize; return { getDnsRecord: Effect.fn("Cloudflare.DNS.getDnsRecord")( function* (dnsRecordId) { diff --git a/packages/alchemy/src/Cloudflare/DNS/ReadDnsLocal.ts b/packages/alchemy/src/Cloudflare/DNS/ReadDnsLocal.ts new file mode 100644 index 000000000..19f4a1582 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/DNS/ReadDnsLocal.ts @@ -0,0 +1,38 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { makeLocalDnsBinding } from "./DnsLocal.ts"; +import { ReadDns } from "./ReadDns.ts"; +import { dnsReadClient } from "./ReadDnsHttp.ts"; + +/** + * Local implementation of the {@link ReadDns} binding — reads Cloudflare DNS + * records over the HTTP API using the **current credentials** instead of a + * scoped API token ({@link ReadDnsHttp}). DNS is a zone-management capability + * with no native Worker binding. + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can read a + * zone's DNS records with the same `getDnsRecord`/`listDnsRecords` client you'd + * use inside a Worker: + * + * @example Reading records from an Action + * ```typescript + * const Check = Alchemy.Action( + * "Check", + * Effect.gen(function* () { + * const dns = yield* Cloudflare.DNS.ReadDns(zone); + * return Effect.fn(function* () { + * return yield* dns.listDnsRecords({ type: "TXT" }); + * }); + * }).pipe(Effect.provide(Cloudflare.DNS.ReadDnsLocal)), + * ); + * ``` + * + * The zone id is resolved at apply time through the ambient + * {@link RuntimeContext} (in an Action, that's the resolve context the engine + * provides around the body), so `ReadDns(zone)` works even though the zone may + * be created/adopted in the same deploy. + */ +export const ReadDnsLocal = Layer.effect( + ReadDns, + Effect.suspend(() => makeLocalDnsBinding({ makeClient: dnsReadClient })), +); diff --git a/packages/alchemy/src/Cloudflare/DNS/ReadWriteDnsHttp.ts b/packages/alchemy/src/Cloudflare/DNS/ReadWriteDnsHttp.ts index 731b69878..70b25fb13 100644 --- a/packages/alchemy/src/Cloudflare/DNS/ReadWriteDnsHttp.ts +++ b/packages/alchemy/src/Cloudflare/DNS/ReadWriteDnsHttp.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { makeHttpDnsBinding, type Token } from "./DnsHttp.ts"; +import { type DnsAuth, makeHttpDnsBinding } from "./DnsHttp.ts"; import { dnsReadClient } from "./ReadDnsHttp.ts"; import { ReadWriteDns, type ReadWriteDnsClient } from "./ReadWriteDns.ts"; import { dnsWriteClient } from "./WriteDnsHttp.ts"; @@ -16,11 +16,11 @@ export const ReadWriteDnsHttp = Layer.effect( ), ); -/** Build the combined read + write client over a bound token and zone id. */ +/** Build the combined read + write client over an injectable auth and zone id. */ export const dnsReadWriteClient = ( - token: Token, + auth: DnsAuth, zoneId: Effect.Effect, ): ReadWriteDnsClient => ({ - ...dnsReadClient(token, zoneId), - ...dnsWriteClient(token, zoneId), + ...dnsReadClient(auth, zoneId), + ...dnsWriteClient(auth, zoneId), }); diff --git a/packages/alchemy/src/Cloudflare/DNS/ReadWriteDnsLocal.ts b/packages/alchemy/src/Cloudflare/DNS/ReadWriteDnsLocal.ts new file mode 100644 index 000000000..a0c754316 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/DNS/ReadWriteDnsLocal.ts @@ -0,0 +1,46 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { makeLocalDnsBinding } from "./DnsLocal.ts"; +import { ReadWriteDns } from "./ReadWriteDns.ts"; +import { dnsReadWriteClient } from "./ReadWriteDnsHttp.ts"; + +/** + * Local implementation of the {@link ReadWriteDns} binding — performs the full + * Cloudflare DNS record CRUD surface over the HTTP API using the **current + * credentials** instead of a scoped API token ({@link ReadWriteDnsHttp}). DNS + * is a zone-management capability with no native Worker binding. + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can manage + * a zone's DNS records with the same read + write client you'd use inside a + * Worker: + * + * @example Seeding and reading records from an Action + * ```typescript + * const Seed = Alchemy.Action( + * "Seed", + * Effect.gen(function* () { + * const dns = yield* Cloudflare.DNS.ReadWriteDns(zone); + * return Effect.fn(function* () { + * const created = yield* dns.createDnsRecord({ + * type: "TXT", + * name: "_seed.example.com", + * content: '"hello"', + * ttl: 60, + * }); + * const record = yield* dns.getDnsRecord(created.id); + * yield* dns.deleteDnsRecord(created.id); + * return record; + * }); + * }).pipe(Effect.provide(Cloudflare.DNS.ReadWriteDnsLocal)), + * ); + * ``` + * + * The zone id is resolved at apply time through the ambient + * {@link RuntimeContext} (in an Action, that's the resolve context the engine + * provides around the body), so `ReadWriteDns(zone)` works even though the zone + * may be created/adopted in the same deploy. + */ +export const ReadWriteDnsLocal = Layer.effect( + ReadWriteDns, + Effect.suspend(() => makeLocalDnsBinding({ makeClient: dnsReadWriteClient })), +); diff --git a/packages/alchemy/src/Cloudflare/DNS/WriteDnsHttp.ts b/packages/alchemy/src/Cloudflare/DNS/WriteDnsHttp.ts index 970532863..3518c1ec6 100644 --- a/packages/alchemy/src/Cloudflare/DNS/WriteDnsHttp.ts +++ b/packages/alchemy/src/Cloudflare/DNS/WriteDnsHttp.ts @@ -1,8 +1,7 @@ import * as dns from "@distilled.cloud/cloudflare/dns"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { authorizeWith } from "../HttpClientUtils.ts"; -import { makeHttpDnsBinding, type Token } from "./DnsHttp.ts"; +import { type DnsAuth, makeHttpDnsBinding } from "./DnsHttp.ts"; import { WriteDns, type WriteDnsClient } from "./WriteDns.ts"; /** Runtime layer for {@link WriteDns}. */ @@ -16,12 +15,12 @@ export const WriteDnsHttp = Layer.effect( ), ); -/** Build the write client over a bound token and zone id. */ +/** Build the write client over an injectable auth and zone id. */ export const dnsWriteClient = ( - token: Token, + auth: DnsAuth, zoneId: Effect.Effect, ): WriteDnsClient => { - const authorize = authorizeWith(token); + const authorize = auth.authorize; return { createDnsRecord: Effect.fn("Cloudflare.DNS.createDnsRecord")( function* (request) { diff --git a/packages/alchemy/src/Cloudflare/DNS/WriteDnsLocal.ts b/packages/alchemy/src/Cloudflare/DNS/WriteDnsLocal.ts new file mode 100644 index 000000000..88e64a3c7 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/DNS/WriteDnsLocal.ts @@ -0,0 +1,44 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { makeLocalDnsBinding } from "./DnsLocal.ts"; +import { WriteDns } from "./WriteDns.ts"; +import { dnsWriteClient } from "./WriteDnsHttp.ts"; + +/** + * Local implementation of the {@link WriteDns} binding — creates, updates, and + * deletes Cloudflare DNS records over the HTTP API using the **current + * credentials** instead of a scoped API token ({@link WriteDnsHttp}). DNS is a + * zone-management capability with no native Worker binding. + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can mutate + * a zone's DNS records with the same + * `createDnsRecord`/`updateDnsRecord`/`deleteDnsRecord`/`batchDnsRecords` + * client you'd use inside a Worker: + * + * @example Creating a record from an Action + * ```typescript + * const Seed = Alchemy.Action( + * "Seed", + * Effect.gen(function* () { + * const dns = yield* Cloudflare.DNS.WriteDns(zone); + * return Effect.fn(function* () { + * return yield* dns.createDnsRecord({ + * type: "TXT", + * name: "_seed.example.com", + * content: '"hello"', + * ttl: 60, + * }); + * }); + * }).pipe(Effect.provide(Cloudflare.DNS.WriteDnsLocal)), + * ); + * ``` + * + * The zone id is resolved at apply time through the ambient + * {@link RuntimeContext} (in an Action, that's the resolve context the engine + * provides around the body), so `WriteDns(zone)` works even though the zone may + * be created/adopted in the same deploy. + */ +export const WriteDnsLocal = Layer.effect( + WriteDns, + Effect.suspend(() => makeLocalDnsBinding({ makeClient: dnsWriteClient })), +); diff --git a/packages/alchemy/src/Cloudflare/DNS/index.ts b/packages/alchemy/src/Cloudflare/DNS/index.ts index a68ae6ccf..68d4ea52c 100644 --- a/packages/alchemy/src/Cloudflare/DNS/index.ts +++ b/packages/alchemy/src/Cloudflare/DNS/index.ts @@ -4,12 +4,15 @@ export * from "./Dnssec.ts"; export * from "./Firewall.ts"; export * from "./ReadDns.ts"; export * from "./ReadDnsHttp.ts"; +export * from "./ReadDnsLocal.ts"; export * from "./ReadWriteDns.ts"; export * from "./ReadWriteDnsHttp.ts"; +export * from "./ReadWriteDnsLocal.ts"; export * from "./Record.ts"; export * from "./View.ts"; export * from "./WriteDns.ts"; export * from "./WriteDnsHttp.ts"; +export * from "./WriteDnsLocal.ts"; export * from "./ZoneSettings.ts"; export * from "./ZoneTransferAcl.ts"; export * from "./ZoneTransferIncoming.ts"; diff --git a/packages/alchemy/src/Cloudflare/Flagship/ReadFlagsLocal.ts b/packages/alchemy/src/Cloudflare/Flagship/ReadFlagsLocal.ts new file mode 100644 index 000000000..566334c4c --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Flagship/ReadFlagsLocal.ts @@ -0,0 +1,224 @@ +import type * as cf from "@cloudflare/workers-types"; +import * as flagship from "@distilled.cloud/cloudflare/flagship"; +import type * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; +import type { App } from "./App.ts"; +import { + type EvaluationContext, + type EvaluationDetails, + FlagshipError, + ReadFlags, + type ReadFlagsClient, +} from "./ReadFlags.ts"; + +/** + * Local implementation of the {@link ReadFlags} binding — evaluates Flagship + * feature flags over the Cloudflare HTTP API (`GET .../flagship/apps/{appId}/evaluate`) + * using the **current credentials** instead of a native Worker binding + * ({@link ReadFlagsBinding}). + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can read + * flag values with the same `getBooleanValue`/`getStringValue`/… client you'd + * use inside a Worker: + * + * @example Reading a flag from an Action + * ```typescript + * const CheckFlag = Alchemy.Action( + * "CheckFlag", + * Effect.gen(function* () { + * const flags = yield* Cloudflare.Flagship.ReadFlags(app); + * return Effect.fn(function* () { + * return yield* flags.getBooleanValue("new-checkout", false); + * }); + * }).pipe(Effect.provide(Cloudflare.Flagship.ReadFlagsLocal)), + * ); + * ``` + * + * The app id is resolved at apply time through the ambient {@link RuntimeContext} + * (in an Action, that's the resolve context the engine provides around the + * body), so `ReadFlags(app)` works even though the app is created in the same + * deploy. + * + * Limitations vs. the Worker binding: the HTTP evaluate endpoint only accepts a + * single `targetingKey` (read from `context.targetingKey`), so attribute-based + * targeting rules that key off other context fields cannot be exercised locally. + * The `raw` runtime binding has no HTTP equivalent and dies if used. + */ +export const ReadFlagsLocal = Layer.effect( + ReadFlags, + Effect.gen(function* () { + // Account + credentials are ambient during stack-eval (the stack's + // providers layer). Capture the full context so the evaluate op can run + // with the current credentials — no `host.bind`, no minted token. + const { accountId } = yield* yield* CloudflareEnvironment; + const context = yield* Effect.context< + Credentials | HttpClient.HttpClient + >(); + + return Effect.fn(function* (app: App) { + // Deferred accessor — resolves the appId against the tracker at apply + // time (in an Action, that's the engine's resolve context). + const appId = yield* app.appId; + return makeLocalFlagshipClient({ accountId, appId, context }); + }); + }), +); + +interface LocalCtx { + accountId: string; + appId: Effect.Effect; + context: Context.Context; +} + +/** + * The HTTP evaluate endpoint only supports a single `targetingKey` query + * param, not the full flat evaluation context the Worker binding accepts. + */ +const targetingKeyOf = ( + context: EvaluationContext | undefined, +): string | undefined => { + const value = context?.["targetingKey"]; + return value === undefined ? undefined : String(value); +}; + +const evaluate = ( + ctx: LocalCtx, + flagKey: string, + context?: EvaluationContext, +) => + ctx.appId.pipe( + Effect.flatMap((appId) => + flagship + .getAppEvaluate({ + accountId: ctx.accountId, + appId, + flagKey, + targetingKey: targetingKeyOf(context), + }) + .pipe(Effect.provideContext(ctx.context)), + ), + ); + +/** + * HTTP-backed {@link ReadFlagsClient}. Mirrors the Worker binding's + * fall-back-to-default semantics: evaluation never fails the effect — an HTTP + * error or a value whose type does not match the requested method resolves to + * `defaultValue` instead. + */ +const makeLocalFlagshipClient = (ctx: LocalCtx): ReadFlagsClient => { + const details = ( + flagKey: string, + defaultValue: T, + match: (value: unknown) => value is T, + context?: EvaluationContext, + ): Effect.Effect, FlagshipError, RuntimeContext> => + evaluate(ctx, flagKey, context).pipe( + Effect.map( + (r): EvaluationDetails => + match(r.value) + ? { flagKey, value: r.value, variant: r.variant, reason: r.reason } + : { + flagKey, + value: defaultValue, + variant: r.variant, + reason: r.reason, + errorCode: "TYPE_MISMATCH", + }, + ), + Effect.catch((error) => + Effect.succeed>({ + flagKey, + value: defaultValue, + reason: "ERROR", + errorCode: error._tag, + }), + ), + ); + + const value = ( + flagKey: string, + defaultValue: T, + match: (value: unknown) => value is T, + context?: EvaluationContext, + ): Effect.Effect => + evaluate(ctx, flagKey, context).pipe( + Effect.map((r) => (match(r.value) ? r.value : defaultValue)), + Effect.catch(() => Effect.succeed(defaultValue)), + ); + + const isBoolean = (v: unknown): v is boolean => typeof v === "boolean"; + const isString = (v: unknown): v is string => typeof v === "string"; + const isNumber = (v: unknown): v is number => typeof v === "number"; + const isObjectLike = (v: unknown): boolean => + v !== null && typeof v === "object"; + + return { + // The raw runtime binding is a workerd object with no HTTP surface. + raw: Effect.die( + new FlagshipError({ + message: + "the raw Flagship runtime binding is unavailable over HTTP; use ReadFlagsBinding inside a Worker", + cause: undefined, + }), + ) as Effect.Effect, + get: (flagKey, defaultValue, context) => + evaluate(ctx, flagKey, context).pipe( + Effect.map((r) => r.value ?? defaultValue), + Effect.catch(() => Effect.succeed(defaultValue)), + ), + getBooleanValue: (flagKey, defaultValue, context) => + value(flagKey, defaultValue, isBoolean, context), + getStringValue: (flagKey, defaultValue, context) => + value(flagKey, defaultValue, isString, context), + getNumberValue: (flagKey, defaultValue, context) => + value(flagKey, defaultValue, isNumber, context), + getObjectValue: (flagKey, defaultValue, context) => + evaluate(ctx, flagKey, context).pipe( + Effect.map((r) => + isObjectLike(r.value) + ? (r.value as typeof defaultValue) + : defaultValue, + ), + Effect.catch(() => Effect.succeed(defaultValue)), + ), + getBooleanDetails: (flagKey, defaultValue, context) => + details(flagKey, defaultValue, isBoolean, context), + getStringDetails: (flagKey, defaultValue, context) => + details(flagKey, defaultValue, isString, context), + getNumberDetails: (flagKey, defaultValue, context) => + details(flagKey, defaultValue, isNumber, context), + getObjectDetails: (flagKey, defaultValue, context) => + evaluate(ctx, flagKey, context).pipe( + Effect.map( + (r): EvaluationDetails => + isObjectLike(r.value) + ? { + flagKey, + value: r.value as typeof defaultValue, + variant: r.variant, + reason: r.reason, + } + : { + flagKey, + value: defaultValue, + variant: r.variant, + reason: r.reason, + errorCode: "TYPE_MISMATCH", + }, + ), + Effect.catch((error) => + Effect.succeed>({ + flagKey, + value: defaultValue, + reason: "ERROR", + errorCode: error._tag, + }), + ), + ), + } satisfies ReadFlagsClient; +}; diff --git a/packages/alchemy/src/Cloudflare/Flagship/index.ts b/packages/alchemy/src/Cloudflare/Flagship/index.ts index 54275f39b..366d0b9cb 100644 --- a/packages/alchemy/src/Cloudflare/Flagship/index.ts +++ b/packages/alchemy/src/Cloudflare/Flagship/index.ts @@ -8,3 +8,4 @@ export { type EvaluationDetails, } from "./ReadFlags.ts"; export { ReadFlagsBinding } from "./ReadFlagsBinding.ts"; +export { ReadFlagsLocal } from "./ReadFlagsLocal.ts"; diff --git a/packages/alchemy/src/Cloudflare/Tunnel/ReadTunnel.ts b/packages/alchemy/src/Cloudflare/Tunnel/ReadTunnel.ts index 95607defd..42a6ca39d 100644 --- a/packages/alchemy/src/Cloudflare/Tunnel/ReadTunnel.ts +++ b/packages/alchemy/src/Cloudflare/Tunnel/ReadTunnel.ts @@ -15,8 +15,7 @@ import * as Binding from "../../Binding.ts"; import type { Worker } from "../Workers/Worker.ts"; import type { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { RuntimeContext } from "../../RuntimeContext.ts"; -import { type Token } from "./TunnelBinding.ts"; -import { authorizeWith } from "../HttpClientUtils.ts"; +import { type TunnelAuth } from "./TunnelBinding.ts"; /** * Binding that lets a Worker read Cloudflare Tunnels at runtime. @@ -117,31 +116,31 @@ export interface ReadTunnelClient { >; } -/** Build the read-only client over a bound token. */ -export const readClient = (token: Token): ReadTunnelClient => { - const authorize = authorizeWith(token); +/** Build the read-only client over an injectable {@link TunnelAuth}. */ +export const readClient = (auth: TunnelAuth): ReadTunnelClient => { + const { authorize } = auth; return { get: Effect.fn("Cloudflare.Tunnel.get")(function* (tunnelId) { - const accountId = yield* token.accountId; + const accountId = yield* auth.accountId; return yield* authorize( zeroTrust.getTunnelCloudflared({ accountId, tunnelId }), ); }), list: Effect.fn("Cloudflare.Tunnel.list")(function* (request) { - const accountId = yield* token.accountId; + const accountId = yield* auth.accountId; return yield* authorize( zeroTrust.listTunnelCloudflareds({ accountId, ...request }), ); }), getToken: Effect.fn("Cloudflare.Tunnel.getToken")(function* (tunnelId) { - const accountId = yield* token.accountId; + const accountId = yield* auth.accountId; return yield* authorize( zeroTrust.getTunnelCloudflaredToken({ accountId, tunnelId }), ); }), getConfiguration: Effect.fn("Cloudflare.Tunnel.getConfiguration")( function* (tunnelId) { - const accountId = yield* token.accountId; + const accountId = yield* auth.accountId; return yield* authorize( zeroTrust.getTunnelCloudflaredConfiguration({ accountId, tunnelId }), ); diff --git a/packages/alchemy/src/Cloudflare/Tunnel/ReadTunnelLocal.ts b/packages/alchemy/src/Cloudflare/Tunnel/ReadTunnelLocal.ts new file mode 100644 index 000000000..526d432b7 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Tunnel/ReadTunnelLocal.ts @@ -0,0 +1,33 @@ +import * as Layer from "effect/Layer"; +import { readClient, ReadTunnel } from "./ReadTunnel.ts"; +import { makeLocalTunnelClient } from "./TunnelLocalBinding.ts"; + +/** + * Local implementation of the {@link ReadTunnel} binding — reads Cloudflare + * Tunnels over the cfd_tunnel HTTP API using the **current credentials** + * instead of a native Worker binding (`ReadTunnelBinding`) or a scoped API + * token. No `host.bind`, no minted token, no Worker host. + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can read + * tunnels with the same `get`/`list`/`getToken`/`getConfiguration` client you'd + * use inside a Worker. + * + * @example Read a tunnel's connector token from an Action + * ```typescript + * const Inspect = Alchemy.Action( + * "Inspect", + * Effect.gen(function* () { + * const tunnels = yield* Cloudflare.Tunnel.ReadTunnel(); + * const tunnelId = yield* tunnel.tunnelId; + * return Effect.fn(function* () { + * const t = yield* tunnels.get(yield* tunnelId); + * return t.result?.name; + * }); + * }).pipe(Effect.provide(Cloudflare.Tunnel.ReadTunnelLocal)), + * ); + * ``` + */ +export const ReadTunnelLocal = Layer.effect( + ReadTunnel, + makeLocalTunnelClient(readClient), +); diff --git a/packages/alchemy/src/Cloudflare/Tunnel/ReadWriteTunnel.ts b/packages/alchemy/src/Cloudflare/Tunnel/ReadWriteTunnel.ts index b42ab9638..00215ba61 100644 --- a/packages/alchemy/src/Cloudflare/Tunnel/ReadWriteTunnel.ts +++ b/packages/alchemy/src/Cloudflare/Tunnel/ReadWriteTunnel.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import * as Binding from "../../Binding.ts"; import type { Worker } from "../Workers/Worker.ts"; import type { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; -import { type Token } from "./TunnelBinding.ts"; +import { type TunnelAuth } from "./TunnelBinding.ts"; import { readClient, type ReadTunnelClient } from "./ReadTunnel.ts"; import { writeClient, type WriteTunnelClient } from "./WriteTunnel.ts"; @@ -68,8 +68,8 @@ export const ReadWriteTunnel = Binding.Service( export interface ReadWriteTunnelClient extends ReadTunnelClient, WriteTunnelClient {} -/** Build the combined read + write client over a bound token. */ -export const readWriteClient = (token: Token): ReadWriteTunnelClient => ({ - ...readClient(token), - ...writeClient(token), +/** Build the combined read + write client over an injectable {@link TunnelAuth}. */ +export const readWriteClient = (auth: TunnelAuth): ReadWriteTunnelClient => ({ + ...readClient(auth), + ...writeClient(auth), }); diff --git a/packages/alchemy/src/Cloudflare/Tunnel/ReadWriteTunnelLocal.ts b/packages/alchemy/src/Cloudflare/Tunnel/ReadWriteTunnelLocal.ts new file mode 100644 index 000000000..6cefb6b7a --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Tunnel/ReadWriteTunnelLocal.ts @@ -0,0 +1,39 @@ +import * as Layer from "effect/Layer"; +import { readWriteClient, ReadWriteTunnel } from "./ReadWriteTunnel.ts"; +import { makeLocalTunnelClient } from "./TunnelLocalBinding.ts"; + +/** + * Local implementation of the {@link ReadWriteTunnel} binding — performs the + * full cfd_tunnel CRUD surface over the HTTP API using the **current + * credentials** instead of a native Worker binding (`ReadWriteTunnelBinding`) + * or a scoped API token. No `host.bind`, no minted token, no Worker host. + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can manage + * tunnels with the same combined read + write client you'd use inside a Worker. + * + * @example Configure a deployed tunnel from an Action + * ```typescript + * const Configure = Alchemy.Action( + * "Configure", + * Effect.gen(function* () { + * const tunnels = yield* Cloudflare.Tunnel.ReadWriteTunnel(); + * const tunnelId = yield* tunnel.tunnelId; + * return Effect.fn(function* () { + * const id = yield* tunnelId; + * yield* tunnels.putConfiguration(id, { + * ingress: [ + * { hostname: "app.example.com", service: "http://localhost:3000" }, + * { service: "http_status:404" }, + * ], + * }); + * const { config } = yield* tunnels.getConfiguration(id); + * return config; + * }); + * }).pipe(Effect.provide(Cloudflare.Tunnel.ReadWriteTunnelLocal)), + * ); + * ``` + */ +export const ReadWriteTunnelLocal = Layer.effect( + ReadWriteTunnel, + makeLocalTunnelClient(readWriteClient), +); diff --git a/packages/alchemy/src/Cloudflare/Tunnel/TunnelBinding.ts b/packages/alchemy/src/Cloudflare/Tunnel/TunnelBinding.ts index dcabce962..c812e9c2b 100644 --- a/packages/alchemy/src/Cloudflare/Tunnel/TunnelBinding.ts +++ b/packages/alchemy/src/Cloudflare/Tunnel/TunnelBinding.ts @@ -1,8 +1,12 @@ import * as Effect from "effect/Effect"; import type * as Redacted from "effect/Redacted"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; import { AccountApiToken } from "../ApiToken/AccountApiToken.ts"; import type { PermissionGroupRef } from "../ApiToken/Common.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; +import { authorizeWith } from "../HttpClientUtils.ts"; import { Worker } from "../Workers/Worker.ts"; /** @@ -16,7 +20,7 @@ import { Worker } from "../Workers/Worker.ts"; export const makeTunnelClient = ( sid: string, permissionGroups: PermissionGroupRef[], - makeClient: (token: Token) => C, + makeClient: (auth: TunnelAuth) => C, ) => Effect.gen(function* () { const Token = yield* AccountApiToken; @@ -39,10 +43,30 @@ export const makeTunnelClient = ( ], }); } - return makeClient(yield* bindTunnelToken(token)); + return makeClient(makeTunnelAuth(yield* bindTunnelToken(token))); }); }); +/** + * Injectable auth for the tunnel client builders. Both the scoped-token + * (`*Binding`) and current-credentials (`*Local`) variants supply an + * `authorize` (which provides `Credentials` + `HttpClient` to a raw SDK op) + * and an `accountId`, so the client builders are agnostic to how creds are + * obtained. + */ +export interface TunnelAuth { + authorize: ( + eff: Effect.Effect, + ) => Effect.Effect; + accountId: Effect.Effect; +} + +/** Build a scoped-token {@link TunnelAuth} from a bound {@link Token}. */ +export const makeTunnelAuth = (token: Token): TunnelAuth => ({ + authorize: authorizeWith(token), + accountId: token.accountId, +}); + /** * Runtime accessors for a tunnel binding's token, obtained by binding the * {@link AccountApiToken}'s outputs in the Worker's Init phase. Each accessor diff --git a/packages/alchemy/src/Cloudflare/Tunnel/TunnelLocalBinding.ts b/packages/alchemy/src/Cloudflare/Tunnel/TunnelLocalBinding.ts new file mode 100644 index 000000000..ce5af26d5 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Tunnel/TunnelLocalBinding.ts @@ -0,0 +1,38 @@ +import * as Effect from "effect/Effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; +import type { TunnelAuth } from "./TunnelBinding.ts"; + +/** + * Shared scaffolding for the `*Local` tunnel services. + * + * Instead of minting a scoped {@link AccountApiToken} (the `*Binding` path), it + * captures the ambient current-credentials context available during stack-eval + * and builds a {@link TunnelAuth} that provides those credentials directly to + * the cfd_tunnel HTTP ops. It then delegates to the same client builders the + * `*Binding` variant uses. + * + * NOT exported from `index.ts` — this is internal scaffolding shared by the + * three access-level Local layers. + */ +export const makeLocalTunnelClient = ( + makeClient: (auth: TunnelAuth) => Client, +) => + Effect.gen(function* () { + // Account + credentials are ambient during stack-eval (the stack's + // providers layer). Capture the full context so cfd_tunnel HTTP ops can run + // with the current credentials — no `host.bind`, no minted token. + const { accountId } = yield* yield* CloudflareEnvironment; + const context = yield* Effect.context< + Credentials | HttpClient.HttpClient + >(); + + return Effect.fn(function* () { + const auth: TunnelAuth = { + authorize: (eff) => eff.pipe(Effect.provideContext(context)), + accountId: Effect.succeed(accountId), + }; + return makeClient(auth); + }); + }); diff --git a/packages/alchemy/src/Cloudflare/Tunnel/WriteTunnel.ts b/packages/alchemy/src/Cloudflare/Tunnel/WriteTunnel.ts index 38a894a96..397a52130 100644 --- a/packages/alchemy/src/Cloudflare/Tunnel/WriteTunnel.ts +++ b/packages/alchemy/src/Cloudflare/Tunnel/WriteTunnel.ts @@ -17,8 +17,7 @@ import * as Binding from "../../Binding.ts"; import type { Worker } from "../Workers/Worker.ts"; import type { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { RuntimeContext } from "../../RuntimeContext.ts"; -import { type Token } from "./TunnelBinding.ts"; -import { authorizeWith } from "../HttpClientUtils.ts"; +import { type TunnelAuth } from "./TunnelBinding.ts"; /** * Binding that lets a Worker create, update, and delete Cloudflare Tunnels at @@ -137,33 +136,33 @@ export interface WriteTunnelClient { >; } -/** Build the write client over a bound token. */ -export const writeClient = (token: Token): WriteTunnelClient => { - const authorize = authorizeWith(token); +/** Build the write client over an injectable {@link TunnelAuth}. */ +export const writeClient = (auth: TunnelAuth): WriteTunnelClient => { + const { authorize } = auth; return { create: Effect.fn("Cloudflare.Tunnel.create")(function* (request) { - const accountId = yield* token.accountId; + const accountId = yield* auth.accountId; return yield* authorize( zeroTrust.createTunnelCloudflared({ accountId, ...request }), ); }), update: Effect.fn("Cloudflare.Tunnel.update")( function* (tunnelId, request) { - const accountId = yield* token.accountId; + const accountId = yield* auth.accountId; return yield* authorize( zeroTrust.patchTunnelCloudflared({ accountId, tunnelId, ...request }), ); }, ), delete: Effect.fn("Cloudflare.Tunnel.delete")(function* (tunnelId) { - const accountId = yield* token.accountId; + const accountId = yield* auth.accountId; return yield* authorize( zeroTrust.deleteTunnelCloudflared({ accountId, tunnelId }), ); }), putConfiguration: Effect.fn("Cloudflare.Tunnel.putConfiguration")( function* (tunnelId, config) { - const accountId = yield* token.accountId; + const accountId = yield* auth.accountId; return yield* authorize( zeroTrust.putTunnelCloudflaredConfiguration({ accountId, diff --git a/packages/alchemy/src/Cloudflare/Tunnel/WriteTunnelLocal.ts b/packages/alchemy/src/Cloudflare/Tunnel/WriteTunnelLocal.ts new file mode 100644 index 000000000..f4e0ab84a --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Tunnel/WriteTunnelLocal.ts @@ -0,0 +1,33 @@ +import * as Layer from "effect/Layer"; +import { makeLocalTunnelClient } from "./TunnelLocalBinding.ts"; +import { WriteTunnel, writeClient } from "./WriteTunnel.ts"; + +/** + * Local implementation of the {@link WriteTunnel} binding — creates, updates, + * and deletes Cloudflare Tunnels over the cfd_tunnel HTTP API using the + * **current credentials** instead of a native Worker binding + * (`WriteTunnelBinding`) or a scoped API token. No `host.bind`, no minted + * token, no Worker host. + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can mutate + * tunnels with the same `create`/`update`/`delete`/`putConfiguration` client + * you'd use inside a Worker. + * + * @example Provision a tunnel from an Action + * ```typescript + * const Provision = Alchemy.Action( + * "Provision", + * Effect.gen(function* () { + * const tunnels = yield* Cloudflare.Tunnel.WriteTunnel(); + * return Effect.fn(function* () { + * const tunnel = yield* tunnels.create({ name: "on-demand-tunnel" }); + * return tunnel.result?.id; + * }); + * }).pipe(Effect.provide(Cloudflare.Tunnel.WriteTunnelLocal)), + * ); + * ``` + */ +export const WriteTunnelLocal = Layer.effect( + WriteTunnel, + makeLocalTunnelClient(writeClient), +); diff --git a/packages/alchemy/src/Cloudflare/Tunnel/index.ts b/packages/alchemy/src/Cloudflare/Tunnel/index.ts index 6df19891d..50045e31d 100644 --- a/packages/alchemy/src/Cloudflare/Tunnel/index.ts +++ b/packages/alchemy/src/Cloudflare/Tunnel/index.ts @@ -5,9 +5,12 @@ export * from "./Tunnel.ts"; export * from "./TunnelBinding.ts"; export * from "./ReadTunnel.ts"; export * from "./ReadTunnelBinding.ts"; +export * from "./ReadTunnelLocal.ts"; export * from "./ReadWriteTunnel.ts"; export * from "./ReadWriteTunnelBinding.ts"; +export * from "./ReadWriteTunnelLocal.ts"; export * from "./WriteTunnel.ts"; export * from "./WriteTunnelBinding.ts"; +export * from "./WriteTunnelLocal.ts"; export * from "./VirtualNetwork.ts"; export * from "./WarpConnector.ts"; diff --git a/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts b/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts new file mode 100644 index 000000000..783e0763b --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts @@ -0,0 +1,236 @@ +import type * as runtime from "@cloudflare/workers-types"; +import { + Credentials, + formatHeaders, +} from "@distilled.cloud/cloudflare/Credentials"; +import * as vectorize from "@distilled.cloud/cloudflare/vectorize"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import { type SearchIndexClient, SearchIndex } from "./SearchIndex.ts"; +import type { Index } from "./VectorizeIndex.ts"; + +/** + * Local implementation of the {@link SearchIndex} binding — talks to a + * Vectorize index over the Cloudflare HTTP API using the **current + * credentials** instead of a native Worker binding (`SearchIndexBinding`). + * + * Provide it on an {@link Action} (or any deploy-time Effect) so you can + * insert, upsert, query, and fetch vectors with the same client you'd use + * inside a Worker — no Worker host, no `host.bind`, no minted token: + * + * @example Seeding an index from an Action + * ```typescript + * const Seed = Alchemy.Action( + * "Seed", + * Effect.gen(function* () { + * const vec = yield* Cloudflare.Vectorize.SearchIndex(index); + * return Effect.fn(function* () { + * yield* vec.upsert([ + * { id: "1", values: [0.1, 0.2, 0.3, 0.4] }, + * { id: "2", values: [0.9, 0.8, 0.7, 0.6] }, + * ]); + * const matches = yield* vec.query([0.1, 0.2, 0.3, 0.4], { topK: 1 }); + * return matches; + * }); + * }).pipe(Effect.provide(Cloudflare.Vectorize.SearchIndexLocal)), + * ); + * ``` + * + * The index name is resolved at apply time through the ambient + * {@link RuntimeContext} (in an Action, that's the resolve context the engine + * provides around the body), so `SearchIndex(index)` works even though the + * index is created in the same deploy. + * + * Two methods have no Cloudflare HTTP equivalent and therefore + * `Effect.die` when called on the Local client: + * - `raw` — there is no HTTP-backed `runtime.Vectorize` object to hand back. + * - `queryById` — the HTTP query endpoint only accepts a raw vector, not an id. + */ +export const SearchIndexLocal = Layer.effect( + SearchIndex, + Effect.gen(function* () { + // Account + credentials are ambient during stack-eval (the stack's + // providers layer). Capture the full context so the HTTP ops run with the + // current credentials — no `host.bind`, no minted token. + const { accountId } = yield* yield* CloudflareEnvironment; + const context = yield* Effect.context< + Credentials | HttpClient.HttpClient + >(); + + return Effect.fn(function* (index: Index) { + // Deferred accessor — resolves the index name against the tracker at + // apply time (in an Action, that's the engine's resolve context). + const indexName = yield* index.indexName; + + // Run a distilled Vectorize op with the captured credentials, resolving + // the index name first. The captured credentials context is provided + // ONLY around the distilled op — never around the `indexName` accessor, + // which must resolve against the ambient apply-time RuntimeContext (same + // split as the D1 / KV Local variants). `orDie` mirrors the native + // binding, whose client methods surface transport failures as defects. + const local = ( + fn: ( + name: string, + ) => Effect.Effect, + ): Effect.Effect => + Effect.flatMap(indexName, (name) => + fn(name).pipe(Effect.provideContext(context)), + ).pipe(Effect.orDie); + + // insert/upsert take an ndjson vector payload. Cloudflare's v2 endpoint + // is `multipart/form-data` with a part literally named `vectors` (the + // distilled op models it as a generic `body` file part, which Cloudflare + // rejects), so build the request directly with the current credentials. + const uploadVectors = ( + op: "insert" | "upsert", + name: string, + vectors: runtime.VectorizeVector[], + ) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const creds = yield* yield* Credentials; + const form = new FormData(); + form.append( + "vectors", + new Blob([toNdjson(vectors)]), + "vectors.ndjson", + ); + const res = yield* client.execute( + HttpClientRequest.post( + `${creds.apiBaseUrl}/accounts/${accountId}/vectorize/v2/indexes/${name}/${op}`, + ).pipe( + HttpClientRequest.setHeaders(formatHeaders(creds)), + HttpClientRequest.bodyFormData(form), + ), + ); + const json = (yield* res.json) as { + // Cloudflare returns the mutation id camelCased on the wire. + result?: { mutationId?: string | null } | null; + success?: boolean; + errors?: unknown; + }; + if (res.status >= 400 || json.success === false) { + return yield* Effect.die( + new Error( + `SearchIndexLocal: ${op} failed (${res.status}): ${JSON.stringify( + json.errors ?? json, + )}`, + ), + ); + } + return { + mutationId: json.result?.mutationId ?? "", + } satisfies runtime.VectorizeAsyncMutation; + }); + + return { + raw: Effect.die( + new Error( + "SearchIndexLocal: `raw` is not available over the Vectorize HTTP API — use a native Worker binding (SearchIndexBinding) for direct access.", + ), + ), + describe: () => + local((name) => + vectorize + .infoIndex({ accountId, indexName: name }) + .pipe(Effect.map(toIndexInfo)), + ), + query: (vector, options) => + local((name) => + vectorize + .queryIndex({ + accountId, + indexName: name, + vector: Array.from(vector), + topK: options?.topK, + returnValues: options?.returnValues, + returnMetadata: toReturnMetadata(options?.returnMetadata), + filter: options?.filter, + }) + .pipe(Effect.map(toMatches)), + ), + queryById: () => + Effect.die( + new Error( + "SearchIndexLocal: `queryById` is not supported over the Vectorize HTTP API — it only accepts a raw query vector. Fetch the vector with `getByIds` and pass its values to `query`.", + ), + ), + insert: (vectors) => + local((name) => uploadVectors("insert", name, vectors)), + upsert: (vectors) => + local((name) => uploadVectors("upsert", name, vectors)), + deleteByIds: (ids) => + local((name) => + vectorize + .deleteByIdsIndex({ accountId, indexName: name, ids }) + .pipe(Effect.map(toMutation)), + ), + getByIds: (ids) => + local((name) => + vectorize + .getByIdsIndex({ accountId, indexName: name, ids }) + .pipe( + Effect.map( + (result) => (result ?? []) as runtime.VectorizeVector[], + ), + ), + ), + } satisfies SearchIndexClient; + }); + }), +); + +/** Serialize vectors to ndjson — one JSON vector per line. */ +const toNdjson = (vectors: runtime.VectorizeVector[]): string => + vectors + .map((v) => + JSON.stringify({ + id: v.id, + // A `VectorFloatArray` (Float32Array) would `JSON.stringify` to an + // object, not an array — normalize to a plain number array. + values: Array.from(v.values), + ...(v.namespace !== undefined ? { namespace: v.namespace } : {}), + ...(v.metadata !== undefined ? { metadata: v.metadata } : {}), + }), + ) + .join("\n"); + +const toReturnMetadata = ( + value: runtime.VectorizeQueryOptions["returnMetadata"], +): "none" | "indexed" | "all" | undefined => + value === undefined + ? undefined + : typeof value === "boolean" + ? value + ? "all" + : "none" + : value; + +const toMutation = (r: { + mutationId?: string | null; +}): runtime.VectorizeAsyncMutation => ({ mutationId: r.mutationId ?? "" }); + +const toIndexInfo = ( + r: vectorize.InfoIndexResponse, +): runtime.VectorizeIndexInfo => + ({ + vectorCount: r.vectorCount ?? 0, + dimensions: r.dimensions ?? 0, + processedUpToDatetime: r.processedUpToDatetime ?? undefined, + processedUpToMutation: r.processedUpToMutation ?? undefined, + }) as unknown as runtime.VectorizeIndexInfo; + +const toMatches = (r: vectorize.QueryIndexResponse): runtime.VectorizeMatches => + ({ + count: r.count ?? r.matches?.length ?? 0, + matches: (r.matches ?? []).map((m) => ({ + id: m.id ?? "", + score: m.score ?? 0, + ...(m.values != null ? { values: m.values } : {}), + ...(m.namespace != null ? { namespace: m.namespace } : {}), + ...(m.metadata != null ? { metadata: m.metadata } : {}), + })), + }) as unknown as runtime.VectorizeMatches; diff --git a/packages/alchemy/src/Cloudflare/Vectorize/index.ts b/packages/alchemy/src/Cloudflare/Vectorize/index.ts index 443ae572f..23a8999ba 100644 --- a/packages/alchemy/src/Cloudflare/Vectorize/index.ts +++ b/packages/alchemy/src/Cloudflare/Vectorize/index.ts @@ -1,4 +1,5 @@ export * from "./SearchIndex.ts"; export * from "./SearchIndexBinding.ts"; +export * from "./SearchIndexLocal.ts"; export * from "./VectorizeIndex.ts"; export * from "./VectorizeMetadataIndex.ts"; diff --git a/packages/alchemy/src/Cloudflare/Workers/BrowserLocal.ts b/packages/alchemy/src/Cloudflare/Workers/BrowserLocal.ts new file mode 100644 index 000000000..6d52af266 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Workers/BrowserLocal.ts @@ -0,0 +1,236 @@ +import * as browser from "@distilled.cloud/cloudflare/browser-rendering"; +import type * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Credentials } from "../Credentials.ts"; +import { + Browser, + type BrowserClient, + type BrowserContentResult, + BrowserError, + type BrowserJsonResult, + type BrowserLinksResult, + type BrowserMarkdownResult, + type BrowserScrapeResult, + type BrowserSnapshotResult, +} from "./Browser.ts"; +import type { BrowserBinding } from "./BrowserBinding.ts"; + +/** A byte stream produced by a binary {@link BrowserClient} action. */ +type BrowserByteStream = Stream.Stream< + Uint8Array, + BrowserError, + RuntimeContext +>; + +/** + * Local implementation of the {@link Browser} binding — drives Cloudflare + * Browser Rendering over its REST data-plane (`/accounts/{id}/browser-rendering/*`) + * using the **current credentials** instead of a native Worker binding + * (`BrowserBinding`). + * + * Provide it on an {@link Action} (or any deploy-time Effect) to run the JSON + * quick actions — `content`, `markdown`, `scrape`, `links`, `snapshot`, + * `json` — with the same client you'd use inside a Worker; no Worker host, no + * `host.bind`, no minted token: + * + * @example Convert a page to Markdown from an Action + * ```typescript + * const Scrape = Alchemy.Action( + * "Scrape", + * Effect.gen(function* () { + * const browser = yield* Cloudflare.Browser("BROWSER"); + * return Effect.fn(function* () { + * const { result } = yield* browser.markdown({ + * url: "https://example.com", + * }); + * return result; + * }); + * }).pipe(Effect.provide(Cloudflare.Workers.BrowserLocal)), + * ); + * ``` + * + * The following client members `Effect.die` (or fail the stream) because they + * have no Cloudflare REST equivalent — use a native {@link BrowserBinding} + * inside a deployed Worker for them: + * - `raw` / `fetch` — the raw `BrowserRun` transport used by + * `@cloudflare/puppeteer` is a Worker-runtime object, not an HTTP call. + * - `screenshot` / `pdf` — the binary endpoints stream image/PDF bytes, which + * the distilled REST codec models as JSON status, not a byte stream. + * + * Because the REST data-plane only returns the action `result` (not the + * runtime binding's `meta` envelope), `content`/`snapshot` populate `meta` + * with a best-effort placeholder. + */ +export const BrowserLocal = Layer.effect( + Browser, + Effect.gen(function* () { + // Account + credentials are ambient during stack-eval (the stack's + // providers layer). Capture the full context so the REST ops run with the + // current credentials — no `host.bind`, no minted token. + const { accountId } = yield* yield* CloudflareEnvironment; + const context = yield* Effect.context< + Credentials | HttpClient.HttpClient + >(); + + return Effect.fn(function* (_binding: BrowserBinding) { + // Browser Rendering is account-scoped; the binding carries no cloud + // resource id, so nothing to resolve — the client only needs the account + // + captured credentials. + return makeLocalBrowserClient({ accountId, context }); + }); + }), +); + +interface LocalContext { + accountId: string; + context: Context.Context; +} + +const makeLocalBrowserClient = (ctx: LocalContext): BrowserClient => { + // Run a distilled Browser Rendering op with the captured credentials and + // surface transport/API failures as {@link BrowserError} (matching the + // native binding's declared error channel). + const run = ( + eff: Effect.Effect, + ): Effect.Effect => + eff.pipe( + Effect.provideContext(ctx.context), + Effect.mapError( + (cause) => + new BrowserError({ + message: "Browser Rendering request failed", + cause, + }), + ), + ); + + // The cf option types (`url`/`html` + puppeteer options) are structurally the + // REST request body; add the account id and let distilled's encoder drop any + // fields it doesn't model. + const req = (options: unknown) => + ({ accountId: ctx.accountId, ...(options as object) }) as never; + + const content = (options: unknown) => + run(browser.createContent(req(options))).pipe( + Effect.map( + (result): BrowserContentResult => ({ + success: true, + result, + meta: { status: 200, title: "" }, + }), + ), + ); + + const markdown = (options: unknown) => + run(browser.createMarkdown(req(options))).pipe( + Effect.map( + (result): BrowserMarkdownResult => ({ success: true, result }), + ), + ); + + const links = (options: unknown) => + run(browser.createLink(req(options))).pipe( + Effect.map( + (result): BrowserLinksResult => ({ + success: true, + result: [...result], + }), + ), + ); + + const json = (options: unknown) => + run(browser.createJson(req(options))).pipe( + Effect.map((result): BrowserJsonResult => ({ success: true, result })), + ); + + const scrape = (options: unknown) => + run(browser.createScrape(req(options))).pipe( + Effect.map( + (result): BrowserScrapeResult => ({ + success: true, + result: result.map((item) => ({ + selector: item.selector, + // distilled models per-selector `results` as a single object; the + // native shape is an array of matched elements. + results: (Array.isArray(item.results) + ? item.results + : [ + item.results, + ]) as BrowserScrapeResult["result"][number]["results"], + })), + }), + ), + ); + + const snapshot = (options: unknown) => + run(browser.createSnapshot(req(options))).pipe( + Effect.map( + (result): BrowserSnapshotResult => ({ + success: true, + result: { + content: result.content ?? "", + screenshot: result.screenshot ?? "", + }, + meta: { status: 200, title: "" }, + }), + ), + ); + + // Binary actions have no REST byte-stream equivalent through the distilled + // codec — fail the stream with a defect explaining the native-binding path. + const binaryUnsupported = (action: string): BrowserByteStream => + Stream.fromEffect( + Effect.die( + new Error( + `BrowserLocal: '${action}' returns binary data and is only available inside a Worker via the native BrowserBinding; the Local client supports the JSON quick actions (content/markdown/scrape/links/snapshot/json).`, + ), + ), + ) as BrowserByteStream; + + const quickAction = ((action: string, options: unknown) => { + switch (action) { + case "content": + return content(options); + case "markdown": + return markdown(options); + case "links": + return links(options); + case "json": + return json(options); + case "scrape": + return scrape(options); + case "snapshot": + return snapshot(options); + default: + return binaryUnsupported(action); + } + }) as BrowserClient["quickAction"]; + + return { + raw: Effect.die( + new Error( + "BrowserLocal: `raw` (the native BrowserRun binding) is only available inside a deployed Worker — use BrowserBinding.", + ), + ), + fetch: () => + Effect.die( + new Error( + "BrowserLocal: `fetch` proxies the raw Browser Run transport used by @cloudflare/puppeteer and is only available inside a Worker via BrowserBinding.", + ), + ), + quickAction, + screenshot: () => binaryUnsupported("screenshot"), + pdf: () => binaryUnsupported("pdf"), + content, + scrape, + links, + snapshot, + json, + markdown, + } satisfies BrowserClient; +}; diff --git a/packages/alchemy/src/Cloudflare/Workers/index.ts b/packages/alchemy/src/Cloudflare/Workers/index.ts index edc115d6b..19f8cc2e8 100644 --- a/packages/alchemy/src/Cloudflare/Workers/index.ts +++ b/packages/alchemy/src/Cloudflare/Workers/index.ts @@ -2,6 +2,7 @@ export * from "./AccountSetting.ts"; export * from "./Assets.ts"; export * from "./Browser.ts"; export * from "./BrowserBinding.ts"; +export * from "./BrowserLocal.ts"; export * from "./Cache.ts"; export * from "./ConfigProvider.ts"; export * from "./CronEventSource.ts"; diff --git a/packages/alchemy/test/Cloudflare/AI/QuerySearchLocal.test.ts b/packages/alchemy/test/Cloudflare/AI/QuerySearchLocal.test.ts new file mode 100644 index 000000000..1fe5e2152 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/AI/QuerySearchLocal.test.ts @@ -0,0 +1,106 @@ +import { Action } from "@/Action"; +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import AiSearchCrawlTargetWorker from "./fixtures/crawl-target-worker.ts"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Exercise `QuerySearchLocal` — the current-credentials HTTP implementation of +// the `QuerySearch` binding — from inside an Action. A web-crawler instance +// needs no service token (unlike an R2 source), so teardown stays fast. The +// Action reads instance metadata (`info`) and indexing stats (`stats`), both +// available immediately after create (no waiting on async indexing), proving +// the client talks to the live AI Search REST API with the current creds. +test.provider( + "QuerySearchLocal: read instance info/stats from an Action", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // Warm the crawl target first — AI Search fetches the seed synchronously + // when it validates a web-crawler at create time. + const warmed = yield* stack.deploy( + Effect.gen(function* () { + return yield* AiSearchCrawlTargetWorker; + }), + ); + const client = yield* HttpClient.HttpClient; + yield* client.get(warmed.url as string).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : Effect.fail(new Error(`crawl target not ready: ${res.status}`)), + ), + Effect.retry({ schedule: Schedule.spaced("3 seconds"), times: 20 }), + ); + + const out = yield* stack.deploy( + Effect.gen(function* () { + const target = yield* AiSearchCrawlTargetWorker; + const instance = yield* Cloudflare.AI.SearchInstance("Search", { + type: "web-crawler", + source: target.url.as(), + sourceParams: { + webCrawler: { + parseType: "crawl", + crawlOptions: { source: "links" }, + }, + }, + }); + + const Probe = Action( + "Probe", + Effect.gen(function* () { + const search = yield* Cloudflare.AI.QuerySearch(instance); + const instanceId = yield* instance.instanceId; + const namespace = yield* instance.namespace; + + return Effect.fn(function* () { + const info = yield* search.info().pipe( + Effect.retry({ + schedule: Schedule.exponential("500 millis"), + times: 6, + }), + ); + const stats = yield* search.stats().pipe( + Effect.retry({ + schedule: Schedule.exponential("500 millis"), + times: 6, + }), + ); + return { + instanceId: yield* instanceId, + namespace: yield* namespace, + info, + stats, + }; + }); + }).pipe(Effect.provide(Cloudflare.AI.QuerySearchLocal)), + ); + + return yield* Probe({}); + }), + ); + + expect(out.instanceId).toBeTruthy(); + // `info()` round-trips the AI Search REST read through the current creds. + expect(out.info.id).toEqual(out.instanceId); + expect(out.info.type).toEqual("web-crawler"); + expect(out.info.namespace).toEqual(out.namespace); + // `stats()` returns the indexing status counts object. + expect(out.stats).toBeTypeOf("object"); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 300_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/AI/QuerySearchNamespaceLocal.test.ts b/packages/alchemy/test/Cloudflare/AI/QuerySearchNamespaceLocal.test.ts new file mode 100644 index 000000000..7728a4bec --- /dev/null +++ b/packages/alchemy/test/Cloudflare/AI/QuerySearchNamespaceLocal.test.ts @@ -0,0 +1,112 @@ +import { Action } from "@/Action"; +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import AiSearchCrawlTargetWorker from "./fixtures/crawl-target-worker.ts"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Exercise `QuerySearchNamespaceLocal` — the current-credentials HTTP +// implementation of the `QuerySearchNamespace` binding — from inside an Action. +// A custom namespace holds a web-crawler instance (no service token, fast +// teardown). The Action lists the namespace's instances and reads one back via +// `.get(instanceId).info()`, proving the namespace client talks to the live AI +// Search REST API with the current creds. +test.provider( + "QuerySearchNamespaceLocal: list + get an instance from an Action", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // Warm the crawl target first — AI Search validates a web-crawler seed + // synchronously at create time. + const warmed = yield* stack.deploy( + Effect.gen(function* () { + return yield* AiSearchCrawlTargetWorker; + }), + ); + const client = yield* HttpClient.HttpClient; + yield* client.get(warmed.url as string).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : Effect.fail(new Error(`crawl target not ready: ${res.status}`)), + ), + Effect.retry({ schedule: Schedule.spaced("3 seconds"), times: 20 }), + ); + + const out = yield* stack.deploy( + Effect.gen(function* () { + const target = yield* AiSearchCrawlTargetWorker; + const namespace = yield* Cloudflare.AI.SearchNamespace( + "SearchNs", + {}, + ); + const instance = yield* Cloudflare.AI.SearchInstance("Search", { + type: "web-crawler", + source: target.url.as(), + namespace: namespace.name, + sourceParams: { + webCrawler: { + parseType: "crawl", + crawlOptions: { source: "links" }, + }, + }, + }); + + const Probe = Action( + "Probe", + Effect.gen(function* () { + const ns = yield* Cloudflare.AI.QuerySearchNamespace(namespace); + const instanceId = yield* instance.instanceId; + + return Effect.fn(function* () { + const id = yield* instanceId; + const list = yield* ns.list().pipe( + Effect.retry({ + schedule: Schedule.exponential("500 millis"), + times: 6, + }), + ); + const info = yield* ns + .get(id) + .info() + .pipe( + Effect.retry({ + schedule: Schedule.exponential("500 millis"), + times: 6, + }), + ); + return { + instanceId: id, + ids: list.result.map((i) => i.id), + info, + }; + }); + }).pipe(Effect.provide(Cloudflare.AI.QuerySearchNamespaceLocal)), + ); + + return yield* Probe({}); + }), + ); + + expect(out.instanceId).toBeTruthy(); + // `list()` enumerates the namespace's instances via the current creds. + expect(out.ids).toContain(out.instanceId); + // `.get(id).info()` scopes a single-instance read to this namespace. + expect(out.info.id).toEqual(out.instanceId); + expect(out.info.type).toEqual("web-crawler"); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 300_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/Dns/DnsLocal.test.ts b/packages/alchemy/test/Cloudflare/Dns/DnsLocal.test.ts new file mode 100644 index 000000000..31f3f87e8 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Dns/DnsLocal.test.ts @@ -0,0 +1,151 @@ +import { Action } from "@/Action"; +import { adopt } from "@/AdoptPolicy"; +import * as Cloudflare from "@/Cloudflare"; +import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; +import { findZoneByName } from "@/Cloudflare/Zone/lookup"; +import * as Test from "@/Test/Vitest"; +import * as dns from "@distilled.cloud/cloudflare/dns"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Stream from "effect/Stream"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const zoneName = + process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us"; + +// Deterministic record name — reused on every run (never derive from +// Date.now()/random), owns its own subdomain so it never collides with the +// managed-Record tests. +const RECORD_NAME = `alchemy-dnslocal.${zoneName}`; +const RECORD_TYPE = "TXT"; +const RECORD_CONTENT = '"alchemy-dnslocal-seed"'; + +const resolveZoneId = Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + const zone = yield* findZoneByName({ accountId, name: zoneName }); + if (!zone) { + return yield* Effect.die( + new Error(`zone "${zoneName}" not found in account`), + ); + } + return zone.id; +}); + +// Delete every record matching (name, type) — used to clear leftovers from +// interrupted runs and to guarantee cleanup on finish. +const purgeRecords = (zoneId: string) => + dns.listRecords + .items({ + zoneId, + name: { exact: RECORD_NAME }, + type: RECORD_TYPE, + }) + .pipe( + Stream.filter((r) => r.name === RECORD_NAME && r.type === RECORD_TYPE), + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + Effect.flatMap( + Effect.forEach((r) => + dns + .deleteRecord({ zoneId, dnsRecordId: r.id }) + .pipe(Effect.catch(() => Effect.void)), + ), + ), + ); + +const findRecord = (zoneId: string) => + dns.listRecords + .items({ zoneId, name: { exact: RECORD_NAME }, type: RECORD_TYPE }) + .pipe( + Stream.filter((r) => r.name === RECORD_NAME && r.type === RECORD_TYPE), + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)[0]), + ); + +// Binding a DNS zone inside an Action via `ReadWriteDnsLocal` — the local +// (current-credentials) implementation of the `ReadWriteDns` binding. Exercises +// the write (create), read (get), list, and delete client surface plus the +// deferred zone-id accessor (`yield* zone.zoneId` resolved at apply time). +test.provider( + "ReadWriteDnsLocal: create, read, list and delete a TXT record from an Action", + (stack) => + Effect.gen(function* () { + const zoneId = yield* resolveZoneId; + + yield* stack.destroy(); + // Clear leftovers from interrupted runs (Cloudflare allows duplicate + // records for the same name/type). + yield* purgeRecords(zoneId); + + const out = yield* stack + .deploy( + Effect.gen(function* () { + // Adopt the standing test zone. Zones default to `retain` on + // removal, so `stack.destroy()` never deletes it. + const zone = yield* Cloudflare.Zone.Zone("DnsLocalZone", { + name: zoneName, + }).pipe(adopt(true)); + + const Seed = Action( + "Seed", + Effect.gen(function* () { + const dnsClient = yield* Cloudflare.DNS.ReadWriteDns(zone); + // Accessor — resolved at apply time against the tracker. + const zoneIdAccessor = yield* zone.zoneId; + + return Effect.fn(function* () { + const created = yield* dnsClient.createDnsRecord({ + type: RECORD_TYPE, + name: RECORD_NAME, + content: RECORD_CONTENT, + ttl: 60, + }); + + const record = yield* dnsClient.getDnsRecord(created.id); + + const listed = yield* dnsClient.listDnsRecords({ + type: RECORD_TYPE, + name: { exact: RECORD_NAME }, + }); + + yield* dnsClient.deleteDnsRecord(created.id); + + return { + zoneId: yield* zoneIdAccessor, + recordId: created.id, + content: record.content, + listedContent: listed.result.find( + (r) => r.id === created.id, + )?.content, + listedCount: listed.result.length, + }; + }); + }).pipe(Effect.provide(Cloudflare.DNS.ReadWriteDnsLocal)), + ); + + return yield* Seed({}); + }), + ) + .pipe(Effect.ensuring(purgeRecords(zoneId).pipe(Effect.ignore))); + + expect(out.zoneId).toEqual(zoneId); + expect(out.recordId).toBeTruthy(); + expect(out.content).toEqual(RECORD_CONTENT); + expect(out.listedContent).toEqual(RECORD_CONTENT); + expect(out.listedCount).toBeGreaterThan(0); + + // The Action deleted the record — it is gone out-of-band. + const gone = yield* findRecord(zoneId); + expect(gone).toBeUndefined(); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/Flagship/ReadFlagsLocal.test.ts b/packages/alchemy/test/Cloudflare/Flagship/ReadFlagsLocal.test.ts new file mode 100644 index 000000000..b57faf4d9 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Flagship/ReadFlagsLocal.test.ts @@ -0,0 +1,87 @@ +import { Action } from "@/Action"; +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +import { poll } from "@/Util/poll.ts"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Reading Flagship feature flags inside an Action via `ReadFlagsLocal` — the +// local (current-credentials) implementation of the `ReadFlags` binding. It +// evaluates flags over the HTTP `evaluate` endpoint instead of the native +// Worker binding. Exercises the value + details client methods and the accessor +// mechanism (`yield* app.appId` resolved at apply time). +test.provider( + "ReadFlagsLocal: evaluate a flag from an Action", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const key = "alchemy-test-flag-read-local"; + + const out = yield* stack.deploy( + Effect.gen(function* () { + const app = yield* Cloudflare.Flagship.App("ReadFlagsLocalApp", { + name: "alchemy-test-flagship-read-local", + }); + // defaultVariation "on" -> with no rules the flag always evaluates + // to `true`, which is distinct from the client-side default `false`, + // so a successful read is observable. + yield* Cloudflare.Flagship.Flag("ReadFlagsLocalFlag", { + appId: app.appId, + key, + defaultVariation: "on", + variations: { off: false, on: true }, + }); + + const Read = Action( + "Read", + Effect.gen(function* () { + const flags = yield* Cloudflare.Flagship.ReadFlags(app); + + return Effect.fn(function* () { + // The edge `evaluate` endpoint can lag a freshly-created flag; + // poll until it returns the flag's true value (bounded). + const enabled = yield* poll({ + description: "evaluate returns the flag value", + effect: flags.getBooleanValue(key, false), + predicate: (v) => v === true, + schedule: Schedule.max([ + Schedule.spaced("3 seconds"), + Schedule.recurs(20), + ]), + }); + + const details = yield* flags.getBooleanDetails(key, false); + // A boolean flag read as a string falls back to the default. + const asString = yield* flags.getStringValue(key, "fallback"); + const untyped = yield* flags.get(key); + + return { enabled, details, asString, untyped }; + }); + }).pipe(Effect.provide(Cloudflare.Flagship.ReadFlagsLocal)), + ); + + return yield* Read({}); + }), + ); + + expect(out.enabled).toBe(true); + expect(out.details.flagKey).toBe(key); + expect(out.details.value).toBe(true); + expect(out.details.variant).toBe("on"); + expect(out.asString).toBe("fallback"); + expect(out.untyped).toBe(true); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/Tunnel/ReadWriteTunnelLocal.test.ts b/packages/alchemy/test/Cloudflare/Tunnel/ReadWriteTunnelLocal.test.ts new file mode 100644 index 000000000..4ebd47b4d --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Tunnel/ReadWriteTunnelLocal.test.ts @@ -0,0 +1,87 @@ +import { Action } from "@/Action"; +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Binding a Cloudflare Tunnel inside an Action via `ReadWriteTunnelLocal` — the +// local (current-credentials) implementation of the `ReadWriteTunnel` binding. +// Exercises the write side (`putConfiguration`) and the read side +// (`getConfiguration`/`get`/`getToken`) against a real tunnel, plus the deferred +// `yield* tunnel.tunnelId` accessor resolved at apply time. +test.provider( + "ReadWriteTunnelLocal: configure and read a tunnel from an Action", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const out = yield* stack.deploy( + Effect.gen(function* () { + const tunnel = yield* Cloudflare.Tunnel.Tunnel("LocalBindingTunnel"); + + const Manage = Action( + "Manage", + Effect.gen(function* () { + const tunnels = yield* Cloudflare.Tunnel.ReadWriteTunnel(); + // Accessor — resolved at apply time against the tracker. + const tunnelId = yield* tunnel.tunnelId; + + return Effect.fn(function* () { + const id = yield* tunnelId; + + // Write: push ingress configuration. + yield* tunnels.putConfiguration(id, { + ingress: [ + { + hostname: "local-binding.alchemy-test-2.us", + service: "http://localhost:3000", + }, + { service: "http_status:404" }, + ], + }); + + // Read back the configuration we just wrote. + const cfg = yield* tunnels.getConfiguration(id); + // Read the tunnel + its connector token. + const t = yield* tunnels.get(id); + const token = yield* tunnels.getToken(id); + + return { + tunnelId: id, + name: t.id === id ? t.name : undefined, + ingress: (cfg.config?.ingress ?? []).map((rule) => ({ + hostname: rule.hostname ?? undefined, + service: rule.service, + })), + hasToken: typeof token === "string" && token.length > 0, + }; + }); + }).pipe(Effect.provide(Cloudflare.Tunnel.ReadWriteTunnelLocal)), + ); + + return yield* Manage({}); + }), + ); + + expect(out.tunnelId).toBeTruthy(); + expect(out.hasToken).toBe(true); + expect(out.ingress).toEqual([ + { + hostname: "local-binding.alchemy-test-2.us", + service: "http://localhost:3000", + }, + { hostname: undefined, service: "http_status:404" }, + ]); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/Vectorize/SearchIndexLocal.test.ts b/packages/alchemy/test/Cloudflare/Vectorize/SearchIndexLocal.test.ts new file mode 100644 index 000000000..43bf93830 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Vectorize/SearchIndexLocal.test.ts @@ -0,0 +1,102 @@ +import { Action } from "@/Action"; +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import type * as runtime from "@cloudflare/workers-types"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Vectorize requires dimensions in [32, 1536]; use the minimum. Two +// deterministic, well-separated 32-d vectors so the nearest neighbor of the +// query vector (v1) is unambiguously "1". +const DIMENSIONS = 32; +const v1 = Array.from({ length: DIMENSIONS }, (_, i) => (i + 1) / 100); +const v2 = Array.from({ length: DIMENSIONS }, (_, i) => 1 - (i + 1) / 100); + +// Binding a Vectorize index inside an Action via `SearchIndexLocal` — the local +// (current-credentials) implementation of the `SearchIndex` binding. Exercises +// the binding client (upsert/query/getByIds) over the Vectorize HTTP API and +// the accessor mechanism (`yield* index.indexName` resolved at apply time). +// +// Vectorize mutations are async / eventually consistent, so after the upsert we +// poll `query` on a bounded schedule until the seeded vector is returnable. +test.provider( + "SearchIndexLocal: seed and query an index from an Action", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const out = yield* stack.deploy( + Effect.gen(function* () { + const index = yield* Cloudflare.Vectorize.Index("SeedIndex", { + dimensions: DIMENSIONS, + metric: "cosine", + }); + + const Seed = Action( + "Seed", + Effect.gen(function* () { + const vec = yield* Cloudflare.Vectorize.SearchIndex(index); + // Accessor — resolved at apply time against the tracker. + const indexName = yield* index.indexName; + + return Effect.fn(function* () { + const vectors: runtime.VectorizeVector[] = [ + { id: "1", values: v1 }, + { id: "2", values: v2 }, + ]; + + const mutation = yield* vec.upsert(vectors); + + // Vectorize processes mutations asynchronously. Poll the query + // until the seeded vector is returnable (bounded — fail fast if + // provisioning is slower than the schedule allows). + const matches = yield* vec + .query(v1, { + topK: 2, + returnValues: true, + }) + .pipe( + Effect.repeat({ + schedule: Schedule.spaced("3 seconds"), + until: (m) => m.matches.some((x) => x.id === "1"), + times: 20, + }), + ); + + const fetched = yield* vec.getByIds(["1"]); + + return { + indexName: yield* indexName, + mutationId: mutation.mutationId, + topId: matches.matches[0]?.id, + matchIds: matches.matches.map((m) => m.id), + fetchedIds: fetched.map((v) => v.id), + }; + }); + }).pipe(Effect.provide(Cloudflare.Vectorize.SearchIndexLocal)), + ); + + return yield* Seed({}); + }), + ); + + expect(out.indexName).toBeTruthy(); + expect(out.mutationId).toBeTruthy(); + // Nearest neighbor of the first vector is itself. + expect(out.topId).toBe("1"); + expect(out.matchIds).toContain("1"); + expect(out.fetchedIds).toContain("1"); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/Workers/BrowserLocal.test.ts b/packages/alchemy/test/Cloudflare/Workers/BrowserLocal.test.ts new file mode 100644 index 000000000..f5d8d6d83 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/BrowserLocal.test.ts @@ -0,0 +1,55 @@ +import { Action } from "@/Action"; +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Driving Cloudflare Browser Rendering inside an Action via `BrowserLocal` — +// the local (current-credentials) implementation of the `Browser` binding. +// Browser Rendering is account-scoped and has no backing cloud resource, so the +// Action runs the REST quick actions directly against a stable page. +test.provider( + "BrowserLocal: render a page to markdown and content from an Action", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const out = yield* stack.deploy( + Effect.gen(function* () { + const Render = Action( + "Render", + Effect.gen(function* () { + const browser = yield* Cloudflare.Browser("BROWSER"); + + return Effect.fn(function* () { + const md = yield* browser.markdown({ + url: "https://example.com", + }); + const html = yield* browser.content({ + url: "https://example.com", + }); + + return { markdown: md.result, content: html.result }; + }); + }).pipe(Effect.provide(Cloudflare.Workers.BrowserLocal)), + ); + + return yield* Render({}); + }), + ); + + expect(out.markdown.toLowerCase()).toContain("example"); + expect(out.content.toLowerCase()).toContain("example"); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); From c41cc63c3e955395a42e18bf18f853b61503aaff Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Sat, 11 Jul 2026 01:19:03 -0700 Subject: [PATCH 04/13] refactor(cloudflare/vectorize): use distilled insert/upsert, drop FormData shim Now that distilled models the vectorize insert/upsert multipart part as `vectors`, SearchIndexLocal calls `vectorize.insertIndex`/`upsertIndex` directly instead of hand-building a FormData request. Bumps the distilled submodule to the regenerated service. Co-Authored-By: Claude Opus 4.8 --- distilled | 2 +- .../Cloudflare/Vectorize/SearchIndexLocal.ts | 81 +++++++------------ 2 files changed, 28 insertions(+), 55 deletions(-) diff --git a/distilled b/distilled index 594e91be8..b4e44fb70 160000 --- a/distilled +++ b/distilled @@ -1 +1 @@ -Subproject commit 594e91be86e2b9a2d18a33bc5d8c9452a2c13e85 +Subproject commit b4e44fb70042f1ce8d7c35bfe63ccb07c722c58a diff --git a/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts b/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts index 783e0763b..43092b4e8 100644 --- a/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts +++ b/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts @@ -1,13 +1,9 @@ import type * as runtime from "@cloudflare/workers-types"; -import { - Credentials, - formatHeaders, -} from "@distilled.cloud/cloudflare/Credentials"; +import type { Credentials } from "@distilled.cloud/cloudflare/Credentials"; import * as vectorize from "@distilled.cloud/cloudflare/vectorize"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import { type SearchIndexClient, SearchIndex } from "./SearchIndex.ts"; import type { Index } from "./VectorizeIndex.ts"; @@ -80,52 +76,6 @@ export const SearchIndexLocal = Layer.effect( fn(name).pipe(Effect.provideContext(context)), ).pipe(Effect.orDie); - // insert/upsert take an ndjson vector payload. Cloudflare's v2 endpoint - // is `multipart/form-data` with a part literally named `vectors` (the - // distilled op models it as a generic `body` file part, which Cloudflare - // rejects), so build the request directly with the current credentials. - const uploadVectors = ( - op: "insert" | "upsert", - name: string, - vectors: runtime.VectorizeVector[], - ) => - Effect.gen(function* () { - const client = yield* HttpClient.HttpClient; - const creds = yield* yield* Credentials; - const form = new FormData(); - form.append( - "vectors", - new Blob([toNdjson(vectors)]), - "vectors.ndjson", - ); - const res = yield* client.execute( - HttpClientRequest.post( - `${creds.apiBaseUrl}/accounts/${accountId}/vectorize/v2/indexes/${name}/${op}`, - ).pipe( - HttpClientRequest.setHeaders(formatHeaders(creds)), - HttpClientRequest.bodyFormData(form), - ), - ); - const json = (yield* res.json) as { - // Cloudflare returns the mutation id camelCased on the wire. - result?: { mutationId?: string | null } | null; - success?: boolean; - errors?: unknown; - }; - if (res.status >= 400 || json.success === false) { - return yield* Effect.die( - new Error( - `SearchIndexLocal: ${op} failed (${res.status}): ${JSON.stringify( - json.errors ?? json, - )}`, - ), - ); - } - return { - mutationId: json.result?.mutationId ?? "", - } satisfies runtime.VectorizeAsyncMutation; - }); - return { raw: Effect.die( new Error( @@ -159,9 +109,25 @@ export const SearchIndexLocal = Layer.effect( ), ), insert: (vectors) => - local((name) => uploadVectors("insert", name, vectors)), + local((name) => + vectorize + .insertIndex({ + accountId, + indexName: name, + vectors: toNdjsonBlob(vectors), + }) + .pipe(Effect.map(toMutation)), + ), upsert: (vectors) => - local((name) => uploadVectors("upsert", name, vectors)), + local((name) => + vectorize + .upsertIndex({ + accountId, + indexName: name, + vectors: toNdjsonBlob(vectors), + }) + .pipe(Effect.map(toMutation)), + ), deleteByIds: (ids) => local((name) => vectorize @@ -183,6 +149,13 @@ export const SearchIndexLocal = Layer.effect( }), ); +/** + * Serialize vectors to an ndjson Blob for the `vectors` multipart part of the + * Vectorize v2 insert/upsert endpoints. + */ +const toNdjsonBlob = (vectors: runtime.VectorizeVector[]): Blob => + new Blob([toNdjson(vectors)]); + /** Serialize vectors to ndjson — one JSON vector per line. */ const toNdjson = (vectors: runtime.VectorizeVector[]): string => vectors From 031d538954c3278ae53572fc756af2b3d7fba8e1 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Sat, 11 Jul 2026 01:23:30 -0700 Subject: [PATCH 05/13] docs(action): document Local bindings, Output accessors, and value-form .make - Add "Binding resources" section: bind a resource inside an Action via its *Local layer (current-credentials HTTP), with the D1 seed example. - Add "Reading a resource's Outputs": `yield* resource.attr` accessors resolved at apply time + the dependency edge they create. - Fix the tagged form example: use the value form (interface + const), not the non-constructable `class extends` shape. Co-Authored-By: Claude Opus 4.8 --- .../docs/infrastructure-as-code/action.mdx | 80 +++++++++++++++++-- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/website/src/content/docs/infrastructure-as-code/action.mdx b/website/src/content/docs/infrastructure-as-code/action.mdx index 75cd4e35c..3618aa037 100644 --- a/website/src/content/docs/infrastructure-as-code/action.mdx +++ b/website/src/content/docs/infrastructure-as-code/action.mdx @@ -71,12 +71,13 @@ const hourly = yield* Sync("hourly", { table: eventsBucket.name }); ### Tagged form (service + layer) When you want to split the contract from the implementation — e.g. for -testing or to keep stack code declarative — use the class form. It -mirrors the class form used by Workers and Lambdas -([Functions & Servers](/infrastructure-as-effects/functions-and-servers)): +testing or to keep stack code declarative — declare the type with an +`interface`, build the value with the no-argument overload, then supply +the runner separately with `.make`: ```typescript -export class Sync extends Action()("Sync") {} +export interface Sync extends Action<"Sync", { table: string }, { rows: number }> {} +export const Sync = Action()("Sync"); export const SyncLive = Sync.make( Effect.gen(function* () { @@ -89,10 +90,75 @@ export const SyncLive = Sync.make( // In a stack: const rows = yield* Sync({ table: bucket.name }); -// ^ requires `Sync` — add `SyncLive` to the stack's providers. +// ^ requires `Sync` — add `SyncLive` to the stack's providers, +// or provide it locally with `Effect.provide(SyncLive)`. ``` -`.make(...)` accepts either a direct runner or an init Effect. +`.make(...)` accepts either a direct runner or an init Effect, and the +init runs under the same context as the inline form — so the resource +bindings and Output accessors below work here too. + +## Binding resources + +An Action's body often needs to *talk to* the resources in your stack — +seed a database, warm a cache, enqueue a job. Bindings like +[`Cloudflare.D1.QueryDatabase`](/cloudflare/data/d1) normally resolve +against a deployed Worker's runtime environment, which an Action doesn't +have. Provide the binding's **`*Local`** layer instead: it talks to the +service over the provider's HTTP API using your current CLI credentials. + +```typescript +const Seed = Action( + "Seed", + Effect.gen(function* () { + const db = yield* Cloudflare.D1.QueryDatabase(database); + return Effect.fn(function* () { + yield* db.exec("CREATE TABLE IF NOT EXISTS users (id TEXT, name TEXT)"); + yield* db + .prepare("INSERT INTO users (id, name) VALUES (?, ?)") + .bind("1", "Ada") + .run(); + }); + }).pipe(Effect.provide(Cloudflare.D1.QueryDatabaseLocal)), +); +``` + +`*Local` is a third binding variant alongside the native Worker binding +(`*Binding`) and the scoped-token HTTP client (`*Http`). It registers no +binding on a host and mints no token — it reuses the credentials Alchemy +is already deploying with. The runtime client is identical, so the same +`db.prepare(...).run()` code works whether it runs inside a deployed +Worker or a deploy-time Action. + +Local layers exist for every Cloudflare capability with an HTTP data +plane — D1, KV, R2, Queues, DNS, Vectorize, Tunnel, AI Search, Flagship, +and Browser Rendering. Worker-runtime-only bindings (Rate Limiting, +Version Metadata, service bindings, …) have no Local variant. + +### Reading a resource's Outputs + +Inside an Action you can `yield*` a resource +[Output](/infrastructure-as-code/outputs) to get an accessor that +resolves at apply time — after the resource exists: + +```typescript +const Seed = Action( + "Seed", + Effect.gen(function* () { + const databaseId = yield* database.databaseId; + // ^ deferred accessor — not the value yet + return Effect.fn(function* () { + const id = yield* databaseId; // resolved during apply + yield* Effect.log(`seeding ${id}`); + }); + }), +); +``` + +Capturing an Output this way also makes the Action **depend** on that +resource, so it runs after the resource is created — the same edge you'd +get from passing `database.databaseId` as input. This works in both the +inline and tagged `.make` forms. ## Lifecycle @@ -126,6 +192,8 @@ alchemy deploy --force Actions live in the same FQN namespace as Resources. They can: - Take Resource outputs as input (`{ table: bucket.name }`) +- Capture a Resource Output in the init (`yield* bucket.name`) — see + [Reading a resource's Outputs](#reading-a-resources-outputs) - Be referenced by Resources via `action.output` (downstream resource waits for the action before reconciling) - Reference other Actions From ca38ed4b45145cf528eaa1c95d5390613006ad41 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 15 Jul 2026 12:59:17 -0700 Subject: [PATCH 06/13] fix(cloudflare/d1): normalize native D1 bind values in QueryDatabaseLocal Distilled now models D1 query/raw `params` as `unknown[]`, so the Local shim forwards native bind values (number/null/string) instead of failing schema encoding. Mirror the native binding's normalization the raw HTTP API skips: booleans -> 1/0, ArrayBuffer/views -> byte arrays (BLOB). Adds single + batch coverage for numeric/null/boolean binds. Bumps the distilled submodule to the params fix. Co-Authored-By: Claude Opus 4.8 --- distilled | 2 +- .../src/Cloudflare/D1/QueryDatabaseLocal.ts | 24 +++++- .../Cloudflare/D1/QueryDatabaseLocal.test.ts | 80 +++++++++++++++++++ 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/distilled b/distilled index b4e44fb70..77edf5148 160000 --- a/distilled +++ b/distilled @@ -1 +1 @@ -Subproject commit b4e44fb70042f1ce8d7c35bfe63ccb07c722c58a +Subproject commit 77edf51483239b59ffd5d89467ac4594921b6b46 diff --git a/packages/alchemy/src/Cloudflare/D1/QueryDatabaseLocal.ts b/packages/alchemy/src/Cloudflare/D1/QueryDatabaseLocal.ts index af8c89fcf..6fffd295a 100644 --- a/packages/alchemy/src/Cloudflare/D1/QueryDatabaseLocal.ts +++ b/packages/alchemy/src/Cloudflare/D1/QueryDatabaseLocal.ts @@ -118,6 +118,26 @@ const toResult = ( meta: (r?.meta ?? {}) as any, }) as runtime.D1Result; +/** + * Normalize a bound value the way the native D1 binding does before it reaches + * SQLite. Over the raw HTTP query API, unlike the Worker binding, values are + * bound verbatim — a JS `true` would arrive as the string `"true"`. Match the + * native semantics: booleans become integers (1/0) and binary becomes a byte + * array (BLOB). `null`, numbers, and strings pass through unchanged. + */ +const normalizeBind = (value: unknown): unknown => { + if (typeof value === "boolean") return value ? 1 : 0; + if (value instanceof ArrayBuffer) { + return Array.from(new Uint8Array(value)); + } + if (ArrayBuffer.isView(value)) { + return Array.from( + new Uint8Array(value.buffer, value.byteOffset, value.byteLength), + ); + } + return value; +}; + const makeHttpD1Database = (ctx: QueryContext): runtime.D1Database => { const makeStatement = ( query: string, @@ -126,7 +146,7 @@ const makeHttpD1Database = (ctx: QueryContext): runtime.D1Database => { const exec = async () => { const res = await runQuery(ctx, { sql: query, - params: binds.length ? [...binds] : undefined, + params: binds.length ? binds.map(normalizeBind) : undefined, }); return res.result[0]; }; @@ -175,7 +195,7 @@ const makeHttpD1Database = (ctx: QueryContext): runtime.D1Database => { batch: statements.map((s) => ({ sql: (s as any).__query as string, params: ((s as any).__params as unknown[]).length - ? ((s as any).__params as unknown[]) + ? ((s as any).__params as unknown[]).map(normalizeBind) : undefined, })), }); diff --git a/packages/alchemy/test/Cloudflare/D1/QueryDatabaseLocal.test.ts b/packages/alchemy/test/Cloudflare/D1/QueryDatabaseLocal.test.ts index 82c25b047..56a7977ff 100644 --- a/packages/alchemy/test/Cloudflare/D1/QueryDatabaseLocal.test.ts +++ b/packages/alchemy/test/Cloudflare/D1/QueryDatabaseLocal.test.ts @@ -85,3 +85,83 @@ test.provider( }).pipe(logLevel), { timeout: 120_000 }, ); + +// Native D1 bind values (number/null/boolean/binary) must round-trip, not just +// strings: the HTTP API binds verbatim, so the Local shim mirrors native D1 +// (booleans -> 1/0, binary -> BLOB) and Distilled models params as unknown[]. +test.provider( + "QueryDatabaseLocal: binds non-string parameters (single + batch)", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const out = yield* stack.deploy( + Effect.gen(function* () { + const database = yield* Cloudflare.D1.Database("TypedBindDatabase"); + + const Query = Action( + "QueryTypedBind", + Effect.gen(function* () { + const db = yield* Cloudflare.D1.QueryDatabase(database); + return Effect.fn(function* () { + // Single statement: number, null, boolean (-> 1/0) via SELECT ?. + const num = yield* db + .prepare("SELECT ? AS value") + .bind(42) + .first("value"); + const nul = yield* db + .prepare("SELECT ? AS value") + .bind(null) + .first("value"); + const boolTrue = yield* db + .prepare("SELECT ? AS value") + .bind(true) + .first("value"); + const boolFalse = yield* db + .prepare("SELECT ? AS value") + .bind(false) + .first("value"); + + // Batch: mixed-type params across statements round-trip too. + yield* db.exec( + "CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, qty INTEGER, note TEXT)", + ); + yield* db.prepare("DELETE FROM items").run(); + yield* db.batch([ + db + .prepare( + "INSERT INTO items (id, qty, note) VALUES (?, ?, ?)", + ) + .bind(1, 10, null), + db + .prepare( + "INSERT INTO items (id, qty, note) VALUES (?, ?, ?)", + ) + .bind(2, 20, "second"), + ]); + const rows = yield* db + .prepare("SELECT id, qty, note FROM items ORDER BY id") + .all<{ id: number; qty: number; note: string | null }>(); + + return { num, nul, boolTrue, boolFalse, rows: rows.results }; + }); + }).pipe(Effect.provide(Cloudflare.D1.QueryDatabaseLocal)), + ); + + return yield* Query({}); + }), + ); + + expect(out.num).toBe(42); + expect(out.nul).toBeNull(); + expect(out.boolTrue).toBe(1); + expect(out.boolFalse).toBe(0); + expect(out.rows).toEqual([ + { id: 1, qty: 10, note: null }, + { id: 2, qty: 20, note: "second" }, + ]); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); From 5e72363aa32867ce3ee9979e21334c632248d28b Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 15 Jul 2026 13:00:40 -0700 Subject: [PATCH 07/13] test(cloudflare/d1): cover binary BLOB bind in QueryDatabaseLocal Verifies the ArrayBuffer/view -> byte-array normalization round-trips: binding a Uint8Array and SELECT length(?) returns the blob byte count. Co-Authored-By: Claude Opus 4.8 --- .../Cloudflare/D1/QueryDatabaseLocal.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/alchemy/test/Cloudflare/D1/QueryDatabaseLocal.test.ts b/packages/alchemy/test/Cloudflare/D1/QueryDatabaseLocal.test.ts index 56a7977ff..efa4c4197 100644 --- a/packages/alchemy/test/Cloudflare/D1/QueryDatabaseLocal.test.ts +++ b/packages/alchemy/test/Cloudflare/D1/QueryDatabaseLocal.test.ts @@ -143,7 +143,20 @@ test.provider( .prepare("SELECT id, qty, note FROM items ORDER BY id") .all<{ id: number; qty: number; note: string | null }>(); - return { num, nul, boolTrue, boolFalse, rows: rows.results }; + // Binary (BLOB): bind a Uint8Array, read the byte length back. + const blobLen = yield* db + .prepare("SELECT length(?) AS len") + .bind(new Uint8Array([1, 2, 3, 4])) + .first("len"); + + return { + num, + nul, + boolTrue, + boolFalse, + rows: rows.results, + blobLen, + }; }); }).pipe(Effect.provide(Cloudflare.D1.QueryDatabaseLocal)), ); @@ -160,6 +173,7 @@ test.provider( { id: 1, qty: 10, note: null }, { id: 2, qty: 20, note: "second" }, ]); + expect(out.blobLen).toBe(4); yield* stack.destroy(); }).pipe(logLevel), From b2c3fab650574ada5e0d28907a451a18e0bbbed5 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 15 Jul 2026 13:09:57 -0700 Subject: [PATCH 08/13] chore(distilled): repoint submodule to main-based d1/vectorize fixes Rebases the D1 params + Vectorize multipart fixes onto distilled main (companion PR alchemy-run/distilled#379), dropping the stale claude/typescript-7-stable base so the submodule pointer no longer conflicts with alchemy main. Co-Authored-By: Claude Opus 4.8 --- distilled | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/distilled b/distilled index 77edf5148..1103d29af 160000 --- a/distilled +++ b/distilled @@ -1 +1 @@ -Subproject commit 77edf51483239b59ffd5d89467ac4594921b6b46 +Subproject commit 1103d29af58b6565d73884a3bcba195dd9d94fce From 93af3f2630e878b4707ca52706b4c73b412f30c2 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 15 Jul 2026 13:20:16 -0700 Subject: [PATCH 09/13] chore(distilled): bump to D1 value-union params fix Repoints the submodule to distilled 7d06469fc (companion PR alchemy-run/distilled#379): D1 params are now a precise value union `string | number | null | number[]` instead of unknown[]. Co-Authored-By: Claude Opus 4.8 --- distilled | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/distilled b/distilled index 1103d29af..7d06469fc 160000 --- a/distilled +++ b/distilled @@ -1 +1 @@ -Subproject commit 1103d29af58b6565d73884a3bcba195dd9d94fce +Subproject commit 7d06469fc11cca5c0dc76bfae32d9ef8bb6e2c7f From 83491e3458c8fed2ba88ca1cd8d97105bf888da8 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 15 Jul 2026 13:44:14 -0700 Subject: [PATCH 10/13] fix(cloudflare/vectorize): send raw ndjson body via distilled binary op Distilled now models vectorize insert/upsert as a raw application/x-ndjson body (companion PR alchemy-run/distilled#379) matching the Cloudflare SDK, instead of the multipart-with-vectors workaround. SearchIndexLocal passes the ndjson blob as `body`. Bumps the distilled submodule. Co-Authored-By: Claude Opus 4.8 --- distilled | 2 +- packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/distilled b/distilled index 7d06469fc..88349b784 160000 --- a/distilled +++ b/distilled @@ -1 +1 @@ -Subproject commit 7d06469fc11cca5c0dc76bfae32d9ef8bb6e2c7f +Subproject commit 88349b784c4614b31e6194c0f36d69b3e1bb03c9 diff --git a/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts b/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts index 43092b4e8..2401c8c51 100644 --- a/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts +++ b/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts @@ -114,7 +114,7 @@ export const SearchIndexLocal = Layer.effect( .insertIndex({ accountId, indexName: name, - vectors: toNdjsonBlob(vectors), + body: toNdjsonBlob(vectors), }) .pipe(Effect.map(toMutation)), ), @@ -124,7 +124,7 @@ export const SearchIndexLocal = Layer.effect( .upsertIndex({ accountId, indexName: name, - vectors: toNdjsonBlob(vectors), + body: toNdjsonBlob(vectors), }) .pipe(Effect.map(toMutation)), ), From 5d240ac1d5a1ba872068c7413466a864a416388f Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 15 Jul 2026 13:51:06 -0700 Subject: [PATCH 11/13] docs(cloudflare/vectorize): fix stale multipart comment on toNdjsonBlob Co-Authored-By: Claude Fable 5 --- packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts b/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts index 2401c8c51..b9790a53b 100644 --- a/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts +++ b/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts @@ -150,8 +150,8 @@ export const SearchIndexLocal = Layer.effect( ); /** - * Serialize vectors to an ndjson Blob for the `vectors` multipart part of the - * Vectorize v2 insert/upsert endpoints. + * Serialize vectors to an ndjson Blob — the raw `application/x-ndjson` request + * body of the Vectorize v2 insert/upsert endpoints. */ const toNdjsonBlob = (vectors: runtime.VectorizeVector[]): Blob => new Blob([toNdjson(vectors)]); From 1a8413c2b8f220b394e9eb5728d89eeb01a7ce45 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 15 Jul 2026 13:55:23 -0700 Subject: [PATCH 12/13] refactor(cloudflare/vectorize): extract shared HTTP client builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the SearchIndexClient construction + shape adapters into unexported SearchIndexHttpClient.ts, parameterized by an injectable SearchIndexAuth ({ authorize, accountId }) — same convention as KV/R2/Tunnel/AI. A future token-scoped SearchIndexHttp layer reuses the builder; SearchIndexLocal is now just the layer + auth. Co-Authored-By: Claude Fable 5 --- .../Vectorize/SearchIndexHttpClient.ts | 183 ++++++++++++++++++ .../Cloudflare/Vectorize/SearchIndexLocal.ts | 163 ++-------------- 2 files changed, 195 insertions(+), 151 deletions(-) create mode 100644 packages/alchemy/src/Cloudflare/Vectorize/SearchIndexHttpClient.ts diff --git a/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexHttpClient.ts b/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexHttpClient.ts new file mode 100644 index 000000000..f9b07b810 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexHttpClient.ts @@ -0,0 +1,183 @@ +import type * as runtime from "@cloudflare/workers-types"; +import * as vectorize from "@distilled.cloud/cloudflare/vectorize"; +import * as Effect from "effect/Effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { Credentials } from "../Credentials.ts"; +import type { SearchIndexClient } from "./SearchIndex.ts"; + +// Shared HTTP scaffolding for the Vectorize `SearchIndex` binding. NOT +// re-exported from `index.ts` — only the contract and the impl layers are +// public. A future token-scoped `SearchIndexHttp` layer reuses this builder +// with an auth minted from an `AccountApiToken`; `SearchIndexLocal` builds the +// auth from the ambient current-credentials context. + +/** + * Injectable auth shared by the Local (current-credentials) impl and a future + * Http (scoped-token) impl. `authorize` discharges the + * `Credentials | HttpClient` requirement of a distilled op; `accountId` is the + * Cloudflare account the ops run against. + */ +export interface SearchIndexAuth { + authorize: ( + eff: Effect.Effect, + ) => Effect.Effect; + accountId: string; +} + +/** + * Build a {@link SearchIndexClient} over the Vectorize HTTP API. + * + * `indexName` is an Effect so the resolution stays deferred to each call — + * inside an Action it resolves through the apply-time RuntimeContext. The + * credentials are provided ONLY around the distilled op (via `auth.authorize`), + * never around the name accessor, matching the D1 / KV Local variants. `orDie` + * mirrors the native binding, whose client methods surface transport failures + * as defects. + * + * Two methods have no Cloudflare HTTP equivalent and `Effect.die`: + * - `raw` — there is no HTTP-backed `runtime.Vectorize` object to hand back. + * - `queryById` — the HTTP query endpoint only accepts a raw vector, not an id. + */ +export const makeHttpSearchIndexClient = ( + auth: SearchIndexAuth, + indexName: Effect.Effect, +): SearchIndexClient => { + const { accountId } = auth; + const local = ( + fn: ( + name: string, + ) => Effect.Effect, + ): Effect.Effect => + Effect.flatMap(indexName, (name) => auth.authorize(fn(name))).pipe( + Effect.orDie, + ); + + return { + raw: Effect.die( + new Error( + "SearchIndex over HTTP: `raw` is not available — use a native Worker binding (SearchIndexBinding) for direct access.", + ), + ), + describe: () => + local((name) => + vectorize + .infoIndex({ accountId, indexName: name }) + .pipe(Effect.map(toIndexInfo)), + ), + query: (vector, options) => + local((name) => + vectorize + .queryIndex({ + accountId, + indexName: name, + vector: Array.from(vector), + topK: options?.topK, + returnValues: options?.returnValues, + returnMetadata: toReturnMetadata(options?.returnMetadata), + filter: options?.filter, + }) + .pipe(Effect.map(toMatches)), + ), + queryById: () => + Effect.die( + new Error( + "SearchIndex over HTTP: `queryById` is not supported — the HTTP query endpoint only accepts a raw query vector. Fetch the vector with `getByIds` and pass its values to `query`.", + ), + ), + insert: (vectors) => + local((name) => + vectorize + .insertIndex({ + accountId, + indexName: name, + body: toNdjsonBlob(vectors), + }) + .pipe(Effect.map(toMutation)), + ), + upsert: (vectors) => + local((name) => + vectorize + .upsertIndex({ + accountId, + indexName: name, + body: toNdjsonBlob(vectors), + }) + .pipe(Effect.map(toMutation)), + ), + deleteByIds: (ids) => + local((name) => + vectorize + .deleteByIdsIndex({ accountId, indexName: name, ids }) + .pipe(Effect.map(toMutation)), + ), + getByIds: (ids) => + local((name) => + vectorize + .getByIdsIndex({ accountId, indexName: name, ids }) + .pipe( + Effect.map((result) => (result ?? []) as runtime.VectorizeVector[]), + ), + ), + } satisfies SearchIndexClient; +}; + +// ── shape adapters (runtime types <-> distilled HTTP types) ───────────────── + +/** + * Serialize vectors to an ndjson Blob — the raw `application/x-ndjson` request + * body of the Vectorize v2 insert/upsert endpoints. + */ +const toNdjsonBlob = (vectors: runtime.VectorizeVector[]): Blob => + new Blob([toNdjson(vectors)]); + +/** Serialize vectors to ndjson — one JSON vector per line. */ +const toNdjson = (vectors: runtime.VectorizeVector[]): string => + vectors + .map((v) => + JSON.stringify({ + id: v.id, + // A `VectorFloatArray` (Float32Array) would `JSON.stringify` to an + // object, not an array — normalize to a plain number array. + values: Array.from(v.values), + ...(v.namespace !== undefined ? { namespace: v.namespace } : {}), + ...(v.metadata !== undefined ? { metadata: v.metadata } : {}), + }), + ) + .join("\n"); + +const toReturnMetadata = ( + value: runtime.VectorizeQueryOptions["returnMetadata"], +): "none" | "indexed" | "all" | undefined => + value === undefined + ? undefined + : typeof value === "boolean" + ? value + ? "all" + : "none" + : value; + +const toMutation = (r: { + mutationId?: string | null; +}): runtime.VectorizeAsyncMutation => ({ mutationId: r.mutationId ?? "" }); + +const toIndexInfo = ( + r: vectorize.InfoIndexResponse, +): runtime.VectorizeIndexInfo => + ({ + vectorCount: r.vectorCount ?? 0, + dimensions: r.dimensions ?? 0, + processedUpToDatetime: r.processedUpToDatetime ?? undefined, + processedUpToMutation: r.processedUpToMutation ?? undefined, + }) as unknown as runtime.VectorizeIndexInfo; + +const toMatches = (r: vectorize.QueryIndexResponse): runtime.VectorizeMatches => + ({ + count: r.count ?? r.matches?.length ?? 0, + matches: (r.matches ?? []).map((m) => ({ + id: m.id ?? "", + score: m.score ?? 0, + ...(m.values != null ? { values: m.values } : {}), + ...(m.namespace != null ? { namespace: m.namespace } : {}), + ...(m.metadata != null ? { metadata: m.metadata } : {}), + })), + }) as unknown as runtime.VectorizeMatches; diff --git a/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts b/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts index b9790a53b..cbd730beb 100644 --- a/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts +++ b/packages/alchemy/src/Cloudflare/Vectorize/SearchIndexLocal.ts @@ -1,11 +1,13 @@ -import type * as runtime from "@cloudflare/workers-types"; import type { Credentials } from "@distilled.cloud/cloudflare/Credentials"; -import * as vectorize from "@distilled.cloud/cloudflare/vectorize"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type * as HttpClient from "effect/unstable/http/HttpClient"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; -import { type SearchIndexClient, SearchIndex } from "./SearchIndex.ts"; +import { SearchIndex } from "./SearchIndex.ts"; +import { + type SearchIndexAuth, + makeHttpSearchIndexClient, +} from "./SearchIndexHttpClient.ts"; import type { Index } from "./VectorizeIndex.ts"; /** @@ -40,10 +42,8 @@ import type { Index } from "./VectorizeIndex.ts"; * provides around the body), so `SearchIndex(index)` works even though the * index is created in the same deploy. * - * Two methods have no Cloudflare HTTP equivalent and therefore - * `Effect.die` when called on the Local client: - * - `raw` — there is no HTTP-backed `runtime.Vectorize` object to hand back. - * - `queryById` — the HTTP query endpoint only accepts a raw vector, not an id. + * `raw` and `queryById` have no Cloudflare HTTP equivalent and `Effect.die` — + * see {@link makeHttpSearchIndexClient}. */ export const SearchIndexLocal = Layer.effect( SearchIndex, @@ -55,155 +55,16 @@ export const SearchIndexLocal = Layer.effect( const context = yield* Effect.context< Credentials | HttpClient.HttpClient >(); + const auth: SearchIndexAuth = { + authorize: (eff) => eff.pipe(Effect.provideContext(context)), + accountId, + }; return Effect.fn(function* (index: Index) { // Deferred accessor — resolves the index name against the tracker at // apply time (in an Action, that's the engine's resolve context). const indexName = yield* index.indexName; - - // Run a distilled Vectorize op with the captured credentials, resolving - // the index name first. The captured credentials context is provided - // ONLY around the distilled op — never around the `indexName` accessor, - // which must resolve against the ambient apply-time RuntimeContext (same - // split as the D1 / KV Local variants). `orDie` mirrors the native - // binding, whose client methods surface transport failures as defects. - const local = ( - fn: ( - name: string, - ) => Effect.Effect, - ): Effect.Effect => - Effect.flatMap(indexName, (name) => - fn(name).pipe(Effect.provideContext(context)), - ).pipe(Effect.orDie); - - return { - raw: Effect.die( - new Error( - "SearchIndexLocal: `raw` is not available over the Vectorize HTTP API — use a native Worker binding (SearchIndexBinding) for direct access.", - ), - ), - describe: () => - local((name) => - vectorize - .infoIndex({ accountId, indexName: name }) - .pipe(Effect.map(toIndexInfo)), - ), - query: (vector, options) => - local((name) => - vectorize - .queryIndex({ - accountId, - indexName: name, - vector: Array.from(vector), - topK: options?.topK, - returnValues: options?.returnValues, - returnMetadata: toReturnMetadata(options?.returnMetadata), - filter: options?.filter, - }) - .pipe(Effect.map(toMatches)), - ), - queryById: () => - Effect.die( - new Error( - "SearchIndexLocal: `queryById` is not supported over the Vectorize HTTP API — it only accepts a raw query vector. Fetch the vector with `getByIds` and pass its values to `query`.", - ), - ), - insert: (vectors) => - local((name) => - vectorize - .insertIndex({ - accountId, - indexName: name, - body: toNdjsonBlob(vectors), - }) - .pipe(Effect.map(toMutation)), - ), - upsert: (vectors) => - local((name) => - vectorize - .upsertIndex({ - accountId, - indexName: name, - body: toNdjsonBlob(vectors), - }) - .pipe(Effect.map(toMutation)), - ), - deleteByIds: (ids) => - local((name) => - vectorize - .deleteByIdsIndex({ accountId, indexName: name, ids }) - .pipe(Effect.map(toMutation)), - ), - getByIds: (ids) => - local((name) => - vectorize - .getByIdsIndex({ accountId, indexName: name, ids }) - .pipe( - Effect.map( - (result) => (result ?? []) as runtime.VectorizeVector[], - ), - ), - ), - } satisfies SearchIndexClient; + return makeHttpSearchIndexClient(auth, indexName); }); }), ); - -/** - * Serialize vectors to an ndjson Blob — the raw `application/x-ndjson` request - * body of the Vectorize v2 insert/upsert endpoints. - */ -const toNdjsonBlob = (vectors: runtime.VectorizeVector[]): Blob => - new Blob([toNdjson(vectors)]); - -/** Serialize vectors to ndjson — one JSON vector per line. */ -const toNdjson = (vectors: runtime.VectorizeVector[]): string => - vectors - .map((v) => - JSON.stringify({ - id: v.id, - // A `VectorFloatArray` (Float32Array) would `JSON.stringify` to an - // object, not an array — normalize to a plain number array. - values: Array.from(v.values), - ...(v.namespace !== undefined ? { namespace: v.namespace } : {}), - ...(v.metadata !== undefined ? { metadata: v.metadata } : {}), - }), - ) - .join("\n"); - -const toReturnMetadata = ( - value: runtime.VectorizeQueryOptions["returnMetadata"], -): "none" | "indexed" | "all" | undefined => - value === undefined - ? undefined - : typeof value === "boolean" - ? value - ? "all" - : "none" - : value; - -const toMutation = (r: { - mutationId?: string | null; -}): runtime.VectorizeAsyncMutation => ({ mutationId: r.mutationId ?? "" }); - -const toIndexInfo = ( - r: vectorize.InfoIndexResponse, -): runtime.VectorizeIndexInfo => - ({ - vectorCount: r.vectorCount ?? 0, - dimensions: r.dimensions ?? 0, - processedUpToDatetime: r.processedUpToDatetime ?? undefined, - processedUpToMutation: r.processedUpToMutation ?? undefined, - }) as unknown as runtime.VectorizeIndexInfo; - -const toMatches = (r: vectorize.QueryIndexResponse): runtime.VectorizeMatches => - ({ - count: r.count ?? r.matches?.length ?? 0, - matches: (r.matches ?? []).map((m) => ({ - id: m.id ?? "", - score: m.score ?? 0, - ...(m.values != null ? { values: m.values } : {}), - ...(m.namespace != null ? { namespace: m.namespace } : {}), - ...(m.metadata != null ? { metadata: m.metadata } : {}), - })), - }) as unknown as runtime.VectorizeMatches; From 4cf2e06e56fcfec058ee4d7400ae22a9742b7b45 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 15 Jul 2026 14:02:36 -0700 Subject: [PATCH 13/13] refactor(cloudflare): extract shared HTTP client builders for D1, Flagship, Browser Same convention as KV/R2/Tunnel/AI/Vectorize: move client construction into unexported {Cap}HttpClient.ts scaffolding parameterized by an injectable auth ({ authorize, accountId }), so a future token-scoped *Http layer reuses the builder instead of duplicating it. The *Local layers are now just layer + auth + deferred id accessor. Co-Authored-By: Claude Fable 5 --- .../Cloudflare/D1/QueryDatabaseHttpClient.ts | 176 +++++++++++++++ .../src/Cloudflare/D1/QueryDatabaseLocal.ts | 161 +------------- .../Flagship/ReadFlagsHttpClient.ts | 183 ++++++++++++++++ .../src/Cloudflare/Flagship/ReadFlagsLocal.ts | 177 +-------------- .../Cloudflare/Workers/BrowserHttpClient.ts | 201 ++++++++++++++++++ .../src/Cloudflare/Workers/BrowserLocal.ts | 197 ++--------------- 6 files changed, 595 insertions(+), 500 deletions(-) create mode 100644 packages/alchemy/src/Cloudflare/D1/QueryDatabaseHttpClient.ts create mode 100644 packages/alchemy/src/Cloudflare/Flagship/ReadFlagsHttpClient.ts create mode 100644 packages/alchemy/src/Cloudflare/Workers/BrowserHttpClient.ts diff --git a/packages/alchemy/src/Cloudflare/D1/QueryDatabaseHttpClient.ts b/packages/alchemy/src/Cloudflare/D1/QueryDatabaseHttpClient.ts new file mode 100644 index 000000000..7e16ce08e --- /dev/null +++ b/packages/alchemy/src/Cloudflare/D1/QueryDatabaseHttpClient.ts @@ -0,0 +1,176 @@ +import type * as runtime from "@cloudflare/workers-types"; +import * as d1 from "@distilled.cloud/cloudflare/d1"; +import * as Effect from "effect/Effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { Credentials } from "../Credentials.ts"; +import { + type QueryDatabaseClient, + PreparedStatement, +} from "./QueryDatabase.ts"; + +// Shared HTTP scaffolding for the D1 `QueryDatabase` binding. NOT re-exported +// from `index.ts` — only the contract and the impl layers are public. A future +// token-scoped `QueryDatabaseHttp` layer reuses this builder with an auth +// minted from an `AccountApiToken`; `QueryDatabaseLocal` builds the auth from +// the ambient current-credentials context. +// +// PreparedStatement (shared with QueryDatabaseBinding) drives a +// `runtime.D1Database` whose executors return Promises. The shim below +// implements the slice PreparedStatement uses (prepare/exec/batch + stmt +// bind/all/first/run/raw) by running `d1.queryDatabase` over HTTP. + +/** + * Injectable auth shared by the Local (current-credentials) impl and a future + * Http (scoped-token) impl. `authorize` discharges the + * `Credentials | HttpClient` requirement of a distilled op; `accountId` is the + * Cloudflare account the ops run against. + */ +export interface D1Auth { + authorize: ( + eff: Effect.Effect, + ) => Effect.Effect; + accountId: string; +} + +/** + * Build a {@link QueryDatabaseClient} over the D1 HTTP query API. + * + * `databaseId` is an Effect so the resolution stays deferred to each call — + * inside an Action it resolves through the apply-time RuntimeContext. The + * credentials are provided ONLY around the distilled op (via `auth.authorize`), + * never around the id accessor, matching the KV / Vectorize Local variants. + */ +export const makeHttpQueryDatabaseClient = ( + auth: D1Auth, + databaseId: Effect.Effect, +): QueryDatabaseClient => { + const rawEff = Effect.map(databaseId, (id) => makeHttpD1Database(auth, id)); + + return { + raw: rawEff, + prepare: (query: string) => new PreparedStatement(query, [], rawEff), + exec: (query: string) => + Effect.flatMap(rawEff, (raw) => Effect.promise(() => raw.exec(query))), + batch: (statements: PreparedStatement[]) => + Effect.flatMap(rawEff, (raw) => + Effect.promise(() => + raw.batch(statements.map((s) => s._build(raw))), + ), + ), + } satisfies QueryDatabaseClient; +}; + +const runQuery = ( + auth: D1Auth, + databaseId: string, + body: + | { sql: string; params?: unknown[] } + | { batch: { sql: string; params?: unknown[] }[] }, +): Promise => + auth + .authorize( + d1.queryDatabase({ + accountId: auth.accountId, + databaseId, + ...(body as any), + }), + ) + .pipe(Effect.runPromise); + +const toResult = ( + r: d1.QueryDatabaseResponse["result"][number] | undefined, +): runtime.D1Result => + ({ + results: (r?.results ?? []) as T[], + success: r?.success ?? true, + meta: (r?.meta ?? {}) as any, + }) as runtime.D1Result; + +/** + * Normalize a bound value the way the native D1 binding does before it reaches + * SQLite. Over the raw HTTP query API, unlike the Worker binding, values are + * bound verbatim — a JS `true` would arrive as the string `"true"`. Match the + * native semantics: booleans become integers (1/0) and binary becomes a byte + * array (BLOB). `null`, numbers, and strings pass through unchanged. + */ +const normalizeBind = (value: unknown): unknown => { + if (typeof value === "boolean") return value ? 1 : 0; + if (value instanceof ArrayBuffer) { + return Array.from(new Uint8Array(value)); + } + if (ArrayBuffer.isView(value)) { + return Array.from( + new Uint8Array(value.buffer, value.byteOffset, value.byteLength), + ); + } + return value; +}; + +const makeHttpD1Database = ( + auth: D1Auth, + databaseId: string, +): runtime.D1Database => { + const makeStatement = ( + query: string, + binds: ReadonlyArray, + ): runtime.D1PreparedStatement => { + const exec = async () => { + const res = await runQuery(auth, databaseId, { + sql: query, + params: binds.length ? binds.map(normalizeBind) : undefined, + }); + return res.result[0]; + }; + return { + bind: (...values: unknown[]) => makeStatement(query, values), + first: (async (column?: string) => { + const first = (await exec())?.results?.[0] as + | Record + | undefined; + if (first == null) return null; + return column !== undefined ? (first[column] ?? null) : first; + }) as runtime.D1PreparedStatement["first"], + all: (async () => + toResult(await exec())) as runtime.D1PreparedStatement["all"], + run: (async () => + toResult(await exec())) as runtime.D1PreparedStatement["run"], + raw: (async (options?: { columnNames?: boolean }) => { + const rows = ((await exec())?.results ?? []) as Record< + string, + unknown + >[]; + const arrays = rows.map((row) => Object.values(row)); + if (options?.columnNames && rows[0]) { + return [Object.keys(rows[0]), ...arrays]; + } + return arrays; + }) as runtime.D1PreparedStatement["raw"], + // Carry query + params so `batch` can reconstruct the request. + __query: query, + __params: binds, + } as unknown as runtime.D1PreparedStatement; + }; + + return { + prepare: (query: string) => makeStatement(query, []), + exec: async (query: string) => { + const res = await runQuery(auth, databaseId, { sql: query }); + const meta = res.result[res.result.length - 1]?.meta; + return { + count: res.result.length, + duration: meta?.duration ?? 0, + } as runtime.D1ExecResult; + }, + batch: async (statements: runtime.D1PreparedStatement[]) => { + const res = await runQuery(auth, databaseId, { + batch: statements.map((s) => ({ + sql: (s as any).__query as string, + params: ((s as any).__params as unknown[]).length + ? ((s as any).__params as unknown[]).map(normalizeBind) + : undefined, + })), + }); + return res.result.map((r) => toResult(r)); + }, + } as unknown as runtime.D1Database; +}; diff --git a/packages/alchemy/src/Cloudflare/D1/QueryDatabaseLocal.ts b/packages/alchemy/src/Cloudflare/D1/QueryDatabaseLocal.ts index 6fffd295a..b08dbab16 100644 --- a/packages/alchemy/src/Cloudflare/D1/QueryDatabaseLocal.ts +++ b/packages/alchemy/src/Cloudflare/D1/QueryDatabaseLocal.ts @@ -1,17 +1,14 @@ -import type * as runtime from "@cloudflare/workers-types"; -import * as d1 from "@distilled.cloud/cloudflare/d1"; -import type * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type * as HttpClient from "effect/unstable/http/HttpClient"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { Credentials } from "../Credentials.ts"; import type { Database } from "./Database.ts"; +import { QueryDatabase } from "./QueryDatabase.ts"; import { - type QueryDatabaseClient, - PreparedStatement, - QueryDatabase, -} from "./QueryDatabase.ts"; + type D1Auth, + makeHttpQueryDatabaseClient, +} from "./QueryDatabaseHttpClient.ts"; /** * Local implementation of the {@link QueryDatabase} binding — queries D1 over @@ -48,158 +45,22 @@ export const QueryDatabaseLocal = Layer.effect( QueryDatabase, Effect.gen(function* () { // Account + credentials are ambient during stack-eval (the stack's - // providers layer). Capture the full context so the HTTP query effect can - // be run from the Promise-based D1 shim below. + // providers layer). Capture the full context so the HTTP query ops run + // with the current credentials — no `host.bind`, no minted token. const { accountId } = yield* yield* CloudflareEnvironment; const context = yield* Effect.context< Credentials | HttpClient.HttpClient >(); + const auth: D1Auth = { + authorize: (eff) => eff.pipe(Effect.provideContext(context)), + accountId, + }; return Effect.fn(function* (database: Database) { // Deferred accessor — resolves the databaseId against the tracker at // apply time. No `host.bind`: the local variant registers no binding. const databaseId = yield* database.databaseId; - - const rawEff = Effect.map(databaseId, (id) => - makeHttpD1Database({ accountId, databaseId: id, context }), - ); - - return { - raw: rawEff, - prepare: (query: string) => new PreparedStatement(query, [], rawEff), - exec: (query: string) => - Effect.flatMap(rawEff, (raw) => - Effect.promise(() => raw.exec(query)), - ), - batch: (statements: PreparedStatement[]) => - Effect.flatMap(rawEff, (raw) => - Effect.promise(() => - raw.batch(statements.map((s) => s._build(raw))), - ), - ), - } satisfies QueryDatabaseClient; + return makeHttpQueryDatabaseClient(auth, databaseId); }); }), ); - -// ── HTTP-backed D1Database shim ────────────────────────────────────────────── -// -// PreparedStatement (shared with QueryDatabaseBinding) drives a -// `runtime.D1Database` whose executors return Promises. This shim implements the -// slice PreparedStatement uses (prepare/exec/batch + stmt bind/all/first/run/raw) -// by running `d1.queryDatabase` over HTTP with the captured credentials context. - -interface QueryContext { - accountId: string; - databaseId: string; - context: Context.Context; -} - -const runQuery = ( - ctx: QueryContext, - body: - | { sql: string; params?: unknown[] } - | { batch: { sql: string; params?: unknown[] }[] }, -): Promise => - d1 - .queryDatabase({ - accountId: ctx.accountId, - databaseId: ctx.databaseId, - ...(body as any), - }) - .pipe(Effect.provideContext(ctx.context), Effect.runPromise); - -const toResult = ( - r: d1.QueryDatabaseResponse["result"][number] | undefined, -): runtime.D1Result => - ({ - results: (r?.results ?? []) as T[], - success: r?.success ?? true, - meta: (r?.meta ?? {}) as any, - }) as runtime.D1Result; - -/** - * Normalize a bound value the way the native D1 binding does before it reaches - * SQLite. Over the raw HTTP query API, unlike the Worker binding, values are - * bound verbatim — a JS `true` would arrive as the string `"true"`. Match the - * native semantics: booleans become integers (1/0) and binary becomes a byte - * array (BLOB). `null`, numbers, and strings pass through unchanged. - */ -const normalizeBind = (value: unknown): unknown => { - if (typeof value === "boolean") return value ? 1 : 0; - if (value instanceof ArrayBuffer) { - return Array.from(new Uint8Array(value)); - } - if (ArrayBuffer.isView(value)) { - return Array.from( - new Uint8Array(value.buffer, value.byteOffset, value.byteLength), - ); - } - return value; -}; - -const makeHttpD1Database = (ctx: QueryContext): runtime.D1Database => { - const makeStatement = ( - query: string, - binds: ReadonlyArray, - ): runtime.D1PreparedStatement => { - const exec = async () => { - const res = await runQuery(ctx, { - sql: query, - params: binds.length ? binds.map(normalizeBind) : undefined, - }); - return res.result[0]; - }; - return { - bind: (...values: unknown[]) => makeStatement(query, values), - first: (async (column?: string) => { - const first = (await exec())?.results?.[0] as - | Record - | undefined; - if (first == null) return null; - return column !== undefined ? (first[column] ?? null) : first; - }) as runtime.D1PreparedStatement["first"], - all: (async () => - toResult(await exec())) as runtime.D1PreparedStatement["all"], - run: (async () => - toResult(await exec())) as runtime.D1PreparedStatement["run"], - raw: (async (options?: { columnNames?: boolean }) => { - const rows = ((await exec())?.results ?? []) as Record< - string, - unknown - >[]; - const arrays = rows.map((row) => Object.values(row)); - if (options?.columnNames && rows[0]) { - return [Object.keys(rows[0]), ...arrays]; - } - return arrays; - }) as runtime.D1PreparedStatement["raw"], - // Carry query + params so `batch` can reconstruct the request. - __query: query, - __params: binds, - } as unknown as runtime.D1PreparedStatement; - }; - - return { - prepare: (query: string) => makeStatement(query, []), - exec: async (query: string) => { - const res = await runQuery(ctx, { sql: query }); - const meta = res.result[res.result.length - 1]?.meta; - return { - count: res.result.length, - duration: meta?.duration ?? 0, - } as runtime.D1ExecResult; - }, - batch: async (statements: runtime.D1PreparedStatement[]) => { - const res = await runQuery(ctx, { - batch: statements.map((s) => ({ - sql: (s as any).__query as string, - params: ((s as any).__params as unknown[]).length - ? ((s as any).__params as unknown[]).map(normalizeBind) - : undefined, - })), - }); - return res.result.map((r) => toResult(r)); - }, - } as unknown as runtime.D1Database; -}; diff --git a/packages/alchemy/src/Cloudflare/Flagship/ReadFlagsHttpClient.ts b/packages/alchemy/src/Cloudflare/Flagship/ReadFlagsHttpClient.ts new file mode 100644 index 000000000..44c93abae --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Flagship/ReadFlagsHttpClient.ts @@ -0,0 +1,183 @@ +import type * as cf from "@cloudflare/workers-types"; +import * as flagship from "@distilled.cloud/cloudflare/flagship"; +import * as Effect from "effect/Effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import type { Credentials } from "../Credentials.ts"; +import { + type EvaluationContext, + type EvaluationDetails, + FlagshipError, + type ReadFlagsClient, +} from "./ReadFlags.ts"; + +// Shared HTTP scaffolding for the Flagship `ReadFlags` binding. NOT +// re-exported from `index.ts` — only the contract and the impl layers are +// public. A future token-scoped `ReadFlagsHttp` layer reuses this builder with +// an auth minted from an `AccountApiToken`; `ReadFlagsLocal` builds the auth +// from the ambient current-credentials context. + +/** + * Injectable auth shared by the Local (current-credentials) impl and a future + * Http (scoped-token) impl. `authorize` discharges the + * `Credentials | HttpClient` requirement of a distilled op; `accountId` is the + * Cloudflare account the ops run against. + */ +export interface FlagshipAuth { + authorize: ( + eff: Effect.Effect, + ) => Effect.Effect; + accountId: string; +} + +/** + * The HTTP evaluate endpoint only supports a single `targetingKey` query + * param, not the full flat evaluation context the Worker binding accepts. + */ +const targetingKeyOf = ( + context: EvaluationContext | undefined, +): string | undefined => { + const value = context?.["targetingKey"]; + return value === undefined ? undefined : String(value); +}; + +/** + * Build a {@link ReadFlagsClient} over the Flagship HTTP evaluate endpoint + * (`GET .../flagship/apps/{appId}/evaluate`). + * + * `appId` is an Effect so the resolution stays deferred to each call — inside + * an Action it resolves through the apply-time RuntimeContext. Mirrors the + * Worker binding's fall-back-to-default semantics: evaluation never fails the + * effect — an HTTP error or a value whose type does not match the requested + * method resolves to `defaultValue` instead. The `raw` runtime binding has no + * HTTP equivalent and dies if used. + */ +export const makeHttpFlagshipClient = ( + auth: FlagshipAuth, + appId: Effect.Effect, +): ReadFlagsClient => { + const evaluate = (flagKey: string, context?: EvaluationContext) => + appId.pipe( + Effect.flatMap((id) => + auth.authorize( + flagship.getAppEvaluate({ + accountId: auth.accountId, + appId: id, + flagKey, + targetingKey: targetingKeyOf(context), + }), + ), + ), + ); + + const details = ( + flagKey: string, + defaultValue: T, + match: (value: unknown) => value is T, + context?: EvaluationContext, + ): Effect.Effect, FlagshipError, RuntimeContext> => + evaluate(flagKey, context).pipe( + Effect.map( + (r): EvaluationDetails => + match(r.value) + ? { flagKey, value: r.value, variant: r.variant, reason: r.reason } + : { + flagKey, + value: defaultValue, + variant: r.variant, + reason: r.reason, + errorCode: "TYPE_MISMATCH", + }, + ), + Effect.catch((error) => + Effect.succeed>({ + flagKey, + value: defaultValue, + reason: "ERROR", + errorCode: error._tag, + }), + ), + ); + + const value = ( + flagKey: string, + defaultValue: T, + match: (value: unknown) => value is T, + context?: EvaluationContext, + ): Effect.Effect => + evaluate(flagKey, context).pipe( + Effect.map((r) => (match(r.value) ? r.value : defaultValue)), + Effect.catch(() => Effect.succeed(defaultValue)), + ); + + const isBoolean = (v: unknown): v is boolean => typeof v === "boolean"; + const isString = (v: unknown): v is string => typeof v === "string"; + const isNumber = (v: unknown): v is number => typeof v === "number"; + const isObjectLike = (v: unknown): boolean => + v !== null && typeof v === "object"; + + return { + // The raw runtime binding is a workerd object with no HTTP surface. + raw: Effect.die( + new FlagshipError({ + message: + "the raw Flagship runtime binding is unavailable over HTTP; use ReadFlagsBinding inside a Worker", + cause: undefined, + }), + ) as Effect.Effect, + get: (flagKey, defaultValue, context) => + evaluate(flagKey, context).pipe( + Effect.map((r) => r.value ?? defaultValue), + Effect.catch(() => Effect.succeed(defaultValue)), + ), + getBooleanValue: (flagKey, defaultValue, context) => + value(flagKey, defaultValue, isBoolean, context), + getStringValue: (flagKey, defaultValue, context) => + value(flagKey, defaultValue, isString, context), + getNumberValue: (flagKey, defaultValue, context) => + value(flagKey, defaultValue, isNumber, context), + getObjectValue: (flagKey, defaultValue, context) => + evaluate(flagKey, context).pipe( + Effect.map((r) => + isObjectLike(r.value) + ? (r.value as typeof defaultValue) + : defaultValue, + ), + Effect.catch(() => Effect.succeed(defaultValue)), + ), + getBooleanDetails: (flagKey, defaultValue, context) => + details(flagKey, defaultValue, isBoolean, context), + getStringDetails: (flagKey, defaultValue, context) => + details(flagKey, defaultValue, isString, context), + getNumberDetails: (flagKey, defaultValue, context) => + details(flagKey, defaultValue, isNumber, context), + getObjectDetails: (flagKey, defaultValue, context) => + evaluate(flagKey, context).pipe( + Effect.map( + (r): EvaluationDetails => + isObjectLike(r.value) + ? { + flagKey, + value: r.value as typeof defaultValue, + variant: r.variant, + reason: r.reason, + } + : { + flagKey, + value: defaultValue, + variant: r.variant, + reason: r.reason, + errorCode: "TYPE_MISMATCH", + }, + ), + Effect.catch((error) => + Effect.succeed>({ + flagKey, + value: defaultValue, + reason: "ERROR", + errorCode: error._tag, + }), + ), + ), + } satisfies ReadFlagsClient; +}; diff --git a/packages/alchemy/src/Cloudflare/Flagship/ReadFlagsLocal.ts b/packages/alchemy/src/Cloudflare/Flagship/ReadFlagsLocal.ts index 566334c4c..7f80a91ad 100644 --- a/packages/alchemy/src/Cloudflare/Flagship/ReadFlagsLocal.ts +++ b/packages/alchemy/src/Cloudflare/Flagship/ReadFlagsLocal.ts @@ -1,20 +1,14 @@ -import type * as cf from "@cloudflare/workers-types"; -import * as flagship from "@distilled.cloud/cloudflare/flagship"; -import type * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type * as HttpClient from "effect/unstable/http/HttpClient"; -import type { RuntimeContext } from "../../RuntimeContext.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { Credentials } from "../Credentials.ts"; import type { App } from "./App.ts"; +import { ReadFlags } from "./ReadFlags.ts"; import { - type EvaluationContext, - type EvaluationDetails, - FlagshipError, - ReadFlags, - type ReadFlagsClient, -} from "./ReadFlags.ts"; + type FlagshipAuth, + makeHttpFlagshipClient, +} from "./ReadFlagsHttpClient.ts"; /** * Local implementation of the {@link ReadFlags} binding — evaluates Flagship @@ -47,7 +41,8 @@ import { * Limitations vs. the Worker binding: the HTTP evaluate endpoint only accepts a * single `targetingKey` (read from `context.targetingKey`), so attribute-based * targeting rules that key off other context fields cannot be exercised locally. - * The `raw` runtime binding has no HTTP equivalent and dies if used. + * The `raw` runtime binding has no HTTP equivalent and dies if used — see + * {@link makeHttpFlagshipClient}. */ export const ReadFlagsLocal = Layer.effect( ReadFlags, @@ -59,166 +54,16 @@ export const ReadFlagsLocal = Layer.effect( const context = yield* Effect.context< Credentials | HttpClient.HttpClient >(); + const auth: FlagshipAuth = { + authorize: (eff) => eff.pipe(Effect.provideContext(context)), + accountId, + }; return Effect.fn(function* (app: App) { // Deferred accessor — resolves the appId against the tracker at apply // time (in an Action, that's the engine's resolve context). const appId = yield* app.appId; - return makeLocalFlagshipClient({ accountId, appId, context }); + return makeHttpFlagshipClient(auth, appId); }); }), ); - -interface LocalCtx { - accountId: string; - appId: Effect.Effect; - context: Context.Context; -} - -/** - * The HTTP evaluate endpoint only supports a single `targetingKey` query - * param, not the full flat evaluation context the Worker binding accepts. - */ -const targetingKeyOf = ( - context: EvaluationContext | undefined, -): string | undefined => { - const value = context?.["targetingKey"]; - return value === undefined ? undefined : String(value); -}; - -const evaluate = ( - ctx: LocalCtx, - flagKey: string, - context?: EvaluationContext, -) => - ctx.appId.pipe( - Effect.flatMap((appId) => - flagship - .getAppEvaluate({ - accountId: ctx.accountId, - appId, - flagKey, - targetingKey: targetingKeyOf(context), - }) - .pipe(Effect.provideContext(ctx.context)), - ), - ); - -/** - * HTTP-backed {@link ReadFlagsClient}. Mirrors the Worker binding's - * fall-back-to-default semantics: evaluation never fails the effect — an HTTP - * error or a value whose type does not match the requested method resolves to - * `defaultValue` instead. - */ -const makeLocalFlagshipClient = (ctx: LocalCtx): ReadFlagsClient => { - const details = ( - flagKey: string, - defaultValue: T, - match: (value: unknown) => value is T, - context?: EvaluationContext, - ): Effect.Effect, FlagshipError, RuntimeContext> => - evaluate(ctx, flagKey, context).pipe( - Effect.map( - (r): EvaluationDetails => - match(r.value) - ? { flagKey, value: r.value, variant: r.variant, reason: r.reason } - : { - flagKey, - value: defaultValue, - variant: r.variant, - reason: r.reason, - errorCode: "TYPE_MISMATCH", - }, - ), - Effect.catch((error) => - Effect.succeed>({ - flagKey, - value: defaultValue, - reason: "ERROR", - errorCode: error._tag, - }), - ), - ); - - const value = ( - flagKey: string, - defaultValue: T, - match: (value: unknown) => value is T, - context?: EvaluationContext, - ): Effect.Effect => - evaluate(ctx, flagKey, context).pipe( - Effect.map((r) => (match(r.value) ? r.value : defaultValue)), - Effect.catch(() => Effect.succeed(defaultValue)), - ); - - const isBoolean = (v: unknown): v is boolean => typeof v === "boolean"; - const isString = (v: unknown): v is string => typeof v === "string"; - const isNumber = (v: unknown): v is number => typeof v === "number"; - const isObjectLike = (v: unknown): boolean => - v !== null && typeof v === "object"; - - return { - // The raw runtime binding is a workerd object with no HTTP surface. - raw: Effect.die( - new FlagshipError({ - message: - "the raw Flagship runtime binding is unavailable over HTTP; use ReadFlagsBinding inside a Worker", - cause: undefined, - }), - ) as Effect.Effect, - get: (flagKey, defaultValue, context) => - evaluate(ctx, flagKey, context).pipe( - Effect.map((r) => r.value ?? defaultValue), - Effect.catch(() => Effect.succeed(defaultValue)), - ), - getBooleanValue: (flagKey, defaultValue, context) => - value(flagKey, defaultValue, isBoolean, context), - getStringValue: (flagKey, defaultValue, context) => - value(flagKey, defaultValue, isString, context), - getNumberValue: (flagKey, defaultValue, context) => - value(flagKey, defaultValue, isNumber, context), - getObjectValue: (flagKey, defaultValue, context) => - evaluate(ctx, flagKey, context).pipe( - Effect.map((r) => - isObjectLike(r.value) - ? (r.value as typeof defaultValue) - : defaultValue, - ), - Effect.catch(() => Effect.succeed(defaultValue)), - ), - getBooleanDetails: (flagKey, defaultValue, context) => - details(flagKey, defaultValue, isBoolean, context), - getStringDetails: (flagKey, defaultValue, context) => - details(flagKey, defaultValue, isString, context), - getNumberDetails: (flagKey, defaultValue, context) => - details(flagKey, defaultValue, isNumber, context), - getObjectDetails: (flagKey, defaultValue, context) => - evaluate(ctx, flagKey, context).pipe( - Effect.map( - (r): EvaluationDetails => - isObjectLike(r.value) - ? { - flagKey, - value: r.value as typeof defaultValue, - variant: r.variant, - reason: r.reason, - } - : { - flagKey, - value: defaultValue, - variant: r.variant, - reason: r.reason, - errorCode: "TYPE_MISMATCH", - }, - ), - Effect.catch((error) => - Effect.succeed>({ - flagKey, - value: defaultValue, - reason: "ERROR", - errorCode: error._tag, - }), - ), - ), - } satisfies ReadFlagsClient; -}; diff --git a/packages/alchemy/src/Cloudflare/Workers/BrowserHttpClient.ts b/packages/alchemy/src/Cloudflare/Workers/BrowserHttpClient.ts new file mode 100644 index 000000000..e7637124f --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Workers/BrowserHttpClient.ts @@ -0,0 +1,201 @@ +import * as browser from "@distilled.cloud/cloudflare/browser-rendering"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { RuntimeContext } from "../../RuntimeContext.ts"; +import type { Credentials } from "../Credentials.ts"; +import { + type BrowserClient, + type BrowserContentResult, + BrowserError, + type BrowserJsonResult, + type BrowserLinksResult, + type BrowserMarkdownResult, + type BrowserScrapeResult, + type BrowserSnapshotResult, +} from "./Browser.ts"; + +// Shared HTTP scaffolding for the Browser Rendering binding. NOT re-exported +// from `index.ts` — only the contract and the impl layers are public. A future +// token-scoped `BrowserHttp` layer reuses this builder with an auth minted from +// an `AccountApiToken`; `BrowserLocal` builds the auth from the ambient +// current-credentials context. + +/** + * Injectable auth shared by the Local (current-credentials) impl and a future + * Http (scoped-token) impl. `authorize` discharges the + * `Credentials | HttpClient` requirement of a distilled op; `accountId` is the + * Cloudflare account the ops run against. + */ +export interface BrowserAuth { + authorize: ( + eff: Effect.Effect, + ) => Effect.Effect; + accountId: string; +} + +/** A byte stream produced by a binary {@link BrowserClient} action. */ +type BrowserByteStream = Stream.Stream< + Uint8Array, + BrowserError, + RuntimeContext +>; + +/** + * Build a {@link BrowserClient} over the Browser Rendering REST data-plane + * (`/accounts/{id}/browser-rendering/*`). + * + * The following client members `Effect.die` (or fail the stream) because they + * have no Cloudflare REST equivalent — use a native `BrowserBinding` inside a + * deployed Worker for them: + * - `raw` / `fetch` — the raw `BrowserRun` transport used by + * `@cloudflare/puppeteer` is a Worker-runtime object, not an HTTP call. + * - `screenshot` / `pdf` — the binary endpoints stream image/PDF bytes, which + * the distilled REST codec models as JSON status, not a byte stream. + * + * Because the REST data-plane only returns the action `result` (not the + * runtime binding's `meta` envelope), `content`/`snapshot` populate `meta` + * with a best-effort placeholder. + */ +export const makeHttpBrowserClient = (auth: BrowserAuth): BrowserClient => { + // Run a distilled Browser Rendering op with the injected auth and surface + // transport/API failures as {@link BrowserError} (matching the native + // binding's declared error channel). + const run = ( + eff: Effect.Effect, + ): Effect.Effect => + auth.authorize(eff).pipe( + Effect.mapError( + (cause) => + new BrowserError({ + message: "Browser Rendering request failed", + cause, + }), + ), + ); + + // The cf option types (`url`/`html` + puppeteer options) are structurally the + // REST request body; add the account id and let distilled's encoder drop any + // fields it doesn't model. + const req = (options: unknown) => + ({ accountId: auth.accountId, ...(options as object) }) as never; + + const content = (options: unknown) => + run(browser.createContent(req(options))).pipe( + Effect.map( + (result): BrowserContentResult => ({ + success: true, + result, + meta: { status: 200, title: "" }, + }), + ), + ); + + const markdown = (options: unknown) => + run(browser.createMarkdown(req(options))).pipe( + Effect.map( + (result): BrowserMarkdownResult => ({ success: true, result }), + ), + ); + + const links = (options: unknown) => + run(browser.createLink(req(options))).pipe( + Effect.map( + (result): BrowserLinksResult => ({ + success: true, + result: [...result], + }), + ), + ); + + const json = (options: unknown) => + run(browser.createJson(req(options))).pipe( + Effect.map((result): BrowserJsonResult => ({ success: true, result })), + ); + + const scrape = (options: unknown) => + run(browser.createScrape(req(options))).pipe( + Effect.map( + (result): BrowserScrapeResult => ({ + success: true, + result: result.map((item) => ({ + selector: item.selector, + // distilled models per-selector `results` as a single object; the + // native shape is an array of matched elements. + results: (Array.isArray(item.results) + ? item.results + : [ + item.results, + ]) as BrowserScrapeResult["result"][number]["results"], + })), + }), + ), + ); + + const snapshot = (options: unknown) => + run(browser.createSnapshot(req(options))).pipe( + Effect.map( + (result): BrowserSnapshotResult => ({ + success: true, + result: { + content: result.content ?? "", + screenshot: result.screenshot ?? "", + }, + meta: { status: 200, title: "" }, + }), + ), + ); + + // Binary actions have no REST byte-stream equivalent through the distilled + // codec — fail the stream with a defect explaining the native-binding path. + const binaryUnsupported = (action: string): BrowserByteStream => + Stream.fromEffect( + Effect.die( + new Error( + `Browser over HTTP: '${action}' returns binary data and is only available inside a Worker via the native BrowserBinding; the HTTP client supports the JSON quick actions (content/markdown/scrape/links/snapshot/json).`, + ), + ), + ) as BrowserByteStream; + + const quickAction = ((action: string, options: unknown) => { + switch (action) { + case "content": + return content(options); + case "markdown": + return markdown(options); + case "links": + return links(options); + case "json": + return json(options); + case "scrape": + return scrape(options); + case "snapshot": + return snapshot(options); + default: + return binaryUnsupported(action); + } + }) as BrowserClient["quickAction"]; + + return { + raw: Effect.die( + new Error( + "Browser over HTTP: `raw` (the native BrowserRun binding) is only available inside a deployed Worker — use BrowserBinding.", + ), + ), + fetch: () => + Effect.die( + new Error( + "Browser over HTTP: `fetch` proxies the raw Browser Run transport used by @cloudflare/puppeteer and is only available inside a Worker via BrowserBinding.", + ), + ), + quickAction, + screenshot: () => binaryUnsupported("screenshot"), + pdf: () => binaryUnsupported("pdf"), + content, + scrape, + links, + snapshot, + json, + markdown, + } satisfies BrowserClient; +}; diff --git a/packages/alchemy/src/Cloudflare/Workers/BrowserLocal.ts b/packages/alchemy/src/Cloudflare/Workers/BrowserLocal.ts index 6d52af266..7a27670a8 100644 --- a/packages/alchemy/src/Cloudflare/Workers/BrowserLocal.ts +++ b/packages/alchemy/src/Cloudflare/Workers/BrowserLocal.ts @@ -1,31 +1,14 @@ -import * as browser from "@distilled.cloud/cloudflare/browser-rendering"; -import type * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; import type * as HttpClient from "effect/unstable/http/HttpClient"; -import type { RuntimeContext } from "../../RuntimeContext.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { Credentials } from "../Credentials.ts"; -import { - Browser, - type BrowserClient, - type BrowserContentResult, - BrowserError, - type BrowserJsonResult, - type BrowserLinksResult, - type BrowserMarkdownResult, - type BrowserScrapeResult, - type BrowserSnapshotResult, -} from "./Browser.ts"; +import { Browser } from "./Browser.ts"; import type { BrowserBinding } from "./BrowserBinding.ts"; - -/** A byte stream produced by a binary {@link BrowserClient} action. */ -type BrowserByteStream = Stream.Stream< - Uint8Array, - BrowserError, - RuntimeContext ->; +import { + type BrowserAuth, + makeHttpBrowserClient, +} from "./BrowserHttpClient.ts"; /** * Local implementation of the {@link Browser} binding — drives Cloudflare @@ -54,17 +37,8 @@ type BrowserByteStream = Stream.Stream< * ); * ``` * - * The following client members `Effect.die` (or fail the stream) because they - * have no Cloudflare REST equivalent — use a native {@link BrowserBinding} - * inside a deployed Worker for them: - * - `raw` / `fetch` — the raw `BrowserRun` transport used by - * `@cloudflare/puppeteer` is a Worker-runtime object, not an HTTP call. - * - `screenshot` / `pdf` — the binary endpoints stream image/PDF bytes, which - * the distilled REST codec models as JSON status, not a byte stream. - * - * Because the REST data-plane only returns the action `result` (not the - * runtime binding's `meta` envelope), `content`/`snapshot` populate `meta` - * with a best-effort placeholder. + * `raw`, `fetch`, and the binary actions (`screenshot`/`pdf`) have no + * Cloudflare REST equivalent and die — see {@link makeHttpBrowserClient}. */ export const BrowserLocal = Layer.effect( Browser, @@ -76,161 +50,16 @@ export const BrowserLocal = Layer.effect( const context = yield* Effect.context< Credentials | HttpClient.HttpClient >(); + const auth: BrowserAuth = { + authorize: (eff) => eff.pipe(Effect.provideContext(context)), + accountId, + }; return Effect.fn(function* (_binding: BrowserBinding) { // Browser Rendering is account-scoped; the binding carries no cloud // resource id, so nothing to resolve — the client only needs the account - // + captured credentials. - return makeLocalBrowserClient({ accountId, context }); + // + injected auth. + return makeHttpBrowserClient(auth); }); }), ); - -interface LocalContext { - accountId: string; - context: Context.Context; -} - -const makeLocalBrowserClient = (ctx: LocalContext): BrowserClient => { - // Run a distilled Browser Rendering op with the captured credentials and - // surface transport/API failures as {@link BrowserError} (matching the - // native binding's declared error channel). - const run = ( - eff: Effect.Effect, - ): Effect.Effect => - eff.pipe( - Effect.provideContext(ctx.context), - Effect.mapError( - (cause) => - new BrowserError({ - message: "Browser Rendering request failed", - cause, - }), - ), - ); - - // The cf option types (`url`/`html` + puppeteer options) are structurally the - // REST request body; add the account id and let distilled's encoder drop any - // fields it doesn't model. - const req = (options: unknown) => - ({ accountId: ctx.accountId, ...(options as object) }) as never; - - const content = (options: unknown) => - run(browser.createContent(req(options))).pipe( - Effect.map( - (result): BrowserContentResult => ({ - success: true, - result, - meta: { status: 200, title: "" }, - }), - ), - ); - - const markdown = (options: unknown) => - run(browser.createMarkdown(req(options))).pipe( - Effect.map( - (result): BrowserMarkdownResult => ({ success: true, result }), - ), - ); - - const links = (options: unknown) => - run(browser.createLink(req(options))).pipe( - Effect.map( - (result): BrowserLinksResult => ({ - success: true, - result: [...result], - }), - ), - ); - - const json = (options: unknown) => - run(browser.createJson(req(options))).pipe( - Effect.map((result): BrowserJsonResult => ({ success: true, result })), - ); - - const scrape = (options: unknown) => - run(browser.createScrape(req(options))).pipe( - Effect.map( - (result): BrowserScrapeResult => ({ - success: true, - result: result.map((item) => ({ - selector: item.selector, - // distilled models per-selector `results` as a single object; the - // native shape is an array of matched elements. - results: (Array.isArray(item.results) - ? item.results - : [ - item.results, - ]) as BrowserScrapeResult["result"][number]["results"], - })), - }), - ), - ); - - const snapshot = (options: unknown) => - run(browser.createSnapshot(req(options))).pipe( - Effect.map( - (result): BrowserSnapshotResult => ({ - success: true, - result: { - content: result.content ?? "", - screenshot: result.screenshot ?? "", - }, - meta: { status: 200, title: "" }, - }), - ), - ); - - // Binary actions have no REST byte-stream equivalent through the distilled - // codec — fail the stream with a defect explaining the native-binding path. - const binaryUnsupported = (action: string): BrowserByteStream => - Stream.fromEffect( - Effect.die( - new Error( - `BrowserLocal: '${action}' returns binary data and is only available inside a Worker via the native BrowserBinding; the Local client supports the JSON quick actions (content/markdown/scrape/links/snapshot/json).`, - ), - ), - ) as BrowserByteStream; - - const quickAction = ((action: string, options: unknown) => { - switch (action) { - case "content": - return content(options); - case "markdown": - return markdown(options); - case "links": - return links(options); - case "json": - return json(options); - case "scrape": - return scrape(options); - case "snapshot": - return snapshot(options); - default: - return binaryUnsupported(action); - } - }) as BrowserClient["quickAction"]; - - return { - raw: Effect.die( - new Error( - "BrowserLocal: `raw` (the native BrowserRun binding) is only available inside a deployed Worker — use BrowserBinding.", - ), - ), - fetch: () => - Effect.die( - new Error( - "BrowserLocal: `fetch` proxies the raw Browser Run transport used by @cloudflare/puppeteer and is only available inside a Worker via BrowserBinding.", - ), - ), - quickAction, - screenshot: () => binaryUnsupported("screenshot"), - pdf: () => binaryUnsupported("pdf"), - content, - scrape, - links, - snapshot, - json, - markdown, - } satisfies BrowserClient; -};