diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index 4d0825945..c2250cbf1 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -4,5 +4,5 @@ "printWidth": 110, "useTabs": true, "tabWidth": 4, - "ignorePatterns": [".context", "deploy"], + "ignorePatterns": [".context", "deploy", "lib/effect-cf"], } diff --git a/.oxlintrc.json b/.oxlintrc.json index 9ae6964d9..8e2313864 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -17,5 +17,5 @@ } ] }, - "ignorePatterns": [".context"] + "ignorePatterns": [".context", "lib/effect-cf"] } diff --git a/apps/alerting/package.json b/apps/alerting/package.json index 9d2d12cb5..bfac6ffd7 100644 --- a/apps/alerting/package.json +++ b/apps/alerting/package.json @@ -15,7 +15,7 @@ "@maple/api": "workspace:*", "@maple/db": "workspace:*", "@maple/domain": "workspace:*", - "@maple/effect-cloudflare": "workspace:*", + "@maple/effect-cf": "workspace:*", "effect": "catalog:effect" }, "devDependencies": { diff --git a/apps/alerting/src/lib/runtime.ts b/apps/alerting/src/lib/runtime.ts new file mode 100644 index 000000000..79ca55c37 --- /dev/null +++ b/apps/alerting/src/lib/runtime.ts @@ -0,0 +1,42 @@ +import type { Effect } from "effect" +import { ManagedRuntime } from "effect" +import type { Layer } from "effect" + +/** + * Minimal shape of CF `ExecutionContext.waitUntil`. + */ +export interface ExecutionContextLike { + waitUntil(promise: Promise): void +} + +/** + * Yield one macrotask so Effect's scheduler can drain tasks queued via + * `scheduleTask(fn, 0)`. `HttpMiddleware.tracer` ends the root Server span this + * way; `scheduleTask(fn, 0)` dispatches via `setImmediate` → `setTimeout(fn, 0)` + * on Workers (a macrotask). Disposing the per-invocation runtime the moment the + * program promise resolves would race that scheduled `span.end` and leave spans + * parentless. Awaiting one `setTimeout(0)` drains the dispatcher first. + */ +const drainScheduler = () => new Promise((resolve) => setTimeout(resolve, 0)) + +/** + * Run a single Effect program to completion under a fresh per-invocation + * runtime. Intended for CF Worker `scheduled` handlers. Disposes the runtime + * after the program settles (draining the scheduler first) and registers the + * whole thing with `ctx.waitUntil`. Rethrows so the CF runtime reports failure. + */ +export const runScheduledEffect = ( + layer: Layer.Layer, + program: Effect.Effect, + ctx: ExecutionContextLike, +): Promise => { + const runtime = ManagedRuntime.make(layer) + const done = runtime.runPromise(program).finally(async () => { + await drainScheduler() + await runtime.dispose().catch((err) => { + console.error("[alerting] scheduled runtime dispose failed:", err) + }) + }) + ctx.waitUntil(done.catch(() => undefined)) + return done +} diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index ac316a738..53393721c 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -16,25 +16,26 @@ import { QueryEngineService, ServiceMapRollupService, WarehouseQueryService, + WorkerEnvironmentLive, } from "@maple/api/alerting" import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare" -import { - runScheduledEffect, - WorkerConfigProviderLayer, - WorkerEnvironment, -} from "@maple/effect-cloudflare" +import { WorkerConfig } from "@maple/effect-cf" import { Cause, Effect, Layer } from "effect" +import { runScheduledEffect } from "./lib/runtime" // Module-scope construction; `flush(env)` resolves env on first call. The // in-isolate buffers coalesce concurrent scheduled ticks into one POST per // signal. const telemetry = MapleCloudflareSDK.make({ serviceName: "alerting" }) -const buildLayer = (_env: Record) => { - const ConfigLive = WorkerConfigProviderLayer +const buildLayer = () => { + // `WorkerConfig.providerLayer` reads the env-backed Effect ConfigProvider via + // `WorkerEnvironment`; `DatabaseD1Live` reads the `MAPLE_DB` binding the same + // way. Both get `WorkerEnvironment` from `WorkerEnvironmentLive`. + const ConfigLive = WorkerConfig.providerLayer.pipe(Layer.provide(WorkerEnvironmentLive)) const EnvLive = Env.layer.pipe(Layer.provide(ConfigLive)) - const DatabaseLive = DatabaseD1Live.pipe(Layer.provide(WorkerEnvironment.layer)) + const DatabaseLive = DatabaseD1Live.pipe(Layer.provide(WorkerEnvironmentLive)) const BaseLive = Layer.mergeAll(EnvLive, DatabaseLive) @@ -231,7 +232,7 @@ export default { ? onboardingTick : Effect.all([alertTick, errorTick], { concurrency: 2, discard: true }) try { - await runScheduledEffect(buildLayer(env), program, ctx) + await runScheduledEffect(buildLayer(), program, ctx) } finally { ctx.waitUntil(telemetry.flush(env)) } diff --git a/apps/api/package.json b/apps/api/package.json index 127900605..ed05d2a09 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -32,7 +32,7 @@ "@maple-dev/effect-sdk": "workspace:*", "@maple/db": "workspace:*", "@maple/domain": "workspace:*", - "@maple/effect-cloudflare": "workspace:*", + "@maple/effect-cf": "workspace:*", "@maple/email": "workspace:*", "@maple/infra": "workspace:*", "@maple/query-engine": "workspace:*", diff --git a/apps/api/src/alerting.ts b/apps/api/src/alerting.ts index 84e06d52d..94af57547 100644 --- a/apps/api/src/alerting.ts +++ b/apps/api/src/alerting.ts @@ -16,4 +16,4 @@ export { OrgClickHouseSettingsService } from "./services/OrgClickHouseSettingsSe export { QueryEngineService } from "./services/QueryEngineService" export { ServiceMapRollupService } from "./services/ServiceMapRollupService" export { WarehouseQueryService } from "./lib/WarehouseQueryService" -export { WorkerEnvironment } from "./lib/WorkerEnvironment" +export { WorkerEnvironment, WorkerEnvironmentLive } from "./lib/WorkerEnvironment" diff --git a/apps/api/src/lib/DatabaseD1Live.ts b/apps/api/src/lib/DatabaseD1Live.ts index 60915e41a..9ad11f065 100644 --- a/apps/api/src/lib/DatabaseD1Live.ts +++ b/apps/api/src/lib/DatabaseD1Live.ts @@ -1,21 +1,17 @@ -import { createMapleD1Client, type CloudflareD1Database } from "@maple/db/client" +import { createMapleD1Client } from "@maple/db/client" import { migrateAlertQuerySignalTypes, reshapeDashboardWidgets } from "@maple/db/migrate" -import { D1Database as D1DatabaseToken } from "@maple/effect-cloudflare/d1-connection" import { Effect, Layer } from "effect" import { Database, type DatabaseClient, type DatabaseShape, toDatabaseError } from "./DatabaseLive" - -const MAPLE_DB = D1DatabaseToken("MAPLE_DB") +import { MapleDb } from "./MapleD1" const makeD1Database = Effect.gen(function* () { - const conn = yield* D1DatabaseToken.bind(MAPLE_DB) - const binding = yield* conn.raw - if (!binding) { - return yield* Effect.die(new Error("Missing worker D1 binding: MAPLE_DB")) - } + // `MapleDb` resolves the validated raw `MAPLE_DB` D1 binding. A missing or + // malformed binding fails `MapleDb.layer` with `BindingNotFound/Validation`, + // converted to a defect via `Layer.orDie` below — preserving the original + // fail-fast (surfaced as a boot error in `wrangler tail`). + const binding = yield* MapleDb - const client = createMapleD1Client( - binding as unknown as CloudflareD1Database, - ) as unknown as DatabaseClient + const client = createMapleD1Client(binding) as unknown as DatabaseClient // The D1 worker never calls runMigrations; the data migration is guarded by // the _maple_data_migrations table, so every later boot is a single SELECT. @@ -45,4 +41,8 @@ const makeD1Database = Effect.gen(function* () { } satisfies DatabaseShape) }) -export const DatabaseD1Live = Layer.effect(Database, makeD1Database) +// Self-provides the D1 binding layer (orDie'd) so the only remaining +// requirement is `WorkerEnvironment`, which `Worker.make` supplies from `env`. +export const DatabaseD1Live = Layer.effect(Database, makeD1Database).pipe( + Layer.provide(Layer.orDie(MapleDb.layer)), +) diff --git a/apps/api/src/lib/MapleD1.ts b/apps/api/src/lib/MapleD1.ts new file mode 100644 index 000000000..a2e03c660 --- /dev/null +++ b/apps/api/src/lib/MapleD1.ts @@ -0,0 +1,27 @@ +import type { CloudflareD1Database } from "@maple/db/client" +import { Binding } from "@maple/effect-cf" + +// Structural guard for a Cloudflare D1 binding. effect-cf's `D1.Service` wraps +// `@effect/sql-d1` (for `sqlLayer()`); maple feeds the raw binding to drizzle +// instead, so we use the lower-level `Binding.Service` directly and keep +// `@effect/sql-d1` out of the worker bundle. +const isD1Database = (value: unknown): value is CloudflareD1Database => { + if (typeof value !== "object" || value === null) return false + const resource = value as Record + return ( + typeof resource.prepare === "function" && + typeof resource.batch === "function" && + typeof resource.exec === "function" + ) +} + +/** + * Validated `MAPLE_DB` D1 binding. `yield* MapleDb` resolves the raw + * `CloudflareD1Database`; provide via `MapleDb.layer` (requires + * `WorkerEnvironment`, supplied by `Worker.make`). + */ +export class MapleDb extends Binding.Service()( + "@maple/api/MapleDb", + "MAPLE_DB", + isD1Database, +) {} diff --git a/apps/api/src/lib/WorkerEnvironment.ts b/apps/api/src/lib/WorkerEnvironment.ts index d085fd9f7..983ca1e42 100644 --- a/apps/api/src/lib/WorkerEnvironment.ts +++ b/apps/api/src/lib/WorkerEnvironment.ts @@ -1,5 +1,19 @@ -// Thin re-export of the runtime-shared `WorkerEnvironment` from -// `@maple/effect-cloudflare`. The shared service uses the same tag -// (`"Cloudflare.Workers.WorkerEnvironment"`) so provision is compatible with -// any prior in-tree usage. -export { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment" +import { WorkerEnvironment } from "@maple/effect-cf" +import { Effect, Layer } from "effect" + +// effect-cf's `WorkerEnvironment` is a bare Context tag. We provide it from the +// `cloudflare:workers` runtime `env` via a dynamic import + fallback, so +// non-worker contexts (tsc, vitest) don't choke on the bare specifier. Mirrors +// the old `@maple/effect-cloudflare` `WorkerEnvironment.layer`. +const workerEnv = Effect.promise(() => + import("cloudflare:workers") + .then((m) => m.env as unknown as Record) + .catch(() => ({}) as Record), +) + +export { WorkerEnvironment } + +export const WorkerEnvironmentLive: Layer.Layer = Layer.effect( + WorkerEnvironment, + workerEnv as Effect.Effect, +) diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 97bf187e5..0c7901563 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -1,13 +1,13 @@ import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare" -import { WorkerConfigProviderLayer, WorkerEnvironment } from "@maple/effect-cloudflare" +import { WorkerConfig } from "@maple/effect-cf" import { Context, FileSystem, Layer, Path } from "effect" import { HttpMiddleware, HttpRouter } from "effect/unstable/http" import * as Etag from "effect/unstable/http/Etag" import * as HttpPlatform from "effect/unstable/http/HttpPlatform" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" import { AllRoutes, ApiAuthLive, ApiObservabilityLive, MainLive } from "./app" -import { persistSession, preloadSession, type SessionsBinding } from "./mcp/lib/session-store" import { DatabaseD1Live } from "./lib/DatabaseD1Live" +import { WorkerEnvironmentLive } from "./lib/WorkerEnvironment" const WorkerFileSystemLive = FileSystem.layerNoop({}) @@ -36,163 +36,49 @@ const WorkerPlatformLive = Layer.mergeAll( WorkerHttpPlatformLive, ) -// Construct telemetry once at module scope — `layer` is stable, `flush(env)` -// resolves env lazily on first call. Including `telemetry.layer` in the -// handler's layer composition is the critical bit: the Tracer reference must -// live in the same runtime as the routes that emit spans. +// Telemetry is built once at module scope; `telemetry.layer` lives in the same +// runtime as the routes (so spans share the Tracer), and `flush(env)` drains the +// in-isolate OTLP buffers — scheduled via `ctx.waitUntil` after each response. const telemetry = MapleCloudflareSDK.make({ serviceName: "maple-api", dropSpanNames: ["McpServer/Notifications."], }) -// POST /mcp hangs indefinitely on Cloudflare Workers when `toWebHandler` is -// called with no middleware (1101 in prod, miniflare "worker hung" locally). -// Suspected Effect RpcServer / HttpRouter scope-propagation bug. Providing -// ANY middleware — even a pass-through — unsticks it. Paired with -// `disableLogger: true` so Effect's default `HttpMiddleware.logger` does not -// double-log; application logs flow through the OTLP logger installed by -// `telemetry.layer`. -const passThroughMiddleware: HttpMiddleware.HttpMiddleware = (httpApp) => httpApp - -const buildHandler = () => - HttpRouter.toWebHandler( - AllRoutes.pipe( - Layer.provideMerge(MainLive), - Layer.provideMerge(ApiAuthLive), - Layer.provideMerge(ApiObservabilityLive), - Layer.provideMerge(WorkerPlatformLive), - Layer.provideMerge(DatabaseD1Live), - Layer.provideMerge(WorkerEnvironment.layer), - Layer.provideMerge(telemetry.layer), - Layer.provideMerge(WorkerConfigProviderLayer), - ), - { middleware: passThroughMiddleware, disableLogger: true }, - ) - -// Single isolate-wide handler — `toWebHandler` builds its own ManagedRuntime -// once and keeps it for the lifetime of the isolate. Built eagerly at module -// load so a layer construction failure surfaces as a startup error in -// `wrangler tail` instead of silently hanging the first request and bricking -// the isolate (Cloudflare 1101). -const cachedHandler = buildHandler() -const getHandler = () => cachedHandler - -const isMcpPost = (request: Request): boolean => { - if (request.method !== "POST") return false - try { - return new URL(request.url).pathname === "/mcp" - } catch { - return false - } -} - -const readMcpSessionsBinding = (env: Record): SessionsBinding | undefined => { - const candidate = env.MCP_SESSIONS - if (candidate && typeof candidate === "object" && "get" in candidate && "put" in candidate) { - return candidate as SessionsBinding - } - return undefined -} - -type McpFrame = { method: string; id: string } - -// Peek the JSON-RPC body without consuming the request stream. Returns the -// first frame's method and id (string-coerced; "-" if absent). Tolerates batch -// payloads and malformed JSON — diagnostics only, never throws. -const peekMcpFrame = (body: string): McpFrame => { - try { - const parsed = JSON.parse(body) - const first = Array.isArray(parsed) ? parsed[0] : parsed - const method = typeof first?.method === "string" ? first.method : "-" - const id = - first?.id === undefined || first?.id === null ? "-" : String(first.id) - return { method, id } - } catch { - return { method: "-", id: "-" } - } -} - -// The handler should never throw under normal operation — Effect surfaces -// errors as HTTP responses. If it does (layer construction failure, fatal -// runtime error), we surface it as a 504 outside Effect. -// -// MCP session persistence runs OUTSIDE the Effect runtime on purpose. Effect's -// fiber scheduler doesn't reliably propagate AsyncLocalStorage through every -// generator resumption / scope finalizer / forked fiber, so reading a binding -// via ALS from inside an `override set()` on the clientSessions Map silently -// no-ops in some paths — sessions stay in-memory only and the next isolate 404s. -// Driving the KV preload+put from this outer async context means the bindings -// come from `env` directly — no AsyncLocalStorage required. -const handle = async ( - request: Request, - env: Record, - ctx: ExecutionContext, -): Promise => { - const kv = readMcpSessionsBinding(env) - const isMcp = isMcpPost(request) - const reqSid = isMcp ? request.headers.get("mcp-session-id") : null - - // MCP diagnostics: buffer the body so we can peek the JSON-RPC method/id - // before handing it off to Effect, then re-emit the request with the - // buffered body so the inner handler still sees a readable stream. - let forwardRequest = request - let mcpFrame: McpFrame | null = null - const startedAt = Date.now() - if (isMcp) { - const bodyText = await request.text() - mcpFrame = peekMcpFrame(bodyText) - forwardRequest = new Request(request.url, { - method: request.method, - headers: request.headers, - body: bodyText, - }) - console.log( - `[mcp-in] method=${mcpFrame.method} id=${mcpFrame.id}` + - ` sid=${reqSid ?? "-"} body_len=${bodyText.length}`, - ) - } - - if (kv && reqSid) await preloadSession(kv, reqSid) +// Load-bearing despite looking pointless: with NO `middleware`, POST /mcp hangs +// (Cloudflare kills it as "worker hung" — verified). Any middleware flips +// `toHandled` onto its `matchCauseEffect(tracer(middleware(responded)))` path, +// which unsticks the RpcServer/HttpRouter scope bug. `disableLogger: true` keeps +// Effect's default request logger off (app logs flow through the OTLP logger). +const passThrough: HttpMiddleware.HttpMiddleware = (httpApp) => httpApp + +// `WorkerEnvironmentLive` (outermost) satisfies the `WorkerEnvironment` +// requirement of `DatabaseD1Live` (D1 binding) and `WorkerConfig.providerLayer` +// (env-backed Effect ConfigProvider). `toWebHandler` provides the HttpRouter and +// runs the full HTTP chain (tracer + CORS pre-response handlers + error→response). +const { handler } = HttpRouter.toWebHandler( + AllRoutes.pipe( + Layer.provideMerge(MainLive), + Layer.provideMerge(ApiAuthLive), + Layer.provideMerge(ApiObservabilityLive), + Layer.provideMerge(WorkerPlatformLive), + Layer.provideMerge(DatabaseD1Live), + Layer.provideMerge(telemetry.layer), + Layer.provideMerge(WorkerConfig.providerLayer), + Layer.provideMerge(WorkerEnvironmentLive), + ), + { middleware: passThrough, disableLogger: true }, +) - const { handler } = getHandler() - try { - const response = await handler(forwardRequest, Context.empty() as never) - if (kv && isMcp) { - const resSid = response.headers.get("mcp-session-id") - // Only persist when the server issued a new session — i.e. on - // `initialize`, where the response sid differs from the request sid - // (or the request had none). Subsequent requests echo the same sid; - // re-putting on every call would burn KV write quota for no reason. - if (resSid && resSid !== reqSid) { - const put = persistSession(kv, resSid) - if (put) ctx.waitUntil(put) - } - } - if (isMcp && mcpFrame) { - console.log( - `[mcp-out] method=${mcpFrame.method} id=${mcpFrame.id}` + - ` status=${response.status} dur=${Date.now() - startedAt}ms` + - ` body_len=${response.headers.get("content-length") ?? "-"}` + - ` resp_sid=${response.headers.get("mcp-session-id") ?? "-"}`, - ) - } +export default { + async fetch( + request: Request, + env: Record, + ctx: ExecutionContext, + ): Promise { + // Providing `middleware` widens `toWebHandler`'s handler to require a base + // context; we have none to add, so pass an empty one. + const response = await handler(request, Context.empty() as never) ctx.waitUntil(telemetry.flush(env)) return response - } catch (err) { - console.error("[worker] handler failed:", err) - if (isMcp && mcpFrame) { - console.error( - `[mcp-err] method=${mcpFrame.method} id=${mcpFrame.id}` + - ` dur=${Date.now() - startedAt}ms`, - ) - } - ctx.waitUntil(telemetry.flush(env)) - const message = err instanceof Error ? err.message : String(err) - return new Response(`worker handler error: ${message}`, { status: 504 }) - } -} - -export default { - fetch: (request: Request, env: Record, ctx: ExecutionContext) => - handle(request, env, ctx), + }, } diff --git a/bun.lock b/bun.lock index 20410bd0e..00e09b0da 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "maple", "devDependencies": { - "@cloudflare/workers-types": "4.20260414.1", + "@cloudflare/workers-types": "4.20260525.1", "@effect/vitest": "4.0.0-beta.70", "@maple/db": "workspace:*", "@maple/infra": "workspace:*", @@ -26,7 +26,7 @@ "@maple/api": "workspace:*", "@maple/db": "workspace:*", "@maple/domain": "workspace:*", - "@maple/effect-cloudflare": "workspace:*", + "@maple/effect-cf": "workspace:*", "effect": "catalog:effect", }, "devDependencies": { @@ -48,7 +48,7 @@ "@maple-dev/effect-sdk": "workspace:*", "@maple/db": "workspace:*", "@maple/domain": "workspace:*", - "@maple/effect-cloudflare": "workspace:*", + "@maple/effect-cf": "workspace:*", "@maple/email": "workspace:*", "@maple/infra": "workspace:*", "@maple/query-engine": "workspace:*", @@ -261,13 +261,15 @@ "vitest": "catalog:", }, }, - "lib/effect-cloudflare": { - "name": "@maple/effect-cloudflare", + "lib/effect-cf": { + "name": "@maple/effect-cf", "dependencies": { + "@effect/sql-d1": "catalog:effect", + "@effect/sql-pg": "catalog:effect", "effect": "catalog:effect", }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260405.0", + "@cloudflare/workers-types": "^4.20260421.1", "@effect/language-service": "catalog:effect", "@types/node": "catalog:tooling", "typescript": "catalog:tooling", @@ -475,6 +477,9 @@ "patchedDependencies": { "effect@4.0.0-beta.70": "patches/effect@4.0.0-beta.70.patch", }, + "overrides": { + "@cloudflare/workers-types": "4.20260525.1", + }, "catalog": { "vite": "^8.0.3", "vitest": "^4.1.2", @@ -488,6 +493,8 @@ "@effect/language-service": "^0.85.1", "@effect/opentelemetry": "4.0.0-beta.70", "@effect/platform-bun": "4.0.0-beta.70", + "@effect/sql-d1": "4.0.0-beta.70", + "@effect/sql-pg": "4.0.0-beta.70", "effect": "4.0.0-beta.70", }, "react": { @@ -880,7 +887,7 @@ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260414.1", "", {}, "sha512-E2wgYT1ywoM1M68nmVpxKdKzXsZm5vOu2plsqUixlK7YIydqsw31dZ+EjwXnAsdEjLaYC6XfsJayil8AEhyaBQ=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260525.1", "", {}, "sha512-vQSjvQINGx2i3dovvVyszew4HU3+82eAuD2ryNmedBIzLcLwluKsnPLTAuL4FDVRyz+MikQEihEBsUmcTElEVQ=="], "@coinbase/wallet-sdk": ["@coinbase/wallet-sdk@4.3.7", "", { "dependencies": { "@noble/hashes": "^1.4.0", "clsx": "^1.2.1", "eventemitter3": "^5.0.1", "preact": "^10.24.2", "viem": "^2.27.2" } }, "sha512-z6e5XDw6EF06RqkeyEa+qD0dZ2ZbLci99vx3zwDY//XO8X7166tqKJrR2XlQnzVmtcUuJtCd5fCvr9Cu6zzX7w=="], @@ -914,6 +921,10 @@ "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.70", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.70" } }, "sha512-3VXuL63IDmq13We+ApRKn2JW3Rb9g5gj1YEmfb8u2b73norur1VsIJ/pRE4qjShevg19dQYi2JsLawSZ6gApug=="], + "@effect/sql-d1": ["@effect/sql-d1@4.0.0-beta.70", "", { "dependencies": { "@cloudflare/workers-types": "^4.20260511.1" }, "peerDependencies": { "effect": "^4.0.0-beta.70" } }, "sha512-86zuNS+j9BCB6BnUDkqbzdS0iRhuGw7+bDw+HqV7PgwYAw53/UspyH0Z+v2dLUP9e9LcFat9KJLVbsCGqmdDZQ=="], + + "@effect/sql-pg": ["@effect/sql-pg@4.0.0-beta.70", "", { "dependencies": { "pg": "^8.20.0", "pg-connection-string": "2.12.0", "pg-cursor": "^2.19.0", "pg-pool": "^3.13.0", "pg-types": "^4.1.0" }, "peerDependencies": { "effect": "^4.0.0-beta.70" } }, "sha512-l1ToDca5mNLp3wB/dyDIGGKxW1BExnSmyzvZV29upzCzY7kbz8UlFqMGeWBIUYo4pUvYfZMANokQWDMIFs4PEQ=="], + "@effect/vitest": ["@effect/vitest@4.0.0-beta.70", "", { "peerDependencies": { "effect": "^4.0.0-beta.70", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-XDteNN0xfOgoMauAVoN5iylxVgEjp7kFsGFq18tZ5XYjek0eOZa0nOoes5s7Bs71VvwjnCeCbFMD7IhxswEt8A=="], "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="], @@ -1296,7 +1307,7 @@ "@maple/domain": ["@maple/domain@workspace:packages/domain"], - "@maple/effect-cloudflare": ["@maple/effect-cloudflare@workspace:lib/effect-cloudflare"], + "@maple/effect-cf": ["@maple/effect-cf@workspace:lib/effect-cf"], "@maple/email": ["@maple/email@workspace:packages/email"], @@ -3744,6 +3755,8 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "octokit": ["octokit@3.1.2", "", { "dependencies": { "@octokit/app": "^14.0.2", "@octokit/core": "^5.0.0", "@octokit/oauth-app": "^6.0.0", "@octokit/plugin-paginate-graphql": "^4.0.0", "@octokit/plugin-paginate-rest": "^9.0.0", "@octokit/plugin-rest-endpoint-methods": "^10.0.0", "@octokit/plugin-retry": "^6.0.0", "@octokit/plugin-throttling": "^8.0.0", "@octokit/request-error": "^5.0.0", "@octokit/types": "^12.0.0" } }, "sha512-MG5qmrTL5y8KYwFgE1A4JWmgfQBaIETE/lOlfwNYx1QOtCQHGVxkRJmdUJltFc1HVn73d61TlMhMyNTOtMl+ng=="], @@ -3834,6 +3847,26 @@ "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], + "pg": ["pg@8.21.0", "", { "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="], + + "pg-cursor": ["pg-cursor@2.20.0", "", { "peerDependencies": { "pg": "^8" } }, "sha512-HP/EbUafheaUOs7DxlG6tda/rhmsX2hCTJJJ+gCnhljGyNEs6pBHddbNuomlW3DqEhP3zYD+GqBWkYnJPIZ4tA=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="], + + "pg-types": ["pg-types@4.1.0", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -3860,6 +3893,16 @@ "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], + "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], + + "postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="], + + "postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="], + + "postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="], + + "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="], + "posthog-node": ["posthog-node@4.18.0", "", { "dependencies": { "axios": "^1.8.2" } }, "sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw=="], "powershell-utils": ["powershell-utils@0.2.0", "", {}, "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw=="], @@ -4204,6 +4247,8 @@ "split-on-first": ["split-on-first@3.0.0", "", {}, "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "sqlite-wasm-kysely": ["sqlite-wasm-kysely@0.3.0", "", { "dependencies": { "@sqlite.org/sqlite-wasm": "^3.48.0-build2" }, "peerDependencies": { "kysely": "*" } }, "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg=="], @@ -4592,6 +4637,8 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -5336,6 +5383,10 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "pg/pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="], + + "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -6254,6 +6305,14 @@ "ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "protobufjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], diff --git a/lib/effect-cf/LICENSE b/lib/effect-cf/LICENSE new file mode 100644 index 000000000..88deddc08 --- /dev/null +++ b/lib/effect-cf/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dan van der Merwe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/effect-cf/README.md b/lib/effect-cf/README.md new file mode 100644 index 000000000..045b0f411 --- /dev/null +++ b/lib/effect-cf/README.md @@ -0,0 +1,29 @@ +# @maple/effect-cf + +Vendored copy of [**effect-cf**](https://github.com/danieljvdm/effect-cf) by Dan van der Merwe — +Effect-native primitives for Cloudflare Workers, Durable Objects, bindings, KV, R2, Queues, and +Workflows. MIT licensed; see [`LICENSE`](./LICENSE). + +This is inlined as a private workspace package (`@maple/effect-cf`) rather than tracked as an npm +dependency. The `src/` is a faithful mirror of upstream so it can be re-synced. + +- **Upstream:** https://github.com/danieljvdm/effect-cf (package `packages/effect-cf`) +- **Vendored at commit:** `9400f05f55abb12c6bcd4cf4f576d215a622bb8a` + +## Local divergences from upstream + +Keep this list current so re-syncs stay tractable. Apply changes additively and minimally. + +- **`src/cloudflare-env.d.ts`** (maple-added): ambient `declare namespace Cloudflare { interface Env {} }` + so the vendored source typechecks standalone in this package. Each consuming worker's generated + `worker-configuration.d.ts` merges with it. + +The `src/` TypeScript is otherwise an unmodified mirror of upstream. Maple consumes only the +binding primitives (`Binding`, `D1`, `WorkerConfig`, `WorkerEnvironment`); the workers keep their +own hand-rolled `fetch`/`scheduled` entrypoints rather than `Worker.make`. + +## Notes + +- Maple uses `Binding.Service` for D1 (raw binding → drizzle), not `D1.Service.sqlLayer`, so + `@effect/sql-d1` / `@effect/sql-pg` are present only so the vendored `D1.ts` / `HyperdrivePg.ts` + typecheck; they should tree-shake out of the worker bundles. diff --git a/lib/effect-cloudflare/package.json b/lib/effect-cf/package.json similarity index 63% rename from lib/effect-cloudflare/package.json rename to lib/effect-cf/package.json index c45047929..f67cce62e 100644 --- a/lib/effect-cloudflare/package.json +++ b/lib/effect-cf/package.json @@ -1,20 +1,21 @@ { - "name": "@maple/effect-cloudflare", + "name": "@maple/effect-cf", "private": true, "type": "module", "exports": { ".": "./src/index.ts", - "./worker-environment": "./src/worker-environment.ts", - "./d1-connection": "./src/d1-connection.ts" + "./package.json": "./package.json" }, "scripts": { "typecheck": "tsc --noEmit" }, "dependencies": { + "@effect/sql-d1": "catalog:effect", + "@effect/sql-pg": "catalog:effect", "effect": "catalog:effect" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260405.0", + "@cloudflare/workers-types": "^4.20260421.1", "@effect/language-service": "catalog:effect", "@types/node": "catalog:tooling", "typescript": "catalog:tooling" diff --git a/lib/effect-cf/src/Binding.ts b/lib/effect-cf/src/Binding.ts new file mode 100644 index 000000000..21e4d6f96 --- /dev/null +++ b/lib/effect-cf/src/Binding.ts @@ -0,0 +1,211 @@ +import { Context, Data, Effect, Layer } from "effect"; + +import { WorkerEnvironment } from "./Environment"; +import type { WorkerEnv } from "./Environment"; + +/** Internal type id marker used by binding helper services. */ +export const TypeId = "effect-cf/Binding" as const; + +/** Error raised when a configured binding does not exist on `env`. */ +export class BindingNotFoundError extends Data.TaggedError("BindingNotFoundError")<{ + readonly binding: string; + readonly message: string; +}> {} + +/** Error raised when a binding exists but does not match the expected shape. */ +export class BindingValidationError extends Data.TaggedError("BindingValidationError")<{ + readonly binding: string; + readonly expected: string; + readonly actual: string; + readonly message: string; +}> {} + +export interface ValidationOptions { + readonly expected?: string; +} + +const defaultExpected = "Cloudflare binding resource"; + +const isPropertyTarget = (value: unknown): value is object => + (typeof value === "object" || typeof value === "function") && value !== null; + +const getObjectName = (value: object | Function): string => { + const tag = (() => { + try { + return Object.prototype.toString.call(value).slice("[object ".length, -1); + } catch { + return typeof value; + } + })(); + const constructorName = (() => { + try { + return "constructor" in value && + typeof value.constructor === "function" && + typeof value.constructor.name === "string" + ? value.constructor.name + : undefined; + } catch { + return undefined; + } + })(); + + if (tag !== "Object") { + return tag; + } + + if (constructorName !== undefined && constructorName !== "" && constructorName !== "Object") { + return constructorName; + } + + return tag; +}; + +const propertyNames = (value: object | Function): ReadonlyArray => { + const names = new Set(); + + for (const target of [value, Object.getPrototypeOf(value)] as const) { + if (target === null || target === Object.prototype || target === Function.prototype) { + continue; + } + + try { + for (const name of Object.getOwnPropertyNames(target)) { + names.add(name); + } + } catch { + continue; + } + } + + return [...names].filter((name) => name !== "constructor").sort(); +}; + +const isMethod = (value: object | Function, name: string): boolean => { + try { + return typeof Reflect.get(value, name) === "function"; + } catch { + return false; + } +}; + +const describeActual = (value: unknown): string => { + if (value === null) { + return "null"; + } + + if (typeof value !== "object" && typeof value !== "function") { + return typeof value; + } + + const names = propertyNames(value); + const methods = names.filter((name) => isMethod(value, name)); + const properties = names.filter((name) => !methods.includes(name)); + const details = [ + methods.length > 0 ? `methods ${methods.join(", ")}` : undefined, + properties.length > 0 ? `properties ${properties.join(", ")}` : undefined, + ].filter((detail) => detail !== undefined); + + if (details.length === 0) { + return getObjectName(value); + } + + return `${getObjectName(value)} with ${details.join("; ")}`; +}; + +const getBinding = ( + env: WorkerEnv, + binding: string, + isResource: (value: unknown) => value is Resource, + options?: ValidationOptions, +): Effect.Effect => + Effect.gen(function* () { + if (!isPropertyTarget(env)) { + const actual = describeActual(env); + return yield* Effect.fail( + new BindingValidationError({ + binding, + expected: "WorkerEnvironment object", + actual, + message: `Cloudflare binding "${binding}" failed validation. Expected WorkerEnvironment object; got ${actual}`, + }), + ); + } + + const resource = Reflect.get(env, binding); + + if (resource === undefined) { + return yield* Effect.fail( + new BindingNotFoundError({ + binding, + message: `Cloudflare binding "${binding}" was not found in WorkerEnvironment`, + }), + ); + } + + if (!isResource(resource)) { + const expected = options?.expected ?? defaultExpected; + const actual = describeActual(resource); + return yield* Effect.fail( + new BindingValidationError({ + binding, + expected, + actual, + message: `Cloudflare binding "${binding}" failed validation. Expected ${expected}; got ${actual}`, + }), + ); + } + + return resource; + }); + +/** + * Creates a Context tag + layer for reading and validating a Cloudflare binding. + */ +export interface BindingService extends Context.ServiceClass< + Self, + Id, + Service +> { + readonly [TypeId]: typeof TypeId; + readonly binding: string; + readonly layer: Layer.Layer< + Self, + BindingNotFoundError | BindingValidationError, + WorkerEnvironment + >; +} + +export const layer = ( + tag: Context.Service, + binding: string, + isResource: (value: unknown) => value is Resource, + wrap?: (resource: Resource) => Service, + options?: ValidationOptions, +): Layer.Layer => + Layer.effect( + tag, + Effect.gen(function* () { + const env = yield* WorkerEnvironment; + const resource = yield* getBinding(env, binding, isResource, options); + return wrap === undefined ? (resource as unknown as Service) : wrap(resource); + }), + ); + +export const Service = + () => + ( + id: Id, + binding: string, + isResource: (value: unknown) => value is Resource, + wrap?: (resource: Resource) => Service, + options?: ValidationOptions, + ): BindingService => { + const tag = Context.Service()(id); + const serviceLayer = layer(tag, binding, isResource, wrap, options); + + return Object.assign(tag, { + [TypeId]: TypeId, + binding, + layer: serviceLayer, + }) as BindingService; + }; diff --git a/lib/effect-cf/src/D1.ts b/lib/effect-cf/src/D1.ts new file mode 100644 index 000000000..5f4240bf9 --- /dev/null +++ b/lib/effect-cf/src/D1.ts @@ -0,0 +1,82 @@ +import { D1Client } from "@effect/sql-d1"; +import { Effect, Layer } from "effect"; + +import * as Binding from "./Binding"; + +const TypeId = "effect-cf/D1" as const; +const expectedD1Database = "D1 database binding with prepare(), batch(), and exec()"; + +/** Typed D1 binding definition. */ +export interface D1Definition { + /** Binding name as configured in `wrangler.jsonc`. */ + readonly binding: string; +} + +/** Options forwarded to `@effect/sql-d1` when building a SQL client layer. */ +export type D1SqlLayerOptions = Omit; + +declare const D1ServiceTypeId: unique symbol; + +/** Nominal service marker for D1 services created with {@link make}. */ +export interface D1Service { + readonly [D1ServiceTypeId]: { + readonly id: Id; + }; +} + +const isD1Database = (value: unknown): value is D1Database => { + if (typeof value !== "object" || value === null) { + return false; + } + + const resource = value as Record; + + return ( + typeof resource.prepare === "function" && + typeof resource.batch === "function" && + typeof resource.exec === "function" + ); +}; + +/** + * Creates a typed D1 service tag plus Effect helpers. + */ +export const make = (id: Id, definition: D1Definition) => + Service>()(id, definition); + +/** + * Builds a D1 service around a Cloudflare D1 database binding. + * + * The returned service exposes the raw `D1Database` binding and `sqlLayer(...)` + * for providing `effect/unstable/sql` via `@effect/sql-d1`. + * + * @example + * ```ts + * class TodoDatabase extends D1.Service()("TodoDatabase", { + * binding: "TODO_DB", + * }) {} + * + * const SqlLive = TodoDatabase.sqlLayer(); + * ``` + */ +export const Service = + () => + (id: Id, definition: D1Definition) => { + const tag = Binding.Service()(id, definition.binding, isD1Database, undefined, { + expected: expectedD1Database, + }); + + const sqlLayer = (options?: D1SqlLayerOptions) => + Layer.unwrap( + Effect.gen(function* () { + const db = yield* tag; + return D1Client.layer({ ...options, db }); + }), + ).pipe(Layer.provide(tag.layer)); + + return Object.assign(tag, { + [TypeId]: TypeId, + definition, + sqlLayer, + }); + }; diff --git a/lib/effect-cf/src/DurableObject.ts b/lib/effect-cf/src/DurableObject.ts new file mode 100644 index 000000000..533a8d832 --- /dev/null +++ b/lib/effect-cf/src/DurableObject.ts @@ -0,0 +1,355 @@ +import { DurableObject as CloudflareDurableObject } from "cloudflare:workers"; +import { Effect, Layer, ManagedRuntime, type Context, type Scope } from "effect"; +import type { Schema as S } from "effect"; + +import { NativeRequest } from "./Worker"; +import { WorkerEnvironment, type WorkerEnv } from "./Environment"; +import { DurableObjectState, fromDurableObjectState } from "./DurableObjectState"; +import { fromWebSocket, type DurableWebSocket } from "./DurableObjectWebSocket"; +import type * as Binding from "./Binding"; +import * as DurableObjectDefinition from "./DurableObjectDefinition"; +import type * as DurableObjectNamespace from "./DurableObjectNamespace"; +import type * as Rpc from "./Rpc"; +import * as Entrypoint from "./internal/Entrypoint"; + +const reservedMethodNames = new Set([ + "constructor", + "dup", + "fetch", + "alarm", + "webSocketMessage", + "webSocketClose", + "webSocketError", +]); + +type RuntimeContext = DurableObjectState | WorkerEnvironment | ROut; + +type HandlerContext = RuntimeContext | Scope.Scope; + +type FetchContext = HandlerContext | NativeRequest; + +const RunSymbol = Symbol.for("effect-cf/DurableObject/run"); + +/** + * Effect type for Durable Object lifecycle and RPC handlers. + */ +export type DurableObjectHandler = Effect.Effect< + A, + unknown, + HandlerContext +>; + +/** + * Shape of Durable Object RPC handlers passed to {@link make}. + */ +export type DurableObjectRpc = Record< + string, + (...args: Array) => DurableObjectHandler +>; + +export type DurableObjectRpcShape, ROut> = { + readonly [Key in keyof Rpc]: Rpc[Key] extends ( + ...args: infer Args + ) => Effect.Effect> + ? (...args: Args) => Promise + : never; +}; + +export type RpcHandlers = { + readonly [Key in keyof Api as Key extends keyof CloudflareDurableObject + ? never + : Key extends string + ? [Api[Key]] extends [never] + ? never + : Api[Key] extends (...args: Array) => Promise + ? Key + : never + : never]: Api[Key] extends (...args: infer Args) => Promise + ? (...args: Args) => DurableObjectHandler + : never; +}; + +/** + * Options for creating a Durable Object class backed by Effect handlers. + */ +export interface DurableObjectOptions> { + /** + * Effect run when Cloudflare loads this Durable Object instance into memory. + * + * Use `DurableObjectState.blockConcurrencyWhile` inside this hook when + * incoming events should wait for setup to finish. Cloudflare may construct + * the same Durable Object id again after eviction or restart; use Durable + * Object storage if work must happen only once per id. + */ + readonly initialize?: Effect.Effect>; + /** Optional RPC methods exposed as Durable Object instance methods. */ + readonly rpc?: Rpc; + /** Optional fetch handler for HTTP/WebSocket requests. */ + readonly fetch?: Effect.Effect>; + /** + * Optional logical alarm processing effect. + * + * This runs before `alarm` and should be built with helpers such as + * `DurableObjectAlarm.processDue(...)` so the reusable scheduler stays inside + * the Durable Object's single managed runtime boundary. + */ + readonly alarms?: Effect.Effect>; + readonly alarm?: ( + alarmInfo?: globalThis.AlarmInvocationInfo, + ) => Effect.Effect>; + readonly webSocketMessage?: ( + socket: DurableWebSocket, + message: string | ArrayBuffer, + ) => Effect.Effect>; + readonly webSocketClose?: ( + socket: DurableWebSocket, + code: number, + reason: string, + wasClean: boolean, + ) => Effect.Effect>; + readonly webSocketError?: ( + socket: DurableWebSocket, + error: unknown, + ) => Effect.Effect>; +} + +/** + * Cloudflare `DurableObject` constructor produced by {@link make}. + */ +export type DurableObjectClass, ROut> = new ( + state: globalThis.DurableObjectState, + env: WorkerEnv, +) => CloudflareDurableObject & DurableObjectRpcShape; + +/** + * Creates a Durable Object class backed by a single managed Effect runtime. + */ +export const make = < + ROut, + LayerError, + const Rpc extends DurableObjectRpc = Record, +>( + layer: Layer.Layer, + options: DurableObjectOptions = {}, +): DurableObjectClass => { + class EffectDurableObject extends CloudflareDurableObject { + readonly runtime: ManagedRuntime.ManagedRuntime, LayerError>; + + constructor(state: globalThis.DurableObjectState, env: WorkerEnv) { + super(state, env); + + const services = Layer.mergeAll( + Layer.succeed(DurableObjectState, fromDurableObjectState(state)), + Layer.succeed(WorkerEnvironment, env), + ); + + const runtimeLayer = Entrypoint.provideEntrypointServices(layer, services); + + this.runtime = ManagedRuntime.make(runtimeLayer); + + const initialize = options.initialize; + if (initialize !== undefined) { + state.waitUntil(this[RunSymbol](initialize)); + } + } + + [RunSymbol](effect: Effect.Effect>): Promise { + return this.runtime.runPromise(Effect.scoped(effect)); + } + + fetch(request: Request): Promise { + const fetchHandler = options.fetch; + + if (fetchHandler === undefined) { + return Promise.resolve(new Response("Not Found", { status: 404 })); + } + + return this[RunSymbol](Effect.provideService(fetchHandler, NativeRequest, request)); + } + + alarm(alarmInfo?: globalThis.AlarmInvocationInfo): Promise | void { + const logicalAlarms = options.alarms?.pipe(Effect.asVoid); + const rawAlarm = options.alarm?.(alarmInfo); + + if (logicalAlarms !== undefined && rawAlarm !== undefined) { + return this[RunSymbol]( + Effect.gen(function* () { + yield* logicalAlarms; + yield* rawAlarm; + }), + ); + } + + if (logicalAlarms !== undefined) { + return this[RunSymbol](logicalAlarms); + } + + if (rawAlarm !== undefined) { + return this[RunSymbol](rawAlarm); + } + } + + webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): Promise | void { + if (options.webSocketMessage !== undefined) { + return this[RunSymbol](options.webSocketMessage(fromWebSocket(socket), message)); + } + } + + webSocketClose( + socket: WebSocket, + code: number, + reason: string, + wasClean: boolean, + ): Promise | void { + if (options.webSocketClose !== undefined) { + return this[RunSymbol]( + options.webSocketClose(fromWebSocket(socket), code, reason, wasClean), + ); + } + } + + webSocketError(socket: WebSocket, error: unknown): Promise | void { + if (options.webSocketError !== undefined) { + return this[RunSymbol](options.webSocketError(fromWebSocket(socket), error)); + } + } + } + + Entrypoint.defineEntrypointRpcMethods( + "Durable Object", + EffectDurableObject.prototype, + options.rpc, + reservedMethodNames, + (self, effect) => self[RunSymbol](effect), + ); + + return Entrypoint.assumeEntrypointClass>(EffectDurableObject); +}; + +export type ServiceFreeSchema = S.Codec; + +export interface Method< + Args extends ReadonlyArray = ReadonlyArray, + Success extends ServiceFreeSchema = ServiceFreeSchema, +> { + readonly args: Args; + readonly success: Success; +} + +export namespace Method { + export type Any = Method, ServiceFreeSchema>; + + type ArgsFromSchemas> = Args extends readonly [] + ? [] + : Args extends readonly [ + infer Head extends ServiceFreeSchema, + ...infer Tail extends ReadonlyArray, + ] + ? [S.Schema.Type, ...ArgsFromSchemas] + : Array>; + + export type Args = ArgsFromSchemas; + + export type Success = S.Schema.Type; +} + +export type Methods = Record; + +export type ReservedMethodName = DurableObjectDefinition.ReservedMethodName; + +export type NoReservedMethods = + Extract extends never ? MethodsShape : never; + +export interface Definition { + readonly id: Id; + readonly methods: MethodsShape; +} + +export namespace Definition { + export type Any = Definition; +} + +export type LayerOptions = DurableObjectDefinition.LayerOptions; + +export type ServerApi = { + readonly [Key in keyof Self["methods"]]: ( + ...args: Method.Args + ) => Promise>; +}; + +export type Api = Rpc.Provider, ReservedMethodName>; + +export type Handlers = { + readonly [Key in keyof Self["methods"]]: ( + ...args: Method.Args + ) => DurableObjectHandler>; +}; + +export interface Options extends Omit< + DurableObjectOptions>, + "rpc" +> { + readonly rpc: Handlers; +} + +export type TagClass = Context.ServiceClass< + Self, + Id, + DurableObjectNamespace.DurableObjectNamespaceEffectClient< + Api>, + Definition + > +> & + DurableObjectNamespace.DurableObjectNamespaceStaticClient< + Self, + Api>, + Definition + > & { + readonly id: Id; + readonly methods: MethodsShape; + readonly make: ( + layer: Layer.Layer, + options: Options>, + ) => DurableObjectClass>, ROut>; + readonly layer: ( + options: LayerOptions, + ) => Layer.Layer< + Self, + Binding.BindingNotFoundError | Binding.BindingValidationError, + WorkerEnvironment + >; + }; + +export type TagFactory = () => ( + id: Id, + methods: MethodsShape & NoReservedMethods, +) => TagClass; + +export const Tag = DurableObjectDefinition.Tag as unknown as TagFactory; + +export const method = DurableObjectDefinition.method as { + (definition: { + readonly success: Success; + }): Method; + < + const Args extends ReadonlyArray, + Success extends ServiceFreeSchema, + >(definition: { + readonly args: Args; + readonly success: Success; + }): Method; +}; + +export const implement = DurableObjectDefinition.implement as unknown as < + ROut, + const Self extends Definition.Any, +>( + _definition: Self, + handlers: Handlers, +) => Handlers; + +export type HandlerEffect< + ROut, + Self extends Definition.Any, + Key extends keyof Self["methods"], +> = DurableObjectHandler>; diff --git a/lib/effect-cf/src/DurableObjectAlarm.ts b/lib/effect-cf/src/DurableObjectAlarm.ts new file mode 100644 index 000000000..0ccdd6843 --- /dev/null +++ b/lib/effect-cf/src/DurableObjectAlarm.ts @@ -0,0 +1,832 @@ +import { Clock, Context, Data, DateTime, Duration, Effect, Exit, Layer, Schema as S } from "effect"; + +import { DurableObjectState } from "./DurableObjectState"; +import type { SqlStorageValue, StorageOperationError } from "./DurableObjectStorage"; + +const INIT_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS effect_cf_scheduled_alarms ( + storage_id TEXT PRIMARY KEY, + alarm_id TEXT NOT NULL, + tag TEXT NOT NULL, + run_at INTEGER NOT NULL, + repeat_every_ms INTEGER, + payload TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_effect_cf_scheduled_alarms_run_at_storage_id + ON effect_cf_scheduled_alarms (run_at, storage_id); +`; + +const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100; +const DEFAULT_PROCESS_DUE_ALARMS_FAILURE_RESCHEDULE_AFTER = "30 seconds" satisfies Duration.Input; + +const getScheduledEventId = (input: { readonly id: string; readonly tag: string }) => + `effect-cf-alarm:${encodeURIComponent(input.tag)}:${encodeURIComponent(input.id)}`; + +/** + * JSON-serializable alarm payload stored with each scheduled alarm. + * + * Payloads are intentionally opaque to `DurableObjectAlarm`. Consumers should use + * stable `tag` values to route due alarms and then decode the payload for that + * tag in their own domain layer. + */ +export type AlarmPayload = S.Json; + +interface AlarmRow extends Record { + readonly alarm_id: string; + readonly payload: string; + readonly repeat_every_ms: number | null; + readonly run_at: number; + readonly storage_id: string; + readonly tag: string; +} + +interface NextAlarmRow extends Record { + readonly run_at: number; +} + +export class InvalidAlarmRefError extends Data.TaggedError("InvalidAlarmRefError")<{ + readonly cause: unknown; +}> {} + +export class InvalidAlarmPayloadError extends Data.TaggedError("InvalidAlarmPayloadError")<{ + readonly cause: unknown; +}> {} + +export class InvalidRepeatEveryError extends Data.TaggedError("InvalidRepeatEveryError")<{ + readonly cause: unknown; +}> {} + +export class InvalidProcessDueAlarmsOptionsError extends Data.TaggedError( + "InvalidProcessDueAlarmsOptionsError", +)<{ + readonly cause: unknown; +}> {} + +export class StoredAlarmDecodeError extends Data.TaggedError("StoredAlarmDecodeError")<{ + readonly cause: unknown; + readonly storageId: string; +}> {} + +export type DurableObjectAlarmError = + | InvalidAlarmPayloadError + | InvalidAlarmRefError + | InvalidProcessDueAlarmsOptionsError + | InvalidRepeatEveryError + | StorageOperationError + | StoredAlarmDecodeError; + +/** + * Events handled by `processDueAlarms`. + * + * `AlarmDue` represents one durable alarm whose scheduled time is now due. The + * `tag` is the consumer-owned routing key, while `id` is the stable alarm id + * within that tag. + * + * `scheduledAt` is the logical due-after timestamp from durable storage. It is + * not expected to match the current Durable Object alarm invocation time, + * especially during retries. Retries re-scan durable rows with `run_at <= now` + * and identify logical events by `{ tag, id }`. + * + * @example Handling events by tag + * ```ts + * yield* durableObjectAlarm.processDueAlarms((event) => + * event.tag === "heartbeat" + * ? heartbeatManager.handleAlarmEvent(event).pipe(Effect.asVoid) + * : Effect.void + * ); + * ``` + */ +export const DurableObjectAlarmEvent = S.TaggedUnion({ + AlarmDue: { + id: S.NonEmptyString, + payload: S.Json, + scheduledAt: S.DateTimeUtc, + tag: S.NonEmptyString, + }, +}); +export type DurableObjectAlarmEvent = typeof DurableObjectAlarmEvent.Type; + +/** + * Stable reference for a scheduled alarm. + * + * The pair of `tag` and `id` is the durable identity. Scheduling another alarm + * with the same pair replaces the previous alarm, and cancellation uses the + * same pair. + * + * @example Cancel an alarm + * ```ts + * yield* durableObjectAlarm.cancelAlarm({ + * tag: "connection-reconnect-grace", + * id: "reconnect-grace-check", + * }); + * ``` + */ +export type AlarmRef = { + readonly id: string; + readonly tag: Tag; +}; + +const AlarmRefSchema = S.Struct({ + id: S.NonEmptyString, + tag: S.NonEmptyString, +}); + +const decodeAlarmRef = (input: AlarmRef) => + S.decodeUnknownEffect(AlarmRefSchema)(input).pipe( + Effect.mapError((cause) => new InvalidAlarmRefError({ cause })), + ); + +/** + * Input for scheduling or replacing an alarm. + * + * `runAt` is the absolute first fire time. `repeatEvery` can be any Effect + * `Duration.Input`, including strings like `"5 seconds"`, numbers interpreted + * as milliseconds, or `Duration` values. + * + * @example One-shot alarm + * ```ts + * const now = yield* DateTime.now; + * + * yield* durableObjectAlarm.scheduleAlarm({ + * tag: "connection-reconnect-grace", + * id: "reconnect-grace-check", + * runAt: DateTime.add(now, { seconds: 15 }), + * payload: { reason: "socket-closed" }, + * }); + * ``` + * + * @example Repeating alarm + * ```ts + * const now = yield* DateTime.now; + * + * yield* durableObjectAlarm.scheduleAlarm({ + * tag: "heartbeat", + * id: "heartbeat-check", + * runAt: DateTime.add(now, { seconds: 5 }), + * repeatEvery: "5 seconds", + * payload: null, + * }); + * ``` + */ +export type ScheduleAlarmInput = AlarmRef & { + readonly payload: AlarmPayload; + readonly repeatEvery?: Duration.Input; + readonly runAt: DateTime.Utc; +}; + +export type ProcessDueAlarmsMode = "isolated" | "ordered"; + +export interface ProcessDueAlarmsFailure { + readonly cause: unknown; + readonly event?: DurableObjectAlarmEvent; + readonly id: string; + readonly storageId: string; + readonly tag: string; +} + +export interface ProcessDueAlarmsResult { + readonly failed: readonly ProcessDueAlarmsFailure[]; + readonly handled: readonly DurableObjectAlarmEvent[]; +} + +export type ProcessDueAlarmsFailureAction = + | "ordered" + | "retry" + | "skip-and-advance-repeat" + | { + readonly mode: "ordered"; + } + | { + readonly mode: "retry"; + readonly retryFailedAfter?: Duration.Input; + } + | { + readonly mode: "skip-and-advance-repeat"; + }; + +export interface ProcessDueAlarmsOptions { + /** Maximum due rows to load and process in one invocation. Defaults to 100. */ + readonly limit?: number; + /** + * Failure isolation mode. Defaults to `isolated` so one poison logical alarm + * does not block unrelated maintenance work. + */ + readonly mode?: ProcessDueAlarmsMode; + /** + * Callback for logical handler/decode failures. Return a failure action to + * override the global failure behavior for this row. + */ + readonly onFailure?: ( + failure: ProcessDueAlarmsFailure, + ) => Effect.Effect; + /** + * Delay before retrying a failed logical row in isolated mode. Defaults to 30 + * seconds. Ignored by ordered mode unless supplied explicitly. + * + * Retrying updates only the selected occurrence, so replacements scheduled by + * a long-running handler are not clobbered by failure handling. + */ + readonly retryFailedAfter?: Duration.Input; +} + +/** + * Handler invoked for each due alarm before that alarm is acknowledged. + * + * If the handler fails, `DurableObjectAlarm` leaves the row due and fails the alarm + * processing effect. Cloudflare can then retry the Durable Object alarm handler, + * and the scheduler will find the same logical row again because `run_at` is + * still less than or equal to the retry time. + */ +export type ProcessDueAlarmsHandler = ( + event: DurableObjectAlarmEvent, +) => Effect.Effect; + +/** + * Durable alarm scheduler API. + * + * This service only owns scheduling semantics. It does not publish, subscribe, + * or dispatch events to consumers. Durable Object alarm handlers should pass a + * domain handler to `processDueAlarms`; rows are acknowledged only after that + * handler succeeds. + * + * This service must be the only code path that owns `storage.setAlarm()` inside + * a Durable Object instance. Cloudflare exposes one platform alarm timestamp per + * object, so another scheduler in the same object can clobber this service's + * reconciled alarm timestamp. + * + * @example Durable Object alarm handler + * ```ts + * const onAlarm = Effect.gen(function* () { + * const durableObjectAlarm = yield* DurableObjectAlarm; + * + * yield* durableObjectAlarm.processDueAlarms((event) => + * Effect.gen(function* () { + * if (event.tag === "heartbeat") { + * yield* heartbeatManager.handleAlarmEvent(event); + * return; + * } + * + * if (event.tag === "connection-reconnect-grace") { + * yield* connectionManager.expireReconnectGracePeriods(); + * } + * }) + * ); + * }); + * ``` + */ +export type AlarmScheduler = { + /** + * Cancel a scheduled alarm by `{ tag, id }`. + * + * This is idempotent. Cancelling a missing alarm is a no-op. The underlying + * Durable Object alarm is reconciled afterward. + */ + readonly cancelAlarm: ( + input: AlarmRef, + ) => Effect.Effect; + + /** + * Process due alarms according to `Clock.currentTimeMillis`. + * + * Each row is acknowledged only after `handle` succeeds. One-shot alarms are + * then deleted. Repeating alarms are rescheduled to `acknowledgedAt + + * repeatEvery`, which intentionally behaves as delay-after-success rather + * than fixed-cadence catch-up. Acknowledgements are conditional on the stored + * row still matching the selected occurrence, so a handler that reschedules + * the same `{ tag, id }` will not have its replacement clobbered by the old + * acknowledgement. + * + * Processing is ordered by `runAt` and stable alarm id. By default, logical + * handler/decode failures are isolated to the failing row: the row is retried + * after `retryFailedAfter`, later due rows still run, and the result reports + * both handled and failed rows. Use `mode: "ordered"` when a workflow + * intentionally requires strict head-of-line blocking. + * + * Handlers must still be idempotent: Cloudflare alarms are at-least-once, and + * if a handler succeeds but the acknowledgement write fails, the logical event + * can be delivered again. + */ + readonly processDueAlarms: ( + handle: ProcessDueAlarmsHandler, + options?: ProcessDueAlarmsOptions, + ) => Effect.Effect< + ProcessDueAlarmsResult, + E | OnFailureE | DurableObjectAlarmError, + R | OnFailureR + >; + + /** + * Schedule or replace an alarm by `{ tag, id }`. + * + * The durable row write and underlying platform alarm reconciliation are run + * in one Durable Object storage transaction, so a failed `setAlarm` rolls back + * the logical schedule instead of leaving a stored-but-unarmed alarm. + */ + readonly scheduleAlarm: ( + input: ScheduleAlarmInput, + ) => Effect.Effect< + void, + | InvalidAlarmPayloadError + | InvalidAlarmRefError + | InvalidRepeatEveryError + | StorageOperationError + >; +}; + +const StoredPayloadString = S.fromJsonString(S.Json); + +const decodeStoredPayload = (row: AlarmRow) => + S.decodeUnknownEffect(StoredPayloadString)(row.payload).pipe( + Effect.mapError((cause) => new StoredAlarmDecodeError({ cause, storageId: row.storage_id })), + ); + +const encodeStoredPayload = (payload: AlarmPayload) => + S.encodeEffect(StoredPayloadString)(payload).pipe( + Effect.mapError((cause) => new InvalidAlarmPayloadError({ cause })), + ); + +const ensureTable = (state: DurableObjectState["Service"]) => + state.storage.sql.exec(INIT_TABLE_SQL).pipe(Effect.asVoid); + +const toRepeatEveryMillis = (input: Duration.Input | undefined) => { + if (input === undefined) { + return Effect.succeed(null); + } + + return Effect.try({ + try: () => { + const millis = Duration.toMillis(Duration.fromInputUnsafe(input)); + if (!Number.isFinite(millis) || millis <= 0) { + throw new Error("Alarm repeatEvery must be a positive finite duration"); + } + + return Math.ceil(millis); + }, + catch: (cause) => new InvalidRepeatEveryError({ cause }), + }); +}; + +const toAlarmDue = (row: AlarmRow) => + Effect.gen(function* () { + if (!Number.isFinite(row.run_at)) { + return yield* Effect.fail( + new StoredAlarmDecodeError({ + cause: new Error("Stored alarm run_at must be a finite number"), + storageId: row.storage_id, + }), + ); + } + + return DurableObjectAlarmEvent.make({ + _tag: "AlarmDue", + id: row.alarm_id, + payload: yield* decodeStoredPayload(row), + scheduledAt: DateTime.toUtc(DateTime.makeUnsafe(row.run_at)), + tag: row.tag, + }); + }); + +const getProcessLimit = (options: ProcessDueAlarmsOptions | undefined) => { + const limit = options?.limit ?? DEFAULT_PROCESS_DUE_ALARMS_LIMIT; + + if (!Number.isSafeInteger(limit) || limit <= 0) { + return Effect.fail( + new InvalidProcessDueAlarmsOptionsError({ + cause: new Error("processDueAlarms limit must be a positive safe integer"), + }), + ); + } + + return Effect.succeed(limit); +}; + +const toFailureRescheduleMillis = (input: Duration.Input) => + Effect.try({ + try: () => { + const millis = Duration.toMillis(Duration.fromInputUnsafe(input)); + if (!Number.isFinite(millis) || millis <= 0) { + throw new Error("Alarm failure rescheduleAfter must be a positive finite duration"); + } + + return Math.ceil(millis); + }, + catch: (cause) => new InvalidProcessDueAlarmsOptionsError({ cause }), + }); + +const getFailureRetryDelay = (options: ProcessDueAlarmsOptions | undefined) => + toFailureRescheduleMillis( + options?.retryFailedAfter ?? DEFAULT_PROCESS_DUE_ALARMS_FAILURE_RESCHEDULE_AFTER, + ); + +const getFailureActionMode = (action: ProcessDueAlarmsFailureAction) => + typeof action === "string" ? action : action.mode; + +const getFailureActionRetryDelay = (action: ProcessDueAlarmsFailureAction) => + typeof action === "string" || action.mode !== "retry" ? undefined : action.retryFailedAfter; + +export const processDue = ( + handle: ProcessDueAlarmsHandler, + options: ProcessDueAlarmsOptions = {}, +) => + Effect.gen(function* () { + const durableObjectAlarm = yield* DurableObjectAlarm; + return yield* durableObjectAlarm.processDueAlarms(handle, options); + }); + +export const process = processDue; + +export type AlarmPayloadSchema = S.Codec; + +export type AlarmFailurePolicy = "ordered" | "retry" | "skip-and-advance-repeat"; + +export interface AlarmRetryPolicy { + readonly initialDelay?: Duration.Input; +} + +export interface AlarmDefinitionConfig { + readonly failure?: AlarmFailurePolicy; + readonly payload: Payload; + readonly retry?: AlarmRetryPolicy; +} + +export type AlarmDefinitionEntry = AlarmDefinitionConfig | AlarmPayloadSchema; + +export type AlarmDefinitions = Readonly>; + +type AlarmDefinitionSchema = + Definition extends AlarmDefinitionConfig ? Payload : Definition; + +export type AlarmDefinitionPayload = + AlarmDefinitionSchema extends S.Codec ? A : never; + +export type DefinedAlarmEvent = Omit< + DurableObjectAlarmEvent, + "payload" | "tag" +> & { + readonly payload: Payload; + readonly tag: Tag; +}; + +export type DefinedAlarmHandlers = { + readonly [Tag in keyof Definitions & string]: ( + event: DefinedAlarmEvent>, + ) => Effect.Effect; +}; + +const isAlarmDefinitionConfig = ( + definition: AlarmDefinitionEntry, +): definition is AlarmDefinitionConfig => + typeof definition === "object" && definition !== null && "payload" in definition; + +const getAlarmDefinitionSchema = (definition: AlarmDefinitionEntry) => + isAlarmDefinitionConfig(definition) ? definition.payload : definition; + +const getAlarmDefinitionFailureAction = ( + definition: AlarmDefinitionEntry | undefined, +): ProcessDueAlarmsFailureAction | undefined => { + if (definition === undefined || !isAlarmDefinitionConfig(definition)) { + return undefined; + } + + if (definition.failure === undefined) { + return undefined; + } + + return definition.failure === "retry" + ? { mode: "retry", retryFailedAfter: definition.retry?.initialDelay } + : { mode: definition.failure }; +}; + +export const define = (definitions: Definitions) => ({ + handlers: ( + handlers: DefinedAlarmHandlers, + options?: ProcessDueAlarmsOptions, + ) => + processDue( + (event) => + Effect.gen(function* () { + const definition = definitions[event.tag]; + if (definition === undefined) { + return; + } + + const schema = getAlarmDefinitionSchema(definition); + const payload = yield* ( + S.decodeUnknownEffect(schema)(event.payload) as Effect.Effect< + AlarmDefinitionPayload, + unknown + > + ).pipe( + Effect.mapError( + (cause) => + new StoredAlarmDecodeError({ + cause, + storageId: getScheduledEventId(event), + }), + ), + ); + const handler = handlers[event.tag]; + + yield* handler({ ...event, payload } as never); + }), + { + ...options, + onFailure: (failure) => + Effect.gen(function* () { + const action = getAlarmDefinitionFailureAction(definitions[failure.tag]); + const optionAction = + options?.onFailure === undefined ? undefined : yield* options.onFailure(failure); + return action ?? optionAction; + }), + }, + ), +}); + +/** + * SQLite-backed Durable Object alarm scheduler. + * + * `DurableObjectAlarm` stores alarms in the current Durable Object's SQLite storage + * and keeps the platform alarm set to the earliest pending scheduled alarm. The + * platform alarm timestamp is only a wake-up hint; logical event identity comes + * from persisted `{ tag, id }` rows. + * + * Retry safety depends on acknowledgement ordering. `processDueAlarms` runs the + * caller's handler first and only then conditionally deletes or advances the + * same selected row. By default, handler failures are isolated: the failed row + * is retried after a delay, later due rows continue, and the platform alarm is + * reconciled once at the end. Use `mode: "ordered"` for strict workflows where + * one failed logical alarm should block later due alarms. + * + * Provide `DurableObjectAlarm.layer` anywhere `DurableObjectState` is available. + * + * @example Providing the service + * ```ts + * const program = Effect.gen(function* () { + * const durableObjectAlarm = yield* DurableObjectAlarm; + * const now = yield* DateTime.now; + * + * yield* durableObjectAlarm.scheduleAlarm({ + * tag: "heartbeat", + * id: "heartbeat-check", + * runAt: DateTime.add(now, { seconds: 5 }), + * repeatEvery: Duration.seconds(5), + * payload: null, + * }); + * }).pipe(Effect.provide(DurableObjectAlarm.layer)); + * ``` + * + * @example Processing due alarms + * ```ts + * const handledEvents = yield* durableObjectAlarm.processDueAlarms((event) => + * Effect.gen(function* () { + * if (event.tag === "heartbeat") { + * yield* heartbeatManager.handleAlarmEvent(event); + * } + * }) + * ); + * ``` + */ +export class DurableObjectAlarm extends Context.Service()( + "effect-cf/DurableObjectAlarm", +) { + static readonly layer = Layer.effect( + DurableObjectAlarm, + Effect.gen(function* () { + const state = yield* DurableObjectState; + + const reconcileAlarm = Effect.fn("DurableObjectAlarm.reconcileAlarm")(function* () { + const cursor = yield* state.storage.sql.exec( + `SELECT run_at FROM effect_cf_scheduled_alarms ORDER BY run_at ASC, storage_id ASC LIMIT 1`, + ); + const next = (yield* cursor.toArray())[0]; + + if (next === undefined) { + yield* state.storage.deleteAlarm(); + return; + } + + yield* state.storage.setAlarm(next.run_at); + }); + + const rescheduleFailedAlarm = Effect.fn("DurableObjectAlarm.rescheduleFailedAlarm")( + function* (row: AlarmRow, retryDelayMillis: number) { + const retryAt = (yield* Clock.currentTimeMillis) + retryDelayMillis; + + if (row.repeat_every_ms === null) { + const cursor = yield* state.storage.sql.exec( + `UPDATE effect_cf_scheduled_alarms + SET run_at = ? + WHERE storage_id = ? + AND run_at = ? + AND repeat_every_ms IS NULL + AND payload = ?`, + retryAt, + row.storage_id, + row.run_at, + row.payload, + ); + yield* cursor.rowsWritten; + return; + } + + const cursor = yield* state.storage.sql.exec( + `UPDATE effect_cf_scheduled_alarms + SET run_at = ? + WHERE storage_id = ? + AND run_at = ? + AND repeat_every_ms = ? + AND payload = ?`, + retryAt, + row.storage_id, + row.run_at, + row.repeat_every_ms, + row.payload, + ); + yield* cursor.rowsWritten; + }, + ); + + const acknowledgeAlarm = Effect.fn("DurableObjectAlarm.acknowledgeAlarm")(function* ( + row: AlarmRow, + ) { + if (row.repeat_every_ms === null) { + const cursor = yield* state.storage.sql.exec( + `DELETE FROM effect_cf_scheduled_alarms + WHERE storage_id = ? + AND run_at = ? + AND repeat_every_ms IS NULL + AND payload = ?`, + row.storage_id, + row.run_at, + row.payload, + ); + yield* cursor.rowsWritten; + return; + } + + const acknowledgedAt = yield* Clock.currentTimeMillis; + const cursor = yield* state.storage.sql.exec( + `UPDATE effect_cf_scheduled_alarms + SET run_at = ? + WHERE storage_id = ? + AND run_at = ? + AND repeat_every_ms = ? + AND payload = ?`, + acknowledgedAt + row.repeat_every_ms, + row.storage_id, + row.run_at, + row.repeat_every_ms, + row.payload, + ); + yield* cursor.rowsWritten; + }); + + const cancelAlarm = Effect.fn("DurableObjectAlarm.cancelAlarm")(function* (input: AlarmRef) { + const ref = yield* decodeAlarmRef(input); + yield* state.storage.transaction(() => + Effect.gen(function* () { + yield* ensureTable(state); + yield* state.storage.sql + .exec( + `DELETE FROM effect_cf_scheduled_alarms WHERE storage_id = ?`, + getScheduledEventId(ref), + ) + .pipe(Effect.asVoid); + yield* reconcileAlarm(); + }), + ); + }); + + const scheduleAlarm = Effect.fn("DurableObjectAlarm.scheduleAlarm")(function* ( + input: ScheduleAlarmInput, + ) { + const ref = yield* decodeAlarmRef(input); + const repeatEveryMillis = yield* toRepeatEveryMillis(input.repeatEvery); + const payload = yield* encodeStoredPayload(input.payload); + const runAt = DateTime.toEpochMillis(input.runAt); + + yield* state.storage.transaction(() => + Effect.gen(function* () { + yield* ensureTable(state); + yield* state.storage.sql + .exec( + `INSERT OR REPLACE INTO effect_cf_scheduled_alarms + (storage_id, alarm_id, tag, run_at, repeat_every_ms, payload) + VALUES (?, ?, ?, ?, ?, ?)`, + getScheduledEventId(ref), + ref.id, + ref.tag, + runAt, + repeatEveryMillis, + payload, + ) + .pipe(Effect.asVoid); + yield* reconcileAlarm(); + }), + ); + }); + + const processDueAlarms = Effect.fn("DurableObjectAlarm.processDueAlarms")(function* < + R, + E, + OnFailureR, + OnFailureE, + >( + handle: ProcessDueAlarmsHandler, + options?: ProcessDueAlarmsOptions, + ) { + yield* ensureTable(state); + const mode = options?.mode ?? "isolated"; + const limit = yield* getProcessLimit(options); + const now = yield* Clock.currentTimeMillis; + const cursor = yield* state.storage.sql.exec( + `SELECT storage_id, alarm_id, tag, run_at, repeat_every_ms, payload + FROM effect_cf_scheduled_alarms + WHERE run_at <= ? + ORDER BY run_at ASC, storage_id ASC + LIMIT ?`, + now, + limit, + ); + const dueRows = yield* cursor.toArray(); + const handled: DurableObjectAlarmEvent[] = []; + const failed: ProcessDueAlarmsFailure[] = []; + + const handleFailure = function* ( + row: AlarmRow, + event: DurableObjectAlarmEvent | undefined, + cause: unknown, + ) { + const failure: ProcessDueAlarmsFailure = { + cause, + event, + id: row.alarm_id, + storageId: row.storage_id, + tag: row.tag, + }; + + failed.push(failure); + const failureAction = + options?.onFailure === undefined ? undefined : yield* options.onFailure(failure); + const actionMode = + failureAction === undefined ? mode : getFailureActionMode(failureAction); + + if (actionMode === "retry" || actionMode === "isolated") { + const actionRetryDelay = + failureAction === undefined ? undefined : getFailureActionRetryDelay(failureAction); + const retryDelay = + actionRetryDelay === undefined + ? yield* getFailureRetryDelay(options) + : yield* toFailureRescheduleMillis(actionRetryDelay); + + yield* rescheduleFailedAlarm(row, retryDelay); + return "continue" as const; + } + + if (actionMode === "skip-and-advance-repeat") { + yield* acknowledgeAlarm(row); + return "continue" as const; + } + + yield* reconcileAlarm(); + return "stop" as const; + }; + + for (const row of dueRows) { + const eventExit = yield* Effect.exit(toAlarmDue(row)); + + if (Exit.isFailure(eventExit)) { + const action = yield* handleFailure(row, undefined, eventExit.cause); + if (action === "stop") { + return yield* Effect.failCause(eventExit.cause); + } + continue; + } + + const event = eventExit.value; + const handleExit = yield* Effect.exit(handle(event)); + + if (Exit.isFailure(handleExit)) { + const action = yield* handleFailure(row, event, handleExit.cause); + if (action === "stop") { + return yield* Effect.failCause(handleExit.cause); + } + continue; + } + + yield* acknowledgeAlarm(row); + handled.push(event); + } + + yield* reconcileAlarm(); + return { failed, handled }; + }); + + return DurableObjectAlarm.of({ + cancelAlarm, + processDueAlarms, + scheduleAlarm, + }); + }).pipe(Effect.withSpan("DurableObjectAlarm.layer")), + ); +} diff --git a/lib/effect-cf/src/DurableObjectDefinition.ts b/lib/effect-cf/src/DurableObjectDefinition.ts new file mode 100644 index 000000000..6020912dc --- /dev/null +++ b/lib/effect-cf/src/DurableObjectDefinition.ts @@ -0,0 +1,393 @@ +import { Context, Effect, type Layer } from "effect"; +import type { Schema as S } from "effect"; + +import * as Binding from "./Binding"; +import * as DurableObjectEntrypoint from "./DurableObject"; +import type { DurableObjectHandler } from "./DurableObject"; +import * as DurableObjectNamespace from "./DurableObjectNamespace"; +import type { DurableObjectState } from "./DurableObjectState"; +import type * as Rpc from "./Rpc"; +import * as RpcDefinition from "./RpcDefinition"; +import type { WorkerEnvironment } from "./Environment"; + +export type ServiceFreeSchema = S.Codec; + +export interface Method< + Args extends ReadonlyArray = ReadonlyArray, + Success extends ServiceFreeSchema = ServiceFreeSchema, +> { + readonly args: Args; + readonly success: Success; +} + +export namespace Method { + export type Any = Method, ServiceFreeSchema>; + + type ArgsFromSchemas> = Args extends readonly [] + ? [] + : Args extends readonly [ + infer Head extends ServiceFreeSchema, + ...infer Tail extends ReadonlyArray, + ] + ? [S.Schema.Type, ...ArgsFromSchemas] + : Array>; + + type EncodedArgsFromSchemas> = + Args extends readonly [] + ? [] + : Args extends readonly [ + infer Head extends ServiceFreeSchema, + ...infer Tail extends ReadonlyArray, + ] + ? [S.Codec.Encoded, ...EncodedArgsFromSchemas] + : Array>; + + export type Args = ArgsFromSchemas; + + export type EncodedArgs = EncodedArgsFromSchemas; + + export type Success = S.Schema.Type; + + export type EncodedSuccess = S.Codec.Encoded; +} + +export type Methods = Record; + +/** + * RPC contract for a Durable Object class. + */ +export interface Definition { + readonly id: Id; + readonly methods: MethodsShape; +} + +export namespace Definition { + export type Any = Definition; +} + +export type ReservedMethodName = + | RpcDefinition.ReservedMethodName + | "fetch" + | "alarm" + | "webSocketMessage" + | "webSocketClose" + | "webSocketError"; + +export type NoReservedMethods = + Extract extends never ? MethodsShape : never; + +const reservedMethodNames = new Set([ + "constructor", + "dup", + "fetch", + "alarm", + "webSocketMessage", + "webSocketClose", + "webSocketError", +]); + +/** + * Promise-based client API derived from a Durable Object definition. + */ +export type ServerApi = { + readonly [Key in keyof Self["methods"]]: ( + ...args: Method.Args + ) => Promise>; +}; + +export type Api = Rpc.Provider, ReservedMethodName>; + +/** + * Effect handlers for each RPC method in a Durable Object definition. + */ +export type Handlers = { + readonly [Key in keyof Self["methods"]]: ( + ...args: Method.Args + ) => DurableObjectHandler>; +}; + +type BoundaryHandlers = { + readonly [Key in keyof Self["methods"]]: ( + ...args: Array + ) => DurableObjectHandler>; +}; + +/** + * Durable Object constructor options for a specific RPC definition. + */ +export interface Options extends Omit< + DurableObjectEntrypoint.DurableObjectOptions>, + "rpc" +> { + readonly rpc: Handlers; +} + +export type LayerOptions = { + readonly binding: string; +}; + +export type TagClass = Context.ServiceClass< + Self, + Id, + DurableObjectNamespace.DurableObjectNamespaceEffectClient< + Api>, + Definition + > +> & + DurableObjectNamespace.DurableObjectNamespaceStaticClient< + Self, + Api>, + Definition + > & { + readonly id: Id; + readonly methods: MethodsShape; + readonly make: ( + layer: Layer.Layer, + options: Options>, + ) => DurableObjectEntrypoint.DurableObjectClass< + Handlers>, + ROut + >; + readonly layer: ( + options: LayerOptions, + ) => Layer.Layer< + Self, + Binding.BindingNotFoundError | Binding.BindingValidationError, + WorkerEnvironment + >; + }; + +/** + * Defines a single RPC method schema in a Durable Object definition. + */ +export const method = RpcDefinition.method as { + (definition: { + readonly success: Success; + }): Method; + < + const Args extends ReadonlyArray, + Success extends ServiceFreeSchema, + >(definition: { + readonly args: Args; + readonly success: Success; + }): Method; +}; + +/** + * Creates a Durable Object RPC definition plus implementation/binding helpers. + * + * @example + * ```ts + * const ChatRoom = DurableObjectDefinition.make("ChatRoom", { + * postMessage: DurableObjectDefinition.method({ + * args: [Schema.String], + * success: Schema.Void, + * }), + * }); + * ``` + */ +const makeDefinition = ( + id: Id, + methods: MethodsShape & NoReservedMethods, +) => { + type SelfDefinition = Definition; + RpcDefinition.assertNoReservedMethods("Durable Object", methods, reservedMethodNames); + const definition: SelfDefinition = RpcDefinition.make(id, methods); + + return Object.assign(definition, { + make: ( + layer: Layer.Layer, + options: Options, + ) => + DurableObjectEntrypoint.make(layer, { + ...options, + rpc: wrapHandlers(definition, options.rpc), + }), + }); +}; + +export const make = ( + id: Id, + methods: MethodsShape & NoReservedMethods, +) => + Tag>()( + id, + methods as MethodsShape & NoReservedMethods, + ); + +export const Tag = + () => + ( + id: Id, + methods: MethodsShape & NoReservedMethods, + ) => { + const definition = makeDefinition(id, methods); + type SelfDefinition = Definition; + type ClientApi = Api; + const tag = Context.Service< + Self, + DurableObjectNamespace.DurableObjectNamespaceEffectClient + >()(id); + + const bindingDefinition = (binding: LayerOptions) => ({ + ...binding, + definition, + }); + + const layer = (binding: LayerOptions) => + DurableObjectNamespace.layer( + tag, + bindingDefinition(binding), + ); + + const newUniqueId = Effect.fnUntraced(function* ( + options?: globalThis.DurableObjectNamespaceNewUniqueIdOptions, + ) { + const namespace = yield* tag; + return yield* namespace.newUniqueId(options); + }); + + const idFromName = Effect.fnUntraced(function* (name: string) { + const namespace = yield* tag; + return yield* namespace.idFromName(name); + }); + + const idFromString = Effect.fnUntraced(function* (value: string) { + const namespace = yield* tag; + return yield* namespace.idFromString(value); + }); + + const get = Effect.fnUntraced(function* ( + objectId: globalThis.DurableObjectId, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) { + const namespace = yield* tag; + return yield* namespace.get(objectId, options); + }); + + const getByName = Effect.fnUntraced(function* ( + name: string, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) { + const namespace = yield* tag; + return yield* namespace.getByName(name, options); + }); + + const jurisdiction = Effect.fnUntraced(function* (value: globalThis.DurableObjectJurisdiction) { + const namespace = yield* tag; + return yield* namespace.jurisdiction(value); + }); + + const fetch = ( + stub: DurableObjectNamespace.DurableObjectStubClient, + input: RequestInfo | URL, + init?: RequestInit, + ) => + Effect.gen(function* () { + const namespace = yield* tag; + return yield* namespace.fetch(stub, input, init); + }); + + const rpc = ( + stub: DurableObjectNamespace.DurableObjectStubClient, + method: Method, + ...args: ClientApi[Method] extends (...args: infer Args) => unknown ? Args : never + ) => + Effect.gen(function* () { + const namespace = yield* tag; + return yield* namespace.rpc(stub, method as never, ...(args as never)); + }); + + const call = ( + stub: DurableObjectNamespace.DurableObjectStubClient, + method: Method, + ...args: ClientApi[Method] extends (...args: infer Args) => unknown ? Args : never + ) => + Effect.gen(function* () { + const namespace = yield* tag; + return yield* namespace.call(stub, method as never, ...(args as never)); + }); + + const scopedCall = ( + stub: DurableObjectNamespace.DurableObjectStubClient, + method: Method, + ...args: ClientApi[Method] extends (...args: infer Args) => unknown ? Args : never + ) => + Effect.gen(function* () { + const namespace = yield* tag; + return yield* namespace.scopedCall(stub, method as never, ...(args as never)); + }); + + const unsafeRaw = Effect.fnUntraced(function* () { + const namespace = yield* tag; + return yield* namespace.unsafeRaw; + }); + + const directMethods = DurableObjectNamespace.makeDirectMethods( + definition, + { + call: call as never, + fetch, + get, + getByName, + }, + ); + + return Object.assign(tag, directMethods, { + id: definition.id, + methods: definition.methods, + make: definition.make, + layer, + newUniqueId, + idFromName, + idFromString, + get, + getByName, + jurisdiction, + fetch, + rpc, + call, + scopedCall, + unsafeRaw, + }) as unknown as TagClass; + }; + +export const DurableObject = Tag; + +const wrapHandlers = ( + definition: Self, + handlers: Handlers, +): BoundaryHandlers => { + const wrapped = {} as Record; + + for (const key of Object.keys(definition.methods) as Array< + RpcDefinition.Definition.MethodNames + >) { + const handler = handlers[key]; + wrapped[key] = (...args: Array) => + Effect.gen(function* () { + const decodedArgs = yield* RpcDefinition.decodeArgs(definition, key, args); + const value = yield* handler(...decodedArgs); + return yield* RpcDefinition.encodeSuccess(definition, key, value); + }); + } + + return wrapped as BoundaryHandlers; +}; + +/** + * Helper for implementing handlers with the exact method shape of a definition. + */ +export const implement = ( + _definition: Self, + handlers: Handlers, +): Handlers => handlers; + +/** + * Convenience alias for a single Durable Object RPC handler Effect. + */ +export type HandlerEffect< + ROut, + Self extends Definition.Any, + Key extends keyof Self["methods"], +> = DurableObjectHandler>; diff --git a/lib/effect-cf/src/DurableObjectNamespace.ts b/lib/effect-cf/src/DurableObjectNamespace.ts new file mode 100644 index 000000000..6f6ae3de5 --- /dev/null +++ b/lib/effect-cf/src/DurableObjectNamespace.ts @@ -0,0 +1,634 @@ +import { Context, Data, Effect, type Scope } from "effect"; + +import * as Binding from "./Binding"; +import * as CloudflareRpc from "./Rpc"; +import type * as DurableObjectDefinition from "./DurableObjectDefinition"; +import * as RpcDefinition from "./RpcDefinition"; +import * as RpcInvocation from "./internal/RpcInvocation"; + +const expectedDurableObjectNamespace = + "Durable Object namespace binding with getByName(), get(), idFromName(), idFromString(), newUniqueId(), and jurisdiction()"; + +interface DurableObjectFetcher { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +} + +type RpcClient = { + readonly [Key in keyof Api as Key extends string + ? Api[Key] extends (...args: Array) => unknown + ? Key + : never + : never]: Api[Key]; +}; + +type ReservedMethodName = DurableObjectDefinition.ReservedMethodName | "fetch"; + +/** + * Cloudflare Durable Object stub, optionally enriched with RPC methods. + */ +export type DurableObjectStubClient = DurableObjectFetcher & + RpcClient> & { + readonly id: globalThis.DurableObjectId; + readonly name?: string; + }; + +/** + * Native Durable Object namespace binding shape. + */ +export interface DurableObjectNamespaceClient { + /** Creates a globally unique Durable Object id. */ + newUniqueId( + options?: globalThis.DurableObjectNamespaceNewUniqueIdOptions, + ): globalThis.DurableObjectId; + /** Deterministically maps a name to a Durable Object id. */ + idFromName(name: string): globalThis.DurableObjectId; + /** Rehydrates a Durable Object id from its string form. */ + idFromString(id: string): globalThis.DurableObjectId; + /** Returns a stub for an existing Durable Object id. */ + get( + id: globalThis.DurableObjectId, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ): DurableObjectStubClient; + /** Returns a stub by deterministic name. */ + getByName( + name: string, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ): DurableObjectStubClient; + /** Selects a namespace pinned to a specific jurisdiction. */ + jurisdiction( + jurisdiction: globalThis.DurableObjectJurisdiction, + ): DurableObjectNamespaceClient; +} + +/** + * Minimal namespace binding metadata. + */ +export interface DurableObjectNamespaceDefinition { + readonly binding: string; +} + +/** + * Namespace binding metadata with optional RPC schema. + */ +export interface DurableObjectNamespaceBindingDefinition< + Definition extends DurableObjectDefinition.Definition.Any | undefined = undefined, +> { + /** Binding name as configured in `wrangler.jsonc`. */ + readonly binding: string; + /** Optional RPC schema used for argument/result encoding. */ + readonly definition?: Definition; +} + +/** + * Failure raised when invoking an RPC method on a Durable Object stub. + */ +export class DurableObjectRpcError extends Data.TaggedError("DurableObjectRpcError")<{ + readonly binding: string; + readonly method: string; + readonly cause: unknown; +}> {} + +/** + * Failure raised when forwarding a request to a Durable Object stub. + */ +export class DurableObjectFetchError extends Data.TaggedError("DurableObjectFetchError")<{ + readonly binding: string; + readonly cause: unknown; +}> {} + +type StubMethodKey = RpcInvocation.AsyncMethodKey; +type StubMethodArgs = RpcInvocation.AsyncMethodArgs; +type StubMethodSuccess = RpcInvocation.AsyncMethodSuccess< + Api, + Method +>; +type StubMethodCloudflareReturn< + Api, + Method extends keyof Api, +> = RpcInvocation.AsyncMethodCloudflareReturn; + +type StubCall = >( + stub: DurableObjectStubClient, + method: Method, + ...args: StubMethodArgs +) => Effect.Effect, DurableObjectRpcError, R>; + +type DurableObjectDirectClient = { + readonly fetch: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Effect.Effect; +} & { + readonly [Method in StubMethodKey]: ( + ...args: StubMethodArgs + ) => Effect.Effect, DurableObjectRpcError, R>; +}; + +type DefinitionNamespaceDirectMethods< + R, + Api extends object, + Definition extends DurableObjectDefinition.Definition.Any, +> = { + readonly [Method in RpcDefinition.Definition.MethodNames]: ( + stub: DurableObjectStubClient, + ...args: RpcDefinition.Method.Args + ) => Effect.Effect< + RpcDefinition.Method.Success, + DurableObjectRpcError, + R + >; +}; + +type DefinitionDurableObjectDirectClient< + R, + Definition extends DurableObjectDefinition.Definition.Any, +> = { + readonly fetch: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Effect.Effect; +} & { + readonly [Method in RpcDefinition.Definition.MethodNames]: ( + ...args: RpcDefinition.Method.Args + ) => Effect.Effect< + RpcDefinition.Method.Success, + DurableObjectRpcError, + R + >; +}; + +type DefinitionNamespaceDirectClientMethods< + R, + Definition extends DurableObjectDefinition.Definition.Any, +> = { + readonly byName: ( + name: string, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) => DefinitionDurableObjectDirectClient; + readonly byId: ( + id: globalThis.DurableObjectId, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) => DefinitionDurableObjectDirectClient; +}; + +type DirectMethods< + R, + Api extends object, + Definition, +> = Definition extends DurableObjectDefinition.Definition.Any + ? DefinitionNamespaceDirectMethods & + DefinitionNamespaceDirectClientMethods + : {}; + +export type DurableObjectNamespaceEffectClient< + Api extends object, + Definition extends DurableObjectDefinition.Definition.Any | undefined = undefined, +> = DirectMethods & { + /** Creates a globally unique Durable Object id. */ + readonly newUniqueId: ( + options?: globalThis.DurableObjectNamespaceNewUniqueIdOptions, + ) => Effect.Effect; + /** Deterministically maps a name to a Durable Object id. */ + readonly idFromName: (name: string) => Effect.Effect; + /** Rehydrates a Durable Object id from its string form. */ + readonly idFromString: (id: string) => Effect.Effect; + /** Returns a stub for an existing Durable Object id. */ + readonly get: ( + id: globalThis.DurableObjectId, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) => Effect.Effect>; + /** Returns a stub by deterministic name. */ + readonly getByName: ( + name: string, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) => Effect.Effect>; + /** Selects a namespace pinned to a specific jurisdiction. */ + readonly jurisdiction: ( + jurisdiction: globalThis.DurableObjectJurisdiction, + ) => Effect.Effect>; + /** + * Forwards an HTTP request to a Durable Object stub. + * + * Use this for fetch-based Durable Object APIs, including WebSocket upgrade + * forwarding where the native response must be preserved. + * + * @example + * ```ts + * import { Effect } from "effect"; + * + * const program = Effect.gen(function* () { + * const rooms = yield* ChatRooms; + * const room = yield* rooms.getByName("general"); + * + * return yield* rooms.fetch(room, new Request("https://worker.example/room")); + * }); + * ``` + */ + readonly fetch: ( + stub: DurableObjectStubClient, + input: RequestInfo | URL, + init?: RequestInit, + ) => Effect.Effect; + /** + * Invokes a Durable Object RPC method and returns Cloudflare's raw RPC result. + * + * This preserves Cloudflare RPC behavior such as promise-like pipelining and + * transferable / disposable result objects. It does not resolve the returned + * promise-like value and it does not decode definition-backed success schemas. + * + * Most application code should use {@link call} instead. + * + * @example + * ```ts + * import { Effect } from "effect"; + * + * const program = Effect.gen(function* () { + * const counters = yield* Counters; + * const counter = yield* counters.getByName("main"); + * + * const result = yield* counters.rpc(counter, "get"); + * const value = yield* Effect.promise(() => result); + * + * return value; + * }); + * ``` + */ + readonly rpc: >( + stub: DurableObjectStubClient, + method: Method, + ...args: StubMethodArgs + ) => Effect.Effect, DurableObjectRpcError>; + /** + * Invokes a Durable Object RPC method, resolves Cloudflare's RPC result, and + * decodes the success value when the namespace was created from a definition. + * + * This is the normal choice when application code wants the final typed value. + * + * @example + * ```ts + * import { Effect } from "effect"; + * + * const program = Effect.gen(function* () { + * const counters = yield* Counters; + * const counter = yield* counters.getByName("main"); + * + * return yield* counters.call(counter, "increment", 1); + * }); + * ``` + */ + readonly call: >( + stub: DurableObjectStubClient, + method: Method, + ...args: StubMethodArgs + ) => Effect.Effect, DurableObjectRpcError>; + /** + * Invokes a Durable Object RPC method in the current `Scope`, resolves + * Cloudflare's RPC result, decodes definition-backed success values, and + * disposes the resolved result when the scope closes if it implements + * `Symbol.dispose`. + * + * Use this for RPC methods that return Cloudflare RPC resources or other + * disposable objects whose lifetime should be tied to an Effect scope. + * + * @example + * ```ts + * import { Effect } from "effect"; + * + * const program = Effect.scoped( + * Effect.gen(function* () { + * const rooms = yield* ChatRooms; + * const room = yield* rooms.getByName("general"); + * const handle = yield* rooms.scopedCall(room, "openStream"); + * + * return yield* handle.read(); + * }), + * ); + * ``` + */ + readonly scopedCall: >( + stub: DurableObjectStubClient, + method: Method, + ...args: StubMethodArgs + ) => Effect.Effect>, unknown, Scope.Scope>; + /** + * Exposes the underlying native Durable Object namespace binding. + * + * Prefer the typed helpers above unless Cloudflare exposes a platform feature + * that is not wrapped by effect-cf yet. + */ + readonly unsafeRaw: Effect.Effect>; +}; + +export type DurableObjectNamespaceStaticClient< + R, + Api extends object, + Definition extends DurableObjectDefinition.Definition.Any | undefined = undefined, +> = DirectMethods & { + readonly newUniqueId: ( + options?: globalThis.DurableObjectNamespaceNewUniqueIdOptions, + ) => Effect.Effect; + readonly idFromName: (name: string) => Effect.Effect; + readonly idFromString: (id: string) => Effect.Effect; + readonly get: ( + id: globalThis.DurableObjectId, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) => Effect.Effect, never, R>; + readonly getByName: ( + name: string, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) => Effect.Effect, never, R>; + readonly jurisdiction: ( + jurisdiction: globalThis.DurableObjectJurisdiction, + ) => Effect.Effect, never, R>; + readonly fetch: ( + stub: DurableObjectStubClient, + input: RequestInfo | URL, + init?: RequestInit, + ) => Effect.Effect; + readonly rpc: >( + stub: DurableObjectStubClient, + method: Method, + ...args: StubMethodArgs + ) => Effect.Effect, DurableObjectRpcError, R>; + readonly call: >( + stub: DurableObjectStubClient, + method: Method, + ...args: StubMethodArgs + ) => Effect.Effect, DurableObjectRpcError, R>; + readonly scopedCall: >( + stub: DurableObjectStubClient, + method: Method, + ...args: StubMethodArgs + ) => Effect.Effect>, unknown, Scope.Scope | R>; + readonly unsafeRaw: () => Effect.Effect, never, R>; +}; + +const hasFunction = (value: object, key: string): boolean => + typeof Reflect.get(value, key) === "function"; + +export const isDurableObjectNamespaceClient = ( + value: unknown, +): value is DurableObjectNamespaceClient => + typeof value === "object" && + value !== null && + hasFunction(value, "getByName") && + hasFunction(value, "get") && + hasFunction(value, "idFromName") && + hasFunction(value, "idFromString") && + hasFunction(value, "newUniqueId") && + hasFunction(value, "jurisdiction"); + +export const makeClient = < + Api extends object, + const Definition extends DurableObjectDefinition.Definition.Any | undefined = undefined, +>( + definition: DurableObjectNamespaceBindingDefinition, +): (( + namespace: DurableObjectNamespaceClient, +) => DurableObjectNamespaceEffectClient) => { + type NamespaceClient = DurableObjectNamespaceClient; + type StubClient = DurableObjectStubClient; + + return (namespace: NamespaceClient) => { + const newUniqueId = (options?: globalThis.DurableObjectNamespaceNewUniqueIdOptions) => + Effect.sync(() => namespace.newUniqueId(options)); + + const idFromName = (name: string) => Effect.sync(() => namespace.idFromName(name)); + + const idFromString = (id: string) => Effect.sync(() => namespace.idFromString(id)); + + const get = ( + id: globalThis.DurableObjectId, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) => Effect.sync(() => namespace.get(id, options)); + + const getByName = ( + name: string, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) => Effect.sync(() => namespace.getByName(name, options)); + + const jurisdiction = (value: globalThis.DurableObjectJurisdiction) => + Effect.sync(() => namespace.jurisdiction(value)); + + const fetch = (stub: StubClient, input: RequestInfo | URL, init?: RequestInit) => + Effect.tryPromise({ + try: () => stub.fetch(input, init), + catch: (cause) => new DurableObjectFetchError({ binding: definition.binding, cause }), + }); + + const rpc = >( + stub: StubClient, + method: Method, + ...args: StubMethodArgs + ) => + Effect.gen(function* () { + const methodName = String(method); + const encodedArgs = + definition.definition === undefined + ? args + : yield* RpcDefinition.encodeArgs( + definition.definition, + methodName as RpcDefinition.Definition.MethodNames>, + args as never, + ).pipe( + Effect.mapError( + (cause) => + new DurableObjectRpcError({ + binding: definition.binding, + method: methodName, + cause, + }), + ), + ); + + return yield* RpcInvocation.invokeRpcMethod( + stub, + method, + encodedArgs as StubMethodArgs, + (cause) => + new DurableObjectRpcError({ + binding: definition.binding, + method: methodName, + cause, + }), + ); + }); + + const decodeSuccess = >( + methodName: string, + value: Awaited>, + ) => + Effect.gen(function* () { + if (definition.definition === undefined) { + return value as StubMethodSuccess; + } + + const decoded = yield* RpcDefinition.decodeSuccess( + definition.definition, + methodName as RpcDefinition.Definition.MethodNames>, + value, + ).pipe( + Effect.mapError( + (cause) => + new DurableObjectRpcError({ + binding: definition.binding, + method: methodName, + cause, + }), + ), + ); + + return decoded as StubMethodSuccess; + }); + + const call = >( + stub: StubClient, + method: Method, + ...args: StubMethodArgs + ) => + Effect.gen(function* () { + const methodName = String(method); + const value = yield* CloudflareRpc.resolve(yield* rpc(stub, method, ...args)).pipe( + Effect.mapError( + (cause) => + new DurableObjectRpcError({ + binding: definition.binding, + method: methodName, + cause, + }), + ), + ); + + return yield* decodeSuccess(methodName, value); + }); + + const scopedCall = >( + stub: StubClient, + method: Method, + ...args: StubMethodArgs + ) => + Effect.gen(function* () { + const methodName = String(method); + const result = yield* rpc(stub, method, ...args); + const value = yield* CloudflareRpc.scoped(result); + return yield* decodeSuccess(methodName, value); + }); + + const directMethods = makeDirectMethods(definition.definition, { + call, + fetch, + get, + getByName, + }); + + return Object.assign(directMethods, { + newUniqueId, + idFromName, + idFromString, + get, + getByName, + jurisdiction, + fetch, + rpc, + call, + scopedCall, + unsafeRaw: Effect.succeed(namespace), + }) as DurableObjectNamespaceEffectClient; + }; +}; + +export const layer = < + Self, + Api extends object, + const Definition extends DurableObjectDefinition.Definition.Any | undefined = undefined, +>( + tag: Context.Service>, + definition: DurableObjectNamespaceBindingDefinition, +) => + Binding.layer( + tag, + definition.binding, + (value): value is DurableObjectNamespaceClient => + isDurableObjectNamespaceClient(value), + makeClient(definition), + { expected: expectedDurableObjectNamespace }, + ); + +export const makeDirectMethods = < + R, + Api extends object, + Definition extends DurableObjectDefinition.Definition.Any | undefined, +>( + rpcDefinition: Definition | undefined, + helpers: { + readonly call: StubCall; + readonly fetch: ( + stub: DurableObjectStubClient, + input: RequestInfo | URL, + init?: RequestInit, + ) => Effect.Effect; + readonly get: ( + id: globalThis.DurableObjectId, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) => Effect.Effect, never, R>; + readonly getByName: ( + name: string, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) => Effect.Effect, never, R>; + }, +): DirectMethods => { + const methods = {} as Record; + + if (rpcDefinition !== undefined) { + for (const methodName of Object.keys(rpcDefinition.methods)) { + methods[methodName] = (stub: DurableObjectStubClient, ...args: Array) => + ( + helpers.call as ( + stub: DurableObjectStubClient, + method: string, + ...args: Array + ) => unknown + )(stub, methodName, ...args); + } + + const makeClient = ( + getStub: () => Effect.Effect, never, R>, + ): DurableObjectDirectClient => { + const client = { + fetch: (input: RequestInfo | URL, init?: RequestInit) => + Effect.gen(function* () { + const stub = yield* getStub(); + return yield* helpers.fetch(stub, input, init); + }), + } as Record; + + for (const methodName of Object.keys(rpcDefinition.methods)) { + client[methodName] = (...args: Array) => + Effect.gen(function* () { + const stub = yield* getStub(); + return yield* ( + helpers.call as ( + stub: DurableObjectStubClient, + method: string, + ...args: Array + ) => Effect.Effect + )(stub, methodName, ...args); + }); + } + + return client as DurableObjectDirectClient; + }; + + methods.byName = ( + name: string, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) => makeClient(() => helpers.getByName(name, options)); + + methods.byId = ( + id: globalThis.DurableObjectId, + options?: globalThis.DurableObjectNamespaceGetDurableObjectOptions, + ) => makeClient(() => helpers.get(id, options)); + } + + return methods as DirectMethods; +}; diff --git a/lib/effect-cf/src/DurableObjectRpcWebSocket.ts b/lib/effect-cf/src/DurableObjectRpcWebSocket.ts new file mode 100644 index 000000000..1286f16d1 --- /dev/null +++ b/lib/effect-cf/src/DurableObjectRpcWebSocket.ts @@ -0,0 +1,226 @@ +import { Context, Effect, Layer, Option, Queue } from "effect"; +import * as RpcMessage from "effect/unstable/rpc/RpcMessage"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as RpcServer from "effect/unstable/rpc/RpcServer"; + +import { DurableObjectState } from "./DurableObjectState"; +import type { DurableWebSocket } from "./DurableObjectWebSocket"; + +const defaultAttachmentKey = "effectCloudflareRpcClientId"; +const defaultTag = "effect-cf-rpc"; + +/** + * Configuration for {@link layer}. + */ +export interface LayerOptions { + /** Tag used to select hibernated sockets. */ + readonly tag?: string | undefined; + /** Socket attachment key used to persist client ids across hibernation. */ + readonly attachmentKey?: string | undefined; +} + +/** Native websocket event payload accepted by Cloudflare Durable Objects. */ +export type NativeWebSocketMessage = string | ArrayBuffer; + +/** + * Service API used to wire websocket lifecycle events to Effect RPC server protocol. + */ +export interface DurableObjectRpcWebSocketService { + readonly accept: (socket: DurableWebSocket) => Effect.Effect; + readonly message: ( + socket: DurableWebSocket, + message: NativeWebSocketMessage, + ) => Effect.Effect; + readonly close: (socket: DurableWebSocket) => Effect.Effect; + readonly error: (socket: DurableWebSocket, error: unknown) => Effect.Effect; +} + +interface RpcConnection { + readonly id: number; + readonly socket: DurableWebSocket; + readonly parser: RpcSerialization.Parser; +} + +/** + * Context tag for the Durable Object RPC websocket service. + */ +export class DurableObjectRpcWebSocket extends Context.Service< + DurableObjectRpcWebSocket, + DurableObjectRpcWebSocketService +>()("effect-cf/DurableObjectRpcWebSocket") {} + +/** + * Builds a layer that bridges Durable Object websocket events to `RpcServer.Protocol`. + */ +export const layer = (options: LayerOptions = {}) => + Layer.effectContext( + Effect.gen(function* () { + const tag = options.tag ?? defaultTag; + const attachmentKey = options.attachmentKey ?? defaultAttachmentKey; + const durableObjectState = yield* DurableObjectState; + const serialization = yield* RpcSerialization.RpcSerialization; + const disconnects = yield* Queue.make(); + const connectionsBySocket = new Map(); + const connectionsById = new Map(); + const clientIds = new Set(); + let nextClientId = 0; + let writeRequest: + | ((clientId: number, data: RpcMessage.FromClientEncoded) => Effect.Effect) + | undefined; + + const reserveClientId = (socket: DurableWebSocket) => { + const attachment = readAttachment(socket.raw, attachmentKey); + if (attachment !== undefined) { + nextClientId = Math.max(nextClientId, attachment + 1); + return attachment; + } + + const id = nextClientId++; + writeAttachment(socket.raw, attachmentKey, id); + return id; + }; + + const register = (socket: DurableWebSocket) => { + const existing = connectionsBySocket.get(socket.raw); + if (existing !== undefined) { + return existing; + } + + const connection = { + id: reserveClientId(socket), + socket, + parser: serialization.makeUnsafe(), + } satisfies RpcConnection; + + connectionsBySocket.set(socket.raw, connection); + connectionsById.set(connection.id, connection); + clientIds.add(connection.id); + return connection; + }; + + const unregister = (socket: DurableWebSocket) => + Effect.sync(() => { + const connection = connectionsBySocket.get(socket.raw); + if (connection === undefined) { + return; + } + + connectionsBySocket.delete(socket.raw); + connectionsById.delete(connection.id); + clientIds.delete(connection.id); + Queue.offerUnsafe(disconnects, connection.id); + }); + + for (const socket of yield* durableObjectState.getWebSockets(tag)) { + register(socket); + } + + const send = (connection: RpcConnection, response: RpcMessage.FromServerEncoded) => + Effect.sync(() => { + try { + const encoded = connection.parser.encode(response); + if (encoded !== undefined) { + connection.socket.raw.send(encoded); + } + } catch (cause) { + const encoded = connection.parser.encode(RpcMessage.ResponseDefectEncoded(cause)); + if (encoded !== undefined) { + connection.socket.raw.send(encoded); + } + } + }); + + const protocol = yield* RpcServer.Protocol.make((writeRequest_) => { + writeRequest = writeRequest_; + + return Effect.succeed({ + disconnects, + send: (clientId, response) => { + const connection = connectionsById.get(clientId); + return connection === undefined ? Effect.void : send(connection, response); + }, + end: (clientId) => + Effect.sync(() => { + const connection = connectionsById.get(clientId); + connection?.socket.raw.close(); + }), + clientIds: Effect.sync(() => clientIds), + initialMessage: Effect.succeed(Option.none()), + supportsAck: true, + supportsTransferables: false, + supportsSpanPropagation: true, + }); + }); + + const service = DurableObjectRpcWebSocket.of({ + accept: (socket) => + Effect.gen(function* () { + register(socket); + yield* durableObjectState.acceptWebSocket(socket, [tag]); + }), + message: (socket, message) => + Effect.gen(function* () { + const connection = register(socket); + const decoded = yield* Effect.try({ + try: () => connection.parser.decode(normalizeMessage(message)), + catch: RpcMessage.ResponseDefectEncoded, + }); + + const run = writeRequest; + if (run === undefined) { + yield* send(connection, RpcMessage.ResponseDefectEncoded("RPC server is not ready")); + return; + } + + for (const current of decoded) { + yield* run(connection.id, current as RpcMessage.FromClientEncoded); + } + }).pipe( + Effect.catch((error) => { + const connection = register(socket); + return send(connection, error); + }), + ), + close: unregister, + error: (socket, error) => + Effect.gen(function* () { + yield* Effect.logDebug("Durable Object RPC websocket error", error); + yield* unregister(socket); + }), + }); + + return Context.mergeAll( + Context.make(RpcServer.Protocol, protocol), + Context.make(DurableObjectRpcWebSocket, service), + ); + }), + ); + +const normalizeMessage = (message: NativeWebSocketMessage) => + typeof message === "string" ? message : new Uint8Array(message); + +const readAttachment = (socket: WebSocket, key: string): number | undefined => { + const value = socket.deserializeAttachment(); + + if ( + value !== null && + typeof value === "object" && + key in value && + typeof value[key] === "number" + ) { + return value[key]; + } + + return undefined; +}; + +const writeAttachment = (socket: WebSocket, key: string, clientId: number) => { + const current = socket.deserializeAttachment(); + const attachment = + current !== null && typeof current === "object" && !Array.isArray(current) ? current : {}; + + socket.serializeAttachment({ + ...attachment, + [key]: clientId, + }); +}; diff --git a/lib/effect-cf/src/DurableObjectState.ts b/lib/effect-cf/src/DurableObjectState.ts new file mode 100644 index 000000000..0c74e737a --- /dev/null +++ b/lib/effect-cf/src/DurableObjectState.ts @@ -0,0 +1,129 @@ +import { Cause, Context, Effect, Exit } from "effect"; + +import { fromDurableObjectStorage, type DurableObjectStorage } from "./DurableObjectStorage"; +import { fromWebSocket, type DurableWebSocket } from "./DurableObjectWebSocket"; + +/** + * Effect-friendly wrapper around Cloudflare `DurableObjectState`. + */ +export interface DurableObjectStateService { + /** Underlying Cloudflare state instance. */ + readonly raw: globalThis.DurableObjectState; + /** Durable Object id for the current instance. */ + readonly id: globalThis.DurableObjectId; + /** Wrapped storage API. */ + readonly storage: DurableObjectStorage; + /** Registers background work with Cloudflare's lifecycle. */ + waitUntil(promise: Promise): Effect.Effect; + /** + * Runs an Effect inside Cloudflare's `blockConcurrencyWhile` gate. + * + * Cloudflare resets a Durable Object if the callback throws/rejects, or if the + * callback exceeds the platform timeout (currently documented as 30 seconds). + * This safe default resolves the callback with the Effect `Exit` for typed + * Effect failures and resumes that `Exit` afterward, so typed failures remain + * ordinary Effect failures instead of resetting the Durable Object. Defects + * and interruptions still reject the callback. + */ + blockConcurrencyWhile(effect: Effect.Effect): Effect.Effect; + /** + * Runs an Effect inside Cloudflare's `blockConcurrencyWhile` gate and lets + * Effect failures reject the callback. + * + * Use this only when Cloudflare's reset-on-throw behavior is intentional. The + * platform also resets the Durable Object if the callback exceeds the + * documented timeout (currently 30 seconds). + */ + blockConcurrencyWhileOrReset(effect: Effect.Effect): Effect.Effect; + /** Accepts a websocket connection for hibernation-capable Durable Objects. */ + acceptWebSocket(ws: DurableWebSocket, tags?: Array): Effect.Effect; + /** Lists active sockets, optionally filtered by tag. */ + getWebSockets(tag?: string): Effect.Effect>; + /** Configures automatic request/response handling for sockets. */ + setWebSocketAutoResponse(pair?: WebSocketRequestResponsePair): Effect.Effect; + /** Gets the configured websocket auto-response pair. */ + getWebSocketAutoResponse: Effect.Effect; + /** Timestamp of the last automatic websocket response for a socket. */ + getWebSocketAutoResponseTimestamp(ws: DurableWebSocket): Effect.Effect; + /** Sets the timeout for hibernatable websocket events. */ + setHibernatableWebSocketEventTimeout(timeoutMs?: number): Effect.Effect; + /** Gets the timeout for hibernatable websocket events. */ + getHibernatableWebSocketEventTimeout: Effect.Effect; + /** Reads tags attached to a websocket. */ + getTags(ws: DurableWebSocket): Effect.Effect>; + /** + * Forcibly resets the Durable Object. Cloudflare logs an uncaught Error using + * the optional reason, and the method is not available in `wrangler dev` local + * development according to the Durable Object State docs. + */ + abort(reason?: string): Effect.Effect; +} + +/** + * Context tag for accessing the current Durable Object state service. + */ +export class DurableObjectState extends Context.Service< + DurableObjectState, + DurableObjectStateService +>()("effect-cf/DurableObjectState") {} + +/** + * Wraps a native Cloudflare `DurableObjectState` as a {@link DurableObjectStateService}. + */ +export const fromDurableObjectState = ( + state: globalThis.DurableObjectState, +): DurableObjectStateService => ({ + raw: state, + id: state.id, + storage: fromDurableObjectStorage(state.storage), + waitUntil: (promise: Promise) => Effect.sync(() => state.waitUntil(promise)), + blockConcurrencyWhile: (effect: Effect.Effect) => + Effect.context().pipe( + Effect.flatMap((context) => + Effect.promise(() => + state.blockConcurrencyWhile(() => + runPromiseExitPreservingTypedFailures(Effect.provideContext(effect, context)), + ), + ), + ), + Effect.flatten, + ), + blockConcurrencyWhileOrReset: (effect: Effect.Effect) => + Effect.context().pipe( + Effect.flatMap((context) => + Effect.promise(() => + state.blockConcurrencyWhile(() => + Effect.runPromise(Effect.provideContext(effect, context)), + ), + ), + ), + ), + acceptWebSocket: (ws: DurableWebSocket, tags?: Array) => + Effect.sync(() => state.acceptWebSocket(ws.raw, tags)), + getWebSockets: (tag?: string) => + Effect.sync(() => state.getWebSockets(tag).map((socket) => fromWebSocket(socket))), + setWebSocketAutoResponse: (pair?: WebSocketRequestResponsePair) => + Effect.sync(() => state.setWebSocketAutoResponse(pair)), + getWebSocketAutoResponse: Effect.sync(() => state.getWebSocketAutoResponse()), + getWebSocketAutoResponseTimestamp: (ws: DurableWebSocket) => + Effect.sync(() => state.getWebSocketAutoResponseTimestamp(ws.raw)), + setHibernatableWebSocketEventTimeout: (timeoutMs?: number) => + Effect.sync(() => state.setHibernatableWebSocketEventTimeout(timeoutMs)), + getHibernatableWebSocketEventTimeout: Effect.sync(() => + state.getHibernatableWebSocketEventTimeout(), + ), + getTags: (ws: DurableWebSocket) => Effect.sync(() => state.getTags(ws.raw)), + abort: (reason?: string) => Effect.sync(() => state.abort(reason)), +}); + +const runPromiseExitPreservingTypedFailures = async ( + effect: Effect.Effect, +): Promise> => { + const exit = await Effect.runPromiseExit(effect); + + if (Exit.isFailure(exit) && (Cause.hasDies(exit.cause) || Cause.hasInterrupts(exit.cause))) { + throw Cause.squash(exit.cause); + } + + return exit; +}; diff --git a/lib/effect-cf/src/DurableObjectStorage.ts b/lib/effect-cf/src/DurableObjectStorage.ts new file mode 100644 index 000000000..e5cf764a8 --- /dev/null +++ b/lib/effect-cf/src/DurableObjectStorage.ts @@ -0,0 +1,381 @@ +import { Data, Effect, Exit, Option, Schema as S } from "effect"; + +/** Supported primitive value types for Durable Object SQL APIs. */ +export type SqlStorageValue = globalThis.SqlStorageValue; + +/** Error type used when a storage operation throws or rejects. */ +export class StorageOperationError extends Data.TaggedError("StorageOperationError")<{ + readonly operation: string; + readonly cause: unknown; +}> {} + +type StorageEffect = Effect.Effect; + +/** + * Effect wrapper for Cloudflare SQL cursor operations. + */ +export interface SqlCursor> { + next(): StorageEffect<{ done?: false; value: T } | { done: true; value?: never }>; + toArray(): StorageEffect>; + one(): StorageEffect; + raw>(): StorageEffect>; + readonly columnNames: Array; + readonly rowsRead: StorageEffect; + readonly rowsWritten: StorageEffect; +} + +/** + * Effect wrapper for Durable Object SQLite APIs. + */ +export interface SqlStorage { + /** Executes a SQL statement and returns a typed cursor. */ + exec>( + query: string, + ...bindings: Array + ): StorageEffect>; + readonly databaseSize: number; +} + +/** + * Schema pair used by `SyncKvStorage.schema(...)`. + */ +export interface SyncKvDefinition { + readonly key: S.Codec; + readonly value: S.Codec; +} + +/** + * Sync KV wrapper that transparently encodes keys/values through Effect Schema. + */ +export interface SchemaBackedSyncKvStorage { + get(key: Key): Effect.Effect, unknown>; + put(key: Key, value: Value): Effect.Effect; + delete(key: Key): Effect.Effect; + list(options?: globalThis.SyncKvListOptions): Effect.Effect, unknown>; +} + +/** + * Effect wrapper for synchronous KV attached to Durable Object SQLite storage. + */ +export interface SyncKvStorage { + get(key: string): StorageEffect; + put(key: string, value: T): StorageEffect; + delete(key: string): StorageEffect; + list(options?: globalThis.SyncKvListOptions): StorageEffect>; + schema( + definition: SyncKvDefinition, + ): SchemaBackedSyncKvStorage; +} + +/** + * Effect wrapper around Cloudflare transaction callbacks. + */ +export interface DurableObjectTransaction { + get( + key: string, + options?: globalThis.DurableObjectGetOptions, + ): StorageEffect; + get( + keys: Array, + options?: globalThis.DurableObjectGetOptions, + ): StorageEffect>; + list(options?: globalThis.DurableObjectListOptions): StorageEffect>; + put(key: string, value: T, options?: globalThis.DurableObjectPutOptions): StorageEffect; + put( + entries: Record, + options?: globalThis.DurableObjectPutOptions, + ): StorageEffect; + delete(key: string, options?: globalThis.DurableObjectPutOptions): StorageEffect; + delete(keys: Array, options?: globalThis.DurableObjectPutOptions): StorageEffect; + rollback(): StorageEffect; + getAlarm(options?: globalThis.DurableObjectGetAlarmOptions): StorageEffect; + setAlarm( + scheduledTime: number | Date, + options?: globalThis.DurableObjectSetAlarmOptions, + ): StorageEffect; + deleteAlarm(options?: globalThis.DurableObjectSetAlarmOptions): StorageEffect; +} + +/** + * Effect wrapper around Cloudflare Durable Object storage. + * + * @example + * ```ts + * const program = Effect.gen(function* () { + * const state = yield* DurableObjectState; + * yield* state.storage.put("counter", 1); + * const value = yield* state.storage.get("counter"); + * return value; + * }); + * ``` + */ +export interface DurableObjectStorage { + get( + key: string, + options?: globalThis.DurableObjectGetOptions, + ): StorageEffect; + put(key: string, value: T, options?: globalThis.DurableObjectPutOptions): StorageEffect; + delete(key: string, options?: globalThis.DurableObjectPutOptions): StorageEffect; + /** + * Deletes all stored data. On compatibility dates before 2026-02-24, Cloudflare + * documents that active alarms must be deleted separately with `deleteAlarm()`. + */ + deleteAll(options?: globalThis.DurableObjectPutOptions): StorageEffect; + getAlarm(options?: globalThis.DurableObjectGetAlarmOptions): StorageEffect; + setAlarm( + scheduledTime: number | Date, + options?: globalThis.DurableObjectSetAlarmOptions, + ): StorageEffect; + deleteAlarm(options?: globalThis.DurableObjectSetAlarmOptions): StorageEffect; + /** + * SQLite-backed Durable Object storage only. + * + * The callback Effect must complete synchronously. If it requires asynchronous + * work, use `transaction` instead so Cloudflare can run the async transaction + * callback under the platform's transaction contract. + */ + transactionSync( + closure: () => Effect.Effect, + ): Effect.Effect; + /** + * Runs an async transaction and exposes a typed transaction wrapper. + */ + transaction( + closure: (txn: DurableObjectTransaction) => Effect.Effect, + ): Effect.Effect; + /** Flushes pending writes to disk. */ + sync(): StorageEffect; + /** SQLite-backed Durable Object storage point-in-time recovery API only. */ + getCurrentBookmark(): StorageEffect; + /** SQLite-backed Durable Object storage point-in-time recovery API only. */ + onNextSessionRestoreBookmark(bookmark: string): StorageEffect; + readonly sql: SqlStorage; + readonly kv: SyncKvStorage; +} + +const storageError = (operation: string, cause: unknown) => + new StorageOperationError({ operation, cause }); + +const tryStorageSync = (operation: string, evaluate: () => A): StorageEffect => + Effect.try({ + try: evaluate, + catch: (cause) => storageError(operation, cause), + }); + +const tryStoragePromise = (operation: string, evaluate: () => Promise): StorageEffect => + Effect.tryPromise({ + try: evaluate, + catch: (cause) => storageError(operation, cause), + }); + +const fromSqlCursor = >( + cursor: globalThis.SqlStorageCursor, +): SqlCursor => ({ + next: () => tryStorageSync("sql.next", () => cursor.next()), + toArray: () => tryStorageSync("sql.toArray", () => cursor.toArray()), + one: () => tryStorageSync("sql.one", () => cursor.one()), + raw: >() => tryStorageSync("sql.raw", () => cursor.raw()), + get columnNames() { + return cursor.columnNames; + }, + rowsRead: tryStorageSync("sql.rowsRead", () => cursor.rowsRead), + rowsWritten: tryStorageSync("sql.rowsWritten", () => cursor.rowsWritten), +}); + +const fromSqlStorage = (sql: globalThis.SqlStorage): SqlStorage => ({ + exec: >( + query: string, + ...bindings: Array + ) => tryStorageSync("sql.exec", () => fromSqlCursor(sql.exec(query, ...bindings))), + get databaseSize() { + return sql.databaseSize; + }, +}); + +const schemaBackedSyncKvStorage = ( + kv: globalThis.SyncKvStorage, + definition: SyncKvDefinition, +): SchemaBackedSyncKvStorage => { + const encodeKey = S.encodeEffect(definition.key); + const decodeKey = S.decodeUnknownEffect(definition.key); + const encodeValue = S.encodeEffect(definition.value); + const decodeValue = S.decodeUnknownEffect(definition.value); + + return { + get: (key) => + Effect.gen(function* () { + const encodedKey = yield* encodeKey(key); + const encodedValue = yield* tryStorageSync("kv.get", () => + kv.get(encodedKey), + ); + + if (encodedValue === undefined) { + return Option.none(); + } + + return yield* decodeValue(encodedValue).pipe(Effect.map(Option.some)); + }), + put: (key, value) => + Effect.gen(function* () { + const encodedKey = yield* encodeKey(key); + const encodedValue = yield* encodeValue(value); + yield* tryStorageSync("kv.put", () => kv.put(encodedKey, encodedValue)); + }), + delete: (key) => + Effect.gen(function* () { + const encodedKey = yield* encodeKey(key); + return yield* tryStorageSync("kv.delete", () => kv.delete(encodedKey)); + }), + list: (options) => + Effect.gen(function* () { + const entries = Array.from( + yield* tryStorageSync("kv.list", () => kv.list(options)), + ); + const decoded: Array<[Key, Value]> = []; + + for (const [key, value] of entries) { + decoded.push([yield* decodeKey(key), yield* decodeValue(value)]); + } + + return decoded; + }), + }; +}; + +const fromSyncKvStorage = (kv: globalThis.SyncKvStorage): SyncKvStorage => ({ + get: (key: string) => tryStorageSync("kv.get", () => kv.get(key)), + put: (key: string, value: T) => tryStorageSync("kv.put", () => kv.put(key, value)), + delete: (key: string) => tryStorageSync("kv.delete", () => kv.delete(key)), + list: (options?: globalThis.SyncKvListOptions) => + tryStorageSync("kv.list", () => Array.from(kv.list(options))), + schema: (definition: SyncKvDefinition) => + schemaBackedSyncKvStorage(kv, definition), +}); + +const fromDurableObjectTransaction = ( + txn: globalThis.DurableObjectTransaction, +): DurableObjectTransaction => + ({ + get: ( + keyOrKeys: string | Array, + options?: globalThis.DurableObjectGetOptions, + ) => + Array.isArray(keyOrKeys) + ? tryStoragePromise("transaction.get", () => txn.get(keyOrKeys, options)) + : tryStoragePromise("transaction.get", () => txn.get(keyOrKeys, options)), + list: (options?: globalThis.DurableObjectListOptions) => + tryStoragePromise("transaction.list", () => txn.list(options)), + put: ( + keyOrEntries: string | Record, + valueOrOptions?: T | globalThis.DurableObjectPutOptions, + maybeOptions?: globalThis.DurableObjectPutOptions, + ) => + tryStoragePromise("transaction.put", () => + typeof keyOrEntries === "string" + ? txn.put(keyOrEntries, valueOrOptions as T, maybeOptions) + : txn.put(keyOrEntries, valueOrOptions as globalThis.DurableObjectPutOptions | undefined), + ), + delete: (keyOrKeys: string | Array, options?: globalThis.DurableObjectPutOptions) => + Array.isArray(keyOrKeys) + ? tryStoragePromise("transaction.delete", () => txn.delete(keyOrKeys, options)) + : tryStoragePromise("transaction.delete", () => txn.delete(keyOrKeys, options)), + rollback: () => tryStorageSync("transaction.rollback", () => txn.rollback()), + getAlarm: (options?: globalThis.DurableObjectGetAlarmOptions) => + tryStoragePromise("transaction.getAlarm", () => txn.getAlarm(options)), + setAlarm: (scheduledTime: number | Date, options?: globalThis.DurableObjectSetAlarmOptions) => + tryStoragePromise("transaction.setAlarm", () => txn.setAlarm(scheduledTime, options)), + deleteAlarm: (options?: globalThis.DurableObjectSetAlarmOptions) => + tryStoragePromise("transaction.deleteAlarm", () => txn.deleteAlarm(options)), + }) as DurableObjectTransaction; + +/** + * Wraps native Cloudflare storage APIs as Effect-returning helpers. + */ +export const fromDurableObjectStorage = ( + storage: globalThis.DurableObjectStorage, +): DurableObjectStorage => ({ + get: (key: string, options?: globalThis.DurableObjectGetOptions) => + tryStoragePromise("get", () => storage.get(key, options)), + put: (key: string, value: T, options?: globalThis.DurableObjectPutOptions) => + tryStoragePromise("put", () => storage.put(key, value, options)), + delete: (key: string, options?: globalThis.DurableObjectPutOptions) => + tryStoragePromise("delete", () => storage.delete(key, options)), + deleteAll: (options?: globalThis.DurableObjectPutOptions) => + tryStoragePromise("deleteAll", () => storage.deleteAll(options)), + getAlarm: (options?: globalThis.DurableObjectGetAlarmOptions) => + tryStoragePromise("getAlarm", () => storage.getAlarm(options)), + setAlarm: (scheduledTime: number | Date, options?: globalThis.DurableObjectSetAlarmOptions) => + tryStoragePromise("setAlarm", () => storage.setAlarm(scheduledTime, options)), + deleteAlarm: (options?: globalThis.DurableObjectSetAlarmOptions) => + tryStoragePromise("deleteAlarm", () => storage.deleteAlarm(options)), + transactionSync: (closure: () => Effect.Effect) => + Effect.context().pipe( + Effect.flatMap((context) => + Effect.suspend(() => { + try { + return Effect.succeed( + storage.transactionSync(() => { + const exit = Effect.runSyncExitWith(context)(closure()); + + if (Exit.isSuccess(exit)) { + return exit.value; + } + + throw exit; + }), + ); + } catch (cause) { + if (Exit.isExit(cause) && Exit.isFailure(cause)) { + return Effect.failCause(cause.cause) as Effect.Effect< + A, + E | StorageOperationError, + R + >; + } + + return Effect.fail(storageError("transactionSync", cause)); + } + }), + ), + ), + transaction: (closure: (txn: DurableObjectTransaction) => Effect.Effect) => + Effect.context().pipe( + Effect.flatMap((context) => + Effect.callback((resume) => { + void storage + .transaction(async (txn) => { + const exit = await Effect.runPromiseExitWith(context)( + closure(fromDurableObjectTransaction(txn)), + ); + + if (Exit.isSuccess(exit)) { + return exit.value; + } + + throw exit; + }) + .then( + (value) => resume(Effect.succeed(value)), + (cause) => { + if (Exit.isExit(cause) && Exit.isFailure(cause)) { + resume( + Effect.failCause(cause.cause) as Effect.Effect, + ); + } else { + resume(Effect.fail(storageError("transaction", cause))); + } + }, + ); + }), + ), + ), + sync: () => tryStoragePromise("sync", () => storage.sync()), + getCurrentBookmark: () => + tryStoragePromise("getCurrentBookmark", () => storage.getCurrentBookmark()), + onNextSessionRestoreBookmark: (bookmark: string) => + tryStoragePromise("onNextSessionRestoreBookmark", () => + storage.onNextSessionRestoreBookmark(bookmark), + ), + sql: fromSqlStorage(storage.sql), + kv: fromSyncKvStorage(storage.kv), +}); diff --git a/lib/effect-cf/src/DurableObjectWebSocket.ts b/lib/effect-cf/src/DurableObjectWebSocket.ts new file mode 100644 index 000000000..c51d705fd --- /dev/null +++ b/lib/effect-cf/src/DurableObjectWebSocket.ts @@ -0,0 +1,259 @@ +import { Data, Effect, Option, Schema as S } from "effect"; + +import { DurableObjectState } from "./DurableObjectState"; + +/** Data supported by Cloudflare websocket `send`. */ +export type DurableWebSocketSendData = string | ArrayBuffer | ArrayBufferView; + +/** Error raised when sending on a Durable Object websocket fails. */ +export class DurableWebSocketSendError extends Data.TaggedError("DurableWebSocketSendError")<{ + readonly cause: unknown; +}> {} + +/** Error raised when closing a Durable Object websocket fails. */ +export class DurableWebSocketCloseError extends Data.TaggedError("DurableWebSocketCloseError")<{ + readonly cause: unknown; +}> {} + +/** Error raised when serializing or deserializing a websocket attachment fails. */ +export class DurableWebSocketAttachmentError extends Data.TaggedError( + "DurableWebSocketAttachmentError", +)<{ + readonly operation: "serialize" | "deserialize"; + readonly cause: unknown; +}> {} + +/** Effect-native wrapper around a hibernatable Durable Object websocket. */ +export interface DurableWebSocket { + /** Underlying Cloudflare websocket. */ + readonly raw: WebSocket; + /** Sends a message through the socket. */ + send(data: DurableWebSocketSendData): Effect.Effect; + /** Closes the socket. */ + close(code?: number, reason?: string): Effect.Effect; + /** Serializes hibernation attachment metadata onto the socket. */ + serializeAttachment( + value: A, + ): Effect.Effect; + /** Deserializes hibernation attachment metadata from the socket. */ + readonly deserializeAttachment: Effect.Effect; +} + +const wrappers = new WeakMap>(); + +/** Wraps a native Cloudflare websocket in the Effect-native Durable Object API. */ +export const fromWebSocket = ( + raw: WebSocket, +): DurableWebSocket => { + const existing = wrappers.get(raw); + if (existing !== undefined) { + return existing as DurableWebSocket; + } + + const socket: DurableWebSocket = { + raw, + send: (data) => + Effect.try({ + try: () => raw.send(data), + catch: (cause) => new DurableWebSocketSendError({ cause }), + }), + close: (code, reason) => + Effect.try({ + try: () => raw.close(code, reason), + catch: (cause) => new DurableWebSocketCloseError({ cause }), + }), + serializeAttachment: (value) => + Effect.try({ + try: () => raw.serializeAttachment(value), + catch: (cause) => new DurableWebSocketAttachmentError({ operation: "serialize", cause }), + }), + deserializeAttachment: Effect.try({ + try: () => raw.deserializeAttachment(), + catch: (cause) => new DurableWebSocketAttachmentError({ operation: "deserialize", cause }), + }), + }; + + wrappers.set(raw, socket); + return socket as DurableWebSocket; +}; + +/** + * Options for accepting an incoming websocket request in a Durable Object. + */ +export interface AcceptUpgradeOptions { + /** Optional websocket tags for Durable Object hibernation filtering. */ + readonly tags?: ReadonlyArray | undefined; + /** Optional attachment serialized onto the server socket. */ + readonly attachment?: Attachment | undefined; +} + +/** + * Result of a websocket upgrade accepted by {@link acceptUpgrade}. + */ +export interface AcceptedUpgrade { + readonly client: WebSocket; + readonly server: DurableWebSocket; + readonly response: Response; +} + +/** + * Accepts a websocket upgrade and registers the server socket on `DurableObjectState`. + * + * @example + * ```ts + * const response = yield* DurableObjectWebSocket.acceptUpgrade({ + * tags: ["room:general"], + * }); + * + * return response.response; + * ``` + */ +export const acceptUpgrade = ( + options: AcceptUpgradeOptions = {}, +): Effect.Effect, never, DurableObjectState> => + Effect.gen(function* () { + const state = yield* DurableObjectState; + const pair = new WebSocketPair(); + const client = pair[0]; + const server = fromWebSocket(pair[1]); + + if (options.attachment !== undefined) { + server.raw.serializeAttachment(options.attachment); + } + + yield* state.acceptWebSocket( + server, + options.tags === undefined ? undefined : [...options.tags], + ); + + return { + client, + server, + response: new Response(null, { + status: 101, + webSocket: client, + }), + }; + }); + +export type AttachmentInvalidPolicy = "ignore" | "ignore-and-close" | "fail"; + +export interface AttachmentRehydrateOptions { + /** Optional Durable Object websocket tag filter. */ + readonly tag?: string | undefined; + /** Invalid attachment behavior. Defaults to skipping invalid sockets. */ + readonly onInvalid?: AttachmentInvalidPolicy | undefined; +} + +export interface RehydratedDurableWebSocket { + readonly socket: DurableWebSocket; + readonly attachment: Attachment; +} + +export interface DurableWebSocketAttachment { + serialize( + socket: DurableWebSocket, + value: Attachment, + ): Effect.Effect; + deserialize( + socket: DurableWebSocket, + ): Effect.Effect, DurableWebSocketAttachmentError>; + rehydrate( + options?: AttachmentRehydrateOptions, + ): Effect.Effect< + Array>, + DurableWebSocketAttachmentError, + DurableObjectState + >; + readonly schema: S.Codec; +} + +/** Creates typed attachment helpers for accepted and rehydrated sockets. */ +export const attachment = >( + schema: AttachmentSchema, +): DurableWebSocketAttachment< + S.Schema.Type, + S.Codec.Encoded +> => { + type Attachment = S.Schema.Type; + type Encoded = S.Codec.Encoded; + + const serialize = (socket: DurableWebSocket, value: Attachment) => + S.encodeEffect(schema)(value).pipe( + Effect.mapError( + (cause) => new DurableWebSocketAttachmentError({ operation: "serialize", cause }), + ), + Effect.flatMap((encoded) => socket.serializeAttachment(encoded as Encoded)), + ); + + const deserialize = (socket: DurableWebSocket) => + Effect.gen(function* () { + const value = yield* socket.deserializeAttachment; + if (value == null) { + return Option.none(); + } + + return Option.some( + yield* S.decodeUnknownEffect(schema)(value).pipe( + Effect.mapError( + (cause) => new DurableWebSocketAttachmentError({ operation: "deserialize", cause }), + ), + ), + ); + }); + + const rehydrate = (options: AttachmentRehydrateOptions = {}) => + Effect.gen(function* () { + const state = yield* DurableObjectState; + const sockets = yield* state.getWebSockets(options.tag); + const restored: Array> = []; + const onInvalid = options.onInvalid ?? "ignore"; + + for (const socket of sockets) { + const decoded = yield* deserialize(socket).pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "Failure" as const, error }), + onSuccess: (value) => ({ _tag: "Success" as const, value }), + }), + ); + + if (decoded._tag === "Success" && Option.isSome(decoded.value)) { + restored.push({ socket, attachment: decoded.value.value }); + continue; + } + + if (decoded._tag === "Failure" && onInvalid === "fail") { + return yield* Effect.fail(decoded.error); + } + + if (decoded._tag === "Failure" && onInvalid === "ignore-and-close") { + yield* socket.close(1008, "invalid websocket attachment").pipe(Effect.ignore); + } + } + + return restored; + }); + + return { serialize, deserialize, rehydrate, schema }; +}; + +export interface DurableWebSocketHandlers { + readonly message?: ( + socket: DurableWebSocket, + message: string | ArrayBuffer, + ) => Effect.Effect; + readonly close?: ( + socket: DurableWebSocket, + code: number, + reason: string, + wasClean: boolean, + ) => Effect.Effect; + readonly error?: (socket: DurableWebSocket, error: unknown) => Effect.Effect; +} + +/** Maps compact websocket lifecycle handler names to `DurableObject.make` options. */ +export const handlers = (options: DurableWebSocketHandlers) => ({ + webSocketMessage: options.message, + webSocketClose: options.close, + webSocketError: options.error, +}); diff --git a/lib/effect-cf/src/Entry.ts b/lib/effect-cf/src/Entry.ts new file mode 100644 index 000000000..08107d74b --- /dev/null +++ b/lib/effect-cf/src/Entry.ts @@ -0,0 +1,259 @@ +import { WorkerEntrypoint as CloudflareWorkerEntrypoint } from "cloudflare:workers"; +import { Cause, Context, Effect, Exit, Layer, ManagedRuntime, type Scope } from "effect"; +import { HttpServerRequest } from "effect/unstable/http"; + +import { WorkerEnvironment, type WorkerEnv } from "./Environment"; + +/** + * Access to Cloudflare's native `ExecutionContext`. + */ +export class ExecutionContext extends Context.Service< + ExecutionContext, + globalThis.ExecutionContext +>()("effect-cf/ExecutionContext") {} + +/** + * Options for {@link WorkerContextService.waitUntil}. + */ +export interface WorkerContextWaitUntilOptions { + /** Custom failure handler for the background effect. */ + readonly onFailure?: (cause: Cause.Cause) => Effect.Effect; +} + +/** + * Effect wrapper around `ExecutionContext` background APIs. + */ +export interface WorkerContextService { + readonly raw: globalThis.ExecutionContext; + waitUntil( + effect: Effect.Effect, + options?: WorkerContextWaitUntilOptions, + ): Effect.Effect; + readonly passThroughOnException: Effect.Effect; +} + +/** + * Service used inside handlers to schedule background work via `waitUntil`. + * + * @example + * ```ts + * const handler = Effect.gen(function* () { + * const ctx = yield* WorkerContext; + * yield* ctx.waitUntil(Effect.log("flush analytics")); + * return new Response("ok"); + * }); + * ``` + */ +export class WorkerContext extends Context.Service()( + "effect-cf/WorkerContext", +) {} + +type RunWaitUntilEffect = ( + effect: Effect.Effect, +) => Promise>; + +const fromExecutionContext = ( + ctx: globalThis.ExecutionContext, + runPromiseExit: RunWaitUntilEffect, +): WorkerContextService => ({ + raw: ctx, + waitUntil: ( + effect: Effect.Effect, + options?: WorkerContextWaitUntilOptions, + ) => + Effect.context().pipe( + Effect.flatMap((context) => + Effect.sync(() => { + const observed = Effect.exit(effect).pipe( + Effect.flatMap((exit) => { + if (Exit.isSuccess(exit)) { + return Effect.void; + } + + const handleFailure = + options?.onFailure?.(exit.cause) ?? + Effect.logError("WorkerContext.waitUntil failed", Cause.pretty(exit.cause)); + + return handleFailure.pipe( + Effect.catchCause((handlerCause) => + Effect.logError( + "WorkerContext.waitUntil failure handler failed", + Cause.pretty(exit.cause), + Cause.pretty(handlerCause), + ), + ), + ); + }), + ); + + ctx.waitUntil( + runPromiseExit(Effect.scoped(Effect.provideContext(observed, context))).then((exit) => { + if (Exit.isFailure(exit)) { + console.error( + "WorkerContext.waitUntil failure handler failed", + Cause.pretty(exit.cause), + ); + } + }), + ); + }), + ), + ), + passThroughOnException: Effect.sync(() => ctx.passThroughOnException()), +}); + +/** + * Access to the incoming `Request` currently handled by a worker or Durable Object fetch. + */ +export class NativeRequest extends Context.Service()( + "effect-cf/NativeRequest", +) {} + +/** + * Returns `true` when the request is a websocket upgrade request. + */ +export const isWebSocketUpgrade = (request: Request): boolean => + request.headers.get("Upgrade")?.toLowerCase() === "websocket"; + +type RequestServices = ExecutionContext | WorkerContext | WorkerEnvironment; + +type HandlerContext = + | ExecutionContext + | WorkerContext + | WorkerEnvironment + | NativeRequest + | HttpServerRequest.HttpServerRequest + | ROut + | Scope.Scope; + +type RuntimeContext = RequestServices | ROut; +type RpcContext = RuntimeContext | Scope.Scope; + +const RunSymbol = Symbol.for("effect-cf/Worker/run"); + +/** + * Effect type for `fetch` handlers executed by {@link make}. + */ +export type WorkerHandler = Effect.Effect>; + +/** + * Effect type for worker RPC handlers. + */ +export type WorkerRpcHandler = Effect.Effect>; + +/** + * Shape of worker RPC handlers passed to {@link make}. + */ +export type WorkerRpc = Record) => WorkerRpcHandler>; + +export type WorkerRpcShape, ROut> = { + readonly [Key in keyof Rpc]: Rpc[Key] extends ( + ...args: infer Args + ) => Effect.Effect> + ? (...args: Args) => Promise + : never; +}; + +export type RpcHandlers = { + readonly [Key in keyof Api as Key extends keyof CloudflareWorkerEntrypoint + ? never + : Key extends string + ? [Api[Key]] extends [never] + ? never + : Api[Key] extends (...args: Array) => Promise + ? Key + : never + : never]: Api[Key] extends (...args: infer Args) => Promise + ? (...args: Args) => WorkerRpcHandler + : never; +}; + +/** + * Options for creating a worker class with Effect handlers. + */ +export interface WorkerOptions> { + /** Main request handler. */ + readonly fetch: Effect.Effect>; + /** Optional RPC methods exposed as class instance methods. */ + readonly rpc?: Rpc; +} + +/** + * Cloudflare `WorkerEntrypoint` constructor produced by {@link make}. + */ +export type WorkerClass, ROut> = new ( + ctx: globalThis.ExecutionContext, + env: WorkerEnv, +) => CloudflareWorkerEntrypoint & WorkerRpcShape; + +/** + * Creates a Cloudflare worker class backed by a single managed Effect runtime. + * + * @example + * ```ts + * const Worker = Entry.make(Layer.empty, { + * fetch: Effect.succeed(new Response("ok")), + * }); + * ``` + */ +export const make = = Record>( + layer: Layer.Layer, + options: WorkerOptions, +): WorkerClass => { + class EffectWorker extends CloudflareWorkerEntrypoint { + readonly runtime: ManagedRuntime.ManagedRuntime, LayerError>; + + constructor(ctx: globalThis.ExecutionContext, env: WorkerEnv) { + super(ctx, env); + + let runWaitUntilEffect: RunWaitUntilEffect = () => + Promise.resolve(Exit.die(new Error("WorkerContext runtime is not initialized"))); + + const services = Layer.mergeAll( + Layer.succeed(ExecutionContext, ctx), + Layer.succeed( + WorkerContext, + fromExecutionContext(ctx, (effect) => runWaitUntilEffect(effect)), + ), + Layer.succeed(WorkerEnvironment, env), + ); + + const runtimeLayer = layer.pipe(Layer.provideMerge(services)) as Layer.Layer< + RuntimeContext, + LayerError, + never + >; + + this.runtime = ManagedRuntime.make(runtimeLayer); + runWaitUntilEffect = (effect: Effect.Effect) => + this.runtime.runPromiseExit(effect as Effect.Effect>); + } + + [RunSymbol](effect: Effect.Effect | Scope.Scope>): Promise { + return this.runtime.runPromise(Effect.scoped(effect)); + } + + fetch(request: Request): Promise { + return this[RunSymbol]( + options.fetch.pipe( + Effect.provideService(NativeRequest, request), + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(request), + ), + ), + ); + } + } + + for (const [key, method] of Object.entries(options.rpc ?? {})) { + Object.defineProperty(EffectWorker.prototype, key, { + enumerable: true, + value(this: EffectWorker, ...args: Array) { + return this[RunSymbol](Effect.suspend(() => method(...args))); + }, + }); + } + + return EffectWorker as WorkerClass; +}; diff --git a/lib/effect-cf/src/Environment.ts b/lib/effect-cf/src/Environment.ts new file mode 100644 index 000000000..2f407516f --- /dev/null +++ b/lib/effect-cf/src/Environment.ts @@ -0,0 +1,100 @@ +import { Config, ConfigProvider, Context, Effect } from "effect"; + +/** Cloudflare worker environment object (`env`). */ +export type WorkerEnv = Cloudflare.Env; + +/** + * Context service for reading worker bindings from the current `env` object. + */ +export class WorkerEnvironment extends Context.Service()( + "effect-cf/WorkerEnvironment", +) {} + +type ScalarConfigValue = string | number | boolean; + +type ScalarConfigKey = Extract< + { + readonly [Key in keyof Cloudflare.Env]-?: NonNullable< + Cloudflare.Env[Key] + > extends ScalarConfigValue + ? Key + : never; + }[keyof Cloudflare.Env], + string +>; + +/** + * Effect `Config` helpers for scalar Cloudflare vars and secrets declared on + * `Cloudflare.Env` / generated `worker-configuration.d.ts`. + * + * Users still author their app config explicitly with Effect `Config`: + * + * ```ts + * import { Config, Effect } from "effect"; + * import { WorkerConfig } from "effect-cf"; + * + * const AppConfig = Config.all({ + * databaseUrl: WorkerConfig.redacted("DATABASE_URL"), + * port: WorkerConfig.integer("PORT").pipe(Config.withDefault(8787)), + * }); + * + * const program = Effect.gen(function* () { + * const config = yield* AppConfig; + * // ... + * }).pipe(Effect.provide(WorkerConfig.layer)); + * ``` + * + * Keys are constrained to scalar `Cloudflare.Env` properties (`string`, + * `number`, or `boolean`, including optional scalar properties). Binding + * objects such as `KVNamespace`, `DurableObjectNamespace`, and service + * bindings are intentionally excluded; keep using the package binding helpers + * for those live resources. + */ +export namespace WorkerConfig { + /** Scalar env value types supported by the typed config key helpers. */ + export type Scalar = ScalarConfigValue; + + /** Scalar keys available on the current consumer's `Cloudflare.Env`. */ + export type Key = ScalarConfigKey; + + /** Read a scalar Cloudflare var or secret as a string. */ + export const string = (name: Name) => Config.string(name); + + /** Read a scalar Cloudflare secret as a redacted string. */ + export const redacted = (name: Name) => Config.redacted(name); + + /** Read a scalar Cloudflare var or secret as a number. */ + export const number = (name: Name) => Config.number(name); + + /** Read a scalar Cloudflare var or secret as an integer. */ + export const integer = (name: Name) => Config.int(name); + + /** Read a scalar Cloudflare var or secret as a boolean. */ + export const boolean = (name: Name) => Config.boolean(name); + + /** Build a `ConfigProvider` from a Cloudflare worker `env` object. */ + export const providerFromEnv = (env: WorkerEnv) => ConfigProvider.fromUnknown(env); + + /** + * Build a `ConfigProvider` from the current `WorkerEnvironment` with a custom + * conversion function. + */ + export const providerWith = (makeProvider: (env: WorkerEnv) => ConfigProvider.ConfigProvider) => + Effect.map(WorkerEnvironment, makeProvider); + + /** Build a `ConfigProvider` from the current `WorkerEnvironment`. */ + export const provider = providerWith(providerFromEnv); + + /** + * Replace the active Effect `ConfigProvider` with one backed by the current + * Cloudflare worker `env` object. + */ + export const providerLayer = ConfigProvider.layer(provider); + + /** Alias for `providerLayer` for concise use in worker layers. */ + export const layer = providerLayer; + + /** Build a `ConfigProvider` layer with a custom `env` conversion function. */ + export const layerWith = (makeProvider: (env: WorkerEnv) => ConfigProvider.ConfigProvider) => + ConfigProvider.layer(providerWith(makeProvider)); +} diff --git a/lib/effect-cf/src/Hyperdrive.ts b/lib/effect-cf/src/Hyperdrive.ts new file mode 100644 index 000000000..5eac5340c --- /dev/null +++ b/lib/effect-cf/src/Hyperdrive.ts @@ -0,0 +1,87 @@ +import { Context, Effect, type Layer } from "effect"; + +import * as Binding from "./Binding"; +import type { WorkerEnvironment } from "./Environment"; + +const expectedHyperdrive = "Hyperdrive binding with connectionString"; + +/** Typed Hyperdrive binding definition. */ +export interface HyperdriveDefinition { + /** Binding name as configured in `wrangler.jsonc`. */ + readonly binding: string; +} + +export interface HyperdriveClient { + readonly connectionString: string; + readonly unsafeRaw: Effect.Effect; + readonly definition: HyperdriveDefinition; +} + +declare const HyperdriveServiceTypeId: unique symbol; + +/** Nominal service marker for Hyperdrive services created with {@link make}. */ +export interface HyperdriveService { + readonly [HyperdriveServiceTypeId]: { + readonly id: Id; + }; +} + +export type LayerOptions = { + readonly binding: string; +}; + +export interface TagClass extends Context.ServiceClass< + Self, + Id, + HyperdriveClient +> { + readonly id: Id; + readonly layer: ( + options: LayerOptions, + ) => Layer.Layer< + Self, + Binding.BindingNotFoundError | Binding.BindingValidationError, + WorkerEnvironment + >; +} + +export const isHyperdrive = (value: unknown): value is Hyperdrive => { + if (typeof value !== "object" || value === null) { + return false; + } + + const resource = value as Record; + + return typeof resource.connectionString === "string"; +}; + +export const makeClient = + (definition: HyperdriveDefinition) => + (hyperdrive: Hyperdrive): HyperdriveClient => ({ + definition, + connectionString: hyperdrive.connectionString, + unsafeRaw: Effect.succeed(hyperdrive), + }); + +export const layer = ( + tag: Context.Service, + definition: HyperdriveDefinition, +) => + Binding.layer(tag, definition.binding, isHyperdrive, makeClient(definition), { + expected: expectedHyperdrive, + }); + +export const make = (id: Id) => Tag>()(id); + +export const Tag = + () => + (id: Id) => { + const tag = Context.Service()(id); + + const makeLayer = (definition: LayerOptions) => layer(tag, definition); + + return Object.assign(tag, { + id, + layer: makeLayer, + }) as TagClass; + }; diff --git a/lib/effect-cf/src/HyperdrivePg.ts b/lib/effect-cf/src/HyperdrivePg.ts new file mode 100644 index 000000000..8d2a06c9b --- /dev/null +++ b/lib/effect-cf/src/HyperdrivePg.ts @@ -0,0 +1,27 @@ +import { PgClient } from "@effect/sql-pg"; +import { Effect, Layer, Redacted } from "effect"; + +import * as Hyperdrive from "./Hyperdrive"; + +export type PgLayerOptions = Omit< + PgClient.PgClientConfig & { readonly acquireForStream?: boolean }, + "url" | "host" | "port" | "path" | "ssl" | "database" | "username" | "password" | "stream" +>; + +export const layer = ( + tag: Hyperdrive.TagClass, + binding: Hyperdrive.LayerOptions, + options?: PgLayerOptions, +) => + Layer.unwrap( + Effect.gen(function* () { + const hyperdrive = yield* tag; + + return PgClient.layerFrom( + PgClient.makeClient({ + ...options, + url: Redacted.make(hyperdrive.connectionString), + }), + ); + }), + ).pipe(Layer.provide(tag.layer(binding))); diff --git a/lib/effect-cf/src/Images.ts b/lib/effect-cf/src/Images.ts new file mode 100644 index 000000000..c7cd1c1b8 --- /dev/null +++ b/lib/effect-cf/src/Images.ts @@ -0,0 +1,336 @@ +import { Context, Data, Effect, Option, type Layer } from "effect"; + +import * as Binding from "./Binding"; +import type { WorkerEnvironment } from "./Environment"; + +const TypeId = "effect-cf/Images/Steps" as const; +const expectedImagesBinding = "Images binding with info() and input()"; + +/** Error raised when a Cloudflare Images operation fails. */ +export class ImagesOperationError extends Data.TaggedError("ImagesOperationError")<{ + readonly binding: string; + readonly operation: string; + readonly cause: unknown; +}> {} + +/** Typed Cloudflare Images binding definition. */ +export interface ImagesDefinition { + /** Binding name as configured in `wrangler.jsonc`. */ + readonly binding: string; +} + +export type ImageInfoResponse = globalThis.ImageInfoResponse; +export type ImageTransform = globalThis.ImageTransform; +export type ImageDrawOptions = globalThis.ImageDrawOptions; +export type ImageInputOptions = globalThis.ImageInputOptions; +export type ImageOutputOptions = globalThis.ImageOutputOptions; +export type ImageTransformationOutputOptions = globalThis.ImageTransformationOutputOptions; +export type ImageTransformationResult = globalThis.ImageTransformationResult; +export type ImageUploadOptions = globalThis.ImageUploadOptions; +export type ImageUpdateOptions = globalThis.ImageUpdateOptions; +export type ImageListOptions = globalThis.ImageListOptions; +export type ImageList = globalThis.ImageList; +export type ImageMetadata = globalThis.ImageMetadata; +export type ImageInputValue = ReadableStream | ArrayBuffer; +export type ImageUploadValue = ReadableStream | ArrayBuffer; + +export interface DrawStepOptions { + readonly image: ReadableStream | globalThis.ImageTransformer; + readonly options?: ImageDrawOptions; +} + +export type Step = Data.TaggedEnum<{ + readonly Transform: { + readonly transform: ImageTransform; + }; + readonly Draw: DrawStepOptions; +}>; + +export interface Steps { + readonly [TypeId]: typeof TypeId; + readonly steps: ReadonlyArray; +} + +export interface ProcessOptions { + readonly stream: ImageInputValue; + readonly inputOptions?: ImageInputOptions; + readonly outputOptions: ImageOutputOptions; +} + +export interface ImagesTransformationResultClient { + readonly raw: globalThis.ImageTransformationResult; + readonly response: Effect.Effect; + readonly contentType: Effect.Effect; + readonly image: ( + options?: ImageTransformationOutputOptions, + ) => Effect.Effect, ImagesOperationError>; +} + +export interface ImageHandleClient { + readonly raw: globalThis.ImageHandle; + readonly details: Effect.Effect, ImagesOperationError>; + readonly bytes: Effect.Effect>, ImagesOperationError>; + readonly update: ( + options: ImageUpdateOptions, + ) => Effect.Effect; + readonly delete: Effect.Effect; +} + +export interface HostedImagesClient { + readonly image: (imageId: string) => ImageHandleClient; + readonly upload: ( + image: ImageUploadValue, + options?: ImageUploadOptions, + ) => Effect.Effect; + readonly list: (options?: ImageListOptions) => Effect.Effect; + readonly unsafeRaw: Effect.Effect; +} + +export interface ImagesRuntimeBinding { + readonly info: ( + image: ImageInputValue, + options?: ImageInputOptions, + ) => Promise; + readonly input: ( + image: ImageInputValue, + options?: ImageInputOptions, + ) => globalThis.ImageTransformer; + readonly hosted?: globalThis.HostedImagesBinding; +} + +export interface ImagesClient { + readonly info: ( + image: ImageInputValue, + options?: ImageInputOptions, + ) => Effect.Effect; + readonly input: ( + image: ImageInputValue, + options?: ImageInputOptions, + ) => Effect.Effect; + readonly process: ( + steps: Steps, + options: ProcessOptions, + ) => Effect.Effect; + readonly hosted: Option.Option; + readonly unsafeRaw: Effect.Effect; + readonly definition: ImagesDefinition; +} + +declare const ImagesServiceTypeId: unique symbol; + +/** Nominal service marker for Images services created with {@link make}. */ +export interface ImagesService { + readonly [ImagesServiceTypeId]: { + readonly id: Id; + }; +} + +export type LayerOptions = { + readonly binding: string; +}; + +export interface TagClass extends Context.ServiceClass< + Self, + Id, + ImagesClient +> { + readonly id: Id; + readonly layer: ( + options: LayerOptions, + ) => Layer.Layer< + Self, + Binding.BindingNotFoundError | Binding.BindingValidationError, + WorkerEnvironment + >; +} + +const makeSteps = (steps: ReadonlyArray): Steps => ({ + [TypeId]: TypeId, + steps, +}); + +/** Empty Images transformation pipeline. */ +export const empty: Steps = makeSteps([]); + +export function transform(transform: ImageTransform): (steps: Steps) => Steps; +export function transform(steps: Steps, transform: ImageTransform): Steps; +export function transform( + stepsOrTransform: Steps | ImageTransform, + transformValue?: ImageTransform, +): Steps | ((steps: Steps) => Steps) { + if (transformValue === undefined) { + return (steps) => transform(steps, stepsOrTransform as ImageTransform); + } + + const steps = stepsOrTransform as Steps; + + return makeSteps([ + ...steps.steps, + { + _tag: "Transform", + transform: transformValue, + }, + ]); +} + +export function draw(draw: DrawStepOptions): (steps: Steps) => Steps; +export function draw(steps: Steps, draw: DrawStepOptions): Steps; +export function draw( + stepsOrDraw: Steps | DrawStepOptions, + drawValue?: DrawStepOptions, +): Steps | ((steps: Steps) => Steps) { + if (drawValue === undefined) { + return (steps) => draw(steps, stepsOrDraw as DrawStepOptions); + } + + const steps = stepsOrDraw as Steps; + + return makeSteps([ + ...steps.steps, + { + _tag: "Draw", + ...drawValue, + }, + ]); +} + +const imagesError = (binding: string, operation: string, cause: unknown) => + new ImagesOperationError({ binding, operation, cause }); + +const tryImagesPromise = ( + binding: string, + operation: string, + evaluate: () => Promise, +): Effect.Effect => + Effect.tryPromise({ + try: evaluate, + catch: (cause) => imagesError(binding, operation, cause), + }); + +const tryImagesSync = ( + binding: string, + operation: string, + evaluate: () => A, +): Effect.Effect => + Effect.try({ + try: evaluate, + catch: (cause) => imagesError(binding, operation, cause), + }); + +const maybe = (value: A | null): Option.Option => + value === null ? Option.none() : Option.some(value); + +const hasFunction = (value: object, key: string): boolean => + typeof Reflect.get(value, key) === "function"; + +const isHostedImagesBinding = (value: unknown): value is globalThis.HostedImagesBinding => + typeof value === "object" && + value !== null && + hasFunction(value, "image") && + hasFunction(value, "upload") && + hasFunction(value, "list"); + +export const isImagesBinding = (value: unknown): value is ImagesRuntimeBinding => + typeof value === "object" && + value !== null && + hasFunction(value, "info") && + hasFunction(value, "input"); + +const wrapResult = ( + binding: string, + result: globalThis.ImageTransformationResult, +): ImagesTransformationResultClient => ({ + raw: result, + response: tryImagesSync(binding, "response", () => result.response()), + contentType: tryImagesSync(binding, "contentType", () => result.contentType()), + image: (options) => tryImagesSync(binding, "image", () => result.image(options)), +}); + +const wrapHandle = (binding: string, handle: globalThis.ImageHandle): ImageHandleClient => ({ + raw: handle, + details: tryImagesPromise(binding, "details", () => handle.details()).pipe(Effect.map(maybe)), + bytes: tryImagesPromise(binding, "bytes", () => handle.bytes()).pipe(Effect.map(maybe)), + update: (options) => tryImagesPromise(binding, "update", () => handle.update(options)), + delete: tryImagesPromise(binding, "delete", () => handle.delete()), +}); + +const wrapHosted = ( + binding: string, + hosted: globalThis.HostedImagesBinding, +): HostedImagesClient => ({ + image: (imageId) => wrapHandle(binding, hosted.image(imageId)), + upload: (image, options) => + tryImagesPromise(binding, "upload", () => hosted.upload(image, options)), + list: (options) => tryImagesPromise(binding, "list", () => hosted.list(options)), + unsafeRaw: Effect.succeed(hosted), +}); + +export const makeClient = + (definition: ImagesDefinition) => + (images: ImagesRuntimeBinding): ImagesClient => { + const input = (image: ImageInputValue, options?: ImageInputOptions) => + tryImagesSync(definition.binding, "input", () => images.input(image, options)); + + const process = (steps: Steps, options: ProcessOptions) => + Effect.gen(function* () { + let transformer = yield* input(options.stream, options.inputOptions); + + for (const step of steps.steps) { + switch (step._tag) { + case "Draw": { + transformer = yield* tryImagesSync(definition.binding, "draw", () => + transformer.draw(step.image, step.options), + ); + break; + } + case "Transform": { + transformer = yield* tryImagesSync(definition.binding, "transform", () => + transformer.transform(step.transform), + ); + break; + } + } + } + + const result = yield* tryImagesPromise(definition.binding, "output", () => + transformer.output(options.outputOptions), + ); + + return wrapResult(definition.binding, result); + }); + + return { + definition, + info: (image, options) => + tryImagesPromise(definition.binding, "info", () => images.info(image, options)), + input, + process, + hosted: isHostedImagesBinding(images.hosted) + ? Option.some(wrapHosted(definition.binding, images.hosted)) + : Option.none(), + unsafeRaw: Effect.succeed(images), + }; + }; + +export const layer = ( + tag: Context.Service, + definition: ImagesDefinition, +) => + Binding.layer(tag, definition.binding, isImagesBinding, makeClient(definition), { + expected: expectedImagesBinding, + }); + +export const make = (id: Id) => Tag>()(id); + +export const Tag = + () => + (id: Id) => { + const tag = Context.Service()(id); + + const makeLayer = (definition: LayerOptions) => layer(tag, definition); + + return Object.assign(tag, { + id, + layer: makeLayer, + }) as TagClass; + }; diff --git a/lib/effect-cf/src/Kv.ts b/lib/effect-cf/src/Kv.ts new file mode 100644 index 000000000..0ef8d5fed --- /dev/null +++ b/lib/effect-cf/src/Kv.ts @@ -0,0 +1,303 @@ +import { Context, Data, Effect, Option, Schema as S, type Layer } from "effect"; + +import * as Binding from "./Binding"; +import type { WorkerEnvironment } from "./Environment"; + +const expectedKvNamespace = + "KV namespace binding with get(), put(), delete(), getWithMetadata(), and list()"; + +/** Error raised when a KV operation fails. */ +export class KvOperationError extends Data.TaggedError("KvOperationError")<{ + readonly binding: string; + readonly operation: string; + readonly cause: unknown; +}> {} + +/** `KVNamespace.put` options. */ +export type KvPutOptions = globalThis.KVNamespacePutOptions; +/** `KVNamespace.list` options with optional metadata decoding schema. */ +export type KvListOptions = globalThis.KVNamespaceListOptions & { + readonly metadataSchema?: S.Codec; +}; + +/** Successful value returned by `getWithMetadata`. */ +export interface KvWithMetadata { + readonly value: Value; + readonly metadata: Option.Option; + readonly cacheStatus: Option.Option; +} + +/** Decoded key entry returned by `list`. */ +export interface KvListKey { + readonly name: Key; + readonly expiration: Option.Option; + readonly metadata: Option.Option; +} + +/** Decoded result returned by `list`. */ +export interface KvListResult { + readonly keys: ReadonlyArray>; + readonly listComplete: boolean; + readonly cursor: Option.Option; + readonly cacheStatus: Option.Option; +} + +/** + * Typed KV binding definition. + */ +export interface KvDefinition { + /** Binding name as configured in `wrangler.jsonc`. */ + readonly binding: string; + /** Codec used to encode/decode keys. */ + readonly key: S.Codec; + /** Codec used to encode/decode values. */ + readonly value: S.Codec; +} + +/** + * Reusable typed KV resource definition without a concrete Cloudflare binding name. + */ +export interface Definition< + Id extends string = string, + Key = unknown, + Value = unknown, + EncodedValue = unknown, +> { + readonly id: Id; + /** Codec used to encode/decode keys. */ + readonly key: S.Codec; + /** Codec used to encode/decode values. */ + readonly value: S.Codec; +} + +export namespace Definition { + export type Any = Definition; +} + +export interface KvClient { + readonly put: ( + key: Key, + value: Value, + options?: KvPutOptions, + ) => Effect.Effect; + readonly get: (key: Key) => Effect.Effect, KvOperationError | S.SchemaError>; + readonly getWithMetadata: ( + key: Key, + metadataSchema: S.Codec, + ) => Effect.Effect< + Option.Option>, + KvOperationError | S.SchemaError + >; + readonly list: ( + options?: KvListOptions, + ) => Effect.Effect, KvOperationError | S.SchemaError>; + readonly remove: (key: Key) => Effect.Effect; + readonly unsafeRaw: Effect.Effect; + readonly definition: KvDefinition; +} + +export type LayerOptions = { + readonly binding: string; +}; + +export interface TagClass< + Self, + Id extends string, + Key, + Value, + EncodedValue, +> extends Context.ServiceClass> { + readonly id: Id; + readonly keySchema: S.Codec; + readonly valueSchema: S.Codec; + readonly layer: ( + options: LayerOptions, + ) => Layer.Layer< + Self, + Binding.BindingNotFoundError | Binding.BindingValidationError, + WorkerEnvironment + >; +} + +const maybeString = (value: string | null | undefined): Option.Option => + value == null ? Option.none() : Option.some(value); + +const maybeNumber = (value: number | undefined): Option.Option => + value === undefined ? Option.none() : Option.some(value); + +const kvError = (binding: string, operation: string, cause: unknown) => + new KvOperationError({ binding, operation, cause }); + +const tryKvPromise = ( + binding: string, + operation: string, + evaluate: () => Promise, +): Effect.Effect => + Effect.tryPromise({ + try: evaluate, + catch: (cause) => kvError(binding, operation, cause), + }); + +export const isKvNamespace = (value: unknown): value is KVNamespace => { + if (typeof value !== "object" || value === null) { + return false; + } + + const resource = value as Record; + + return ( + typeof resource.get === "function" && + typeof resource.put === "function" && + typeof resource.delete === "function" && + typeof resource.getWithMetadata === "function" && + typeof resource.list === "function" + ); +}; + +export const makeClient = ( + definition: KvDefinition, +): ((kv: KVNamespace) => KvClient) => { + const encodeKey = S.encodeEffect(definition.key); + const decodeKey = S.decodeUnknownEffect(definition.key); + const encodeValue = S.encodeEffect(S.fromJsonString(S.toCodecJson(definition.value))); + const decodeValue = S.decodeUnknownEffect(S.fromJsonString(S.toCodecJson(definition.value))); + + return (kv) => ({ + definition, + put: Effect.fnUntraced(function* (key: Key, value: Value, options?: KvPutOptions) { + const keyEncoded = yield* encodeKey(key); + const valueEncoded = yield* encodeValue(value); + yield* tryKvPromise(definition.binding, "put", () => + kv.put(keyEncoded, valueEncoded, options), + ); + }), + get: Effect.fnUntraced(function* (key: Key) { + const keyEncoded = yield* encodeKey(key); + const valueEncoded = yield* tryKvPromise(definition.binding, "get", () => kv.get(keyEncoded)); + + if (valueEncoded === null) { + return Option.none(); + } + + return yield* decodeValue(valueEncoded).pipe(Effect.map(Option.some)); + }), + getWithMetadata: Effect.fnUntraced(function* ( + key: Key, + metadataSchema: S.Codec, + ) { + const keyEncoded = yield* encodeKey(key); + const result = yield* tryKvPromise(definition.binding, "getWithMetadata", () => + kv.getWithMetadata(keyEncoded), + ); + + if (result.value === null) { + return Option.none(); + } + + const value = yield* decodeValue(result.value); + const metadata = + result.metadata === null + ? Option.none() + : Option.some(yield* S.decodeUnknownEffect(metadataSchema)(result.metadata)); + + return Option.some({ + value, + metadata, + cacheStatus: maybeString(result.cacheStatus), + }); + }), + list: Effect.fnUntraced(function* (options?: KvListOptions) { + const { metadataSchema, ...kvOptions } = options ?? {}; + const result = yield* tryKvPromise(definition.binding, "list", () => + kv.list(kvOptions), + ); + const keys: Array> = []; + + for (const key of result.keys) { + const decodedName = yield* decodeKey(key.name); + const decodedMetadata = + key.metadata === undefined + ? Option.none() + : metadataSchema === undefined + ? Option.some(key.metadata as Metadata) + : Option.some(yield* S.decodeUnknownEffect(metadataSchema)(key.metadata)); + + keys.push({ + name: decodedName, + expiration: maybeNumber(key.expiration), + metadata: decodedMetadata, + }); + } + + return { + keys, + listComplete: result.list_complete, + cursor: maybeString("cursor" in result ? result.cursor : undefined), + cacheStatus: maybeString(result.cacheStatus), + }; + }), + remove: Effect.fnUntraced(function* (key: Key) { + const keyEncoded = yield* encodeKey(key); + yield* tryKvPromise(definition.binding, "delete", () => kv.delete(keyEncoded)); + }), + unsafeRaw: Effect.succeed(kv), + }); +}; + +export const layer = ( + tag: Context.Service>, + definition: KvDefinition, +) => + Binding.layer( + tag, + definition.binding, + isKvNamespace, + makeClient(definition), + { expected: expectedKvNamespace }, + ); + +const makeDefinition = ( + id: Id, + definition: { readonly key: S.Codec; readonly value: S.Codec }, +) => { + type SelfDefinition = Definition; + const kvDefinition: SelfDefinition = { + id, + key: definition.key, + value: definition.value, + }; + + return Object.assign(kvDefinition, { + layer: ( + tag: Context.Service>, + binding: LayerOptions, + ) => + layer(tag, { + ...binding, + key: definition.key, + value: definition.value, + }), + }); +}; + +export const Tag = + () => + ( + id: Id, + definition: { + readonly key: S.Codec; + readonly value: S.Codec; + }, + ) => { + const kvDefinition = makeDefinition(id, definition); + const tag = Context.Service>()(id); + + const makeLayer = (binding: LayerOptions) => kvDefinition.layer(tag, binding); + + return Object.assign(tag, { + id: kvDefinition.id, + keySchema: kvDefinition.key, + valueSchema: kvDefinition.value, + layer: makeLayer, + }) as TagClass; + }; diff --git a/lib/effect-cf/src/Queue.ts b/lib/effect-cf/src/Queue.ts new file mode 100644 index 000000000..f6ff2dbba --- /dev/null +++ b/lib/effect-cf/src/Queue.ts @@ -0,0 +1,130 @@ +import { Data, Effect, type Scope } from "effect"; + +import type { ExecutionContext, WorkerContext } from "./Worker"; +import type { WorkerEnvironment } from "./Environment"; +import * as QueueDefinition from "./QueueDefinition"; + +export interface QueueMessage { + readonly raw: globalThis.Message; + readonly id: string; + readonly timestamp: Date; + readonly body: Body; + readonly attempts: number; + readonly ack: Effect.Effect; + readonly retry: (options?: globalThis.QueueRetryOptions) => Effect.Effect; +} + +export interface QueueBatch { + readonly raw: globalThis.MessageBatch; + readonly messages: ReadonlyArray>; + readonly queue: string; + readonly metadata: globalThis.MessageBatchMetadata; + readonly ackAll: Effect.Effect; + readonly retryAll: (options?: globalThis.QueueRetryOptions) => Effect.Effect; +} + +export class QueueMessageDecodeError extends Data.TaggedError("QueueMessageDecodeError")<{ + readonly queue: string; + readonly messageId: string; + readonly index: number; + readonly cause: unknown; +}> {} + +type RuntimeContext = ExecutionContext | WorkerContext | WorkerEnvironment | ROut; + +type QueueHandlerContext = RuntimeContext | Scope.Scope; + +export type QueueHandler = ( + batch: QueueBatch, +) => Effect.Effect>; + +export interface QueueOptions { + readonly queue: QueueHandler; +} + +export const fromMessage = (message: globalThis.Message, body: Body) => ({ + raw: message, + id: message.id, + timestamp: message.timestamp, + body, + attempts: message.attempts, + ack: Effect.sync(() => message.ack()), + retry: (options?: globalThis.QueueRetryOptions) => Effect.sync(() => message.retry(options)), +}); + +export const fromMessageBatch = ( + batch: globalThis.MessageBatch, + messages: ReadonlyArray>, +): QueueBatch => ({ + raw: batch, + messages, + queue: batch.queue, + metadata: batch.metadata, + ackAll: Effect.sync(() => batch.ackAll()), + retryAll: (options?: globalThis.QueueRetryOptions) => Effect.sync(() => batch.retryAll(options)), +}); + +export const decodeBatch = ( + batch: globalThis.MessageBatch, + decodeBody: (body: unknown) => Effect.Effect, +): Effect.Effect, QueueMessageDecodeError> => + Effect.gen(function* () { + const messages: Array> = []; + + for (let index = 0; index < batch.messages.length; index++) { + const message = batch.messages[index]; + const body = yield* decodeBody(message.body).pipe( + Effect.mapError( + (cause) => + new QueueMessageDecodeError({ + queue: batch.queue, + messageId: message.id, + index, + cause, + }), + ), + ); + + messages.push(fromMessage(message, body)); + } + + return fromMessageBatch(batch, messages); + }); + +export type Definition< + Id extends string = string, + Message extends QueueDefinition.Definition.Any["message"] = + QueueDefinition.Definition.Any["message"], +> = QueueDefinition.Definition; + +export namespace Definition { + export type Any = QueueDefinition.Definition.Any; +} + +export type LayerOptions = QueueDefinition.LayerOptions; + +export type TagClass< + Self, + Id extends string, + Message extends QueueDefinition.Definition.Any["message"], +> = QueueDefinition.TagClass; + +export const Tag: () => < + Id extends string, + Message extends QueueDefinition.Definition.Any["message"], +>( + id: Id, + definition: { readonly message: Message }, +) => TagClass = QueueDefinition.Tag; + +export const implement = QueueDefinition.implement; + +export type Handler = QueueDefinition.Handler< + ROut, + Self +>; + +export type Options = QueueDefinition.Options< + ROut, + Self +>; diff --git a/lib/effect-cf/src/QueueBinding.ts b/lib/effect-cf/src/QueueBinding.ts new file mode 100644 index 000000000..194c40aa5 --- /dev/null +++ b/lib/effect-cf/src/QueueBinding.ts @@ -0,0 +1,147 @@ +import { Context, Data, Effect, Schema as S } from "effect"; + +import * as Binding from "./Binding"; +import type * as RpcDefinition from "./RpcDefinition"; + +export type QueueSendOptions = globalThis.QueueSendOptions; +export type QueueSendResponse = globalThis.QueueSendResponse; +export type QueueSendBatchOptions = globalThis.QueueSendBatchOptions; +export type QueueSendBatchResponse = globalThis.QueueSendBatchResponse; +export type QueueMetrics = globalThis.QueueMetrics; +export type MessageSendRequest = globalThis.MessageSendRequest; + +export type QueueProducer = Pick, "send"> & + Partial, "sendBatch" | "metrics">>; + +const expectedQueueProducer = "Queue producer binding with send(); optional sendBatch()/metrics()"; + +export interface QueueBindingDefinition { + /** Binding name as configured in `wrangler.jsonc`. */ + readonly binding: string; + /** Codec used to encode messages before sending them to Cloudflare Queues. */ + readonly message: Message; +} + +export interface QueueBindingClient { + readonly send: ( + message: S.Schema.Type, + options?: QueueSendOptions, + ) => Effect.Effect; + readonly sendBatch: ( + messages: Iterable>>, + options?: QueueSendBatchOptions, + ) => Effect.Effect; + readonly metrics: () => Effect.Effect; + readonly unsafeRaw: Effect.Effect>>; +} + +export class QueueOperationError extends Data.TaggedError("QueueOperationError")<{ + readonly binding: string; + readonly operation: string; + readonly cause: unknown; + readonly message: string; +}> {} + +const tryQueuePromise = ( + binding: string, + operation: string, + evaluate: () => Promise, +): Effect.Effect => + Effect.tryPromise({ + try: evaluate, + catch: (cause) => + new QueueOperationError({ + binding, + operation, + cause, + message: `Cloudflare queue binding "${binding}" operation "${operation}" failed`, + }), + }); + +export const isQueue = (value: unknown): value is QueueProducer => { + if (typeof value !== "object" || value === null) { + return false; + } + + const resource = value as Record; + + return typeof resource.send === "function"; +}; + +export const makeClient = ( + definition: QueueBindingDefinition, +): ((queue: QueueProducer>) => QueueBindingClient) => { + type Body = S.Schema.Type; + type EncodedBody = S.Codec.Encoded; + + const encodeMessage = S.encodeEffect(definition.message); + + return (queue) => ({ + send: Effect.fnUntraced(function* (message: Body, options?: QueueSendOptions) { + const encoded = yield* encodeMessage(message); + + yield* tryQueuePromise(definition.binding, "send", () => queue.send(encoded, options)); + }), + sendBatch: Effect.fnUntraced(function* ( + messages: Iterable>, + options?: QueueSendBatchOptions, + ) { + const encodedMessages: Array> = []; + + for (const message of messages) { + encodedMessages.push({ + ...message, + body: yield* encodeMessage(message.body), + }); + } + + const sendBatch = queue.sendBatch; + + if (sendBatch !== undefined) { + yield* tryQueuePromise(definition.binding, "sendBatch", () => + sendBatch.call(queue, encodedMessages, options), + ); + return; + } + + yield* tryQueuePromise(definition.binding, "sendBatch", async () => { + for (const message of encodedMessages) { + await queue.send(message.body, { + contentType: message.contentType, + delaySeconds: message.delaySeconds ?? options?.delaySeconds, + }); + } + }); + }), + metrics: () => { + const metrics = queue.metrics; + + if (metrics === undefined) { + return Effect.fail( + new QueueOperationError({ + binding: definition.binding, + operation: "metrics", + cause: new Error(`Queue binding "${definition.binding}" does not provide metrics()`), + message: `Cloudflare queue binding "${definition.binding}" does not provide metrics()`, + }), + ); + } + + return tryQueuePromise(definition.binding, "metrics", () => metrics.call(queue)); + }, + unsafeRaw: Effect.succeed(queue as globalThis.Queue), + }); +}; + +export const layer = ( + tag: Context.Service>, + definition: QueueBindingDefinition, +) => + Binding.layer( + tag, + definition.binding, + (value): value is QueueProducer> => + isQueue>(value), + makeClient(definition), + { expected: expectedQueueProducer }, + ); diff --git a/lib/effect-cf/src/QueueDefinition.ts b/lib/effect-cf/src/QueueDefinition.ts new file mode 100644 index 000000000..bea823f1e --- /dev/null +++ b/lib/effect-cf/src/QueueDefinition.ts @@ -0,0 +1,175 @@ +import { Context, Effect, Schema as S, type Layer, type Scope } from "effect"; + +import * as Binding from "./Binding"; +import type { WorkerEnvironment } from "./Environment"; +import * as QueueEntrypoint from "./Queue"; +import * as QueueBinding from "./QueueBinding"; +import type { ExecutionContext, WorkerContext } from "./Worker"; +import * as WorkerEntrypoint from "./Worker"; +import type * as RpcDefinition from "./RpcDefinition"; + +export interface Definition< + Id extends string = string, + Message extends RpcDefinition.ServiceFreeSchema = RpcDefinition.ServiceFreeSchema, +> { + readonly id: Id; + readonly message: Message; +} + +export namespace Definition { + export type Any = Definition; +} + +export type Handler = ( + batch: QueueEntrypoint.QueueBatch>, +) => Effect.Effect< + void, + unknown, + ExecutionContext | WorkerEntrypoint.WorkerContext | WorkerEnvironment | Scope.Scope | ROut +>; + +export interface Options extends Omit< + WorkerEntrypoint.WorkerOptions>, + "queue" | "rpc" +> { + readonly queue: Handler; + readonly rpc?: never; +} + +export type LayerOptions = { + readonly binding: string; +}; + +export interface TagClass< + Self, + Id extends string, + Message extends RpcDefinition.ServiceFreeSchema, +> extends Context.ServiceClass> { + readonly id: Id; + readonly message: Message; + readonly make: ( + layer: Layer.Layer, + options: Options>, + ) => WorkerEntrypoint.WorkerClass, ROut>; + readonly layer: ( + options: LayerOptions, + ) => Layer.Layer< + Self, + Binding.BindingNotFoundError | Binding.BindingValidationError, + WorkerEnvironment + >; + readonly send: ( + message: S.Schema.Type, + options?: QueueBinding.QueueSendOptions, + ) => Effect.Effect; + readonly sendBatch: ( + messages: Iterable>>, + options?: QueueBinding.QueueSendBatchOptions, + ) => Effect.Effect; + readonly metrics: () => Effect.Effect< + QueueBinding.QueueMetrics, + QueueBinding.QueueOperationError, + Self + >; + readonly unsafeRaw: () => Effect.Effect>, never, Self>; +} + +const makeDefinition = ( + id: Id, + definition: { readonly message: Message }, +) => { + type SelfDefinition = Definition; + const queueDefinition: SelfDefinition = { + id, + message: definition.message, + }; + + return Object.assign(queueDefinition, { + make: ( + layer: Layer.Layer, + options: Options, + ) => + WorkerEntrypoint.make(layer, { + ...options, + queue: wrapHandler(queueDefinition, options.queue), + }), + }); +}; + +export const make = ( + id: Id, + definition: { readonly message: Message }, +) => Tag>()(id, definition); + +export const Tag = + () => + ( + id: Id, + definition: { readonly message: Message }, + ) => { + const queueDefinition = makeDefinition(id, definition); + const tag = Context.Service>()(id); + + const layer = (binding: LayerOptions) => + QueueBinding.layer(tag, { + ...binding, + message: definition.message, + }); + + const send = Effect.fnUntraced(function* ( + message: S.Schema.Type, + options?: QueueBinding.QueueSendOptions, + ) { + const queue = yield* tag; + yield* queue.send(message, options); + }); + + const sendBatch = Effect.fnUntraced(function* ( + messages: Iterable>>, + options?: QueueBinding.QueueSendBatchOptions, + ) { + const queue = yield* tag; + yield* queue.sendBatch(messages, options); + }); + + const metrics = Effect.fnUntraced(function* () { + const queue = yield* tag; + return yield* queue.metrics(); + }); + + const unsafeRaw = Effect.fnUntraced(function* () { + const queue = yield* tag; + return yield* queue.unsafeRaw; + }); + + return Object.assign(tag, { + id: queueDefinition.id, + message: queueDefinition.message, + make: queueDefinition.make, + layer, + send, + sendBatch, + metrics, + unsafeRaw, + }) as TagClass; + }; + +export const Queue = Tag; + +const wrapHandler = ( + definition: Self, + handler: Handler, +): QueueEntrypoint.QueueHandler => { + const decodeBody = S.decodeUnknownEffect(definition.message); + + return (batch) => + Effect.gen(function* () { + const decoded = yield* QueueEntrypoint.decodeBatch(batch.raw, decodeBody); + yield* handler(decoded); + }); +}; + +export const implement = ( + _definition: Self, + handler: Handler, +): Handler => handler; diff --git a/lib/effect-cf/src/R2.ts b/lib/effect-cf/src/R2.ts new file mode 100644 index 000000000..cf13ca808 --- /dev/null +++ b/lib/effect-cf/src/R2.ts @@ -0,0 +1,295 @@ +import { Context, Data, Effect, Option, type Layer } from "effect"; + +import * as Binding from "./Binding"; +import type { WorkerEnvironment } from "./Environment"; + +const expectedR2Bucket = + "R2 bucket binding with head(), get(), put(), createMultipartUpload(), resumeMultipartUpload(), delete(), and list()"; + +/** Error raised when an R2 operation fails. */ +export class R2OperationError extends Data.TaggedError("R2OperationError")<{ + readonly binding: string; + readonly operation: string; + readonly cause: unknown; +}> {} + +/** Typed R2 bucket binding definition. */ +export interface R2Definition { + /** Binding name as configured in `wrangler.jsonc`. */ + readonly binding: string; +} + +export type R2GetOptions = globalThis.R2GetOptions; +export type R2PutOptions = globalThis.R2PutOptions; +export type R2ListOptions = globalThis.R2ListOptions; +export type R2MultipartOptions = globalThis.R2MultipartOptions; +export type R2UploadPartOptions = globalThis.R2UploadPartOptions; +export type R2PutValue = ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob; +export type R2UploadPartValue = ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob; + +export interface R2ObjectBodyClient extends Omit< + globalThis.R2ObjectBody, + "arrayBuffer" | "blob" | "bytes" | "json" | "text" +> { + readonly raw: globalThis.R2ObjectBody; + readonly arrayBuffer: Effect.Effect; + readonly bytes: Effect.Effect; + readonly text: Effect.Effect; + readonly json: () => Effect.Effect; + readonly blob: Effect.Effect; +} + +export interface R2MultipartUploadClient { + readonly raw: globalThis.R2MultipartUpload; + readonly key: string; + readonly uploadId: string; + readonly uploadPart: ( + partNumber: number, + value: R2UploadPartValue, + options?: R2UploadPartOptions, + ) => Effect.Effect; + readonly abort: Effect.Effect; + readonly complete: ( + uploadedParts: ReadonlyArray, + ) => Effect.Effect; +} + +export interface R2Client { + readonly head: ( + key: string, + ) => Effect.Effect, R2OperationError>; + readonly get: { + ( + key: string, + options: R2GetOptions & { readonly onlyIf: globalThis.R2Conditional | Headers }, + ): Effect.Effect, R2OperationError>; + ( + key: string, + options?: R2GetOptions, + ): Effect.Effect, R2OperationError>; + }; + readonly put: { + ( + key: string, + value: R2PutValue, + options: R2PutOptions & { readonly onlyIf: globalThis.R2Conditional | Headers }, + ): Effect.Effect, R2OperationError>; + ( + key: string, + value: R2PutValue, + options?: R2PutOptions, + ): Effect.Effect; + }; + readonly createMultipartUpload: ( + key: string, + options?: R2MultipartOptions, + ) => Effect.Effect; + readonly resumeMultipartUpload: ( + key: string, + uploadId: string, + ) => Effect.Effect; + readonly delete: (keys: string | ReadonlyArray) => Effect.Effect; + readonly list: (options?: R2ListOptions) => Effect.Effect; + readonly unsafeRaw: Effect.Effect; + readonly definition: R2Definition; +} + +declare const R2ServiceTypeId: unique symbol; + +/** Nominal service marker for R2 services created with {@link make}. */ +export interface R2Service { + readonly [R2ServiceTypeId]: { + readonly id: Id; + }; +} + +export type LayerOptions = { + readonly binding: string; +}; + +export interface TagClass extends Context.ServiceClass< + Self, + Id, + R2Client +> { + readonly id: Id; + readonly layer: ( + options: LayerOptions, + ) => Layer.Layer< + Self, + Binding.BindingNotFoundError | Binding.BindingValidationError, + WorkerEnvironment + >; +} + +const r2Error = (binding: string, operation: string, cause: unknown) => + new R2OperationError({ binding, operation, cause }); + +const tryR2Promise = ( + binding: string, + operation: string, + evaluate: () => Promise, +): Effect.Effect => + Effect.tryPromise({ + try: evaluate, + catch: (cause) => r2Error(binding, operation, cause), + }); + +const tryR2Sync = ( + binding: string, + operation: string, + evaluate: () => A, +): Effect.Effect => + Effect.try({ + try: evaluate, + catch: (cause) => r2Error(binding, operation, cause), + }); + +const maybe = (value: A | null): Option.Option => + value === null ? Option.none() : Option.some(value); + +const isR2ObjectBody = ( + value: globalThis.R2ObjectBody | globalThis.R2Object, +): value is globalThis.R2ObjectBody => + "body" in value && + typeof (value as globalThis.R2ObjectBody).arrayBuffer === "function" && + typeof (value as globalThis.R2ObjectBody).bytes === "function" && + typeof (value as globalThis.R2ObjectBody).text === "function" && + typeof (value as globalThis.R2ObjectBody).json === "function" && + typeof (value as globalThis.R2ObjectBody).blob === "function"; + +const wrapObjectBody = (binding: string, object: globalThis.R2ObjectBody): R2ObjectBodyClient => ({ + key: object.key, + version: object.version, + size: object.size, + etag: object.etag, + httpEtag: object.httpEtag, + checksums: object.checksums, + uploaded: object.uploaded, + httpMetadata: object.httpMetadata, + customMetadata: object.customMetadata, + range: object.range, + storageClass: object.storageClass, + ssecKeyMd5: object.ssecKeyMd5, + writeHttpMetadata: (headers) => object.writeHttpMetadata(headers), + raw: object, + body: object.body, + get bodyUsed() { + return object.bodyUsed; + }, + arrayBuffer: tryR2Promise(binding, "arrayBuffer", () => object.arrayBuffer()), + bytes: tryR2Promise(binding, "bytes", () => object.bytes()), + text: tryR2Promise(binding, "text", () => object.text()), + json: () => tryR2Promise(binding, "json", () => object.json()), + blob: tryR2Promise(binding, "blob", () => object.blob()), +}); + +const wrapGetResult = ( + binding: string, + object: globalThis.R2ObjectBody | globalThis.R2Object | null, +): R2ObjectBodyClient | globalThis.R2Object | null => { + if (object === null || !isR2ObjectBody(object)) { + return object; + } + + return wrapObjectBody(binding, object); +}; + +export const isR2Bucket = (value: unknown): value is globalThis.R2Bucket => { + if (typeof value !== "object" || value === null) { + return false; + } + + const resource = value as Record; + + return ( + typeof resource.head === "function" && + typeof resource.get === "function" && + typeof resource.put === "function" && + typeof resource.createMultipartUpload === "function" && + typeof resource.resumeMultipartUpload === "function" && + typeof resource.delete === "function" && + typeof resource.list === "function" + ); +}; + +export const makeClient = ( + definition: R2Definition, +): ((bucket: globalThis.R2Bucket) => R2Client) => { + const wrapUpload = (upload: globalThis.R2MultipartUpload): R2MultipartUploadClient => ({ + raw: upload, + key: upload.key, + uploadId: upload.uploadId, + uploadPart: (partNumber, value, options) => + tryR2Promise(definition.binding, "uploadPart", () => + upload.uploadPart(partNumber, value, options), + ), + abort: tryR2Promise(definition.binding, "abortMultipartUpload", () => upload.abort()), + complete: (uploadedParts) => + tryR2Promise(definition.binding, "completeMultipartUpload", () => + upload.complete([...uploadedParts]), + ), + }); + + return (bucket) => { + const get = ((key: string, options?: R2GetOptions) => + tryR2Promise(definition.binding, "get", () => bucket.get(key, options)).pipe( + Effect.map((object) => maybe(wrapGetResult(definition.binding, object))), + )) as R2Client["get"]; + + return { + definition, + head: (key) => + tryR2Promise(definition.binding, "head", () => bucket.head(key)).pipe(Effect.map(maybe)), + get, + put: ((key: string, value: R2PutValue, options?: R2PutOptions) => + tryR2Promise(definition.binding, "put", () => bucket.put(key, value, options)).pipe( + Effect.map((object) => { + if (object === null) { + return Option.none(); + } + + if (options !== undefined && "onlyIf" in options) { + return Option.some(object); + } + + return object; + }), + )) as R2Client["put"], + createMultipartUpload: (key, options) => + tryR2Promise(definition.binding, "createMultipartUpload", () => + bucket.createMultipartUpload(key, options), + ).pipe(Effect.map(wrapUpload)), + resumeMultipartUpload: (key, uploadId) => + tryR2Sync(definition.binding, "resumeMultipartUpload", () => + wrapUpload(bucket.resumeMultipartUpload(key, uploadId)), + ), + delete: (keys) => { + const nativeKeys = typeof keys === "string" ? keys : [...keys]; + return tryR2Promise(definition.binding, "delete", () => bucket.delete(nativeKeys)); + }, + list: (options) => tryR2Promise(definition.binding, "list", () => bucket.list(options)), + unsafeRaw: Effect.succeed(bucket), + }; + }; +}; + +export const layer = (tag: Context.Service, definition: R2Definition) => + Binding.layer(tag, definition.binding, isR2Bucket, makeClient(definition), { + expected: expectedR2Bucket, + }); + +export const make = (id: Id) => Tag>()(id); + +export const Tag = + () => + (id: Id) => { + const tag = Context.Service()(id); + + const makeLayer = (definition: LayerOptions) => layer(tag, definition); + + return Object.assign(tag, { + id, + layer: makeLayer, + }) as TagClass; + }; diff --git a/lib/effect-cf/src/Rpc.ts b/lib/effect-cf/src/Rpc.ts new file mode 100644 index 000000000..19660dcd3 --- /dev/null +++ b/lib/effect-cf/src/Rpc.ts @@ -0,0 +1,127 @@ +import { RpcStub, RpcTarget } from "cloudflare:workers"; +import { Effect, type Scope } from "effect"; + +export { RpcStub, RpcTarget }; + +export type Stubable = Rpc.Stubable; + +export type Stub = Rpc.Stub; + +export type Provider = Rpc.Provider; + +type BaseType = + | void + | undefined + | null + | boolean + | number + | bigint + | string + | TypedArray + | ArrayBuffer + | DataView + | Date + | Error + | RegExp + | ReadableStream + | WritableStream + | Request + | Response + | Headers; + +export type Serializable = + | BaseType + | Map< + T extends Map ? Serializable : never, + T extends Map ? Serializable : never + > + | Set ? Serializable : never> + | ReadonlyArray ? Serializable : never> + | { + [Key in keyof T]: Key extends number | string ? Serializable : never; + } + | Stub + | Stubable; + +export type Stubify = T extends Stubable + ? Stub + : T extends Map + ? Map, Stubify> + : T extends Set + ? Set> + : T extends Array + ? Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends BaseType + ? T + : T extends { + [key: string | number]: unknown; + } + ? { + [Key in keyof T]: Stubify; + } + : T; + +type MaybeProvider = T extends object ? Provider : unknown; +type MaybeDisposable = T extends object ? Disposable : unknown; + +export type Result = T extends Stubable + ? Promise> & Provider + : T extends Serializable + ? Promise & MaybeDisposable> & MaybeProvider + : never; + +export type MethodKey = { + [Key in keyof Api]-?: Key extends string + ? Api[Key] extends (...args: Array) => unknown + ? Key + : never + : never; +}[keyof Api]; + +export type MethodArgs = Api[Method] extends ( + ...args: infer Args +) => unknown + ? Args + : never; + +export type MethodReturn = Api[Method] extends ( + ...args: Array +) => infer Return + ? Return + : never; + +export type DisposableValue = { + [Symbol.dispose](): void; +}; + +export const isDisposable = (value: unknown): value is DisposableValue => + (typeof value === "object" || typeof value === "function") && + value !== null && + Symbol.dispose in value && + typeof (value as { readonly [Symbol.dispose]?: unknown })[Symbol.dispose] === "function"; + +export const dispose = (value: unknown): Effect.Effect => + Effect.sync(() => { + if (isDisposable(value)) { + value[Symbol.dispose](); + } + }); + +export const resolve = (value: A): Effect.Effect, unknown> => + isPromiseLike(value) + ? Effect.tryPromise({ + try: () => value, + catch: (cause) => cause, + }) + : Effect.sync(() => value as Awaited); + +export const scoped = (value: A): Effect.Effect, unknown, Scope.Scope> => + Effect.acquireRelease(resolve(value), (resolved) => dispose(resolved)); + +const isPromiseLike = (value: A): value is A & PromiseLike> => + (typeof value === "object" || typeof value === "function") && + value !== null && + "then" in value && + typeof (value as { readonly then?: unknown }).then === "function"; diff --git a/lib/effect-cf/src/RpcDefinition.ts b/lib/effect-cf/src/RpcDefinition.ts new file mode 100644 index 000000000..a2749d770 --- /dev/null +++ b/lib/effect-cf/src/RpcDefinition.ts @@ -0,0 +1,354 @@ +import { Data, Effect, Schema as S } from "effect"; + +import type * as Rpc from "./Rpc"; + +export class RpcReservedMethodNameError extends Data.TaggedError("RpcReservedMethodNameError")<{ + readonly definition: string; + readonly method: string; +}> {} + +export class RpcArgumentCountError extends Data.TaggedError("RpcArgumentCountError")<{ + readonly definition: string; + readonly method: string; + readonly expected: number; + readonly actual: number; +}> {} + +export class RpcArgumentDecodeError extends Data.TaggedError("RpcArgumentDecodeError")<{ + readonly definition: string; + readonly method: string; + readonly index: number; + readonly cause: unknown; +}> {} + +export class RpcArgumentEncodeError extends Data.TaggedError("RpcArgumentEncodeError")<{ + readonly definition: string; + readonly method: string; + readonly index: number; + readonly cause: unknown; +}> {} + +export class RpcSuccessDecodeError extends Data.TaggedError("RpcSuccessDecodeError")<{ + readonly definition: string; + readonly method: string; + readonly cause: unknown; +}> {} + +export class RpcSuccessEncodeError extends Data.TaggedError("RpcSuccessEncodeError")<{ + readonly definition: string; + readonly method: string; + readonly cause: unknown; +}> {} + +export const reservedMethodNames = new Set([ + "constructor", + "fetch", + "connect", + "alarm", + "webSocketMessage", + "webSocketClose", + "webSocketError", + "then", + "dup", + "dispose", + "serialize", + "deserialize", +]); + +export type ReservedMethodName = + | "constructor" + | "fetch" + | "connect" + | "alarm" + | "webSocketMessage" + | "webSocketClose" + | "webSocketError" + | "then" + | "dup" + | "dispose" + | "serialize" + | "deserialize"; + +export type ServiceFreeSchema = S.Codec; + +export interface Method< + Args extends ReadonlyArray = ReadonlyArray, + Success extends ServiceFreeSchema = ServiceFreeSchema, +> { + readonly args: Args; + readonly success: Success; +} + +export namespace Method { + export type Any = Method, ServiceFreeSchema>; + + type ArgsFromSchemas> = Args extends readonly [] + ? [] + : Args extends readonly [ + infer Head extends ServiceFreeSchema, + ...infer Tail extends ReadonlyArray, + ] + ? [S.Schema.Type, ...ArgsFromSchemas] + : Array>; + + type EncodedArgsFromSchemas> = + Args extends readonly [] + ? [] + : Args extends readonly [ + infer Head extends ServiceFreeSchema, + ...infer Tail extends ReadonlyArray, + ] + ? [S.Codec.Encoded, ...EncodedArgsFromSchemas] + : Array>; + + export type Args = ArgsFromSchemas; + + export type EncodedArgs = EncodedArgsFromSchemas; + + export type Success = S.Schema.Type; + + export type EncodedSuccess = S.Codec.Encoded; +} + +export type Methods = Record; + +export type NoReservedMethods< + MethodsShape extends Methods, + Reserved extends string = ReservedMethodName, +> = Extract extends never ? MethodsShape : never; + +export interface Definition { + readonly id: Id; + readonly methods: MethodsShape; +} + +export namespace Definition { + export type Any = Definition; + + export type ServerApi = { + readonly [Key in keyof Self["methods"]]: ( + ...args: Method.Args + ) => Promise>; + }; + + export type Api = Rpc.Provider< + ServerApi, + Reserved + >; + + export type MethodNames = Extract; +} + +export class ReservedMethodNameError extends Error { + readonly method: string; + readonly target: string; + + constructor(target: string, method: string) { + super(`${target} RPC method "${method}" is reserved by Cloudflare Workers RPC`); + this.name = "ReservedMethodNameError"; + this.target = target; + this.method = method; + } +} + +export const assertNoReservedMethods = ( + target: string, + methods: Record, + reserved: ReadonlySet, +) => { + for (const method of Object.keys(methods)) { + if (reserved.has(method)) { + throw new ReservedMethodNameError(target, method); + } + } +}; + +export function method(definition: { + readonly success: Success; +}): Method; +export function method< + const Args extends ReadonlyArray, + Success extends ServiceFreeSchema, +>(definition: { readonly args: Args; readonly success: Success }): Method; +export function method(definition: { + readonly args?: ReadonlyArray; + readonly success: ServiceFreeSchema; +}) { + return { + args: definition.args ?? [], + success: definition.success, + }; +} + +export const assertNoReservedMethodNames = ( + definition: Definition.Any, +): Effect.Effect => + Effect.forEach(Object.keys(definition.methods), (method) => + reservedMethodNames.has(method) + ? Effect.fail(new RpcReservedMethodNameError({ definition: definition.id, method })) + : Effect.void, + ).pipe(Effect.asVoid); + +export const decodeArgs = < + const Self extends Definition.Any, + MethodName extends Definition.MethodNames, +>( + definition: Self, + methodName: MethodName, + args: ReadonlyArray, +): Effect.Effect< + Method.Args, + RpcArgumentCountError | RpcArgumentDecodeError +> => + Effect.gen(function* () { + const methodDefinition = definition.methods[methodName]; + + if (args.length !== methodDefinition.args.length) { + return yield* Effect.fail( + new RpcArgumentCountError({ + definition: definition.id, + method: methodName, + expected: methodDefinition.args.length, + actual: args.length, + }), + ); + } + + const decoded: Array = []; + + for (let index = 0; index < methodDefinition.args.length; index++) { + const schema = methodDefinition.args[index]; + decoded.push( + yield* (S.decodeUnknownEffect(schema)(args[index]) as Effect.Effect).pipe( + Effect.mapError( + (cause) => + new RpcArgumentDecodeError({ + definition: definition.id, + method: methodName, + index, + cause, + }), + ), + ), + ); + } + + return decoded as Method.Args; + }); + +export const encodeArgs = < + const Self extends Definition.Any, + MethodName extends Definition.MethodNames, +>( + definition: Self, + methodName: MethodName, + args: Method.Args, +): Effect.Effect< + Method.EncodedArgs, + RpcArgumentCountError | RpcArgumentEncodeError +> => + Effect.gen(function* () { + const methodDefinition = definition.methods[methodName]; + + if (args.length !== methodDefinition.args.length) { + return yield* Effect.fail( + new RpcArgumentCountError({ + definition: definition.id, + method: methodName, + expected: methodDefinition.args.length, + actual: args.length, + }), + ); + } + + const encoded: Array = []; + + for (let index = 0; index < methodDefinition.args.length; index++) { + const schema = methodDefinition.args[index]; + encoded.push( + yield* (S.encodeEffect(schema)(args[index]) as Effect.Effect).pipe( + Effect.mapError( + (cause) => + new RpcArgumentEncodeError({ + definition: definition.id, + method: methodName, + index, + cause, + }), + ), + ), + ); + } + + return encoded as Method.EncodedArgs; + }); + +export const encodeSuccess = < + const Self extends Definition.Any, + MethodName extends Definition.MethodNames, +>( + definition: Self, + methodName: MethodName, + value: Method.Success, +): Effect.Effect, RpcSuccessEncodeError> => { + const methodDefinition = definition.methods[methodName]; + + return ( + S.encodeEffect(methodDefinition.success)(value) as Effect.Effect< + Method.EncodedSuccess, + unknown + > + ).pipe( + Effect.mapError( + (cause) => + new RpcSuccessEncodeError({ + definition: definition.id, + method: methodName, + cause, + }), + ), + ); +}; + +export const decodeSuccess = < + const Self extends Definition.Any, + MethodName extends Definition.MethodNames, +>( + definition: Self, + methodName: MethodName, + value: unknown, +): Effect.Effect, RpcSuccessDecodeError> => { + const methodDefinition = definition.methods[methodName]; + + return ( + S.decodeUnknownEffect(methodDefinition.success)(value) as Effect.Effect< + Method.Success, + unknown + > + ).pipe( + Effect.mapError( + (cause) => + new RpcSuccessDecodeError({ + definition: definition.id, + method: methodName, + cause, + }), + ), + ); +}; + +export const make = ( + id: Id, + methods: MethodsShape, +): Definition => { + const definition = { id, methods } as Definition; + const reserved = Object.keys(definition.methods).find((methodName) => + reservedMethodNames.has(methodName), + ); + + if (reserved !== undefined) { + throw new RpcReservedMethodNameError({ definition: id, method: reserved }); + } + + return definition; +}; diff --git a/lib/effect-cf/src/ServiceBinding.ts b/lib/effect-cf/src/ServiceBinding.ts new file mode 100644 index 000000000..740ab7c15 --- /dev/null +++ b/lib/effect-cf/src/ServiceBinding.ts @@ -0,0 +1,487 @@ +import { Context, Data, Effect, type Scope } from "effect"; + +import * as Binding from "./Binding"; +import * as CloudflareRpc from "./Rpc"; +import * as RpcDefinition from "./RpcDefinition"; +import type * as WorkerDefinition from "./WorkerDefinition"; +import * as RpcInvocation from "./internal/RpcInvocation"; + +const TypeId = "effect-cf/ServiceBinding" as const; +const expectedServiceBinding = "Worker service binding with fetch()"; + +/** + * Minimum shape for a Cloudflare service binding. + */ +export interface ServiceFetcher { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +} + +type RpcClient = { + readonly [Key in keyof Api as Key extends string + ? Api[Key] extends (...args: Array) => unknown + ? Key + : never + : never]: Api[Key]; +}; + +type ReservedMethodName = WorkerDefinition.ReservedMethodName | "fetch"; + +/** + * Native Cloudflare service object including optional RPC methods. + */ +export type ServiceBindingClient = ServiceFetcher & + RpcClient>; + +type ApiFromDefinition = Definition extends WorkerDefinition.Definition.Any + ? WorkerDefinition.ServerApi + : never; + +type ApiOrDefinition = [Api] extends [never] + ? ApiFromDefinition + : Api; + +/** + * Binding metadata used to create an Effect service from a Worker binding. + */ +export interface ServiceBindingDefinition< + Definition extends WorkerDefinition.Definition.Any | undefined = undefined, +> { + /** Binding name as configured in `wrangler.jsonc`. */ + readonly binding: string; + /** Optional RPC schema used for argument/result encoding. */ + readonly definition?: Definition; +} + +/** + * Failure raised when calling `fetch` on a service binding. + */ +export class ServiceBindingFetchError extends Data.TaggedError("ServiceBindingFetchError")<{ + readonly binding: string; + readonly cause: unknown; +}> {} + +/** + * Failure raised when invoking an RPC method on a service binding. + */ +export class ServiceBindingRpcError extends Data.TaggedError("ServiceBindingRpcError")<{ + readonly binding: string; + readonly method: string; + readonly cause: unknown; +}> {} + +type ServiceMethodKey = RpcInvocation.AsyncMethodKey; +type ServiceMethodArgs = RpcInvocation.AsyncMethodArgs; +type ServiceMethodSuccess = RpcInvocation.AsyncMethodSuccess< + Api, + Method +>; +type ServiceMethodCloudflareReturn< + Api, + Method extends keyof Api, +> = RpcInvocation.AsyncMethodCloudflareReturn; + +type ServiceCall = >( + method: Method, + ...args: ServiceMethodArgs +) => Effect.Effect, ServiceBindingRpcError, R>; + +type DefinitionDirectMethods = { + readonly [Method in RpcDefinition.Definition.MethodNames]: ( + ...args: RpcDefinition.Method.Args + ) => Effect.Effect< + RpcDefinition.Method.Success, + ServiceBindingRpcError, + R + >; +}; + +type DirectMethods = Definition extends WorkerDefinition.Definition.Any + ? DefinitionDirectMethods + : {}; + +export type ServiceBindingEffectClient< + Api extends object, + Definition extends WorkerDefinition.Definition.Any | undefined = undefined, +> = DirectMethods & { + /** + * Forwards an HTTP request to the bound Worker service. + * + * Use this when the service binding is acting as an HTTP origin rather than a + * Cloudflare RPC target. + * + * @example + * ```ts + * import { Effect } from "effect"; + * + * const program = Effect.gen(function* () { + * const api = yield* ApiWorker; + * return yield* api.fetch(new Request("https://internal.example/users")); + * }); + * ``` + */ + readonly fetch: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Effect.Effect; + /** + * Invokes a Worker RPC method and returns Cloudflare's raw RPC result. + * + * This preserves Cloudflare RPC behavior such as promise-like pipelining and + * transferable / disposable result objects. It does not resolve the returned + * promise-like value and it does not decode definition-backed success schemas. + * + * Most application code should use {@link call} instead. + * + * @example + * ```ts + * import { Effect } from "effect"; + * + * const program = Effect.gen(function* () { + * const counter = yield* CounterService; + * + * const result = yield* counter.rpc("increment", 41); + * const value = yield* Effect.promise(() => result); + * + * return value; + * }); + * ``` + */ + readonly rpc: >( + method: Method, + ...args: ServiceMethodArgs + ) => Effect.Effect, ServiceBindingRpcError>; + /** + * Invokes a Worker RPC method, resolves Cloudflare's RPC result, and decodes + * the success value when the binding was created from a definition. + * + * This is the normal choice when application code wants the final typed value. + * + * @example + * ```ts + * import { Effect } from "effect"; + * + * const program = Effect.gen(function* () { + * const counter = yield* CounterService; + * const value = yield* counter.call("increment", 41); + * + * return value; + * }); + * ``` + */ + readonly call: >( + method: Method, + ...args: ServiceMethodArgs + ) => Effect.Effect, ServiceBindingRpcError>; + /** + * Invokes a Worker RPC method in the current `Scope`, resolves Cloudflare's RPC + * result, decodes definition-backed success values, and disposes the resolved + * result when the scope closes if it implements `Symbol.dispose`. + * + * Use this for RPC methods that return Cloudflare RPC resources or other + * disposable objects whose lifetime should be tied to an Effect scope. + * + * @example + * ```ts + * import { Effect } from "effect"; + * + * const program = Effect.scoped( + * Effect.gen(function* () { + * const files = yield* FileService; + * const handle = yield* files.scopedCall("open", "report.csv"); + * + * return yield* handle.read(); + * }), + * ); + * ``` + */ + readonly scopedCall: >( + method: Method, + ...args: ServiceMethodArgs + ) => Effect.Effect>, unknown, Scope.Scope>; +}; + +export type ServiceBindingStaticClient< + R, + Api extends object, + Definition extends WorkerDefinition.Definition.Any | undefined = undefined, +> = DirectMethods & { + readonly fetch: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Effect.Effect; + readonly rpc: >( + method: Method, + ...args: ServiceMethodArgs + ) => Effect.Effect, ServiceBindingRpcError, R>; + readonly call: >( + method: Method, + ...args: ServiceMethodArgs + ) => Effect.Effect, ServiceBindingRpcError, R>; + readonly scopedCall: >( + method: Method, + ...args: ServiceMethodArgs + ) => Effect.Effect>, unknown, Scope.Scope | R>; +}; + +export const isServiceBindingClient = ( + value: unknown, +): value is ServiceBindingClient => + typeof value === "object" && value !== null && typeof Reflect.get(value, "fetch") === "function"; + +export const makeClient = < + Api extends object, + const Definition extends WorkerDefinition.Definition.Any | undefined = undefined, +>( + definition: ServiceBindingDefinition, +): ((service: ServiceBindingClient) => ServiceBindingEffectClient) => { + return (service: ServiceBindingClient) => { + const fetch = (input: RequestInfo | URL, init?: RequestInit) => + Effect.tryPromise({ + try: () => service.fetch(input, init), + catch: (cause) => new ServiceBindingFetchError({ binding: definition.binding, cause }), + }); + + const rpc = >( + method: Method, + ...args: ServiceMethodArgs + ) => + Effect.gen(function* () { + const methodName = String(method); + const encodedArgs = + definition.definition === undefined + ? args + : yield* RpcDefinition.encodeArgs( + definition.definition, + methodName as RpcDefinition.Definition.MethodNames>, + args as never, + ).pipe( + Effect.mapError( + (cause) => + new ServiceBindingRpcError({ + binding: definition.binding, + method: methodName, + cause, + }), + ), + ); + + return yield* RpcInvocation.invokeRpcMethod( + service, + method, + encodedArgs as ServiceMethodArgs, + (cause) => + new ServiceBindingRpcError({ + binding: definition.binding, + method: methodName, + cause, + }), + ); + }); + + const decodeSuccess = >( + methodName: string, + value: Awaited>, + ) => + Effect.gen(function* () { + if (definition.definition === undefined) { + return value as ServiceMethodSuccess; + } + + const decoded = yield* RpcDefinition.decodeSuccess( + definition.definition, + methodName as RpcDefinition.Definition.MethodNames>, + value, + ).pipe( + Effect.mapError( + (cause) => + new ServiceBindingRpcError({ + binding: definition.binding, + method: methodName, + cause, + }), + ), + ); + + return decoded as ServiceMethodSuccess; + }); + + const call = >( + method: Method, + ...args: ServiceMethodArgs + ) => + Effect.gen(function* () { + const methodName = String(method); + const value = yield* CloudflareRpc.resolve(yield* rpc(method, ...args)).pipe( + Effect.mapError( + (cause) => + new ServiceBindingRpcError({ + binding: definition.binding, + method: methodName, + cause, + }), + ), + ); + + return yield* decodeSuccess(methodName, value); + }); + + const scopedCall = >( + method: Method, + ...args: ServiceMethodArgs + ) => + Effect.gen(function* () { + const methodName = String(method); + const result = yield* rpc(method, ...args); + const value = yield* CloudflareRpc.scoped(result); + return yield* decodeSuccess(methodName, value); + }); + + const directMethods = makeDirectMethods(definition.definition, call); + + return Object.assign(directMethods, { + fetch, + rpc, + call, + scopedCall, + }) as ServiceBindingEffectClient; + }; +}; + +export const layer = < + Self, + Api extends object, + const Definition extends WorkerDefinition.Definition.Any | undefined = undefined, +>( + tag: Context.Service>, + definition: ServiceBindingDefinition, +) => + Binding.layer( + tag, + definition.binding, + (value): value is ServiceBindingClient => isServiceBindingClient(value), + makeClient(definition), + { expected: expectedServiceBinding }, + ); + +/** + * Creates a typed Effect service for a Worker service binding. + * + * Returned value includes: + * - a Context tag for dependency injection + * - `fetch(...)` for raw HTTP forwarding + * - `rpc(...)` for raw Cloudflare RPC results + * - `call(...)` for resolved and decoded RPC results + * - `scopedCall(...)` for scoped and decoded disposable RPC results + * - direct RPC methods when `definition` is provided + * + * @example + * ```ts + * const Counter = WorkerDefinition.make("Counter", { + * increment: WorkerDefinition.method({ + * args: [Schema.Number], + * success: Schema.Number, + * }), + * }); + * + * const CounterLive = Counter.layer({ binding: "COUNTER" }); + * + * const program = Effect.gen(function* () { + * const counter = yield* Counter; + * const next = yield* counter.increment(1); + * return next; + * }); + * ``` + */ +export const Service = + () => + < + Id extends string, + const Definition extends WorkerDefinition.Definition.Any | undefined = undefined, + >( + id: Id, + definition: ServiceBindingDefinition, + ) => { + type ServiceApi = ApiOrDefinition; + + const tag = Binding.Service()( + id, + definition.binding, + (value): value is ServiceBindingClient => isServiceBindingClient(value), + makeClient(definition), + ); + + const fetch = (input: RequestInfo | URL, init?: RequestInit) => + Effect.gen(function* () { + const service = yield* tag; + return yield* service.fetch(input, init); + }); + + const rpc = >( + method: Method, + ...args: ServiceMethodArgs + ) => + Effect.gen(function* () { + const service = yield* tag; + return yield* service.rpc(method, ...args); + }); + + const call = >( + method: Method, + ...args: ServiceMethodArgs + ) => + Effect.gen(function* () { + const service = yield* tag; + return yield* service.call(method, ...args); + }); + + const scopedCall = >( + method: Method, + ...args: ServiceMethodArgs + ) => + Effect.gen(function* () { + const service = yield* tag; + return yield* service.scopedCall(method, ...args); + }); + + const directMethods = makeDirectMethods( + definition.definition, + call, + ); + + return Object.assign(tag, directMethods, { + [TypeId]: TypeId, + definition, + fetch, + rpc, + call, + scopedCall, + }) as typeof tag & + DirectMethods & { + readonly [TypeId]: typeof TypeId; + readonly definition: ServiceBindingDefinition; + readonly fetch: typeof fetch; + readonly rpc: typeof rpc; + readonly call: typeof call; + readonly scopedCall: typeof scopedCall; + }; + }; + +export const makeDirectMethods = < + R, + Api, + Definition extends WorkerDefinition.Definition.Any | undefined, +>( + rpcDefinition: Definition | undefined, + call: ServiceCall, +): DirectMethods => { + const methods = {} as Record; + + if (rpcDefinition !== undefined) { + for (const methodName of Object.keys(rpcDefinition.methods)) { + methods[methodName] = (...args: Array) => + (call as (method: string, ...args: Array) => unknown)(methodName, ...args); + } + } + + return methods as DirectMethods; +}; diff --git a/lib/effect-cf/src/Worker.ts b/lib/effect-cf/src/Worker.ts new file mode 100644 index 000000000..81deda21a --- /dev/null +++ b/lib/effect-cf/src/Worker.ts @@ -0,0 +1,413 @@ +import { WorkerEntrypoint as CloudflareWorkerEntrypoint } from "cloudflare:workers"; +import { Cause, ConfigProvider, Context, Effect, Layer, ManagedRuntime, type Scope } from "effect"; +import type { Schema as S } from "effect"; +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import type * as Binding from "./Binding"; +import { WorkerConfig, WorkerEnvironment, type WorkerEnv } from "./Environment"; +import { fromMessage, fromMessageBatch, type QueueHandler } from "./Queue"; +import type * as Rpc from "./Rpc"; +import type * as RpcDefinition from "./RpcDefinition"; +import type * as ServiceBinding from "./ServiceBinding"; +import * as WorkerDefinition from "./WorkerDefinition"; +import * as Entrypoint from "./internal/Entrypoint"; +import { fromExecutionContext, type RunWaitUntilEffect } from "./internal/WorkerContext"; + +export class ExecutionContext extends Context.Service< + ExecutionContext, + globalThis.ExecutionContext +>()("effect-cf/ExecutionContext") {} + +export interface WorkerContextWaitUntilOptions { + readonly mode?: "observe" | "propagate"; + readonly onFailure?: (cause: Cause.Cause) => Effect.Effect; +} + +export interface WorkerContextService { + readonly raw: globalThis.ExecutionContext; + waitUntil( + effect: Effect.Effect, + options?: WorkerContextWaitUntilOptions, + ): Effect.Effect; + waitUntilPropagating( + effect: Effect.Effect, + options?: Omit, "mode">, + ): Effect.Effect; + readonly passThroughOnException: Effect.Effect; +} + +export class WorkerContext extends Context.Service()( + "effect-cf/WorkerContext", +) {} + +export class NativeRequest extends Context.Service()( + "effect-cf/NativeRequest", +) {} + +export const isWebSocketUpgrade = (request: Request): boolean => + request.headers.get("Upgrade")?.toLowerCase() === "websocket"; + +export type ReservedMethodName = + | RpcDefinition.ReservedMethodName + | "fetch" + | "connect" + | "queue" + | "scheduled" + | "tail" + | "tailStream" + | "test" + | "trace" + | "alarm" + | "webSocketMessage" + | "webSocketClose" + | "webSocketError"; + +const reservedMethodNames = new Set([ + "constructor", + "dup", + "fetch", + "connect", + "queue", + "scheduled", + "tail", + "tailStream", + "test", + "trace", + "alarm", + "webSocketMessage", + "webSocketClose", + "webSocketError", +]); + +type WorkerBaseContext = ExecutionContext | WorkerContext | WorkerEnvironment | ROut; +type WorkerFetchContext = + | WorkerBaseContext + | NativeRequest + | HttpServerRequest.HttpServerRequest + | Scope.Scope; +type WorkerRpcContext = WorkerBaseContext | Scope.Scope; + +type RuntimeContext = WorkerBaseContext; + +const RunSymbol = Symbol.for("effect-cf/Worker/run"); + +export type WorkerFetchSuccess = Response | HttpServerResponse.HttpServerResponse; + +export type WorkerHandler = Effect.Effect< + A, + unknown, + WorkerFetchContext +>; + +export type WorkerRpcHandler = Effect.Effect>; + +export type WorkerRpc = Record) => WorkerRpcHandler>; + +export type WorkerRpcShape, ROut> = { + readonly [Key in keyof Rpc]: Rpc[Key] extends ( + ...args: infer Args + ) => Effect.Effect> + ? (...args: Args) => Promise + : never; +}; + +export type RpcHandlers = { + readonly [Key in keyof Api as Key extends keyof CloudflareWorkerEntrypoint + ? never + : Key extends string + ? Key extends ReservedMethodName + ? never + : [Api[Key]] extends [never] + ? never + : Api[Key] extends (...args: Array) => Promise + ? Key + : never + : never]: Api[Key] extends (...args: infer Args) => Promise + ? (...args: Args) => WorkerRpcHandler + : never; +}; + +export interface WorkerOptions> { + readonly fetch?: Effect.Effect>; + readonly queue?: QueueHandler; + readonly rpc?: Rpc; +} + +export type FetchWorkerOptions = Omit>, "rpc"> & { + readonly rpc?: never; +}; + +export type WorkerClass, ROut> = new ( + ctx: globalThis.ExecutionContext, + env: WorkerEnv, +) => CloudflareWorkerEntrypoint & { + fetch(request: Request): Promise; + queue(batch: globalThis.MessageBatch): Promise; +} & WorkerRpcShape; + +export interface FetchHandler { + readonly fetch: ( + request: Request, + env: Env, + ctx: globalThis.ExecutionContext, + ) => Promise; +} + +export const renderHttpResponse = ( + effect: Effect.Effect, +): Effect.Effect => + Effect.flatMap(effect, (response) => + Effect.map(Effect.context(), (context) => + HttpServerResponse.toWeb(response, { context }), + ), + ); + +const renderFetchSuccess = ( + effect: Effect.Effect, +): Effect.Effect => + Effect.flatMap(effect, (response) => + response instanceof Response + ? Effect.succeed(response) + : Effect.map(Effect.context(), (context) => + HttpServerResponse.toWeb(response, { context }), + ), + ); + +const isWorkerOptions = >( + options: WorkerOptions | WorkerHandler, +): options is WorkerOptions => + typeof options === "object" && + options !== null && + ("fetch" in options || "queue" in options || "rpc" in options); + +export function make( + layer: Layer.Layer, + fetch: WorkerHandler, +): WorkerClass, ROut>; +export function make = Record>( + layer: Layer.Layer, + options: WorkerOptions, +): WorkerClass; +export function make = Record>( + layer: Layer.Layer, + optionsOrFetch: WorkerOptions | WorkerHandler, +): WorkerClass { + const options = isWorkerOptions(optionsOrFetch) + ? optionsOrFetch + : ({ fetch: optionsOrFetch } as WorkerOptions); + + class EffectWorker extends CloudflareWorkerEntrypoint { + readonly runtime: ManagedRuntime.ManagedRuntime, LayerError>; + + constructor(ctx: globalThis.ExecutionContext, env: WorkerEnv) { + super(ctx, env); + + let runWaitUntilEffect: RunWaitUntilEffect = () => + Promise.reject(new Error("WorkerContext runtime is not initialized")); + + const services = Layer.mergeAll( + Layer.succeed(ExecutionContext, ctx), + ConfigProvider.layer(Effect.succeed(WorkerConfig.providerFromEnv(env))), + Layer.succeed( + WorkerContext, + fromExecutionContext(ctx, (effect) => runWaitUntilEffect(effect)), + ), + Layer.succeed(WorkerEnvironment, env), + ) as Layer.Layer; + + const runtimeLayer = Entrypoint.provideEntrypointServices< + ROut, + LayerError, + ExecutionContext | WorkerContext | WorkerEnvironment + >(layer, services); + + this.runtime = ManagedRuntime.make(runtimeLayer); + runWaitUntilEffect = (effect: Effect.Effect) => + this.runtime.runPromiseExit(effect as Effect.Effect>); + } + + [RunSymbol](effect: Effect.Effect | Scope.Scope>): Promise { + return this.runtime.runPromise(Effect.scoped(effect)); + } + + fetch(request: Request): Promise { + const fetchHandler = options.fetch; + + if (fetchHandler === undefined) { + return Promise.resolve(new Response("Not Found", { status: 404 })); + } + + const requestServices = Layer.mergeAll( + Layer.succeed(NativeRequest, request), + Layer.succeed(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request)), + ); + + return this[RunSymbol]( + renderFetchSuccess(fetchHandler).pipe(Effect.provide(requestServices)) as Effect.Effect< + Response, + unknown, + RuntimeContext | Scope.Scope + >, + ); + } + + queue(batch: globalThis.MessageBatch): Promise { + const queueHandler = options.queue; + + if (queueHandler === undefined) { + return Promise.resolve(); + } + + const messages = batch.messages.map((message) => fromMessage(message, message.body)); + + return this[RunSymbol](queueHandler(fromMessageBatch(batch, messages))); + } + } + + Entrypoint.defineEntrypointRpcMethods( + "Worker", + EffectWorker.prototype, + options.rpc, + reservedMethodNames, + (self, effect) => self[RunSymbol](effect), + ); + + return Entrypoint.assumeEntrypointClass>(EffectWorker); +} + +export const makeFetchHandler = ( + layer: Layer.Layer, + options: FetchWorkerOptions, +): FetchHandler => { + const WorkerClass = make(layer, options); + + return { + fetch: (request, env, ctx) => Promise.resolve(new WorkerClass(ctx, env).fetch(request)), + }; +}; + +export type ServiceFreeSchema = S.Codec; + +export interface Method< + Args extends ReadonlyArray = ReadonlyArray, + Success extends ServiceFreeSchema = ServiceFreeSchema, +> { + readonly args: Args; + readonly success: Success; +} + +export namespace Method { + export type Any = Method, ServiceFreeSchema>; + + type ArgsFromSchemas> = Args extends readonly [] + ? [] + : Args extends readonly [ + infer Head extends ServiceFreeSchema, + ...infer Tail extends ReadonlyArray, + ] + ? [S.Schema.Type, ...ArgsFromSchemas] + : Array>; + + export type Args = ArgsFromSchemas; + + export type Success = S.Schema.Type; +} + +export type Methods = Record; + +export type NoReservedMethods = + Extract extends never ? MethodsShape : never; + +export interface Definition { + readonly id: Id; + readonly methods: MethodsShape; +} + +export namespace Definition { + export type Any = Definition; +} + +export type ServerApi = { + readonly [Key in keyof Self["methods"]]: ( + ...args: Method.Args + ) => Promise>; +}; + +export type Api = Rpc.Provider, ReservedMethodName>; + +export type Handlers = { + readonly [Key in keyof Self["methods"]]: ( + ...args: Method.Args + ) => WorkerRpcHandler>; +}; + +export interface Options extends Omit< + WorkerOptions>, + "rpc" +> { + readonly rpc: Handlers; +} + +export type LayerOptions = WorkerDefinition.LayerOptions; + +export type TagClass = Context.ServiceClass< + Self, + Id, + ServiceBinding.ServiceBindingEffectClient< + Api>, + Definition + > +> & + ServiceBinding.ServiceBindingStaticClient< + Self, + Api>, + Definition + > & { + readonly id: Id; + readonly methods: MethodsShape; + readonly make: ( + layer: Layer.Layer, + options: Options>, + ) => WorkerClass>, ROut>; + readonly layer: ( + options: LayerOptions, + ) => Layer.Layer< + Self, + Binding.BindingNotFoundError | Binding.BindingValidationError, + WorkerEnvironment + >; + }; + +export type TagFactory = () => ( + id: Id, + methods: MethodsShape & NoReservedMethods, +) => TagClass; + +export const Tag = WorkerDefinition.Tag as unknown as TagFactory; + +export const method = WorkerDefinition.method as { + (definition: { + readonly success: Success; + }): Method; + < + const Args extends ReadonlyArray, + Success extends ServiceFreeSchema, + >(definition: { + readonly args: Args; + readonly success: Success; + }): Method; +}; + +export const implement = WorkerDefinition.implement as unknown as < + ROut, + const Self extends Definition.Any, +>( + _definition: Self, + handlers: Handlers, +) => Handlers; + +export type HandlerEffect< + ROut, + Self extends Definition.Any, + Key extends keyof Self["methods"], +> = WorkerRpcHandler>; diff --git a/lib/effect-cf/src/WorkerDefinition.ts b/lib/effect-cf/src/WorkerDefinition.ts new file mode 100644 index 000000000..f225c9a8a --- /dev/null +++ b/lib/effect-cf/src/WorkerDefinition.ts @@ -0,0 +1,336 @@ +import { Context, Effect, type Layer } from "effect"; +import type { Schema as S } from "effect"; + +import * as Binding from "./Binding"; +import type { WorkerEnvironment } from "./Environment"; +import * as WorkerEntrypoint from "./Worker"; +import type { WorkerRpcHandler } from "./Worker"; +import type * as Rpc from "./Rpc"; +import * as RpcDefinition from "./RpcDefinition"; +import * as ServiceBinding from "./ServiceBinding"; + +export type ServiceFreeSchema = S.Codec; + +export interface Method< + Args extends ReadonlyArray = ReadonlyArray, + Success extends ServiceFreeSchema = ServiceFreeSchema, +> { + readonly args: Args; + readonly success: Success; +} + +export namespace Method { + export type Any = Method, ServiceFreeSchema>; + + type ArgsFromSchemas> = Args extends readonly [] + ? [] + : Args extends readonly [ + infer Head extends ServiceFreeSchema, + ...infer Tail extends ReadonlyArray, + ] + ? [S.Schema.Type, ...ArgsFromSchemas] + : Array>; + + type EncodedArgsFromSchemas> = + Args extends readonly [] + ? [] + : Args extends readonly [ + infer Head extends ServiceFreeSchema, + ...infer Tail extends ReadonlyArray, + ] + ? [S.Codec.Encoded, ...EncodedArgsFromSchemas] + : Array>; + + export type Args = ArgsFromSchemas; + + export type EncodedArgs = EncodedArgsFromSchemas; + + export type Success = S.Schema.Type; + + export type EncodedSuccess = S.Codec.Encoded; +} + +export type Methods = Record; + +/** + * RPC contract for a Worker service. + * + * Create with {@link make} and reuse to type both worker implementations and + * service bindings in other workers. + */ +export interface Definition { + readonly id: Id; + readonly methods: MethodsShape; +} + +export namespace Definition { + export type Any = Definition; +} + +export type ReservedMethodName = WorkerEntrypoint.ReservedMethodName; + +export type NoReservedMethods = + Extract extends never ? MethodsShape : never; + +const reservedMethodNames = new Set([ + "constructor", + "dup", + "fetch", + "connect", + "queue", + "scheduled", + "tail", + "tailStream", + "test", + "trace", + "alarm", + "webSocketMessage", + "webSocketClose", + "webSocketError", +]); + +/** + * Promise-based client API derived from a {@link Definition}. + */ +export type ServerApi = { + readonly [Key in keyof Self["methods"]]: ( + ...args: Method.Args + ) => Promise>; +}; + +export type Api = Rpc.Provider, ReservedMethodName>; + +/** + * Effect handlers for each RPC method in a worker definition. + */ +export type Handlers = { + readonly [Key in keyof Self["methods"]]: ( + ...args: Method.Args + ) => WorkerRpcHandler>; +}; + +type BoundaryHandlers = { + readonly [Key in keyof Self["methods"]]: ( + ...args: Array + ) => WorkerRpcHandler>; +}; + +/** + * Worker constructor options for a specific RPC definition. + */ +export interface Options extends Omit< + WorkerEntrypoint.WorkerOptions>, + "rpc" +> { + readonly rpc: Handlers; +} + +export type LayerOptions = { + readonly binding: string; +}; + +export type TagClass = Context.ServiceClass< + Self, + Id, + ServiceBinding.ServiceBindingEffectClient< + Api>, + Definition + > +> & + ServiceBinding.ServiceBindingStaticClient< + Self, + Api>, + Definition + > & { + readonly id: Id; + readonly methods: MethodsShape; + readonly make: ( + layer: Layer.Layer< + ROut, + LayerError, + WorkerEntrypoint.ExecutionContext | WorkerEntrypoint.WorkerContext | WorkerEnvironment + >, + options: Options>, + ) => WorkerEntrypoint.WorkerClass>, ROut>; + readonly layer: ( + options: LayerOptions, + ) => Layer.Layer< + Self, + Binding.BindingNotFoundError | Binding.BindingValidationError, + WorkerEnvironment + >; + }; + +/** + * Defines a single RPC method schema in a worker definition. + */ +export const method = RpcDefinition.method as { + (definition: { + readonly success: Success; + }): Method; + < + const Args extends ReadonlyArray, + Success extends ServiceFreeSchema, + >(definition: { + readonly args: Args; + readonly success: Success; + }): Method; +}; + +/** + * Creates a typed worker RPC definition plus helpers for implementation and bindings. + * + * @example + * ```ts + * const CounterWorker = WorkerDefinition.make("CounterWorker", { + * increment: WorkerDefinition.method({ + * args: [Schema.Number], + * success: Schema.Number, + * }), + * }); + * ``` + */ +const makeDefinition = ( + id: Id, + methods: MethodsShape & NoReservedMethods, +) => { + type SelfDefinition = Definition; + RpcDefinition.assertNoReservedMethods("Worker", methods, reservedMethodNames); + const definition: SelfDefinition = RpcDefinition.make(id, methods); + + return Object.assign(definition, { + make: ( + layer: Layer.Layer< + ROut, + LayerError, + WorkerEntrypoint.ExecutionContext | WorkerEntrypoint.WorkerContext | WorkerEnvironment + >, + options: Options, + ) => + WorkerEntrypoint.make(layer, { + ...options, + rpc: wrapHandlers(definition, options.rpc), + }), + }); +}; + +export const make = ( + id: Id, + methods: MethodsShape & NoReservedMethods, +) => + Tag>()( + id, + methods as MethodsShape & NoReservedMethods, + ); + +export const Tag = + () => + ( + id: Id, + methods: MethodsShape & NoReservedMethods, + ) => { + const definition = makeDefinition(id, methods); + type SelfDefinition = Definition; + type ClientApi = Api; + const tag = Context.Service< + Self, + ServiceBinding.ServiceBindingEffectClient + >()(id); + + const bindingDefinition = (binding: LayerOptions) => ({ + ...binding, + definition, + }); + + const layer = (binding: LayerOptions) => + ServiceBinding.layer(tag, bindingDefinition(binding)); + + const fetch = (input: RequestInfo | URL, init?: RequestInit) => + Effect.gen(function* () { + const service = yield* tag; + return yield* service.fetch(input, init); + }); + + const rpc = ( + method: Method, + ...args: ClientApi[Method] extends (...args: infer Args) => unknown ? Args : never + ) => + Effect.gen(function* () { + const service = yield* tag; + return yield* service.rpc(method as never, ...(args as never)); + }); + + const call = ( + method: Method, + ...args: ClientApi[Method] extends (...args: infer Args) => unknown ? Args : never + ) => + Effect.gen(function* () { + const service = yield* tag; + return yield* service.call(method as never, ...(args as never)); + }); + + const scopedCall = ( + method: Method, + ...args: ClientApi[Method] extends (...args: infer Args) => unknown ? Args : never + ) => + Effect.gen(function* () { + const service = yield* tag; + return yield* service.scopedCall(method as never, ...(args as never)); + }); + + const directMethods = ServiceBinding.makeDirectMethods( + definition, + call as never, + ); + + return Object.assign(tag, directMethods, { + id: definition.id, + methods: definition.methods, + make: definition.make, + layer, + fetch, + rpc, + call, + scopedCall, + }) as unknown as TagClass; + }; + +export const Worker = Tag; + +const wrapHandlers = ( + definition: Self, + handlers: Handlers, +): BoundaryHandlers => { + const wrapped = {} as Record; + + for (const key of Object.keys(definition.methods) as Array< + RpcDefinition.Definition.MethodNames + >) { + const handler = handlers[key]; + wrapped[key] = (...args: Array) => + Effect.gen(function* () { + const decodedArgs = yield* RpcDefinition.decodeArgs(definition, key, args); + const value = yield* handler(...decodedArgs); + return yield* RpcDefinition.encodeSuccess(definition, key, value); + }); + } + + return wrapped as BoundaryHandlers; +}; + +/** + * Helper for implementing handlers with the exact method shape of a definition. + */ +export const implement = ( + _definition: Self, + handlers: Handlers, +): Handlers => handlers; + +/** + * Convenience alias for a single worker RPC handler Effect. + */ +export type HandlerEffect< + ROut, + Self extends Definition.Any, + Key extends keyof Self["methods"], +> = WorkerRpcHandler>; diff --git a/lib/effect-cf/src/Workflow.ts b/lib/effect-cf/src/Workflow.ts new file mode 100644 index 000000000..3d24b9905 --- /dev/null +++ b/lib/effect-cf/src/Workflow.ts @@ -0,0 +1,294 @@ +import { + WorkflowEntrypoint as CloudflareWorkflowEntrypoint, + type WorkflowEvent as CloudflareWorkflowEvent, + type WorkflowSleepDuration, + type WorkflowStep as CloudflareWorkflowStep, + type WorkflowStepConfig, + type WorkflowStepContext as CloudflareWorkflowStepContext, + type WorkflowStepEvent, + type WorkflowTimeoutDuration, +} from "cloudflare:workers"; +import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, type Scope } from "effect"; + +import { WorkerConfig, WorkerEnvironment, type WorkerEnv } from "./Environment"; +import { ExecutionContext, WorkerContext } from "./Worker"; +import * as WorkflowDefinition from "./WorkflowDefinition"; +import * as Entrypoint from "./internal/Entrypoint"; +import { fromExecutionContext, type RunWaitUntilEffect } from "./internal/WorkerContext"; + +export interface WorkflowEventService { + readonly raw: CloudflareWorkflowEvent; + readonly payload: Payload; + readonly timestamp: Date; + readonly instanceId: string; +} + +export class WorkflowEvent extends Context.Service()( + "effect-cf/WorkflowEvent", +) {} + +type RunWorkflowStepEffect = (effect: Effect.Effect) => Promise; + +export interface WorkflowStepService { + readonly raw: CloudflareWorkflowStep; + do( + name: string, + effect: Effect.Effect, + config?: WorkflowStepConfig, + ): Effect.Effect>; + readonly sleep: (name: string, duration: WorkflowSleepDuration) => Effect.Effect; + readonly sleepUntil: (name: string, timestamp: Date | number) => Effect.Effect; + readonly waitForEvent: ( + name: string, + options: { + readonly type: string; + readonly timeout?: WorkflowTimeoutDuration | number; + }, + ) => Effect.Effect, unknown>; +} + +export class WorkflowStep extends Context.Service()( + "effect-cf/WorkflowStep", +) {} + +export class WorkflowStepContext extends Context.Service< + WorkflowStepContext, + CloudflareWorkflowStepContext +>()("effect-cf/WorkflowStepContext") {} + +const fromWorkflowEvent = ( + event: CloudflareWorkflowEvent, +): WorkflowEventService => ({ + raw: event as CloudflareWorkflowEvent, + payload: event.payload, + timestamp: event.timestamp, + instanceId: event.instanceId, +}); + +const fromWorkflowStep = ( + step: CloudflareWorkflowStep, + runPromise: RunWorkflowStepEffect, +): WorkflowStepService => ({ + raw: step, + do: (name: string, effect: Effect.Effect, config?: WorkflowStepConfig) => + Effect.context>().pipe( + Effect.flatMap((context) => + Effect.tryPromise({ + try: () => { + const run = (stepContext: CloudflareWorkflowStepContext) => + runPromise( + Effect.scoped( + Effect.provideService( + Effect.provideContext( + effect as Effect.Effect>, + context, + ), + WorkflowStepContext, + stepContext, + ), + ), + ); + const rawStep = step as { + do( + name: string, + callback: (context: CloudflareWorkflowStepContext) => Promise, + ): Promise; + do( + name: string, + config: WorkflowStepConfig, + callback: (context: CloudflareWorkflowStepContext) => Promise, + ): Promise; + }; + + return config === undefined ? rawStep.do(name, run) : rawStep.do(name, config, run); + }, + catch: (cause) => cause, + }), + ), + ) as Effect.Effect>, + sleep: (name, duration) => + Effect.tryPromise({ + try: () => step.sleep(name, duration), + catch: (cause) => cause, + }), + sleepUntil: (name, timestamp) => + Effect.tryPromise({ + try: () => step.sleepUntil(name, timestamp), + catch: (cause) => cause, + }), + waitForEvent: ( + name: string, + options: { + readonly type: string; + readonly timeout?: WorkflowTimeoutDuration | number; + }, + ) => + Effect.tryPromise({ + try: () => step.waitForEvent(name, options) as Promise>, + catch: (cause) => cause, + }), +}); + +type RuntimeContext = ExecutionContext | WorkerContext | WorkerEnvironment | ROut; + +export type WorkflowRunContext = + | RuntimeContext + | WorkflowEvent + | WorkflowStep + | Scope.Scope; + +export type WorkflowHandler = ( + payload: Payload, +) => Effect.Effect>; + +export interface WorkflowOptions { + readonly run: WorkflowHandler; +} + +export type WorkflowClass = new ( + ctx: globalThis.ExecutionContext, + env: WorkerEnv, +) => CloudflareWorkflowEntrypoint & { + run( + event: Readonly>, + step: CloudflareWorkflowStep, + ): Promise; +}; + +export const make = ( + layer: Layer.Layer, + options: WorkflowOptions, +): WorkflowClass => { + class EffectWorkflow extends CloudflareWorkflowEntrypoint { + readonly runtime: ManagedRuntime.ManagedRuntime, LayerError>; + + constructor(ctx: globalThis.ExecutionContext, env: WorkerEnv) { + super(ctx, env); + + let runWaitUntilEffect: RunWaitUntilEffect = () => + Promise.reject(new Error("WorkerContext runtime is not initialized")); + + const services = Layer.mergeAll( + Layer.succeed(ExecutionContext, ctx), + ConfigProvider.layer(Effect.succeed(WorkerConfig.providerFromEnv(env))), + Layer.succeed( + WorkerContext, + fromExecutionContext(ctx, (effect) => runWaitUntilEffect(effect)), + ), + Layer.succeed(WorkerEnvironment, env), + ) as Layer.Layer; + + const runtimeLayer = Entrypoint.provideEntrypointServices< + ROut, + LayerError, + ExecutionContext | WorkerContext | WorkerEnvironment + >(layer, services); + + this.runtime = ManagedRuntime.make(runtimeLayer); + runWaitUntilEffect = (effect: Effect.Effect) => + this.runtime.runPromiseExit(effect as Effect.Effect>); + } + + run( + event: Readonly>, + step: CloudflareWorkflowStep, + ): Promise { + const workflowServices = Layer.mergeAll( + Layer.succeed(WorkflowEvent, fromWorkflowEvent(event)), + Layer.succeed( + WorkflowStep, + fromWorkflowStep( + step, + (effect) => + this.runtime.runPromise( + effect as Effect.Effect>, + ) as never, + ), + ), + ); + + return this.runtime.runPromise( + Effect.scoped( + options.run(event.payload).pipe(Effect.provide(workflowServices)), + ) as Effect.Effect>, + ); + } + } + + return Entrypoint.assumeEntrypointClass>(EffectWorkflow); +}; + +export const step = ( + name: string, + effect: Effect.Effect, + config?: WorkflowStepConfig, +): Effect.Effect> => + Effect.flatMap(WorkflowStep, (workflowStep) => + workflowStep.do(name, effect, config), + ) as Effect.Effect>; + +export const sleep = ( + name: string, + duration: WorkflowSleepDuration, +): Effect.Effect => + Effect.flatMap(WorkflowStep, (workflowStep) => workflowStep.sleep(name, duration)); + +export const sleepUntil = ( + name: string, + timestamp: Date | number, +): Effect.Effect => + Effect.flatMap(WorkflowStep, (workflowStep) => workflowStep.sleepUntil(name, timestamp)); + +export const waitForEvent = ( + name: string, + options: { + readonly type: string; + readonly timeout?: WorkflowTimeoutDuration | number; + }, +): Effect.Effect, unknown, WorkflowStep> => + Effect.flatMap(WorkflowStep, (workflowStep) => workflowStep.waitForEvent(name, options)); + +export type Definition< + Id extends string = string, + Payload extends WorkflowDefinition.Definition.Any["payload"] = + WorkflowDefinition.Definition.Any["payload"], + Result extends WorkflowDefinition.Definition.Any["result"] = + WorkflowDefinition.Definition.Any["result"], +> = WorkflowDefinition.Definition; + +export namespace Definition { + export type Any = WorkflowDefinition.Definition.Any; +} + +export type LayerOptions = WorkflowDefinition.LayerOptions; + +export type TagClass< + Self, + Id extends string, + Payload extends WorkflowDefinition.Definition.Any["payload"], + Result extends WorkflowDefinition.Definition.Any["result"], +> = WorkflowDefinition.TagClass; + +export const Tag: () => < + Id extends string, + Payload extends WorkflowDefinition.Definition.Any["payload"], + Result extends WorkflowDefinition.Definition.Any["result"], +>( + id: Id, + definition: { + readonly payload: Payload; + readonly result: Result; + }, +) => TagClass = WorkflowDefinition.Tag; + +export const implement = WorkflowDefinition.implement; + +export type Handler< + ROut, + Self extends WorkflowDefinition.Definition.Any, +> = WorkflowDefinition.Handler; + +export type Options< + ROut, + Self extends WorkflowDefinition.Definition.Any, +> = WorkflowDefinition.Options; diff --git a/lib/effect-cf/src/WorkflowBinding.ts b/lib/effect-cf/src/WorkflowBinding.ts new file mode 100644 index 000000000..9edffbbff --- /dev/null +++ b/lib/effect-cf/src/WorkflowBinding.ts @@ -0,0 +1,248 @@ +import { Context, Data, Effect, Option, Schema as S } from "effect"; + +import * as Binding from "./Binding"; +import type * as RpcDefinition from "./RpcDefinition"; + +const expectedWorkflow = "Workflow binding with create(), createBatch(), and get()"; + +export type WorkflowInstanceCreateOptions = Omit< + globalThis.WorkflowInstanceCreateOptions, + "params" +>; + +export type WorkflowInstanceCreateBatchOptions = ReadonlyArray< + { readonly payload: Payload } & WorkflowInstanceCreateOptions +>; + +export interface WorkflowInstanceRestartOptions { + readonly from?: { + readonly name?: string; + readonly count?: number; + readonly type?: string; + }; +} + +export type WorkflowInstanceStatusName = globalThis.InstanceStatus["status"]; + +export interface WorkflowInstanceStatus { + readonly status: WorkflowInstanceStatusName; + readonly output: Option.Option; + readonly error: Option.Option<{ + readonly name: string; + readonly message: string; + }>; +} + +export interface WorkflowInstance { + readonly raw: globalThis.WorkflowInstance; + readonly id: string; + readonly pause: Effect.Effect; + readonly resume: Effect.Effect; + readonly terminate: Effect.Effect; + readonly restart: ( + options?: WorkflowInstanceRestartOptions, + ) => Effect.Effect; + readonly status: Effect.Effect< + WorkflowInstanceStatus, + WorkflowOperationError | WorkflowResultDecodeError + >; + readonly sendEvent: (event: WorkflowInstanceEvent) => Effect.Effect; +} + +export interface WorkflowInstanceEvent { + readonly type: string; + readonly payload: unknown; +} + +export interface WorkflowBindingDefinition< + Payload extends RpcDefinition.ServiceFreeSchema, + Result extends RpcDefinition.ServiceFreeSchema, +> { + /** Binding name as configured in `wrangler.jsonc`. */ + readonly binding: string; + /** Codec used to encode payloads passed to `Workflow.create`. */ + readonly payload: Payload; + /** Codec used to decode completed workflow status output. */ + readonly result: Result; +} + +export interface WorkflowBindingClient< + Payload extends RpcDefinition.ServiceFreeSchema, + Result extends RpcDefinition.ServiceFreeSchema, +> { + readonly create: ( + payload: S.Schema.Type, + options?: WorkflowInstanceCreateOptions>, + ) => Effect.Effect< + WorkflowInstance>, + WorkflowOperationError | S.SchemaError + >; + readonly createBatch: ( + batch: WorkflowInstanceCreateBatchOptions, S.Codec.Encoded>, + ) => Effect.Effect< + ReadonlyArray>>, + WorkflowOperationError | S.SchemaError + >; + readonly get: ( + instanceId: string, + ) => Effect.Effect>, WorkflowOperationError>; + readonly unsafeRaw: Effect.Effect>>; +} + +export class WorkflowOperationError extends Data.TaggedError("WorkflowOperationError")<{ + readonly binding: string; + readonly operation: string; + readonly cause: unknown; +}> {} + +export class WorkflowResultDecodeError extends Data.TaggedError("WorkflowResultDecodeError")<{ + readonly binding: string; + readonly instanceId: string; + readonly cause: unknown; +}> {} + +const workflowError = (binding: string, operation: string, cause: unknown) => + new WorkflowOperationError({ binding, operation, cause }); + +const tryWorkflowPromise = ( + binding: string, + operation: string, + evaluate: () => Promise, +): Effect.Effect => + Effect.tryPromise({ + try: evaluate, + catch: (cause) => workflowError(binding, operation, cause), + }); + +export const isWorkflow = (value: unknown): value is globalThis.Workflow => { + if (typeof value !== "object" || value === null) { + return false; + } + + const resource = value as Record; + + return ( + typeof resource.create === "function" && + typeof resource.createBatch === "function" && + typeof resource.get === "function" + ); +}; + +export const makeClient = < + Payload extends RpcDefinition.ServiceFreeSchema, + Result extends RpcDefinition.ServiceFreeSchema, +>( + definition: WorkflowBindingDefinition, +): (( + workflow: globalThis.Workflow>, +) => WorkflowBindingClient) => { + type PayloadValue = S.Schema.Type; + type EncodedPayload = S.Codec.Encoded; + type ResultValue = S.Schema.Type; + + const encodePayload = S.encodeEffect(definition.payload); + const decodeResult = S.decodeUnknownEffect(definition.result); + + const wrapInstance = (raw: globalThis.WorkflowInstance): WorkflowInstance => { + const operation = (name: string, evaluate: () => Promise) => + tryWorkflowPromise(definition.binding, name, evaluate); + + return { + raw, + id: raw.id, + pause: operation("pause", () => raw.pause()), + resume: operation("resume", () => raw.resume()), + terminate: operation("terminate", () => raw.terminate()), + restart: (options) => + operation("restart", () => + (raw as { restart(options?: WorkflowInstanceRestartOptions): Promise }).restart( + options, + ), + ), + status: operation("status", () => raw.status()).pipe( + Effect.flatMap((status) => + Effect.gen(function* () { + const output = + status.output === undefined + ? Option.none() + : Option.some( + yield* decodeResult(status.output).pipe( + Effect.mapError( + (cause) => + new WorkflowResultDecodeError({ + binding: definition.binding, + instanceId: raw.id, + cause, + }), + ), + ), + ); + + return { + status: status.status, + output, + error: status.error === undefined ? Option.none() : Option.some(status.error), + }; + }), + ), + ), + sendEvent: (event) => operation("sendEvent", () => raw.sendEvent(event)), + }; + }; + + return (workflow) => ({ + create: Effect.fnUntraced(function* ( + payload: PayloadValue, + options?: WorkflowInstanceCreateOptions, + ) { + const encoded = yield* encodePayload(payload); + const raw = yield* tryWorkflowPromise(definition.binding, "create", () => + workflow.create({ ...options, params: encoded }), + ); + + return wrapInstance(raw); + }), + createBatch: Effect.fnUntraced(function* ( + batch: WorkflowInstanceCreateBatchOptions, + ) { + const encodedBatch: Array> = []; + + for (const item of batch) { + const { payload, ...options } = item; + + encodedBatch.push({ + ...options, + params: yield* encodePayload(payload), + }); + } + + const rawInstances = yield* tryWorkflowPromise(definition.binding, "createBatch", () => + workflow.createBatch(encodedBatch), + ); + + return rawInstances.map(wrapInstance); + }), + get: (instanceId) => + tryWorkflowPromise(definition.binding, "get", () => workflow.get(instanceId)).pipe( + Effect.map(wrapInstance), + ), + unsafeRaw: Effect.succeed(workflow), + }); +}; + +export const layer = < + Self, + Payload extends RpcDefinition.ServiceFreeSchema, + Result extends RpcDefinition.ServiceFreeSchema, +>( + tag: Context.Service>, + definition: WorkflowBindingDefinition, +) => + Binding.layer( + tag, + definition.binding, + (value): value is globalThis.Workflow> => + isWorkflow>(value), + makeClient(definition), + { expected: expectedWorkflow }, + ); diff --git a/lib/effect-cf/src/WorkflowDefinition.ts b/lib/effect-cf/src/WorkflowDefinition.ts new file mode 100644 index 000000000..4f53a8f4c --- /dev/null +++ b/lib/effect-cf/src/WorkflowDefinition.ts @@ -0,0 +1,232 @@ +import { Context, Effect, Schema as S, type Layer } from "effect"; + +import * as Binding from "./Binding"; +import type { WorkerEnvironment } from "./Environment"; +import type { ExecutionContext, WorkerContext } from "./Worker"; +import type * as RpcDefinition from "./RpcDefinition"; +import * as WorkflowBinding from "./WorkflowBinding"; +import * as WorkflowEntrypoint from "./Workflow"; + +export interface Definition< + Id extends string = string, + Payload extends RpcDefinition.ServiceFreeSchema = RpcDefinition.ServiceFreeSchema, + Result extends RpcDefinition.ServiceFreeSchema = RpcDefinition.ServiceFreeSchema, +> { + readonly id: Id; + readonly payload: Payload; + readonly result: Result; +} + +export namespace Definition { + export type Any = Definition< + string, + RpcDefinition.ServiceFreeSchema, + RpcDefinition.ServiceFreeSchema + >; +} + +export type Handler = ( + payload: S.Schema.Type, +) => Effect.Effect< + S.Schema.Type, + unknown, + WorkflowEntrypoint.WorkflowRunContext +>; + +export interface Options { + readonly run: Handler; +} + +export type LayerOptions = { + readonly binding: string; +}; + +export interface TagClass< + Self, + Id extends string, + Payload extends RpcDefinition.ServiceFreeSchema, + Result extends RpcDefinition.ServiceFreeSchema, +> extends Context.ServiceClass> { + readonly id: Id; + readonly payload: Payload; + readonly result: Result; + readonly make: ( + layer: Layer.Layer, + options: Options>, + ) => WorkflowEntrypoint.WorkflowClass, S.Codec.Encoded, ROut>; + readonly layer: ( + options: LayerOptions, + ) => Layer.Layer< + Self, + Binding.BindingNotFoundError | Binding.BindingValidationError, + WorkerEnvironment + >; + readonly create: ( + payload: S.Schema.Type, + options?: WorkflowBinding.WorkflowInstanceCreateOptions>, + ) => Effect.Effect< + WorkflowBinding.WorkflowInstance>, + WorkflowBinding.WorkflowOperationError | S.SchemaError, + Self + >; + readonly createBatch: ( + batch: WorkflowBinding.WorkflowInstanceCreateBatchOptions< + S.Schema.Type, + S.Codec.Encoded + >, + ) => Effect.Effect< + ReadonlyArray>>, + WorkflowBinding.WorkflowOperationError | S.SchemaError, + Self + >; + readonly get: ( + instanceId: string, + ) => Effect.Effect< + WorkflowBinding.WorkflowInstance>, + WorkflowBinding.WorkflowOperationError, + Self + >; + readonly unsafeRaw: () => Effect.Effect< + globalThis.Workflow>, + never, + Self + >; +} + +const makeDefinition = < + Id extends string, + Payload extends RpcDefinition.ServiceFreeSchema, + Result extends RpcDefinition.ServiceFreeSchema, +>( + id: Id, + definition: { + readonly payload: Payload; + readonly result: Result; + }, +) => { + type SelfDefinition = Definition; + const workflowDefinition: SelfDefinition = { + id, + payload: definition.payload, + result: definition.result, + }; + + return Object.assign(workflowDefinition, { + make: ( + layer: Layer.Layer, + options: Options, + ) => + WorkflowEntrypoint.make(layer, { + run: wrapHandler(workflowDefinition, options.run), + }), + }); +}; + +export const make = < + Id extends string, + Payload extends RpcDefinition.ServiceFreeSchema, + Result extends RpcDefinition.ServiceFreeSchema, +>( + id: Id, + definition: { + readonly payload: Payload; + readonly result: Result; + }, +) => Tag>()(id, definition); + +export const Tag = + () => + < + Id extends string, + Payload extends RpcDefinition.ServiceFreeSchema, + Result extends RpcDefinition.ServiceFreeSchema, + >( + id: Id, + definition: { + readonly payload: Payload; + readonly result: Result; + }, + ) => { + const workflowDefinition = makeDefinition(id, definition); + const tag = Context.Service>()(id); + + const layer = (binding: LayerOptions) => + WorkflowBinding.layer(tag, { + ...binding, + payload: definition.payload, + result: definition.result, + }); + + const create = Effect.fnUntraced(function* ( + payload: S.Schema.Type, + options?: WorkflowBinding.WorkflowInstanceCreateOptions>, + ) { + const workflow = yield* tag; + return yield* workflow.create(payload, options); + }); + + const createBatch = Effect.fnUntraced(function* ( + batch: WorkflowBinding.WorkflowInstanceCreateBatchOptions< + S.Schema.Type, + S.Codec.Encoded + >, + ) { + const workflow = yield* tag; + return yield* workflow.createBatch(batch); + }); + + const get = Effect.fnUntraced(function* (instanceId: string) { + const workflow = yield* tag; + return yield* workflow.get(instanceId); + }); + + const unsafeRaw = Effect.fnUntraced(function* () { + const workflow = yield* tag; + return yield* workflow.unsafeRaw; + }); + + return Object.assign(tag, { + id: workflowDefinition.id, + payload: workflowDefinition.payload, + result: workflowDefinition.result, + make: workflowDefinition.make, + layer, + create, + createBatch, + get, + unsafeRaw, + }) as TagClass; + }; + +export const Workflow = Tag; + +const wrapHandler = ( + definition: Self, + handler: Handler, +): WorkflowEntrypoint.WorkflowHandler< + ROut, + S.Codec.Encoded, + S.Codec.Encoded +> => { + const decodePayload = S.decodeUnknownEffect(definition.payload); + const encodeResult = S.encodeEffect(definition.result); + + return (payload) => + Effect.gen(function* () { + const decodedPayload = yield* decodePayload(payload); + const event = yield* WorkflowEntrypoint.WorkflowEvent; + const decodedEvent = { + ...event, + payload: decodedPayload, + } as WorkflowEntrypoint.WorkflowEventService>; + const result = yield* handler(decodedPayload as S.Schema.Type).pipe( + Effect.provideService(WorkflowEntrypoint.WorkflowEvent, decodedEvent), + ); + return yield* encodeResult(result as S.Schema.Type); + }); +}; + +export const implement = ( + _definition: Self, + handler: Handler, +): Handler => handler; diff --git a/lib/effect-cf/src/cloudflare-env.d.ts b/lib/effect-cf/src/cloudflare-env.d.ts new file mode 100644 index 000000000..9b27122f4 --- /dev/null +++ b/lib/effect-cf/src/cloudflare-env.d.ts @@ -0,0 +1,11 @@ +// Ambient fallback so the vendored effect-cf source typechecks standalone. +// +// effect-cf's `Environment.ts` references the global `Cloudflare.Env` type, +// which is normally produced per-worker by `wrangler types` +// (`worker-configuration.d.ts`). The library itself ships no such global, so +// in this package's isolated `tsc --noEmit` it would be unresolved. This empty +// interface merges with each consuming worker's generated `Cloudflare.Env` +// (declaration merging) and provides a safe `{}` shape here. +declare namespace Cloudflare { + interface Env {} +} diff --git a/lib/effect-cf/src/index.ts b/lib/effect-cf/src/index.ts new file mode 100644 index 000000000..27a42276a --- /dev/null +++ b/lib/effect-cf/src/index.ts @@ -0,0 +1,26 @@ +export * as Binding from "./Binding"; +export * as D1 from "./D1"; +export * as DurableObject from "./DurableObject"; +export * as DurableObjectAlarm from "./DurableObjectAlarm"; +export * as DurableObjectDefinition from "./DurableObjectDefinition"; +export * as DurableObjectNamespace from "./DurableObjectNamespace"; +export * as DurableObjectRpcWebSocket from "./DurableObjectRpcWebSocket"; +export * as DurableObjectState from "./DurableObjectState"; +export * as DurableObjectWebSocket from "./DurableObjectWebSocket"; +export * as DurableObjectStorage from "./DurableObjectStorage"; +export * as Hyperdrive from "./Hyperdrive"; +export * as Images from "./Images"; +export * as Kv from "./Kv"; +export * as Queue from "./Queue"; +export * as QueueBinding from "./QueueBinding"; +export * as QueueDefinition from "./QueueDefinition"; +export * as R2 from "./R2"; +export * as Rpc from "./Rpc"; +export * as RpcDefinition from "./RpcDefinition"; +export * as ServiceBinding from "./ServiceBinding"; +export * as Worker from "./Worker"; +export * as WorkerDefinition from "./WorkerDefinition"; +export * as Workflow from "./Workflow"; +export * as WorkflowBinding from "./WorkflowBinding"; +export * as WorkflowDefinition from "./WorkflowDefinition"; +export { WorkerConfig, WorkerEnvironment, type WorkerEnv } from "./Environment"; diff --git a/lib/effect-cf/src/internal/Entrypoint.ts b/lib/effect-cf/src/internal/Entrypoint.ts new file mode 100644 index 000000000..ac3fa3190 --- /dev/null +++ b/lib/effect-cf/src/internal/Entrypoint.ts @@ -0,0 +1,40 @@ +import { Effect, Layer } from "effect"; + +import * as RpcDefinition from "../RpcDefinition"; + +type AnyArgs = Array; +type EntrypointRpcMethod = (...args: AnyArgs) => Effect.Effect; + +export type EntrypointRpc = Record; + +export const provideEntrypointServices = ( + layer: Layer.Layer, + services: Layer.Layer, +): Layer.Layer => + layer.pipe(Layer.provideMerge(services)) as Layer.Layer; + +export const defineEntrypointRpcMethods = ( + target: string, + prototype: object, + rpc: EntrypointRpc | undefined, + reservedMethodNames: ReadonlySet, + run: (self: Self, effect: Effect.Effect) => Promise, +): void => { + const methods = rpc ?? {}; + + RpcDefinition.assertNoReservedMethods(target, methods, reservedMethodNames); + + for (const [key, method] of Object.entries(methods)) { + Object.defineProperty(prototype, key, { + enumerable: true, + value(this: Self, ...args: AnyArgs) { + return run( + this, + Effect.suspend(() => method(...args)), + ); + }, + }); + } +}; + +export const assumeEntrypointClass = (entrypoint: unknown): Class => entrypoint as Class; diff --git a/lib/effect-cf/src/internal/RpcInvocation.ts b/lib/effect-cf/src/internal/RpcInvocation.ts new file mode 100644 index 000000000..af4d6ace1 --- /dev/null +++ b/lib/effect-cf/src/internal/RpcInvocation.ts @@ -0,0 +1,73 @@ +import { Effect } from "effect"; + +import type * as CloudflareRpc from "../Rpc"; + +type AnyArgs = Array; + +export type AsyncMethodKey = { + [Key in keyof Api]-?: Key extends string + ? Api[Key] extends (...args: AnyArgs) => Promise + ? Key + : never + : never; +}[keyof Api]; + +export type AsyncMethodArgs = Api[Method] extends ( + ...args: infer Args +) => Promise + ? Args + : never; + +export type AsyncMethodSuccess = Api[Method] extends ( + ...args: AnyArgs +) => Promise + ? A + : never; + +export type AsyncMethodCloudflareReturn = CloudflareRpc.Result< + AsyncMethodSuccess +>; + +const isPropertyTarget = (value: unknown): value is object => + (typeof value === "object" || typeof value === "function") && value !== null; + +export const lookupRpcMethod = , Error>( + target: unknown, + method: Method, + makeError: (cause: unknown) => Error, +): Effect.Effect< + (...args: AsyncMethodArgs) => AsyncMethodCloudflareReturn, + Error +> => + Effect.try({ + try: () => { + if (!isPropertyTarget(target)) { + throw new TypeError(`RPC target is not object-like`); + } + + const value = Reflect.get(target, method); + + if (typeof value !== "function") { + throw new TypeError(`RPC method "${String(method)}" is not callable`); + } + + return ((...args: AsyncMethodArgs) => Reflect.apply(value, target, args)) as ( + ...args: AsyncMethodArgs + ) => AsyncMethodCloudflareReturn; + }, + catch: makeError, + }); + +export const invokeRpcMethod = , Error>( + target: unknown, + method: Method, + args: AsyncMethodArgs, + makeError: (cause: unknown) => Error, +): Effect.Effect, Error> => + Effect.gen(function* () { + const fn = yield* lookupRpcMethod(target, method, makeError); + return yield* Effect.try({ + try: () => fn(...args), + catch: makeError, + }); + }); diff --git a/lib/effect-cf/src/internal/WorkerContext.ts b/lib/effect-cf/src/internal/WorkerContext.ts new file mode 100644 index 000000000..b507f213d --- /dev/null +++ b/lib/effect-cf/src/internal/WorkerContext.ts @@ -0,0 +1,83 @@ +import { Cause, Effect, Exit } from "effect"; + +import type { WorkerContextService, WorkerContextWaitUntilOptions } from "../Worker"; + +export type RunWaitUntilEffect = ( + effect: Effect.Effect, +) => Promise>; + +const causeError = (cause: Cause.Cause) => new Error(Cause.pretty(cause)); + +const failureHandler = ( + cause: Cause.Cause, + options: WorkerContextWaitUntilOptions | undefined, +) => + ( + options?.onFailure?.(cause) ?? + Effect.logError("WorkerContext.waitUntil failed", Cause.pretty(cause)) + ).pipe( + Effect.catchCause((handlerCause) => + Effect.logError( + "WorkerContext.waitUntil failure handler failed", + Cause.pretty(cause), + Cause.pretty(handlerCause), + ), + ), + ); + +export const fromExecutionContext = ( + ctx: globalThis.ExecutionContext, + runPromiseExit: RunWaitUntilEffect, +): WorkerContextService => { + const schedule = ( + effect: Effect.Effect, + options: WorkerContextWaitUntilOptions | undefined, + mode: "observe" | "propagate", + ) => + Effect.context().pipe( + Effect.flatMap((context) => + Effect.sync(() => { + const runHandler = (cause: Cause.Cause) => + runPromiseExit( + Effect.scoped(Effect.provideContext(failureHandler(cause, options), context)), + ).then((exit) => { + if (Exit.isFailure(exit)) { + console.error( + "WorkerContext.waitUntil failure handler failed", + Cause.pretty(exit.cause), + ); + } + }); + + ctx.waitUntil( + runPromiseExit(Effect.scoped(Effect.provideContext(effect, context))).then( + async (exit) => { + if (Exit.isSuccess(exit)) { + return; + } + + await runHandler(exit.cause as Cause.Cause); + + if (mode === "propagate") { + throw causeError(exit.cause as Cause.Cause); + } + }, + ), + ); + }), + ), + ); + + return { + raw: ctx, + waitUntil: ( + effect: Effect.Effect, + options?: WorkerContextWaitUntilOptions, + ) => schedule(effect, options, options?.mode ?? "observe"), + waitUntilPropagating: ( + effect: Effect.Effect, + options?: Omit, "mode">, + ) => schedule(effect, options, "propagate"), + passThroughOnException: Effect.sync(() => ctx.passThroughOnException()), + }; +}; diff --git a/lib/effect-cloudflare/tsconfig.json b/lib/effect-cf/tsconfig.json similarity index 100% rename from lib/effect-cloudflare/tsconfig.json rename to lib/effect-cf/tsconfig.json diff --git a/lib/effect-cloudflare/src/cloudflare-workers.ts b/lib/effect-cloudflare/src/cloudflare-workers.ts deleted file mode 100644 index afe12e363..000000000 --- a/lib/effect-cloudflare/src/cloudflare-workers.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copied (with no adaptation) from alchemy-effect to stay API-compatible for a -// future migration: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/cloudflare_workers.ts -// -// Dynamic import of the `cloudflare:workers` runtime module. Falls back to -// structural stubs so non-worker runtimes (local tsc, vitest outside miniflare) -// can still type-check and load this package without crashing on the bare -// specifier. -import * as Effect from "effect/Effect" - -const cloudflareWorkers: Effect.Effect = - /** @__PURE__ #__PURE__ */ Effect.promise(() => - import("cloudflare:workers").catch( - () => - ({ - env: {}, - DurableObject: class {}, - WorkflowEntrypoint: class { - async run() {} - }, - }) as any, - ), - ) - -export default cloudflareWorkers diff --git a/lib/effect-cloudflare/src/config-provider.ts b/lib/effect-cloudflare/src/config-provider.ts deleted file mode 100644 index fc7be63d8..000000000 --- a/lib/effect-cloudflare/src/config-provider.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Copied from alchemy-effect to stay API-compatible for a future migration: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/ConfigProvider.ts -// -// Produces an Effect ConfigProvider backed by the worker's env. Compose with -// `Layer.setConfigProvider(...)` to make `Config.string("FOO")` resolve from -// `env.FOO` inside an Effect workflow. -import * as ConfigProvider from "effect/ConfigProvider" -import * as Effect from "effect/Effect" -import type * as Layer from "effect/Layer" -import cloudflareWorkers from "./cloudflare-workers.ts" - -export const WorkerConfigProvider = () => - cloudflareWorkers.pipe(Effect.map(({ env }) => ConfigProvider.fromUnknown(env))) - -/** - * A Layer that sets Effect's ConfigProvider to read from the `cloudflare:workers` - * env. Compose this into a worker's main layer so `Config.string("FOO")` — - * and anything downstream that uses Effect `Config` — resolves against the - * runtime env without the worker having to pass env around manually. - */ -export const WorkerConfigProviderLayer: Layer.Layer = ConfigProvider.layer(WorkerConfigProvider()) diff --git a/lib/effect-cloudflare/src/d1-connection.ts b/lib/effect-cloudflare/src/d1-connection.ts deleted file mode 100644 index e4d57b8ef..000000000 --- a/lib/effect-cloudflare/src/d1-connection.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Simplified port of alchemy-effect's D1 connection binding: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/D1/D1Connection.ts -// -// The alchemy D1 binding is split across three files (`D1Database.ts` — -// resource provider, `D1Connection.ts` — runtime service, `D1DatabaseBinding.ts` -// — IaC bind-to-worker helper). For maple we only need the runtime half: -// `D1Database("MAPLE_DB")` is a lightweight token identifying the env var -// name, and `D1Database.bind(token)` returns the connection client. -// -// The `.raw` accessor on the client is important — it lets `DatabaseD1Live` -// pull out the underlying `runtime.D1Database` to hand to drizzle. -import type * as runtime from "@cloudflare/workers-types" -import * as Effect from "effect/Effect" -import { WorkerEnvironment } from "./worker-environment.ts" - -export interface D1DatabaseToken { - readonly Type: "Cloudflare.D1Database" - readonly LogicalId: string -} - -const makeToken = (logicalId: string): D1DatabaseToken => ({ - Type: "Cloudflare.D1Database", - LogicalId: logicalId, -}) - -export interface D1ConnectionClient { - /** - * Resolves to the raw underlying Cloudflare `D1Database` binding. Use this - * when a driver (e.g. drizzle, better-auth) needs direct access. - */ - raw: Effect.Effect - /** - * Prepare a SQL statement for parameterised execution. - */ - prepare: (query: string) => Effect.Effect - /** - * Execute raw SQL without prepared statements. - */ - exec: (query: string) => Effect.Effect - /** - * Batch multiple prepared statements — rolled back on failure. - */ - batch: ( - statements: runtime.D1PreparedStatement[], - ) => Effect.Effect[], never, WorkerEnvironment> -} - -const makeClient = (token: D1DatabaseToken): D1ConnectionClient => { - const env = WorkerEnvironment - const d1 = env.pipe(Effect.map((e) => (e as Record)[token.LogicalId])) - - return { - raw: d1, - prepare: (query: string) => d1.pipe(Effect.map((db) => db.prepare(query))), - exec: (query: string) => d1.pipe(Effect.flatMap((db) => Effect.promise(() => db.exec(query)))), - batch: (statements: runtime.D1PreparedStatement[]) => - d1.pipe(Effect.flatMap((db) => Effect.promise(() => db.batch(statements)))), - } -} - -/** - * Declare a D1 database binding by env var name. - * - * ```ts - * export const MAPLE_DB = D1Database("MAPLE_DB") - * - * // Then in worker handler: - * const conn = yield* D1Database.bind(MAPLE_DB) - * const stmt = yield* conn.prepare("SELECT ?").bind(1) - * ``` - */ -export const D1Database = Object.assign((logicalId: string): D1DatabaseToken => makeToken(logicalId), { - bind: (token: D1DatabaseToken): Effect.Effect => - Effect.succeed(makeClient(token)), -}) - -// Alias preserving alchemy's `D1Connection` export name. Both surface the -// same `.bind()` API so either import style works during migration. -export const D1Connection = D1Database diff --git a/lib/effect-cloudflare/src/durable-object-namespace.ts b/lib/effect-cloudflare/src/durable-object-namespace.ts deleted file mode 100644 index dc33b3f97..000000000 --- a/lib/effect-cloudflare/src/durable-object-namespace.ts +++ /dev/null @@ -1,190 +0,0 @@ -// Simplified port of alchemy-effect's DurableObjectNamespace factory: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/DurableObjectNamespace.ts -// -// Upstream uses an IaC-aware class+effect hybrid (`effectClass` + -// `taggedFunction`) so `yield* MyDO` resolves the namespace handle. That -// hybrid depends on alchemy's `Worker` / `Output` / `Platform` IaC -// abstractions we are NOT porting. -// -// This port keeps the same authoring ergonomics for the class itself: -// -// export class ChatAgent extends DurableObjectNamespace()( -// "ChatAgent", -// Effect.gen(function* () { -// return Effect.gen(function* () { -// const state = yield* DurableObjectState -// return { fetch: ..., sayHi: () => Effect.succeed("hi") } -// }) -// }), -// ) {} -// -// …but replaces `yield* ChatAgent` (alchemy's sugar) with an explicit helper: -// -// const chat = yield* namespaceOf(ChatAgent) -// const stub = chat.getByName("room-123") -// -// When migrating to alchemy-effect later, search-and-replace -// `namespaceOf(X)` → `X`. -// -// IMPORTANT: This module statically imports from `cloudflare:workers`; it can -// only be loaded inside a Cloudflare Worker isolate. -import type * as cf from "@cloudflare/workers-types" -import { DurableObject } from "cloudflare:workers" -import * as Effect from "effect/Effect" -import type { HttpServerError } from "effect/unstable/http/HttpServerError" -import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest" -import type * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" -import { DurableObjectState, fromDurableObjectState } from "./durable-object-state.ts" -import type { HttpEffect } from "./http.ts" -import { makeDurableObjectBridge, makeRpcStub } from "./rpc.ts" -import type { DurableWebSocket } from "./websocket.ts" -import { WorkerEnvironment } from "./worker-environment.ts" - -export type DurableObjectId = cf.DurableObjectId -export type AlarmInvocationInfo = cf.AlarmInvocationInfo - -export interface DurableObjectShape { - fetch?: HttpEffect - alarm?: (alarmInfo?: AlarmInvocationInfo) => Effect.Effect - webSocketMessage?: (socket: DurableWebSocket, message: string | ArrayBuffer) => Effect.Effect - webSocketClose?: ( - socket: DurableWebSocket, - code: number, - reason: string, - wasClean: boolean, - ) => Effect.Effect -} - -export type DurableObjectStub = { - [K in keyof Shape]: Shape[K] -} & { - fetch( - request: HttpServerRequest.HttpServerRequest, - ): Effect.Effect -} - -export interface DurableObjectNamespaceHandle { - readonly name: string - getByName(name: string): DurableObjectStub - idFromName(name: string): DurableObjectId - idFromString(id: string): DurableObjectId - newUniqueId(): DurableObjectId -} - -// --------------------------------------------------------------------------- -// Module-level registry: name -> init effect -// -// The DO bridge class needs to look up the user-provided impl at CF- -// instantiation time. The impl is registered when the factory is called -// (at module evaluation in the DO's source file) and retrieved by the -// bridge constructor (when CF invokes `new ClassName(state, env)`). -// --------------------------------------------------------------------------- - -type DurableObjectImpl = Effect.Effect< - Effect.Effect, never, DurableObjectState>, - never, - any -> - -const implRegistry = new Map() - -export const registerDurableObjectImpl = (name: string, impl: DurableObjectImpl): void => { - implRegistry.set(name, impl) -} - -export const getDurableObjectImpl = (name: string): DurableObjectImpl | undefined => implRegistry.get(name) - -// --------------------------------------------------------------------------- -// Bridge base class — built once, parameterised per DO name. -// --------------------------------------------------------------------------- - -const Bridge = makeDurableObjectBridge( - DurableObject as unknown as abstract new (state: unknown, env: unknown) => cf.DurableObject, - async (name: string) => { - const impl = implRegistry.get(name) - if (!impl) { - throw new Error( - `Durable Object impl for '${name}' is not registered. Ensure the class module is loaded before CF instantiates the DO.`, - ) - } - return (state: unknown, env: unknown) => - Effect.gen(function* () { - const doState = fromDurableObjectState(state as cf.DurableObjectState) - const innerEffect = yield* impl - const methods = yield* innerEffect.pipe( - Effect.provideService(DurableObjectState, doState), - Effect.provideService(WorkerEnvironment, env as Record), - ) - return methods as Record - }) as Effect.Effect> - }, -) - -// --------------------------------------------------------------------------- -// Public factory -// --------------------------------------------------------------------------- - -/** - * Define a Durable Object class with an Effect-based runtime implementation. - * - * Usage: - * ```ts - * export class ChatAgent extends DurableObjectNamespace()( - * "ChatAgent", - * Effect.gen(function* () { - * // Phase 1 — shared init (bindings, etc.) - * return Effect.gen(function* () { - * // Phase 2 — per-instance setup - * const state = yield* DurableObjectState - * return { - * fetch: Effect.gen(function* () { ... }), - * sayHi: () => Effect.succeed("hi"), - * } - * }) - * }), - * ) {} - * ``` - * - * Export the resulting class as the DO binding target in wrangler.jsonc. - * Use `namespaceOf(ChatAgent)` in a worker handler to get a namespace - * handle with `.getByName(id)` → typed stub. - */ -export const DurableObjectNamespace = <_Self = unknown>() => { - return ( - name: string, - impl: Effect.Effect, never, InitReq>, - ) => { - registerDurableObjectImpl(name, impl as unknown as DurableObjectImpl) - return Bridge(name) as unknown as new (state: cf.DurableObjectState, env: unknown) => cf.DurableObject - } -} - -/** - * Resolve a namespace handle for a DO class registered via - * `DurableObjectNamespace`. The `Shape` type parameter controls the - * methods exposed on the stub returned by `.getByName(id)`. - * - * The class reference is used purely to look up the registered name — the - * actual binding comes from the worker env at runtime. - */ -export const namespaceOf = Effect.fn("namespaceOf")(function* ( - classOrName: { name: string } | string, -) { - const env = yield* WorkerEnvironment - const name = typeof classOrName === "string" ? classOrName : classOrName.name - const binding = env[name] as cf.DurableObjectNamespace | undefined - if (!binding || typeof binding.getByName !== "function") { - return yield* Effect.die( - new Error( - `Worker env has no DurableObjectNamespace binding named '${name}'. Check wrangler.jsonc.`, - ), - ) - } - return { - name, - getByName: (id: string) => makeRpcStub>(binding.getByName(id)), - idFromName: (id: string) => binding.idFromName(id), - idFromString: (id: string) => binding.idFromString(id), - newUniqueId: () => binding.newUniqueId(), - } satisfies DurableObjectNamespaceHandle -}) diff --git a/lib/effect-cloudflare/src/durable-object-state.ts b/lib/effect-cloudflare/src/durable-object-state.ts deleted file mode 100644 index 934c62d54..000000000 --- a/lib/effect-cloudflare/src/durable-object-state.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Copied verbatim from alchemy-effect to stay API-compatible for a future -// migration: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/DurableObjectState.ts -// -// Context.Service carrying the DO's `state` handle. Provided by the DO bridge -// once per constructor call; `yield* DurableObjectState` from inside any -// method body to access storage, websockets, concurrency control, etc. -import type * as cf from "@cloudflare/workers-types" -import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import { fromDurableObjectStorage, type DurableObjectStorage } from "./durable-object-storage.ts" -import { fromWebSocket, type DurableWebSocket } from "./websocket.ts" - -export class DurableObjectState extends Context.Service< - DurableObjectState, - { - readonly id: cf.DurableObjectId - readonly storage: DurableObjectStorage - container?: cf.Container - blockConcurrencyWhile(callback: () => Effect.Effect): Effect.Effect - acceptWebSocket(ws: DurableWebSocket, tags?: string[]): Effect.Effect - getWebSockets(tag?: string): Effect.Effect - setWebSocketAutoResponse(maybeReqResp?: cf.WebSocketRequestResponsePair): Effect.Effect - getWebSocketAutoResponse(): Effect.Effect - getWebSocketAutoResponseTimestamp(ws: cf.WebSocket): Effect.Effect - setHibernatableWebSocketEventTimeout(timeoutMs?: number): Effect.Effect - getHibernatableWebSocketEventTimeout(): Effect.Effect - getTags(ws: cf.WebSocket): Effect.Effect - abort(reason?: string): Effect.Effect - } ->()("Cloudflare.DurableObjectState") {} - -export const fromDurableObjectState = (state: cf.DurableObjectState): DurableObjectState["Service"] => ({ - id: state.id, - container: state.container, - storage: fromDurableObjectStorage(state.storage), - blockConcurrencyWhile: (callback: () => Effect.Effect) => - Effect.tryPromise(() => state.blockConcurrencyWhile(() => Effect.runPromise(callback()))), - acceptWebSocket: (ws: DurableWebSocket, tags?: string[]) => - Effect.sync(() => state.acceptWebSocket(ws.ws, tags)), - getWebSockets: (tag?: string) => Effect.sync(() => state.getWebSockets(tag).map(fromWebSocket)), - setWebSocketAutoResponse: (maybeReqResp?: cf.WebSocketRequestResponsePair) => - Effect.sync(() => state.setWebSocketAutoResponse(maybeReqResp)), - getWebSocketAutoResponse: () => Effect.sync(() => state.getWebSocketAutoResponse()), - getWebSocketAutoResponseTimestamp: (ws: cf.WebSocket) => - Effect.sync(() => state.getWebSocketAutoResponseTimestamp(ws)), - setHibernatableWebSocketEventTimeout: (timeoutMs?: number) => - Effect.sync(() => state.setHibernatableWebSocketEventTimeout(timeoutMs)), - getHibernatableWebSocketEventTimeout: () => - Effect.sync(() => state.getHibernatableWebSocketEventTimeout()), - getTags: (ws: cf.WebSocket) => Effect.sync(() => state.getTags(ws)), - abort: (reason?: string) => Effect.sync(() => state.abort(reason)), -}) diff --git a/lib/effect-cloudflare/src/durable-object-storage.ts b/lib/effect-cloudflare/src/durable-object-storage.ts deleted file mode 100644 index cb1a53632..000000000 --- a/lib/effect-cloudflare/src/durable-object-storage.ts +++ /dev/null @@ -1,170 +0,0 @@ -// Copied verbatim from alchemy-effect to stay API-compatible for a future -// migration: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/DurableObjectStorage.ts -// -// Effect-native wrappers around `cf.DurableObjectStorage`, `cf.SqlStorage`, -// and `cf.DurableObjectTransaction`. Pure runtime utilities — no IaC deps. -import type * as cf from "@cloudflare/workers-types" -import * as Effect from "effect/Effect" -import * as Stream from "effect/Stream" - -// --------------------------------------------------------------------------- -// SqlStorage — Effect-native wrapper around cf.SqlStorage -// --------------------------------------------------------------------------- - -export type SqlStorageValue = cf.SqlStorageValue - -export interface SqlCursor> extends Stream.Stream { - next(): Effect.Effect<{ done?: false; value: T } | { done: true; value?: never }> - toArray(): Effect.Effect - one(): Effect.Effect - raw(): Stream.Stream - readonly columnNames: string[] - readonly rowsRead: Effect.Effect - readonly rowsWritten: Effect.Effect -} - -export interface SqlStorage { - exec>( - query: string, - ...bindings: any[] - ): Effect.Effect> - readonly databaseSize: number -} - -const fromSqlCursor = >( - cursor: cf.SqlStorageCursor, -): SqlCursor => { - const stream = Stream.fromIterableEffect(Effect.sync(() => cursor)) - return Object.assign(stream, { - next: () => Effect.sync(() => cursor.next()), - toArray: () => Effect.sync(() => cursor.toArray()), - one: () => Effect.sync(() => cursor.one()), - raw: () => Stream.fromIterableEffect(Effect.sync(() => cursor.raw())), - get columnNames() { - return cursor.columnNames - }, - rowsRead: Effect.sync(() => cursor.rowsRead), - rowsWritten: Effect.sync(() => cursor.rowsWritten), - }) as SqlCursor -} - -const fromSqlStorage = (sql: cf.SqlStorage): SqlStorage => ({ - exec: >( - query: string, - ...bindings: any[] - ): Effect.Effect> => Effect.sync(() => fromSqlCursor(sql.exec(query, ...bindings))), - get databaseSize() { - return sql.databaseSize - }, -}) - -// --------------------------------------------------------------------------- -// DurableObjectTransaction -// --------------------------------------------------------------------------- - -export interface DurableObjectTransaction { - get(key: string, options?: cf.DurableObjectGetOptions): Effect.Effect - get(keys: string[], options?: cf.DurableObjectGetOptions): Effect.Effect> - list(options?: cf.DurableObjectListOptions): Effect.Effect> - put(key: string, value: T, options?: cf.DurableObjectPutOptions): Effect.Effect - put(entries: Record, options?: cf.DurableObjectPutOptions): Effect.Effect - delete(key: string, options?: cf.DurableObjectPutOptions): Effect.Effect - delete(keys: string[], options?: cf.DurableObjectPutOptions): Effect.Effect - rollback(): Effect.Effect - getAlarm(options?: cf.DurableObjectGetAlarmOptions): Effect.Effect - setAlarm(scheduledTime: number | Date, options?: cf.DurableObjectSetAlarmOptions): Effect.Effect - deleteAlarm(options?: cf.DurableObjectSetAlarmOptions): Effect.Effect -} - -// --------------------------------------------------------------------------- -// DurableObjectStorage -// --------------------------------------------------------------------------- - -export interface DurableObjectStorage { - get(key: string, options?: cf.DurableObjectGetOptions): Effect.Effect - get(keys: string[], options?: cf.DurableObjectGetOptions): Effect.Effect> - list(options?: cf.DurableObjectListOptions): Effect.Effect> - put(key: string, value: T, options?: cf.DurableObjectPutOptions): Effect.Effect - put(entries: Record, options?: cf.DurableObjectPutOptions): Effect.Effect - delete(key: string, options?: cf.DurableObjectPutOptions): Effect.Effect - delete(keys: string[], options?: cf.DurableObjectPutOptions): Effect.Effect - deleteAll(options?: cf.DurableObjectPutOptions): Effect.Effect - transaction(closure: (txn: DurableObjectTransaction) => Effect.Effect): Effect.Effect - getAlarm(options?: cf.DurableObjectGetAlarmOptions): Effect.Effect - setAlarm(scheduledTime: number | Date, options?: cf.DurableObjectSetAlarmOptions): Effect.Effect - deleteAlarm(options?: cf.DurableObjectSetAlarmOptions): Effect.Effect - sync(): Effect.Effect - sql: SqlStorage - kv: cf.SyncKvStorage - transactionSync(closure: () => T): T - getCurrentBookmark(): Effect.Effect - getBookmarkForTime(timestamp: number | Date): Effect.Effect - onNextSessionRestoreBookmark(bookmark: string): Effect.Effect -} - -// --------------------------------------------------------------------------- -// Constructors from raw Cloudflare types -// --------------------------------------------------------------------------- - -export const fromDurableObjectTransaction = (txn: cf.DurableObjectTransaction): DurableObjectTransaction => ({ - get: ((keyOrKeys: string | string[], options?: cf.DurableObjectGetOptions) => - Effect.tryPromise(() => txn.get(keyOrKeys as any, options))) as any, - list: (options?: cf.DurableObjectListOptions) => Effect.tryPromise(() => txn.list(options)), - put: (( - keyOrEntries: string | Record, - valueOrOptions?: unknown, - maybeOptions?: cf.DurableObjectPutOptions, - ) => - typeof keyOrEntries === "string" - ? Effect.tryPromise(() => txn.put(keyOrEntries, valueOrOptions, maybeOptions)) - : Effect.tryPromise(() => - txn.put(keyOrEntries, valueOrOptions as cf.DurableObjectPutOptions | undefined), - )) as any, - delete: ((keyOrKeys: string | string[], options?: cf.DurableObjectPutOptions) => - Effect.tryPromise(() => txn.delete(keyOrKeys as any, options))) as any, - rollback: () => Effect.sync(() => txn.rollback()), - getAlarm: (options?: cf.DurableObjectGetAlarmOptions) => Effect.tryPromise(() => txn.getAlarm(options)), - setAlarm: (scheduledTime: number | Date, options?: cf.DurableObjectSetAlarmOptions) => - Effect.tryPromise(() => txn.setAlarm(scheduledTime, options)), - deleteAlarm: (options?: cf.DurableObjectSetAlarmOptions) => - Effect.tryPromise(() => txn.deleteAlarm(options)), -}) - -export const fromDurableObjectStorage = (storage: cf.DurableObjectStorage): DurableObjectStorage => ({ - get: ((keyOrKeys: string | string[], options?: cf.DurableObjectGetOptions) => - Effect.tryPromise(() => storage.get(keyOrKeys as any, options))) as any, - list: (options?: cf.DurableObjectListOptions) => Effect.tryPromise(() => storage.list(options)), - put: (( - keyOrEntries: string | Record, - valueOrOptions?: unknown, - maybeOptions?: cf.DurableObjectPutOptions, - ) => - typeof keyOrEntries === "string" - ? Effect.tryPromise(() => storage.put(keyOrEntries, valueOrOptions, maybeOptions)) - : Effect.tryPromise(() => - storage.put(keyOrEntries, valueOrOptions as cf.DurableObjectPutOptions | undefined), - )) as any, - delete: ((keyOrKeys: string | string[], options?: cf.DurableObjectPutOptions) => - Effect.tryPromise(() => storage.delete(keyOrKeys as any, options))) as any, - deleteAll: (options?: cf.DurableObjectPutOptions) => Effect.tryPromise(() => storage.deleteAll(options)), - transaction: (closure: (txn: DurableObjectTransaction) => Effect.Effect) => - Effect.tryPromise(() => - storage.transaction((txn) => Effect.runPromise(closure(fromDurableObjectTransaction(txn)))), - ), - getAlarm: (options?: cf.DurableObjectGetAlarmOptions) => - Effect.tryPromise(() => storage.getAlarm(options)), - setAlarm: (scheduledTime: number | Date, options?: cf.DurableObjectSetAlarmOptions) => - Effect.tryPromise(() => storage.setAlarm(scheduledTime, options)), - deleteAlarm: (options?: cf.DurableObjectSetAlarmOptions) => - Effect.tryPromise(() => storage.deleteAlarm(options)), - sync: () => Effect.tryPromise(() => storage.sync()), - sql: fromSqlStorage(storage.sql), - kv: storage.kv, - transactionSync: (closure: () => T) => storage.transactionSync(closure), - getCurrentBookmark: () => Effect.tryPromise(() => storage.getCurrentBookmark()), - getBookmarkForTime: (timestamp: number | Date) => - Effect.tryPromise(() => storage.getBookmarkForTime(timestamp)), - onNextSessionRestoreBookmark: (bookmark: string) => - Effect.tryPromise(() => storage.onNextSessionRestoreBookmark(bookmark)), -}) diff --git a/lib/effect-cloudflare/src/fetch.ts b/lib/effect-cloudflare/src/fetch.ts deleted file mode 100644 index 18bff3671..000000000 --- a/lib/effect-cloudflare/src/fetch.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Simplified port of alchemy-effect's Fetch binding: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/Fetch.ts -// -// Upstream resolves the fetcher through a Policy + WorkerEnvironment lookup -// tied to the alchemy Worker resource. Here we expose the same behaviour -// but keyed on a lightweight logical-id token (the env var name declared -// in wrangler.jsonc's `services:` section). -// -// ```ts -// export const AUTH = ServiceBinding("AUTH") -// const call = yield* ServiceBinding.bind(AUTH) -// yield* call(HttpClientRequest.get("https://auth.local/me")) -// ``` -import type * as runtime from "@cloudflare/workers-types" -import * as Effect from "effect/Effect" -import * as Option from "effect/Option" -import * as Result from "effect/Result" -import * as Stream from "effect/Stream" -import * as HttpClientError from "effect/unstable/http/HttpClientError" -import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" -import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" -import * as UrlParams from "effect/unstable/http/UrlParams" -import { WorkerEnvironment } from "./worker-environment.ts" - -export interface ServiceBindingToken { - readonly Type: "Cloudflare.ServiceBinding" - readonly LogicalId: string -} - -const makeToken = (logicalId: string): ServiceBindingToken => ({ - Type: "Cloudflare.ServiceBinding", - LogicalId: logicalId, -}) - -export type ServiceBindingFetch = ( - request: HttpClientRequest.HttpClientRequest, -) => Effect.Effect - -const makeFetch = (token: ServiceBindingToken): ServiceBindingFetch => - Effect.fn("ServiceBinding.fetch")(function* (request: HttpClientRequest.HttpClientRequest) { - const env = yield* WorkerEnvironment - const fetcher = (env as Record)[token.LogicalId] - if (!fetcher) { - return yield* Effect.fail( - new HttpClientError.TransportError({ - request, - cause: new Error(`No service binding named '${token.LogicalId}' in worker env`), - description: "Service binding lookup failed", - }), - ) - } - return yield* doFetch(fetcher, request) - }) - -const doFetch = ( - fetcher: runtime.Fetcher, - request: HttpClientRequest.HttpClientRequest, -): Effect.Effect => { - const urlResult = UrlParams.makeUrl( - request.url, - request.urlParams, - request.hash.pipe(Option.getOrUndefined), - ) - if (Result.isFailure(urlResult)) { - return Effect.fail( - new HttpClientError.InvalidUrlError({ - request, - cause: urlResult.failure, - description: "Failed to construct URL", - }), - ) - } - const url = urlResult.success - - const send = (body: BodyInit | undefined) => - Effect.mapError( - Effect.map( - Effect.tryPromise({ - try: () => - fetcher.fetch( - url.toString() as runtime.RequestInfo, - { - method: request.method, - headers: request.headers as unknown as runtime.HeadersInit, - body, - duplex: request.body._tag === "Stream" ? "half" : undefined, - } as runtime.RequestInit, - ) as unknown as Promise, - catch: (cause) => cause, - }), - (response) => HttpClientResponse.fromWeb(request, response), - ), - (cause) => - new HttpClientError.TransportError({ - request, - cause, - description: "Service binding fetch failed", - }), - ) - - switch (request.body._tag) { - case "Raw": - case "Uint8Array": - return send(request.body.body as BodyInit) - case "FormData": - return send(request.body.formData) - case "Stream": - return Effect.flatMap( - Effect.mapError( - Stream.toReadableStreamEffect(request.body.stream), - (cause) => - new HttpClientError.EncodeError({ - request, - cause, - description: "Failed to encode stream body", - }), - ), - send, - ) - default: - return send(undefined) - } -} - -export const ServiceBinding = Object.assign( - (logicalId: string): ServiceBindingToken => makeToken(logicalId), - { - bind: (token: ServiceBindingToken): Effect.Effect => - Effect.succeed(makeFetch(token)), - }, -) diff --git a/lib/effect-cloudflare/src/fetcher.ts b/lib/effect-cloudflare/src/fetcher.ts deleted file mode 100644 index 26e46772e..000000000 --- a/lib/effect-cloudflare/src/fetcher.ts +++ /dev/null @@ -1,236 +0,0 @@ -// Copied verbatim from alchemy-effect to stay API-compatible for a future -// migration: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Fetcher.ts -// -// Bidirectional adapters between Cloudflare `Fetcher` / `Socket` and Effect's -// `HttpClient` / `Socket`. Used by the RPC module to wrap DO / service-binding -// stubs. -import type * as cf from "@cloudflare/workers-types" -import * as Deferred from "effect/Deferred" -import * as Effect from "effect/Effect" -import * as FiberSet from "effect/FiberSet" -import { pipe } from "effect/Function" -import * as Latch from "effect/Latch" -import * as Scope from "effect/Scope" -import * as HttpBody from "effect/unstable/http/HttpBody" -import { HttpClientError } from "effect/unstable/http/HttpClientError" -import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" -import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" -import type { HttpServerError } from "effect/unstable/http/HttpServerError" -import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest" -import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" -import * as Socket from "effect/unstable/socket/Socket" - -export type SocketAddress = cf.SocketAddress -export type SocketOptions = cf.SocketOptions - -export interface Fetcher { - fetch( - request: HttpClientRequest.HttpClientRequest, - ): Effect.Effect - fetch( - request: HttpServerRequest.HttpServerRequest, - ): Effect.Effect - - connect(address: SocketAddress | string, options?: SocketOptions): Socket.Socket -} - -export const toCloudflareFetcher = Effect.fnUntraced(function* (fetcher: Fetcher) { - const context = yield* Effect.context() - return { - fetch: (input, init) => - fetcher - .fetch(HttpServerRequest.fromWeb(new Request(input as any, init as any) as any as Request)) - .pipe( - Effect.map( - (response) => - HttpServerResponse.toWeb(response, { - context, - }) as any as cf.Response, - ), - Effect.provideContext(context), - Effect.runPromise, - ), - connect() { - throw new Error("toCloudflareFetcher does not support connect()") - }, - } satisfies cf.Fetcher -}) - -export const fromCloudflareFetcher = (fetcher: cf.Fetcher): Fetcher => { - const fetch = (request: Request) => - Effect.promise((signal) => - fetcher.fetch(request as any as cf.Request, { - signal: signal as cf.AbortSignal, - }), - ) - - return { - connect: (address, options) => fromCloudflareSocket(fetcher.connect(address, options)), - fetch: (request: HttpClientRequest.HttpClientRequest | HttpServerRequest.HttpServerRequest): any => - HttpClientRequest.isHttpClientRequest(request) - ? pipe( - HttpServerRequest.toWeb(HttpServerRequest.fromClientRequest(request)), - Effect.flatMap(fetch), - Effect.map((response) => - HttpClientResponse.fromWeb(request, response as any as Response), - ), - Effect.catchTags({ - InternalError: (error) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(error.message, { status: 500 }), - ), - ), - RequestParseError: (error) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(error.message, { status: 400 }), - ), - ), - }), - ) - : pipe( - HttpServerRequest.toWeb(request), - Effect.flatMap(fetch), - Effect.map((response) => { - if ((response as any).status === 101) { - return HttpServerResponse.setBody( - HttpServerResponse.empty({ status: 101 }), - HttpBody.raw(response), - ) - } - return HttpServerResponse.fromWeb(response as any as Response) - }), - ), - } -} - -export const fromCloudflareSocket = (cfSocket: cf.Socket): Socket.Socket => { - const latch = Latch.makeUnsafe(false) - let currentFiberSet: FiberSet.FiberSet | undefined - let writerRef: WritableStreamDefaultWriter | undefined - const encoder = new TextEncoder() - const closeError = (code: number, closeReason?: string) => - new Socket.SocketError({ - reason: new Socket.SocketCloseError({ code, closeReason }), - }) - - const runRaw = <_, E, R>( - handler: (_: string | Uint8Array) => Effect.Effect<_, E, R> | void, - opts?: { readonly onOpen?: Effect.Effect | undefined }, - ): Effect.Effect => - Effect.scopedWith( - Effect.fnUntraced(function* (scope) { - yield* Effect.tryPromise({ - try: () => cfSocket.opened, - catch: (cause) => - new Socket.SocketError({ - reason: new Socket.SocketOpenError({ - kind: "Unknown", - cause, - }), - }), - }) - - const reader = cfSocket.readable.getReader() - yield* Scope.addFinalizer( - scope, - Effect.promise(() => reader.cancel()), - ) - - const fiberSet = yield* FiberSet.make().pipe( - Scope.provide(scope), - ) - const runFork = yield* FiberSet.runtime(fiberSet)() - - yield* Effect.tryPromise({ - try: async () => { - await cfSocket.closed - throw closeError(1000) - }, - catch: (cause) => (Socket.isSocketError(cause) ? cause : closeError(1006)), - }).pipe(FiberSet.run(fiberSet)) - - yield* Effect.tryPromise({ - try: async () => { - while (true) { - const { done, value } = await reader.read() - if (done) { - throw closeError(1000) - } - const result = handler(value) - if (Effect.isEffect(result)) { - runFork(result) - } - } - }, - catch: (cause) => - Socket.isSocketError(cause) - ? cause - : new Socket.SocketError({ - reason: new Socket.SocketReadError({ cause }), - }), - }).pipe(FiberSet.run(fiberSet)) - - currentFiberSet = fiberSet - latch.openUnsafe() - if (opts?.onOpen) yield* opts.onOpen - - return yield* Effect.catchFilter( - FiberSet.join(fiberSet), - Socket.SocketCloseError.filterClean((code) => code === 1000 || code === 1006), - () => Effect.void, - ) - }), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - latch.closeUnsafe() - currentFiberSet = undefined - }), - ), - ) - - const run = <_, E, R>( - handler: (_: Uint8Array) => Effect.Effect<_, E, R> | void, - opts?: { readonly onOpen?: Effect.Effect | undefined }, - ): Effect.Effect => - runRaw((data) => (typeof data === "string" ? handler(encoder.encode(data)) : handler(data)), opts) - - const write = (chunk: Uint8Array | string | Socket.CloseEvent): Effect.Effect => - latch.whenOpen( - Effect.suspend(() => { - if (Socket.isCloseEvent(chunk)) { - return Deferred.fail(currentFiberSet!.deferred, closeError(chunk.code, chunk.reason)) - } - if (!writerRef) { - writerRef = cfSocket.writable.getWriter() - } - const data = typeof chunk === "string" ? encoder.encode(chunk) : chunk - return Effect.tryPromise({ - try: () => writerRef!.write(data), - catch: (cause) => - new Socket.SocketError({ - reason: new Socket.SocketWriteError({ cause }), - }), - }) - }), - ) - - const writer = Effect.acquireRelease(Effect.succeed(write), () => - Effect.promise(async () => { - if (writerRef) { - await writerRef.close().catch(() => {}) - } - }), - ) - - return Socket.make({ - run, - runRaw, - writer, - }) -} diff --git a/lib/effect-cloudflare/src/http-server.ts b/lib/effect-cloudflare/src/http-server.ts deleted file mode 100644 index 8017051cc..000000000 --- a/lib/effect-cloudflare/src/http-server.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Copied (with minimal adaptation) from alchemy-effect to stay API-compatible -// for a future migration: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/HttpServer.ts -// -// Keep names and signatures aligned with upstream. When alchemy-effect ships, -// swapping the `@maple/effect-cloudflare` import for `alchemy/Cloudflare` -// should be a mechanical find-and-replace. -import * as Cause from "effect/Cause" -import * as Effect from "effect/Effect" -import * as Option from "effect/Option" -import type { Scope } from "effect/Scope" -import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest" -import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" -import { type HttpEffect, safeHttpEffect } from "./http.ts" -import { Request as RequestService } from "./request.ts" - -/** - * Adapt a CF/Web `Request` into an Effect handler that provides - * `HttpServerRequest` + the raw `Request` service, runs the handler, and - * returns a `Response`. Any uncaught cause is converted into a 500 via - * `safeHttpEffect`. - */ -export const serveWebRequest = ( - webRequest: globalThis.Request, - handler: HttpEffect, - options: { - remoteAddress?: string - acceptWebSocket?: (socket: unknown) => void - } = {}, -): Effect.Effect> => - Effect.gen(function* () { - const request = HttpServerRequest.fromWeb(webRequest).modify({ - remoteAddress: Option.fromUndefinedOr(options.remoteAddress), - }) - - Object.defineProperty(request, "raw", { - get: () => - Object.assign(request.stream, { - raw: webRequest.body, - }), - }) - - const response = yield* safeHttpEffect(handler).pipe( - Effect.provideService(HttpServerRequest.HttpServerRequest, request), - Effect.provideService(RequestService, webRequest), - Effect.catchCause((cause) => { - const message = Option.match(Cause.findErrorOption(cause), { - onNone: () => "Internal Server Error", - onSome: (error: unknown) => - error instanceof Error && error.message ? error.message : "Internal Server Error", - }) - return Effect.succeed( - HttpServerResponse.text(message, { - status: 500, - statusText: message, - }), - ) - }), - ) - - return HttpServerResponse.toWeb(response, { - context: yield* Effect.context(), - }) - }) as Effect.Effect> diff --git a/lib/effect-cloudflare/src/http.ts b/lib/effect-cloudflare/src/http.ts deleted file mode 100644 index a98232bdd..000000000 --- a/lib/effect-cloudflare/src/http.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copied (with minimal adaptation) from alchemy-effect to stay API-compatible -// for a future migration: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Http.ts -// -// Keep names and signatures aligned with upstream. When alchemy-effect ships, -// swapping the `@maple/effect-cloudflare` import for `alchemy/Http` should be -// a mechanical find-and-replace. -import * as Cause from "effect/Cause" -import * as Effect from "effect/Effect" -import * as Option from "effect/Option" -import type { Scope } from "effect/Scope" -import type { HttpBodyError } from "effect/unstable/http/HttpBody" -import type { HttpServerError } from "effect/unstable/http/HttpServerError" -import type { HttpServerRequest } from "effect/unstable/http/HttpServerRequest" -import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" - -export type HttpEffect = Effect.Effect< - HttpServerResponse.HttpServerResponse, - HttpServerError | HttpBodyError, - HttpServerRequest | Scope | Req -> - -export const safeHttpEffect = (handler: HttpEffect) => - Effect.catchCause(handler, (cause) => { - const message = Option.match(Cause.findErrorOption(cause), { - onNone: () => "Internal Server Error", - onSome: (error) => error.message ?? "Internal Server Error", - }) - - return Effect.map(Effect.logError(message, { cause }), () => - HttpServerResponse.text(message, { - status: 500, - statusText: message, - }), - ) - }) - -// Request moved to ./request.ts to match upstream layout. Re-exported here so -// existing imports of `@maple/effect-cloudflare`'s Request continue to resolve. -export { Request } from "./request.ts" diff --git a/lib/effect-cloudflare/src/index.ts b/lib/effect-cloudflare/src/index.ts deleted file mode 100644 index e02a1b61d..000000000 --- a/lib/effect-cloudflare/src/index.ts +++ /dev/null @@ -1,139 +0,0 @@ -// --------------------------------------------------------------------------- -// Core HTTP primitives -// --------------------------------------------------------------------------- -export { type HttpEffect, Request, safeHttpEffect } from "./http.ts" -export { serveWebRequest } from "./http-server.ts" - -// --------------------------------------------------------------------------- -// Per-request / per-invocation runtime (maple-specific) -// --------------------------------------------------------------------------- -export { - buildRequestRuntime, - type ExecutionContextLike, - layerFromEnv, - runScheduledEffect, - withRequestRuntime, -} from "./runtime.ts" - -// --------------------------------------------------------------------------- -// Worker env + config (from alchemy-effect) -// --------------------------------------------------------------------------- -export { default as cloudflareWorkers } from "./cloudflare-workers.ts" -export { WorkerConfigProvider, WorkerConfigProviderLayer } from "./config-provider.ts" -export { WorkerEnvironment, layerFromEnvRecord } from "./worker-environment.ts" - -// --------------------------------------------------------------------------- -// Durable Objects -// --------------------------------------------------------------------------- -export { DurableObjectState, fromDurableObjectState } from "./durable-object-state.ts" -export { - type DurableObjectStorage, - type DurableObjectTransaction, - fromDurableObjectStorage, - fromDurableObjectTransaction, - type SqlCursor, - type SqlStorage, - type SqlStorageValue, -} from "./durable-object-storage.ts" -export { - type DurableObjectId, - type AlarmInvocationInfo, - type DurableObjectShape, - type DurableObjectStub, - type DurableObjectNamespaceHandle, - DurableObjectNamespace, - namespaceOf, - registerDurableObjectImpl, - getDurableObjectImpl, -} from "./durable-object-namespace.ts" -export { type DurableWebSocket, type RawWebSocket, fromWebSocket, upgrade } from "./websocket.ts" -export { - type ScheduledEvent, - scheduleEvent, - cancelEvent, - listEvents, - processScheduledEvents, -} from "./scheduled-events.ts" - -// --------------------------------------------------------------------------- -// Workflows -// --------------------------------------------------------------------------- -export { - type WorkflowBody, - type WorkflowHandle, - type WorkflowInstance, - type WorkflowInstanceStatus, - type WorkflowRunServices, - Workflow, - WorkflowEvent, - WorkflowStep, - registerWorkflowImpl, - sleep, - sleepUntil, - task, - workflowHandle, -} from "./workflow.ts" - -// --------------------------------------------------------------------------- -// RPC -// --------------------------------------------------------------------------- -export { - decodeRpcResult, - decodeRpcValue, - encodeRpcError, - ErrorTag, - fromRpcReadableStream, - fromRpcStreamEnvelope, - isRpcErrorEnvelope, - isRpcStreamEnvelope, - isRpcStreamErrorMarker, - makeDurableObjectBridge, - makeRpcStub, - makeWorkflowBridge, - RpcCallError, - RpcDecodeError, - RpcRemoteStreamError, - type RpcErrorEnvelope, - type RpcStreamEnvelope, - type RpcStreamErrorMarker, - StreamErrorTag, - StreamTag, - toRpcStream, -} from "./rpc.ts" - -// --------------------------------------------------------------------------- -// Storage bindings (runtime clients) -// --------------------------------------------------------------------------- -export { D1Connection, D1Database, type D1ConnectionClient, type D1DatabaseToken } from "./d1-connection.ts" -export { - KVNamespace, - KVNamespaceError, - type KVNamespaceClient, - type KVNamespaceToken, -} from "./kv-namespace.ts" -export { - R2Bucket, - R2Error, - type R2BucketClient, - type R2BucketToken, - type R2GetOptions, - type R2ListOptions, - type R2MultipartUpload, - type R2Object, - type R2ObjectBody, - type R2Objects, - type R2PutOptions, -} from "./r2-bucket.ts" - -// --------------------------------------------------------------------------- -// Outbound fetch (service bindings) -// --------------------------------------------------------------------------- -export { ServiceBinding, type ServiceBindingFetch, type ServiceBindingToken } from "./fetch.ts" -export { - type Fetcher, - type SocketAddress, - type SocketOptions, - fromCloudflareFetcher, - fromCloudflareSocket, - toCloudflareFetcher, -} from "./fetcher.ts" diff --git a/lib/effect-cloudflare/src/kv-namespace.ts b/lib/effect-cloudflare/src/kv-namespace.ts deleted file mode 100644 index 3549f1438..000000000 --- a/lib/effect-cloudflare/src/kv-namespace.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Simplified port of alchemy-effect's KV namespace binding: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/KV/KVNamespaceBinding.ts -// -// Upstream distinguishes a `KVNamespace` resource (IaC — create/delete -// namespaces via the Cloudflare API) from the `KVNamespaceBinding` service -// (runtime — wraps the binding). We keep the runtime half and replace the -// resource with a lightweight token: `KVNamespace("MY_KV")` records the env -// var name from wrangler.jsonc — nothing more. -// -// API surface matches upstream so `yield* KVNamespace.bind(MY_KV)` is a -// source-compatible call. -import type * as runtime from "@cloudflare/workers-types" -import * as Data from "effect/Data" -import * as Effect from "effect/Effect" -import { WorkerEnvironment } from "./worker-environment.ts" - -export class KVNamespaceError extends Data.TaggedError("@maple/effect-cloudflare/KVNamespaceError")<{ - message: string - cause: unknown -}> {} - -/** - * A reference to a KV namespace binding declared in wrangler.jsonc. - * `logicalId` is the env var name (e.g. `"MY_KV"`). - */ -export interface KVNamespaceToken { - readonly Type: "Cloudflare.KVNamespace" - readonly LogicalId: string -} - -const makeToken = (logicalId: string): KVNamespaceToken => ({ - Type: "Cloudflare.KVNamespace", - LogicalId: logicalId, -}) - -export interface KVNamespaceClient { - raw: Effect.Effect - get( - key: Key, - options?: Partial>, - ): Effect.Effect - get(key: Key, type: "text"): Effect.Effect - get( - key: Key, - type: "json", - ): Effect.Effect - get(key: Key, type: "arrayBuffer"): Effect.Effect - get(key: Key, type: "stream"): Effect.Effect - getWithMetadata( - key: Key, - options?: Partial>, - ): Effect.Effect< - runtime.KVNamespaceGetWithMetadataResult, - KVNamespaceError, - WorkerEnvironment - > - list( - options?: runtime.KVNamespaceListOptions, - ): Effect.Effect, KVNamespaceError, WorkerEnvironment> - put( - key: Key, - value: string | ArrayBuffer | ArrayBufferView | ReadableStream, - options?: runtime.KVNamespacePutOptions, - ): Effect.Effect - delete(key: Key): Effect.Effect -} - -const makeClient = (token: KVNamespaceToken): KVNamespaceClient => { - const env = WorkerEnvironment - const raw = env.pipe(Effect.map((e) => (e as Record)[token.LogicalId])) - const tryPromise = (fn: () => Promise): Effect.Effect => - Effect.tryPromise({ - try: fn, - catch: (cause) => - new KVNamespaceError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }) - - const use = ( - fn: (raw: runtime.KVNamespace) => Promise, - ): Effect.Effect => - raw.pipe(Effect.flatMap((r) => tryPromise(() => fn(r)))) - - return { - raw, - get: (...args: Parameters) => use((r) => (r.get as any)(...args)), - getWithMetadata: (...args: Parameters) => - use((r) => (r.getWithMetadata as any)(...args)), - put: (...args: Parameters) => use((r) => r.put(...args)), - list: (...args: Parameters) => use((r) => r.list(...args)), - delete: (...args: Parameters) => use((r) => r.delete(...args)), - } as unknown as KVNamespaceClient -} - -/** - * Declare a KV namespace binding by env var name. - * - * ```ts - * export const MY_KV = KVNamespace("MY_KV") - * - * // Then in worker handler: - * const kv = yield* KVNamespace.bind(MY_KV) - * yield* kv.put("key", "value") - * ``` - */ -export const KVNamespace = Object.assign((logicalId: string): KVNamespaceToken => makeToken(logicalId), { - bind: (token: KVNamespaceToken): Effect.Effect => - Effect.succeed(makeClient(token)), -}) diff --git a/lib/effect-cloudflare/src/r2-bucket.ts b/lib/effect-cloudflare/src/r2-bucket.ts deleted file mode 100644 index bb70ac74a..000000000 --- a/lib/effect-cloudflare/src/r2-bucket.ts +++ /dev/null @@ -1,196 +0,0 @@ -// Simplified port of alchemy-effect's R2 bucket binding: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/R2/R2BucketBinding.ts -// -// As with KV, we drop the resource half (bucket provisioning via the CF -// Account API) and keep the runtime half. `R2Bucket("MY_BUCKET")` is a -// lightweight token; `R2Bucket.bind(token)` yields the client. -import type * as runtime from "@cloudflare/workers-types" -import * as Data from "effect/Data" -import * as Effect from "effect/Effect" -import * as Stream from "effect/Stream" -import { WorkerEnvironment } from "./worker-environment.ts" - -export class R2Error extends Data.TaggedError("@maple/effect-cloudflare/R2Error")<{ - message: string - cause: unknown -}> {} - -export interface R2BucketToken { - readonly Type: "Cloudflare.R2Bucket" - readonly LogicalId: string -} - -const makeToken = (logicalId: string): R2BucketToken => ({ - Type: "Cloudflare.R2Bucket", - LogicalId: logicalId, -}) - -export interface R2Object extends Omit { - writeHttpMetadata(headers: Headers): Effect.Effect -} - -export interface R2ObjectBody extends R2Object { - get body(): Stream.Stream - get bodyUsed(): boolean - arrayBuffer(): Effect.Effect - bytes(): Effect.Effect - text(): Effect.Effect - json(): Effect.Effect - blob(): Effect.Effect -} - -export type R2GetOptions = runtime.R2GetOptions -export type R2PutOptions = runtime.R2PutOptions & { - contentLength?: number -} -export type R2ListOptions = runtime.R2ListOptions -export type R2Objects = { - objects: R2Object[] - delimitedPrefixes: string[] -} & ( - | { - truncated: true - cursor: string - } - | { - truncated: false - } -) - -export interface R2MultipartUpload { - raw: runtime.R2MultipartUpload - readonly key: string - readonly uploadId: string - uploadPart( - partNumber: number, - value: ReadableStream | ArrayBuffer | ArrayBufferView | string | runtime.Blob, - options?: runtime.R2UploadPartOptions, - ): Effect.Effect - abort(): Effect.Effect - complete(uploadedParts: runtime.R2UploadedPart[]): Effect.Effect -} - -export interface R2BucketClient { - raw: Effect.Effect - head(key: string): Effect.Effect - get(key: string, options?: R2GetOptions): Effect.Effect - put( - key: string, - value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | runtime.Blob, - options?: R2PutOptions, - ): Effect.Effect - delete(keys: string | string[]): Effect.Effect - list(options?: R2ListOptions): Effect.Effect - createMultipartUpload( - key: string, - options?: runtime.R2MultipartOptions, - ): Effect.Effect - resumeMultipartUpload( - key: string, - uploadId: string, - ): Effect.Effect -} - -const makeClient = (token: R2BucketToken): R2BucketClient => { - const env = WorkerEnvironment - const raw = env.pipe(Effect.map((e) => (e as Record)[token.LogicalId])) - - const tryPromise = (fn: () => Promise): Effect.Effect => - Effect.tryPromise({ - try: fn, - catch: (cause) => - new R2Error({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }) - - const use = ( - fn: (raw: runtime.R2Bucket) => Promise, - ): Effect.Effect => - raw.pipe(Effect.flatMap((r) => tryPromise(() => fn(r)))) - - const wrapR2Object = (object: runtime.R2Object): R2Object => ({ - ...object, - writeHttpMetadata: (headers: Headers) => - Effect.sync(() => object.writeHttpMetadata(headers as unknown as runtime.Headers)), - }) - - const wrapR2ObjectBody = (object: runtime.R2ObjectBody): R2ObjectBody => ({ - ...wrapR2Object(object), - body: Stream.fromReadableStream({ - evaluate: () => object.body as unknown as ReadableStream, - onError: (cause) => - new R2Error({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }), - bodyUsed: object.bodyUsed, - arrayBuffer: () => tryPromise(() => object.arrayBuffer()), - bytes: () => tryPromise(() => object.bytes()), - text: () => tryPromise(() => object.text()), - json: () => tryPromise(() => object.json()), - blob: () => tryPromise(() => object.blob()), - }) - - const wrapR2Objects = (objects: runtime.R2Objects): R2Objects => - ({ - objects: objects.objects.map(wrapR2Object), - delimitedPrefixes: objects.delimitedPrefixes, - ...("cursor" in objects ? { cursor: objects.cursor } : {}), - ...("truncated" in objects ? { truncated: objects.truncated } : {}), - }) as R2Objects - - const wrapR2MultipartUpload = (upload: runtime.R2MultipartUpload): R2MultipartUpload => ({ - ...upload, - raw: upload, - uploadId: upload.uploadId, - abort: () => tryPromise(() => upload.abort()), - complete: (uploadedParts: runtime.R2UploadedPart[]) => - tryPromise(() => upload.complete(uploadedParts)).pipe(Effect.map(wrapR2Object)), - uploadPart: ( - partNumber: number, - value: ReadableStream | ArrayBuffer | ArrayBufferView | string | runtime.Blob, - options?: runtime.R2UploadPartOptions, - ) => tryPromise(() => upload.uploadPart(partNumber, value as any, options)), - }) - - return { - raw, - head: (key: string) => - use((r) => r.head(key)).pipe(Effect.map((object) => (object ? wrapR2Object(object) : object))), - get: (key: string, options?: R2GetOptions) => - use((r) => r.get(key, options)).pipe( - Effect.map((object) => - object === null ? null : wrapR2ObjectBody(object as runtime.R2ObjectBody), - ), - ), - put: ( - key: string, - value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | runtime.Blob, - options?: R2PutOptions, - ) => - use((r) => r.put(key, value as any, options)).pipe( - Effect.map((object) => - object === null - ? (null as unknown as R2Object) - : wrapR2Object(object as runtime.R2Object), - ), - ) as Effect.Effect, - delete: (keys: string | string[]) => use((r) => r.delete(keys)), - list: (options?: R2ListOptions) => use((r) => r.list(options)).pipe(Effect.map(wrapR2Objects)), - createMultipartUpload: (key: string, options?: runtime.R2MultipartOptions) => - use((r) => r.createMultipartUpload(key, options)).pipe(Effect.map(wrapR2MultipartUpload)), - resumeMultipartUpload: (key: string, uploadId: string) => - raw.pipe( - Effect.map((r) => r.resumeMultipartUpload(key, uploadId)), - Effect.map(wrapR2MultipartUpload), - ), - } satisfies R2BucketClient as R2BucketClient -} - -export const R2Bucket = Object.assign((logicalId: string): R2BucketToken => makeToken(logicalId), { - bind: (token: R2BucketToken): Effect.Effect => - Effect.succeed(makeClient(token)), -}) diff --git a/lib/effect-cloudflare/src/request.ts b/lib/effect-cloudflare/src/request.ts deleted file mode 100644 index 5616e5338..000000000 --- a/lib/effect-cloudflare/src/request.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copied from alchemy-effect to stay API-compatible for a future migration: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/Request.ts -// -// Context service exposing the raw platform `Request`. Use this when a handler -// needs CF-specific fields (`cf`, non-standard headers) that the Effect -// `HttpServerRequest` abstracts away. -import * as Context from "effect/Context" - -export class Request extends Context.Service()("@maple/effect-cloudflare/Request") {} diff --git a/lib/effect-cloudflare/src/rpc.ts b/lib/effect-cloudflare/src/rpc.ts deleted file mode 100644 index 1828ad99e..000000000 --- a/lib/effect-cloudflare/src/rpc.ts +++ /dev/null @@ -1,418 +0,0 @@ -// Copied from alchemy-effect to stay API-compatible for a future migration: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/Rpc.ts -// -// RPC envelope encoding/decoding + bridge factories used by the Durable Object -// and Workflow modules. `DurableObjectShape` is redeclared locally (rather -// than imported from `./durable-object-namespace.ts`) to avoid a module cycle; -// it must stay in sync with the canonical definition there. -import type * as cf from "@cloudflare/workers-types" - -import * as Cause from "effect/Cause" -import * as Data from "effect/Data" -import * as Effect from "effect/Effect" -import * as Option from "effect/Option" -import * as Sink from "effect/Sink" -import * as Stream from "effect/Stream" -import type { HttpServerError } from "effect/unstable/http/HttpServerError" -import * as Socket from "effect/unstable/socket/Socket" -import type { HttpEffect } from "./http.ts" -import { fromCloudflareFetcher } from "./fetcher.ts" -import { serveWebRequest } from "./http-server.ts" -import { fromWebSocket } from "./websocket.ts" - -// Local redeclaration to avoid importing from durable-object-namespace.ts -// (which imports from this file). Must match the exported shape there. -interface DurableObjectShapeLocal { - fetch?: HttpEffect - alarm?: (alarmInfo?: cf.AlarmInvocationInfo) => Effect.Effect - webSocketMessage?: (socket: any, message: string | ArrayBuffer) => Effect.Effect - webSocketClose?: (socket: any, code: number, reason: string, wasClean: boolean) => Effect.Effect -} - -export const StreamTag = "~alchemy/rpc/stream" -export const ErrorTag = "~alchemy/rpc/error" -export const StreamErrorTag = "~alchemy/rpc/stream-error" - -type StreamEncoding = "bytes" | "jsonl" - -export type RpcStreamEnvelope = { - _tag: typeof StreamTag - encoding: StreamEncoding - body: ReadableStream -} - -export class RpcDecodeError extends Data.TaggedError("@maple/effect-cloudflare/RpcDecodeError")<{ - readonly cause: unknown -}> { - override get message() { - return this.cause instanceof Error ? this.cause.message : String(this.cause) - } -} - -export class RpcCallError extends Data.TaggedError("@maple/effect-cloudflare/RpcCallError")<{ - readonly method: string - readonly cause: unknown -}> { - override get message() { - return `RPC call to "${this.method}" failed: ${ - this.cause instanceof Error ? this.cause.message : String(this.cause) - }` - } -} - -export class RpcRemoteError extends Data.TaggedError("@maple/effect-cloudflare/RpcRemoteError")<{ - readonly error: unknown -}> {} - -export class RpcRemoteStreamError extends Data.TaggedError("@maple/effect-cloudflare/RpcRemoteStreamError")<{ - readonly error: unknown -}> {} - -export type RpcErrorEnvelope = { - _tag: typeof ErrorTag - error: unknown -} - -export type RpcStreamErrorMarker = { - _tag: typeof StreamErrorTag - error: unknown -} - -export const isRpcStreamErrorMarker = (value: unknown): value is RpcStreamErrorMarker => - typeof value === "object" && - value !== null && - "_tag" in value && - value._tag === StreamErrorTag && - "error" in value - -export const isRpcErrorEnvelope = (value: unknown): value is RpcErrorEnvelope => - typeof value === "object" && - value !== null && - "_tag" in value && - value._tag === ErrorTag && - "error" in value - -export const encodeRpcError = (error: unknown): unknown => { - if (error === null || error === undefined) return error - if (typeof error !== "object") return error - - const obj = error as Record - if ("_tag" in obj && typeof obj._tag === "string") { - const out: Record = {} - for (const key of Object.keys(obj)) { - out[key] = obj[key] - } - if (error instanceof Error && !("message" in out)) { - out.message = (error as Error).message - } - return out - } - - if (error instanceof Error) { - return { name: error.name, message: error.message, stack: error.stack } - } - - return error -} - -export const isRpcStreamEnvelope = (value: unknown): value is RpcStreamEnvelope => - typeof value === "object" && - value !== null && - "_tag" in value && - value._tag === StreamTag && - "encoding" in value && - (value.encoding === "bytes" || value.encoding === "jsonl") && - "body" in value && - value.body instanceof ReadableStream - -export const fromRpcReadableStream = ( - body: ReadableStream, - encoding: StreamEncoding, -): Stream.Stream => { - const stream = Stream.fromReadableStream({ - evaluate: () => body, - onError: (cause) => - Socket.isSocketError(cause) - ? cause - : new Socket.SocketError({ - reason: new Socket.SocketReadError({ cause }), - }), - }) - - if (encoding === "bytes") { - return stream - } - - return stream.pipe( - Stream.decodeText, - Stream.splitLines, - Stream.filter((line) => line.length > 0), - Stream.mapEffect((line) => - Effect.try({ - try: () => JSON.parse(line), - catch: (cause) => new RpcDecodeError({ cause }), - }), - ), - Stream.flatMap((value) => - isRpcStreamErrorMarker(value) - ? Stream.fail(new RpcRemoteStreamError({ error: value.error })) - : Stream.succeed(value), - ), - ) -} - -export const fromRpcStreamEnvelope = ( - envelope: RpcStreamEnvelope, -): Stream.Stream => - fromRpcReadableStream(envelope.body, envelope.encoding) - -export const decodeRpcValue = (value: unknown) => { - if (isRpcStreamEnvelope(value)) { - return fromRpcReadableStream(value.body, value.encoding) - } - - if (value instanceof ReadableStream) { - return fromRpcReadableStream(value, "bytes") - } - - return value -} - -export const decodeRpcResult = (value: unknown): Effect.Effect => { - if (isRpcErrorEnvelope(value)) { - return Effect.fail(new RpcRemoteError({ error: value.error })) - } - return Effect.succeed(decodeRpcValue(value)) -} - -export const makeRpcStub = (stub: any): Shape => { - const fetcher = fromCloudflareFetcher(stub) - - return new Proxy(fetcher, { - get: (target: any, prop) => - prop in target - ? target[prop] - : (...args: any[]) => - Effect.tryPromise({ - try: () => stub[prop](...args), - catch: (cause) => new RpcCallError({ method: String(prop), cause }), - }).pipe(Effect.flatMap(decodeRpcResult)), - }) as Shape -} - -export const makeDurableObjectBridge = - ( - DurableObject: abstract new (state: unknown, env: unknown) => cf.DurableObject, - getExport: ( - name: string, - ) => Promise<(state: unknown, env: unknown) => Effect.Effect>>, - ) => - (className: string) => - class DurableObjectBridge extends DurableObject { - readonly object: Promise - - async fetch(request: cf.Request): Promise { - const methods = await this.object - if (methods.fetch) { - const fetch = methods.fetch as HttpEffect - const response = await serveWebRequest( - request as unknown as globalThis.Request, - fetch, - ).pipe(Effect.runPromise) - return response as any - } else { - return new Response("Method not found", { status: 404 }) as any - } - } - - async alarm(alarmInfo?: cf.AlarmInvocationInfo) { - const methods = await this.object - if (methods.alarm) { - await Effect.runPromise(methods.alarm(alarmInfo)) - } - } - - async webSocketMessage(ws: cf.WebSocket, message: string | ArrayBuffer) { - const methods = await this.object - if (methods.webSocketMessage) { - const socket = fromWebSocket(ws) - const value = methods.webSocketMessage(socket, message) - if (Effect.isEffect(value)) { - await Effect.runPromise(value as Effect.Effect) - } - } - } - - async webSocketClose(ws: cf.WebSocket, code: number, reason: string, wasClean: boolean) { - const methods = await this.object - if (methods.webSocketClose) { - const socket = fromWebSocket(ws) - const value = methods.webSocketClose(socket, code, reason, wasClean) - if (Effect.isEffect(value)) { - await Effect.runPromise(value as Effect.Effect) - } - } - } - - constructor( - state: { - blockConcurrencyWhile: (fn: () => Promise) => Promise - }, - env: unknown, - ) { - super(state, env) - - this.object = state.blockConcurrencyWhile(async () => { - const makeDurableObject = await getExport(className) - return await Effect.runPromise(makeDurableObject(state, env)) - }) as Promise - - return new Proxy(this, { - get: (target: any, prop) => - prop in target - ? target[prop] - : async (...args: unknown[]) => { - const methods = await this.object - const method = methods[prop as keyof DurableObjectShapeLocal] as any - const value = method(...args) - if (Effect.isEffect(value)) { - const exit = await Effect.runPromiseExit( - value as Effect.Effect, - ) - if (exit._tag === "Success") { - if (Stream.isStream(exit.value)) { - return await Effect.runPromise( - toRpcStream( - exit.value, - ) as Effect.Effect, - ) - } - return exit.value - } - const failReason = exit.cause.reasons.find(Cause.isFailReason) - if (failReason) { - return { - _tag: ErrorTag, - error: encodeRpcError(failReason.error), - } satisfies RpcErrorEnvelope - } - const dieReason = exit.cause.reasons.find(Cause.isDieReason) - throw ( - dieReason?.defect ?? - new Error("RPC method failed with an unexpected cause") - ) - } - return value - }, - }) - } - } - -export const makeWorkflowBridge = - ( - WorkflowEntrypoint: abstract new ( - ctx: unknown, - env: unknown, - ) => { run(event: any, step: any): Promise }, - getExport: ( - name: string, - ) => Promise<(env: unknown) => Effect.Effect>>, - ) => - (className: string) => - class WorkflowBridge extends WorkflowEntrypoint { - readonly body: Promise> - readonly env: unknown - - constructor(ctx: unknown, env: unknown) { - super(ctx, env) - this.env = env - this.body = getExport(className).then((factory) => Effect.runPromise(factory(env))) - } - - async run(event: any, step: any): Promise { - const body = await this.body - const { WorkflowEvent, WorkflowStep } = await import("./workflow.ts") - return Effect.runPromise( - body.pipe( - Effect.provideService(WorkflowEvent, wrapWorkflowEvent(event)), - Effect.provideService(WorkflowStep, wrapWorkflowStep(step)), - ) as Effect.Effect, - ) - } - } - -const wrapWorkflowEvent = (event: any): { payload: unknown; timestamp: Date; instanceId: string } => ({ - payload: event.payload, - timestamp: event.timestamp instanceof Date ? event.timestamp : new Date(event.timestamp), - instanceId: event.instanceId ?? "", -}) - -const wrapWorkflowStep = (step: any) => ({ - do: (name: string, effect: Effect.Effect): Effect.Effect => - Effect.tryPromise(() => step.do(name, () => Effect.runPromise(effect)) as Promise), - sleep: (name: string, duration: string | number): Effect.Effect => - Effect.tryPromise(() => step.sleep(name, duration)), - sleepUntil: (name: string, timestamp: Date | number): Effect.Effect => - Effect.tryPromise(() => - step.sleepUntil(name, timestamp instanceof Date ? timestamp.toISOString() : timestamp), - ), -}) - -const encodeStreamErrorMarker = (cause: Cause.Cause): string => { - const failReason = cause.reasons.find(Cause.isFailReason) - const error = failReason ? encodeRpcError(failReason.error) : undefined - return ( - JSON.stringify({ - _tag: StreamErrorTag, - error, - } satisfies RpcStreamErrorMarker) + "\n" - ) -} - -const appendStreamErrors = (s: Stream.Stream) => - s.pipe(Stream.catchCause((cause) => Stream.succeed(encodeStreamErrorMarker(cause)))) - -export const toRpcStream = (stream: Stream.Stream) => - Effect.scoped( - Effect.gen(function* () { - const [head, rest] = yield* Stream.peel(stream, Sink.head()) - - if (Option.isSome(head) && head.value instanceof Uint8Array) { - return { - _tag: StreamTag, - encoding: "bytes", - body: Stream.toReadableStream(rest.pipe(Stream.prepend([head.value]))), - } satisfies RpcStreamEnvelope - } - - const body = Option.isSome(head) ? rest.pipe(Stream.prepend([head.value])) : rest - - return { - _tag: StreamTag, - encoding: "jsonl", - body: Stream.toReadableStream( - appendStreamErrors(body.pipe(Stream.map((value) => JSON.stringify(value) + "\n"))).pipe( - Stream.encodeText, - ), - ), - } satisfies RpcStreamEnvelope - }), - ).pipe( - Effect.catchCause((cause) => { - const failReason = cause.reasons.find(Cause.isFailReason) - if (failReason) { - return Effect.succeed({ - _tag: StreamTag, - encoding: "jsonl", - body: Stream.toReadableStream( - Stream.succeed(encodeStreamErrorMarker(cause)).pipe(Stream.encodeText), - ), - } satisfies RpcStreamEnvelope) - } - return Effect.die(Cause.squash(cause)) - }), - ) - -// The HttpServerError import is kept for consumers that need it in type -// positions (e.g. DurableObjectStub signatures). Export re-exports below. -export type { HttpServerError } diff --git a/lib/effect-cloudflare/src/runtime.ts b/lib/effect-cloudflare/src/runtime.ts deleted file mode 100644 index 9a38e7a62..000000000 --- a/lib/effect-cloudflare/src/runtime.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { Context, Effect } from "effect" -import { ConfigProvider, Layer, ManagedRuntime } from "effect" - -/** - * Minimal shape of CF `ExecutionContext.waitUntil`. Accept any structurally - * compatible object so callers don't need to depend on - * `@cloudflare/workers-types` transitively. - */ -export interface ExecutionContextLike { - waitUntil(promise: Promise): void -} - -/** - * Yield one macrotask so Effect's scheduler can drain tasks queued via - * `scheduleTask(fn, 0)`. Specifically, `HttpMiddleware.tracer` ends the HTTP - * root Server span through this path: - * - * fiber.currentDispatcher.scheduleTask(() => span.end(endTime, exit), 0) - * - * `scheduleTask(fn, 0)` is dispatched via `setImmediate`, which falls back to - * `setTimeout(fn, 0)` on CF Workers — a macrotask. If we dispose the - * per-request runtime the moment the response promise resolves, the microtask - * firing dispose wins the race against that scheduled `span.end`, the root - * span never lands in the OTLP buffer, and every request appears parentless - * in Tinybird. Awaiting one `setTimeout(0)` drains the dispatcher so - * `span.end` runs before we close the scope. - */ -const drainScheduler = () => new Promise((r) => setTimeout(r, 0)) - -/** - * Low-level primitive: build a fresh per-request `ManagedRuntime` from a - * layer, return its services plus a `flush()` that drains the Effect - * scheduler and then closes the scope. Prefer `withRequestRuntime` or - * `runScheduledEffect` — they make the flush contract structural. - * - * `flush()` MUST be awaited inside `ctx.waitUntil` (or equivalent). Skipping - * it leaks forked fibers. - */ -export const buildRequestRuntime = ( - layer: Layer.Layer, -): { - readonly services: Promise> - readonly flush: () => Promise -} => { - const runtime = ManagedRuntime.make(layer) - const services = runtime.context().catch((err) => { - console.error("[effect-cloudflare] runtime build failed:", err) - throw err - }) - const flush = async () => { - await drainScheduler() - try { - await runtime.dispose() - } catch (err) { - console.error("[effect-cloudflare] runtime flush failed:", err) - } - } - return { services, flush } -} - -/** - * Higher-order wrapper for CF Worker `fetch` handlers. Builds a fresh - * per-request runtime from `makeLayer(env)`, injects the resolved services - * into `handler`, and schedules `flush()` via `ctx.waitUntil` so the scope - * is always closed after the response resolves. - * - * For OTLP/MapleCloudflareSDK telemetry, prefer including the SDK's - * `telemetry.layer` directly in the layer composition that runs your routes - * (e.g. inside `HttpRouter.toWebHandler`'s layer arg) and call - * `ctx.waitUntil(telemetry.flush(env))` yourself — that way the Tracer - * reference lives in the same runtime as your handler code. - */ -export const withRequestRuntime = , Ctx extends ExecutionContextLike>( - makeLayer: (env: Env) => Layer.Layer, - handler: (request: Request, services: Context.Context, env: Env, ctx: Ctx) => Promise, -): ((request: Request, env: Env, ctx: Ctx) => Promise) => { - return async (request, env, ctx) => { - const { services, flush } = buildRequestRuntime(makeLayer(env)) - const resolvedServices = await services - const response = handler(request, resolvedServices, env, ctx) - ctx.waitUntil( - (async () => { - try { - await response - } catch { - // Swallow handler errors — the handler's own error path is - // responsible for surfacing them. - } - await flush() - })(), - ) - return response - } -} - -/** - * Run a single Effect program to completion under a fresh per-invocation - * runtime. Intended for CF Worker `scheduled` / `queue` / workflow handlers. - * - * Disposes the runtime after the program settles (success or failure), - * draining the scheduler first and registering the whole thing with - * `ctx.waitUntil`. Rethrows so the CF runtime reports the failure. - */ -export const runScheduledEffect = ( - layer: Layer.Layer, - program: Effect.Effect, - ctx: ExecutionContextLike, -): Promise => { - const runtime = ManagedRuntime.make(layer) - const done = runtime.runPromise(program).finally(async () => { - await drainScheduler() - await runtime.dispose().catch((err) => { - console.error("[effect-cloudflare] scheduled runtime dispose failed:", err) - }) - }) - ctx.waitUntil(done.catch(() => undefined)) - return done -} - -/** - * Convenience: wrap `env` as an Effect `ConfigProvider` layer. Useful when - * composing telemetry / config-reading layers inside `makeLayer`. - */ -export const layerFromEnv = (env: Record): Layer.Layer => - ConfigProvider.layer(ConfigProvider.fromUnknown(env)) diff --git a/lib/effect-cloudflare/src/scheduled-events.ts b/lib/effect-cloudflare/src/scheduled-events.ts deleted file mode 100644 index 4753591ff..000000000 --- a/lib/effect-cloudflare/src/scheduled-events.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Copied verbatim from alchemy-effect to stay API-compatible for a future -// migration: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/ScheduledEvents.ts -// -// SQLite-backed cron/timer for Durable Objects. Use inside a DO alarm handler: -// alarm: () => Effect.gen(function* () { -// const fired = yield* processScheduledEvents -// for (const event of fired) { ... } -// }) -import * as Clock from "effect/Clock" -import * as Effect from "effect/Effect" -import * as Option from "effect/Option" -import * as Stream from "effect/Stream" -import { DurableObjectState } from "./durable-object-state.ts" -import type { SqlStorageValue } from "./durable-object-storage.ts" - -const INIT_TABLE_SQL = ` -CREATE TABLE IF NOT EXISTS alchemy_scheduled_events ( - id TEXT PRIMARY KEY, - run_at INTEGER NOT NULL, - repeat_ms INTEGER, - payload TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_alchemy_scheduled_events_run_at - ON alchemy_scheduled_events (run_at); -` - -const ensureTable = Effect.gen(function* () { - const ctx = yield* DurableObjectState - yield* ctx.storage.sql.exec(INIT_TABLE_SQL) -}) - -export interface ScheduledEvent { - id: string - runAt: Date - repeatMs?: number - payload: unknown -} - -interface EventRow extends Record { - id: string - run_at: number - repeat_ms: number | null - payload: string -} - -const toScheduledEvent = (row: EventRow): ScheduledEvent => ({ - id: row.id, - runAt: new Date(row.run_at), - repeatMs: row.repeat_ms ?? undefined, - payload: JSON.parse(row.payload) as unknown, -}) - -export const scheduleEvent = Effect.fnUntraced(function* ( - id: string, - runAt: Date, - payload: unknown, - repeatMs?: number, -) { - yield* ensureTable - const ctx = yield* DurableObjectState - - yield* ctx.storage.sql.exec( - `INSERT OR REPLACE INTO alchemy_scheduled_events (id, run_at, repeat_ms, payload) - VALUES (?, ?, ?, ?)`, - id, - runAt.getTime(), - repeatMs ?? null, - JSON.stringify(payload), - ) - - yield* reconcileAlarm -}) - -export const cancelEvent = Effect.fnUntraced(function* (id: string) { - yield* ensureTable - const ctx = yield* DurableObjectState - - yield* ctx.storage.sql.exec(`DELETE FROM alchemy_scheduled_events WHERE id = ?`, id) - - yield* reconcileAlarm -}) - -export const listEvents: Effect.Effect = Effect.gen( - function* () { - yield* ensureTable - const ctx = yield* DurableObjectState - - const cursor = yield* ctx.storage.sql.exec( - `SELECT id, run_at, repeat_ms, payload FROM alchemy_scheduled_events ORDER BY run_at ASC`, - ) - - return yield* cursor.pipe(Stream.map(toScheduledEvent), Stream.runCollect) - }, -) - -export const processScheduledEvents: Effect.Effect = Effect.gen( - function* () { - yield* ensureTable - const ctx = yield* DurableObjectState - const now = yield* Clock.currentTimeMillis - - const cursor = yield* ctx.storage.sql.exec( - `SELECT id, run_at, repeat_ms, payload FROM alchemy_scheduled_events WHERE run_at <= ? ORDER BY run_at ASC`, - now, - ) - - const fired = yield* cursor.pipe( - Stream.mapEffect((row) => - (row.repeat_ms != null - ? ctx.storage.sql.exec( - `UPDATE alchemy_scheduled_events SET run_at = ? WHERE id = ?`, - now + row.repeat_ms, - row.id, - ) - : ctx.storage.sql.exec(`DELETE FROM alchemy_scheduled_events WHERE id = ?`, row.id) - ).pipe(Effect.as(toScheduledEvent(row))), - ), - Stream.runCollect, - ) - - yield* reconcileAlarm - return fired - }, -) - -const reconcileAlarm: Effect.Effect = Effect.gen(function* () { - const ctx = yield* DurableObjectState - - const next = yield* (yield* ctx.storage.sql.exec<{ - run_at: number - }>(`SELECT run_at FROM alchemy_scheduled_events ORDER BY run_at ASC LIMIT 1`)).pipe( - Stream.take(1), - Stream.runHead, - ) - - if (Option.isSome(next)) { - yield* ctx.storage.setAlarm(next.value.run_at) - } else { - yield* ctx.storage.deleteAlarm() - } -}) diff --git a/lib/effect-cloudflare/src/websocket.ts b/lib/effect-cloudflare/src/websocket.ts deleted file mode 100644 index d3c2e85a2..000000000 --- a/lib/effect-cloudflare/src/websocket.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Copied verbatim from alchemy-effect to stay API-compatible for a future -// migration: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/WebSocket.ts -// -// Effect-native wrapper around Cloudflare WebSocket + `upgrade()` helper for -// accepting a WebSocket inside a Durable Object fetch handler. -import type * as cf from "@cloudflare/workers-types" -import * as Effect from "effect/Effect" -import * as HttpBody from "effect/unstable/http/HttpBody" -import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" -import { DurableObjectState } from "./durable-object-state.ts" - -export type RawWebSocket = cf.WebSocket - -export interface DurableWebSocket { - readonly ws: RawWebSocket - send(data: string | Uint8Array): Effect.Effect - close(code: number, reason: string): Effect.Effect - serializeAttachment(value: T): void - deserializeAttachment(): T | null -} - -export const fromWebSocket = (ws: RawWebSocket): DurableWebSocket => ({ - ws, - send: (data) => Effect.sync(() => ws.send(data as any)), - close: (code, reason) => Effect.sync(() => ws.close(code, reason)), - serializeAttachment: (value) => ws.serializeAttachment(value), - deserializeAttachment: () => ws.deserializeAttachment() as any, -}) - -export const upgrade = Effect.fnUntraced(function* () { - const _Response = Response as any as typeof cf.Response - const ctx = yield* DurableObjectState - // @ts-expect-error — WebSocketPair is a Worker global - const [client, server] = new WebSocketPair() - const serverSocket = fromWebSocket(server) - yield* ctx.acceptWebSocket(serverSocket) - const rawResponse = new _Response(null, { - status: 101, - webSocket: client, - }) - const effectResponse = HttpServerResponse.setBody( - HttpServerResponse.empty({ status: 101 }), - HttpBody.raw(rawResponse), - ) - return [effectResponse, serverSocket] as const -}) diff --git a/lib/effect-cloudflare/src/worker-environment.ts b/lib/effect-cloudflare/src/worker-environment.ts deleted file mode 100644 index 73eaf4ccb..000000000 --- a/lib/effect-cloudflare/src/worker-environment.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Inspired by alchemy-effect's `WorkerEnvironment` service (defined inside -// `packages/alchemy/src/Cloudflare/Workers/Worker.ts`). Copied here with the -// IaC machinery removed — we only need the runtime surface: a Context.Service -// holding the worker env, plus a Layer that reads it from -// `cloudflare:workers`. -// -// For future migration to alchemy-effect: the service tag name -// ("Cloudflare.WorkerEnvironment") matches upstream, so call sites that -// `yield* WorkerEnvironment` are source-compatible. -import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import cloudflareWorkers from "./cloudflare-workers.ts" - -export class WorkerEnvironment extends Context.Service>()( - "Cloudflare.Workers.WorkerEnvironment", -) { - /** - * Read the worker env from the `cloudflare:workers` global import. This is - * the canonical way to source bindings at runtime. Outside a Worker isolate, - * the dynamic import falls back to `{}` (see `cloudflare-workers.ts`), so - * this layer is safe to provide even in test/local contexts — bindings will - * simply be undefined. - */ - static readonly layer: Layer.Layer = Layer.effect( - this, - cloudflareWorkers.pipe(Effect.map(({ env }) => env as Record)), - ) -} - -/** - * Alternative to `WorkerEnvironment.layer` for cases where the caller already - * has the env in hand (e.g. inside a Durable Object / Workflow constructor, - * where CF passes env explicitly and the `cloudflare:workers` global env - * may not reflect it). - */ -export const layerFromEnvRecord = (env: Record): Layer.Layer => - Layer.succeed(WorkerEnvironment, env) diff --git a/lib/effect-cloudflare/src/workflow.ts b/lib/effect-cloudflare/src/workflow.ts deleted file mode 100644 index 8953da565..000000000 --- a/lib/effect-cloudflare/src/workflow.ts +++ /dev/null @@ -1,217 +0,0 @@ -// Simplified port of alchemy-effect's Workflow factory: -// https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/Workflow.ts -// -// Upstream couples the Workflow class to the alchemy IaC Worker resource -// (automatic binding registration + `WorkflowResource` provider for the -// Cloudflare Workflows API). This port drops the IaC half and keeps the -// runtime half: -// -// export class TinybirdSyncWorkflow extends Workflow<{ orgId: string }>()( -// "TinybirdSyncWorkflow", -// Effect.gen(function* () { -// return Effect.gen(function* () { -// const event = yield* WorkflowEvent -// yield* task("sync", syncEffect(event.payload)) -// yield* sleep("cooldown", "5 seconds") -// }) -// }), -// ) {} -// -// For instance creation/lookup from worker code, use `workflowHandle(name)`. -// -// IMPORTANT: This module statically imports from `cloudflare:workers`; it can -// only be loaded inside a Cloudflare Worker isolate. -import { WorkflowEntrypoint } from "cloudflare:workers" -import * as Context from "effect/Context" -import * as Effect from "effect/Effect" -import { makeWorkflowBridge } from "./rpc.ts" -import { WorkerEnvironment } from "./worker-environment.ts" - -// --------------------------------------------------------------------------- -// Runtime services provided by the bridge while a workflow executes -// --------------------------------------------------------------------------- - -export class WorkflowEvent extends Context.Service< - WorkflowEvent, - { - payload: unknown - timestamp: Date - instanceId: string - } ->()("Cloudflare.WorkflowEvent") {} - -export class WorkflowStep extends Context.Service< - WorkflowStep, - { - do(name: string, effect: Effect.Effect): Effect.Effect - sleep(name: string, duration: string | number): Effect.Effect - sleepUntil(name: string, timestamp: Date | number): Effect.Effect - } ->()("Cloudflare.WorkflowStep") {} - -// --------------------------------------------------------------------------- -// Step primitives — thin wrappers over WorkflowStep methods -// --------------------------------------------------------------------------- - -export const task = (name: string, effect: Effect.Effect): Effect.Effect => - WorkflowStep.pipe(Effect.flatMap((step) => step.do(name, effect))) - -export const sleep = (name: string, duration: string | number): Effect.Effect => - WorkflowStep.pipe( - Effect.flatMap((step) => step.sleep(name, duration)), - Effect.orDie, - ) - -export const sleepUntil = ( - name: string, - timestamp: Date | number, -): Effect.Effect => - WorkflowStep.pipe( - Effect.flatMap((step) => step.sleepUntil(name, timestamp)), - Effect.orDie, - ) - -export type WorkflowRunServices = WorkflowEvent | WorkflowStep - -export type WorkflowBody = Effect.Effect - -// --------------------------------------------------------------------------- -// Handles returned to worker code for starting / inspecting instances -// --------------------------------------------------------------------------- - -export interface WorkflowHandle { - readonly name: string - create(params?: unknown): Effect.Effect - get(instanceId: string): Effect.Effect -} - -export interface WorkflowInstance { - readonly id: string - status(): Effect.Effect - pause(): Effect.Effect - resume(): Effect.Effect - terminate(): Effect.Effect -} - -export interface WorkflowInstanceStatus { - status: string - output?: unknown - error?: { name: string; message: string } | null -} - -// --------------------------------------------------------------------------- -// Module-level impl registry (mirrors the DO namespace registry) -// --------------------------------------------------------------------------- - -type WorkflowImpl = Effect.Effect - -const implRegistry = new Map() - -export const registerWorkflowImpl = (name: string, impl: WorkflowImpl): void => { - implRegistry.set(name, impl) -} - -// --------------------------------------------------------------------------- -// Bridge base class -// --------------------------------------------------------------------------- - -const Bridge = makeWorkflowBridge( - WorkflowEntrypoint as unknown as abstract new ( - ctx: unknown, - env: unknown, - ) => { run(event: any, step: any): Promise }, - async (name: string) => { - const impl = implRegistry.get(name) - if (!impl) { - throw new Error( - `Workflow impl for '${name}' is not registered. Ensure the class module is loaded before CF instantiates the workflow.`, - ) - } - return (env: unknown) => - impl.pipe( - Effect.provideService(WorkerEnvironment, env as Record), - ) as Effect.Effect> - }, -) - -// --------------------------------------------------------------------------- -// Public factory -// --------------------------------------------------------------------------- - -/** - * Define a Cloudflare Workflow class with an Effect-native body. - * - * ```ts - * export class TinybirdSyncWorkflow extends Workflow<{ orgId: string }>()( - * "TinybirdSyncWorkflow", - * Effect.gen(function* () { - * // Phase 1 — shared init - * return Effect.gen(function* () { - * // Phase 2 — workflow body (durable steps) - * const event = yield* WorkflowEvent - * yield* task("sync", doSync(event.payload)) - * yield* sleep("cooldown", "5 seconds") - * }) - * }), - * ) {} - * ``` - * - * Export the class as the `class_name` of a `workflows` binding in - * wrangler.jsonc. Use `workflowHandle(name)` to resolve the binding - * at runtime and start / inspect instances. - */ -export const Workflow = <_Self = unknown>() => { - return ( - name: string, - impl: Effect.Effect, never, InitReq>, - ) => { - registerWorkflowImpl(name, impl as unknown as WorkflowImpl) - return Bridge(name) as unknown as new ( - ctx: unknown, - env: unknown, - ) => { run(event: any, step: any): Promise } - } -} - -/** - * Resolve a workflow handle from the worker env for creating / inspecting - * workflow instances. - */ -export const workflowHandle = Effect.fn("workflowHandle")(function* ( - classOrName: { name: string } | string, -) { - const env = yield* WorkerEnvironment - const name = typeof classOrName === "string" ? classOrName : classOrName.name - const binding = env[name] as any - if (!binding || typeof binding.create !== "function") { - return yield* Effect.die( - new Error(`Worker env has no Workflow binding named '${name}'. Check wrangler.jsonc.`), - ) - } - return { - name, - create: (params?: unknown) => - Effect.tryPromise(() => binding.create({ params })).pipe( - Effect.map(wrapInstance), - Effect.orDie, - ), - get: (instanceId: string) => - Effect.tryPromise(() => binding.get(instanceId)).pipe(Effect.map(wrapInstance), Effect.orDie), - } satisfies WorkflowHandle -}) - -const wrapInstance = (raw: any): WorkflowInstance => ({ - id: raw.id, - status: () => - Effect.tryPromise(() => raw.status()).pipe( - Effect.map((s: any) => ({ - status: s.status as string, - output: s.output, - error: s.error, - })), - Effect.orDie, - ), - pause: () => Effect.tryPromise(() => raw.pause()).pipe(Effect.orDie), - resume: () => Effect.tryPromise(() => raw.resume()).pipe(Effect.orDie), - terminate: () => Effect.tryPromise(() => raw.terminate()).pipe(Effect.orDie), -}) diff --git a/lib/effect-sdk/src/server/layer.ts b/lib/effect-sdk/src/server/layer.ts index f9cf14d5b..6f3ecf488 100644 --- a/lib/effect-sdk/src/server/layer.ts +++ b/lib/effect-sdk/src/server/layer.ts @@ -44,10 +44,10 @@ export interface MapleConfig { * when no endpoint is configured, making it safe for local development. * * For Cloudflare Workers, prefer `@maple-dev/effect-sdk/cloudflare`'s `make()` - * — it has no background fiber and exposes an explicit `flush` Effect that - * `@maple/effect-cloudflare`'s `withRequestRuntime` schedules in - * `ctx.waitUntil`. This layer's `Otlp.layerJson` background-export fiber - * doesn't tick on Workers between invocations. + * — it has no background fiber and exposes an explicit `flush` that the worker + * schedules in `ctx.waitUntil` (via `Worker.WorkerContext.waitUntil` under + * `@maple/effect-cf`'s `Worker.make`). This layer's `Otlp.layerJson` + * background-export fiber doesn't tick on Workers between invocations. * * @example * ```typescript diff --git a/package.json b/package.json index 6580a5da2..14ecd1cc3 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "dependencies": {}, "devDependencies": { - "@cloudflare/workers-types": "4.20260414.1", + "@cloudflare/workers-types": "4.20260525.1", "@effect/vitest": "4.0.0-beta.70", "@maple/db": "workspace:*", "@maple/infra": "workspace:*", @@ -43,6 +43,9 @@ "typescript": "catalog:tooling" }, "packageManager": "bun@1.3.11", + "overrides": { + "@cloudflare/workers-types": "4.20260525.1" + }, "catalog": { "vite": "^8.0.3", "vitest": "^4.1.2" @@ -56,6 +59,8 @@ "@effect/language-service": "^0.85.1", "@effect/opentelemetry": "4.0.0-beta.70", "@effect/platform-bun": "4.0.0-beta.70", + "@effect/sql-d1": "4.0.0-beta.70", + "@effect/sql-pg": "4.0.0-beta.70", "effect": "4.0.0-beta.70" }, "react": {