From 56b9e3f712818b72235f97af665ad6445d19de50 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:36:14 -0500 Subject: [PATCH 1/5] feat(cloudflare): node bindings for kv, d1, r2, and queue --- alchemy/src/cloudflare/bucket-object.ts | 1 + alchemy/src/cloudflare/bucket.ts | 195 +++--------------- alchemy/src/cloudflare/d1-database.ts | 18 +- alchemy/src/cloudflare/kv-namespace.ts | 20 +- .../miniflare/build-worker-options.ts | 63 ++++-- .../src/cloudflare/miniflare/node-binding.ts | 103 +++++++++ alchemy/src/cloudflare/queue.ts | 17 +- 7 files changed, 227 insertions(+), 190 deletions(-) create mode 100644 alchemy/src/cloudflare/miniflare/node-binding.ts diff --git a/alchemy/src/cloudflare/bucket-object.ts b/alchemy/src/cloudflare/bucket-object.ts index 5242a97a1..013b7128a 100644 --- a/alchemy/src/cloudflare/bucket-object.ts +++ b/alchemy/src/cloudflare/bucket-object.ts @@ -113,6 +113,7 @@ export const R2Object = Resource( // Create or update the object in the bucket const response = await props.bucket.put( props.key, + // @ts-expect-error - ReadableStream types are incompatible props.content, props.options, ); diff --git a/alchemy/src/cloudflare/bucket.ts b/alchemy/src/cloudflare/bucket.ts index 5c36957e1..31ed16ab0 100644 --- a/alchemy/src/cloudflare/bucket.ts +++ b/alchemy/src/cloudflare/bucket.ts @@ -1,10 +1,9 @@ +import type { R2Bucket as R2BucketType } from "@cloudflare/workers-types"; import type { R2PutOptions } from "@cloudflare/workers-types/experimental/index.ts"; -import * as mf from "miniflare"; import { isDeepStrictEqual } from "node:util"; import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { Scope } from "../scope.ts"; -import { streamToBuffer } from "../serde.ts"; import { isRetryableError } from "../state/r2-rest-state-store.ts"; import { withExponentialBackoff } from "../util/retry.ts"; import { CloudflareApiError, handleApiError } from "./api-error.ts"; @@ -22,7 +21,10 @@ import { type R2BucketCustomDomainOptions, } from "./bucket-custom-domain.ts"; import { deleteMiniflareBinding } from "./miniflare/delete.ts"; -import { getDefaultPersistPath } from "./miniflare/paths.ts"; +import { + makeAsyncProxyForBinding, + type Lazy, +} from "./miniflare/node-binding.ts"; export type R2BucketJurisdiction = "default" | "eu" | "fedramp"; @@ -306,23 +308,7 @@ export type R2Objects = { } ); -export type R2Bucket = _R2Bucket & { - head(key: string): Promise; - get(key: string): Promise; - put( - key: string, - value: - | ReadableStream - | ArrayBuffer - | ArrayBufferView - | string - | null - | Blob, - options?: Pick, - ): Promise; - delete(key: string): Promise; - list(options?: R2ListOptions): Promise; -}; +export type R2Bucket = _R2Bucket & Lazy; /** * Output returned after R2 Bucket creation/update @@ -461,9 +447,6 @@ export async function R2Bucket( id: string, props: BucketProps = {}, ): Promise { - const scope = Scope.current; - const isLocal = scope.local && props.dev?.remote !== true; - const api = await createCloudflareApi(props); const bucket = await _R2Bucket(id, { ...props, dev: { @@ -472,144 +455,22 @@ export async function R2Bucket( }, }); - let _miniflare: mf.Miniflare | undefined; - const miniflare = () => { - if (_miniflare) { - return _miniflare; - } - _miniflare = new mf.Miniflare({ - script: "", - modules: true, - defaultPersistRoot: getDefaultPersistPath(scope.rootDir), - r2Buckets: [bucket.dev.id], - log: process.env.DEBUG ? new mf.Log(mf.LogLevel.DEBUG) : undefined, - }); - scope.onCleanup(async () => _miniflare?.dispose()); - return _miniflare; - }; - const localBucket = () => miniflare().getR2Bucket(bucket.dev.id); - - return { - ...bucket, - head: async (key: string) => { - if (isLocal) { - const result = await (await localBucket()).head(key); - if (result) { - return { - key: result.key, - etag: result.etag, - uploaded: result.uploaded, - size: result.size, - httpMetadata: result.httpMetadata, - } as R2ObjectMetadata; - } - return null; - } - return headObject(api, { - bucketName: bucket.name, - key, - }); - }, - get: async (key: string) => { - if (isLocal) { - const result = await (await localBucket()).get(key); - if (result) { - // cast because workers vs node built-ins - return result as unknown as R2ObjectContent; - } - return null; - } - const response = await getObject(api, { - bucketName: bucket.name, - key, - }); - if (response.ok) { - return parseR2Object(key, response); - } else if (response.status === 404) { - return null; - } else { - throw await handleApiError(response, "get", "object", key); - } - }, - list: async (options?: R2ListOptions): Promise => { - if (isLocal) { - return (await localBucket()).list(options); - } - return listObjects(api, bucket.name, { - ...options, - jurisdiction: bucket.jurisdiction, - }); - }, - put: async ( - key: string, - value: PutObjectObject, - options?: Pick, - ): Promise => { - if (isLocal) { - return await (await localBucket()).put( - key, - typeof value === "string" - ? value - : Buffer.isBuffer(value) || - value instanceof Uint8Array || - value instanceof ArrayBuffer - ? new Uint8Array(value) - : value instanceof Blob - ? new Uint8Array(await value.arrayBuffer()) - : value instanceof ReadableStream - ? new Uint8Array(await streamToBuffer(value)) - : value, - options, - ); - } - const response = await putObject(api, { - bucketName: bucket.name, - key: key, - object: value, - options: options, - }); - const body = (await response.json()) as { - result: { - key: string; - etag: string; - uploaded: string; - version: string; - size: string; - }; - }; - return { - key: body.result.key, - etag: body.result.etag, - uploaded: new Date(body.result.uploaded), - version: body.result.version, - size: Number(body.result.size), - }; - }, - delete: async (key: string) => { - if (isLocal) { - await (await localBucket()).delete(key); - } - return deleteObject(api, { - bucketName: bucket.name, - key: key, - }); - }, - }; + return makeAsyncProxyForBinding({ + apiOptions: props, + name: id, + binding: bucket as Omit, + properties: [ + "createMultipartUpload", + "delete", + "get", + "head", + "list", + "put", + "resumeMultipartUpload", + ], + }); } -const parseR2Object = (key: string, response: Response): R2ObjectContent => ({ - etag: response.headers.get("ETag")!, - uploaded: parseDate(response.headers), - key, - size: Number(response.headers.get("Content-Length")), - httpMetadata: mapHeadersToHttpMetadata(response.headers), - arrayBuffer: () => response.arrayBuffer(), - bytes: () => response.bytes(), - text: () => response.text(), - json: () => response.json(), - blob: () => response.blob(), -}); - const parseDate = (headers: Headers) => new Date(headers.get("Last-Modified") ?? headers.get("Date")!); @@ -1143,7 +1004,9 @@ export async function putBucketLifecycleRules( api.put( `/accounts/${api.accountId}/r2/buckets/${bucketName}/lifecycle`, rulesBody, - { headers: withJurisdiction(props) }, + { + headers: withJurisdiction(props), + }, ), ); } @@ -1158,7 +1021,9 @@ export async function getBucketLifecycleRules( ): Promise { const res = await api.get( `/accounts/${api.accountId}/r2/buckets/${bucketName}/lifecycle`, - { headers: withJurisdiction(props) }, + { + headers: withJurisdiction(props), + }, ); const json: any = await res.json(); if (!json?.success) { @@ -1196,7 +1061,9 @@ export async function putBucketLockRules( api.put( `/accounts/${api.accountId}/r2/buckets/${bucketName}/lock`, rulesBody, - { headers: withJurisdiction(props) }, + { + headers: withJurisdiction(props), + }, ), ); } @@ -1211,7 +1078,9 @@ export async function getBucketLockRules( ): Promise { const res = await api.get( `/accounts/${api.accountId}/r2/buckets/${bucketName}/lock`, - { headers: withJurisdiction(props) }, + { + headers: withJurisdiction(props), + }, ); const json: any = await res.json(); if (!json?.success) { diff --git a/alchemy/src/cloudflare/d1-database.ts b/alchemy/src/cloudflare/d1-database.ts index 672ff62a8..3e11bc8de 100644 --- a/alchemy/src/cloudflare/d1-database.ts +++ b/alchemy/src/cloudflare/d1-database.ts @@ -1,3 +1,4 @@ +import type { D1Database as D1DatabaseType } from "@cloudflare/workers-types"; import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { Scope } from "../scope.ts"; @@ -13,6 +14,10 @@ import { cloneD1Database } from "./d1-clone.ts"; import { applyLocalD1Migrations } from "./d1-local-migrations.ts"; import { applyMigrations, listMigrationsFiles } from "./d1-migrations.ts"; import { deleteMiniflareBinding } from "./miniflare/delete.ts"; +import { + makeAsyncProxyForBinding, + type Lazy, +} from "./miniflare/node-binding.ts"; const DEFAULT_MIGRATIONS_TABLE = "d1_migrations"; @@ -171,7 +176,7 @@ export type D1Database = Pick< * The jurisdiction of the database */ jurisdiction: D1DatabaseJurisdiction; -}; +} & Lazy; /** * Creates and manages Cloudflare D1 Databases. @@ -257,7 +262,7 @@ export async function D1Database( ? await listMigrationsFiles(props.migrationsDir) : []; - return _D1Database(id, { + const database = await _D1Database(id, { ...props, migrationsFiles, dev: { @@ -267,6 +272,13 @@ export async function D1Database( force: Scope.current.local, }, }); + + return makeAsyncProxyForBinding({ + apiOptions: props, + name: id, + binding: database, + properties: ["prepare", "batch", "exec", "withSession", "dump"], + }); } const _D1Database = Resource( @@ -275,7 +287,7 @@ const _D1Database = Resource( this: Context, id: string, props: D1DatabaseProps, - ): Promise { + ): Promise> { const databaseName = props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); const jurisdiction = props.jurisdiction ?? "default"; diff --git a/alchemy/src/cloudflare/kv-namespace.ts b/alchemy/src/cloudflare/kv-namespace.ts index f83c6e50c..d9f9a2f6b 100644 --- a/alchemy/src/cloudflare/kv-namespace.ts +++ b/alchemy/src/cloudflare/kv-namespace.ts @@ -1,3 +1,5 @@ +import type { KVNamespace as KVNamespaceType } from "@cloudflare/workers-types"; +import * as mf from "miniflare"; import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { Scope } from "../scope.ts"; @@ -13,7 +15,10 @@ import { type CloudflareApiOptions, } from "./api.ts"; import { deleteMiniflareBinding } from "./miniflare/delete.ts"; -import * as mf from "miniflare"; +import { + makeAsyncProxyForBinding, + type Lazy, +} from "./miniflare/node-binding.ts"; import { getDefaultPersistPath } from "./miniflare/paths.ts"; /** @@ -136,7 +141,7 @@ export type KVNamespace = Omit & { */ remote: boolean; }; -}; +} & Lazy; /** * A Cloudflare KV Namespace is a key-value store that can be used to store data for your application. @@ -196,13 +201,20 @@ export async function KVNamespace( id: string, props: KVNamespaceProps = {}, ): Promise { - return await _KVNamespace(id, { + const namespace = await _KVNamespace(id, { ...props, dev: { ...(props.dev ?? {}), force: Scope.current.local, }, }); + + return makeAsyncProxyForBinding({ + apiOptions: props, + name: id, + binding: namespace, + properties: ["get", "list", "put", "getWithMetadata", "delete"], + }); } const _KVNamespace = Resource( @@ -211,7 +223,7 @@ const _KVNamespace = Resource( this: Context, id: string, props: KVNamespaceProps, - ): Promise { + ): Promise> { const title = props.title ?? this.output?.title ?? this.scope.createPhysicalName(id); diff --git a/alchemy/src/cloudflare/miniflare/build-worker-options.ts b/alchemy/src/cloudflare/miniflare/build-worker-options.ts index 22bd66566..f9ea85b3b 100644 --- a/alchemy/src/cloudflare/miniflare/build-worker-options.ts +++ b/alchemy/src/cloudflare/miniflare/build-worker-options.ts @@ -69,8 +69,7 @@ export const buildWorkerOptions = async ( watch: (signal: AbortSignal) => AsyncGenerator; remoteProxy: HTTPServer | undefined; }> => { - const remoteBindings: RemoteBinding[] = []; - const options: Partial = { + const baseOptions: Partial = { name: input.name, compatibilityDate: input.compatibilityDate, compatibilityFlags: input.compatibilityFlags, @@ -85,6 +84,46 @@ export const buildWorkerOptions = async ( routes: [input.name], }; const port = input.port ?? (await reservePort(input.name)); + const { options: bindingsOptions, remoteProxy } = await buildBindings({ + api: input.api, + name: input.name, + bindings: input.bindings ?? {}, + eventSources: input.eventSources ?? [], + assets: input.assets, + port, + cwd: input.cwd, + }); + async function* watch(signal: AbortSignal) { + for await (const bundle of input.bundle.watch(signal)) { + const { modules, rootPath } = normalizeBundle(bundle); + yield { + ...baseOptions, + ...bindingsOptions, + modules, + rootPath, + }; + } + } + return { + watch, + remoteProxy, + }; +}; + +export async function buildBindings(input: { + api: CloudflareApi; + name: string; + bindings: Bindings; + eventSources: EventSource[] | undefined; + assets?: AssetsConfig; + port: number; + cwd: string; +}): Promise<{ + options: Partial; + remoteProxy: HTTPServer | undefined; +}> { + const options: Partial = {}; + const remoteBindings: RemoteBinding[] = []; for (const [key, binding] of Object.entries(input.bindings ?? {})) { if (typeof binding === "string") { (options.bindings ??= {})[key] = binding; @@ -95,11 +134,11 @@ export const buildWorkerOptions = async ( continue; } if (binding.type === "cloudflare::Worker::DevDomain") { - (options.bindings ??= {})[key] = `localhost:${port}`; + (options.bindings ??= {})[key] = `localhost:${input.port}`; continue; } if (binding.type === "cloudflare::Worker::DevUrl") { - (options.bindings ??= {})[key] = `http://localhost:${port}`; + (options.bindings ??= {})[key] = `http://localhost:${input.port}`; continue; } switch (binding.type) { @@ -363,16 +402,6 @@ export const buildWorkerOptions = async ( (options.queueConsumers ??= {})[eventSource.name] = {}; } } - async function* watch(signal: AbortSignal) { - for await (const bundle of input.bundle.watch(signal)) { - const { modules, rootPath } = normalizeBundle(bundle); - yield { - ...options, - modules, - rootPath, - }; - } - } if (remoteBindings.length > 0) { const remoteProxy = await createRemoteProxyWorker({ api: input.api, @@ -453,15 +482,15 @@ export const buildWorkerOptions = async ( } } return { - watch, + options, remoteProxy: remoteProxy.server, }; } return { - watch, + options, remoteProxy: undefined, }; -}; +} const moduleTypes = { esm: "ESModule", diff --git a/alchemy/src/cloudflare/miniflare/node-binding.ts b/alchemy/src/cloudflare/miniflare/node-binding.ts new file mode 100644 index 000000000..a6d6e9356 --- /dev/null +++ b/alchemy/src/cloudflare/miniflare/node-binding.ts @@ -0,0 +1,103 @@ +import * as mf from "miniflare"; +import { Scope } from "../../scope.ts"; +import { + createCloudflareApi, + type CloudflareApi, + type CloudflareApiOptions, +} from "../api.ts"; +import type { Binding } from "../bindings.ts"; +import type { Bound } from "../bound.ts"; +import { buildBindings } from "./build-worker-options.ts"; +import { getDefaultPersistPath } from "./paths.ts"; + +type AllKeysOf = + Exclude extends never ? K : never; + +export function makeAsyncProxyForBinding< + B extends Extract, + const K extends readonly (keyof Lazy>)[], +>(input: { + apiOptions: CloudflareApiOptions; + name: string; + binding: Omit>>; + properties: AllKeysOf>, K>; +}): B { + return makeAsyncProxy( + async () => { + const { options, remoteProxy } = await buildBindings({ + api: makeAsyncProxy(() => + createCloudflareApi(input.apiOptions), + ) as CloudflareApi, + name: input.name, + bindings: { + binding: input.binding as B, + }, + eventSources: undefined, + assets: undefined, + port: 0, + cwd: Scope.current.rootDir, + }); + const miniflare = new mf.Miniflare({ + script: "", + modules: true, + defaultPersistRoot: getDefaultPersistPath(Scope.current.rootDir), + log: process.env.DEBUG ? new mf.Log(mf.LogLevel.DEBUG) : undefined, + ...options, + analyticsEngineDatasetsPersist: !!options.analyticsEngineDatasets, + d1Persist: !!options.d1Databases, + durableObjectsPersist: !!options.durableObjects, + kvPersist: !!options.kvNamespaces, + r2Persist: !!options.r2Buckets, + secretsStorePersist: !!options.secretsStoreSecrets, + workflowsPersist: !!options.workflows, + } as mf.MiniflareOptions); + Scope.current.onCleanup(async () => { + await remoteProxy?.close(); + await miniflare.dispose(); + }); + await miniflare.ready; + return (await miniflare.getBindings())["binding"] as Bound; + }, + input.binding, + input.properties, + ) as B; +} + +export type Lazy = T[keyof T] extends (...args: any[]) => Promise + ? T + : { + [k in keyof T]: EnsurePromiseReturnType; + }; + +type EnsurePromiseReturnType = T extends ( + ...args: infer Args +) => infer Return + ? (...args: Args) => Return extends Promise ? Return : Promise + : never; + +function makeAsyncProxy( + make: () => Promise, + target?: Target, + properties?: readonly (keyof Functions)[], +): Target & Lazy { + let promise: Promise | undefined; + return new Proxy(target ?? {}, { + get(target, prop) { + if (properties?.includes(prop as keyof Functions)) { + return Reflect.get(target, prop); + } + return async (...args: any[]) => { + promise ??= make(); + const obj = await promise; + // @ts-expect-error - prop is a valid key of T + return obj[prop as keyof T].apply(obj, args); + }; + }, + has(target, prop) { + return ( + properties?.includes(prop as keyof Functions) || + Reflect.has(target, prop) + ); + }, + }) as Target & Lazy; +} diff --git a/alchemy/src/cloudflare/queue.ts b/alchemy/src/cloudflare/queue.ts index 51df4dab4..f15f9b679 100644 --- a/alchemy/src/cloudflare/queue.ts +++ b/alchemy/src/cloudflare/queue.ts @@ -1,3 +1,4 @@ +import type { Queue as QueueType } from "@cloudflare/workers-types"; import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { Scope } from "../scope.ts"; @@ -7,6 +8,10 @@ import { type CloudflareApi, type CloudflareApiOptions, } from "./api.ts"; +import { + makeAsyncProxyForBinding, + type Lazy, +} from "./miniflare/node-binding.ts"; /** * Settings for a Cloudflare Queue @@ -148,7 +153,7 @@ export type Queue = Omit & { */ remote: boolean; }; -}; +} & Lazy; /** * Creates and manages Cloudflare Queues. @@ -233,19 +238,25 @@ export async function Queue( id: string, props: QueueProps = {}, ): Promise> { - return await _Queue(id, { + const queue = await _Queue(id, { ...props, dev: { ...(props.dev ?? {}), force: Scope.current.local, }, }); + return makeAsyncProxyForBinding({ + apiOptions: props, + name: id, + binding: queue, + properties: ["send", "sendBatch"], + }); } const _Queue = Resource("cloudflare::Queue", async function < T = unknown, >(this: Context>, id: string, props: QueueProps = {}): Promise< - Queue + Omit, keyof QueueType> > { const queueName = props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); From 2e0b7d9720700d72cf23fc340cc25219694ca1b2 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Thu, 11 Dec 2025 18:07:24 -0500 Subject: [PATCH 2/5] match runtime types --- alchemy/src/cloudflare/bucket.ts | 31 +++++--- alchemy/src/cloudflare/d1-database.ts | 50 ++++++++++++- alchemy/src/cloudflare/kv-namespace.ts | 15 ++-- .../src/cloudflare/miniflare/node-binding.ts | 74 +++++++++---------- alchemy/src/cloudflare/queue.ts | 12 +-- 5 files changed, 116 insertions(+), 66 deletions(-) diff --git a/alchemy/src/cloudflare/bucket.ts b/alchemy/src/cloudflare/bucket.ts index 31ed16ab0..43c45ef41 100644 --- a/alchemy/src/cloudflare/bucket.ts +++ b/alchemy/src/cloudflare/bucket.ts @@ -22,8 +22,8 @@ import { } from "./bucket-custom-domain.ts"; import { deleteMiniflareBinding } from "./miniflare/delete.ts"; import { + makeAsyncProxy, makeAsyncProxyForBinding, - type Lazy, } from "./miniflare/node-binding.ts"; export type R2BucketJurisdiction = "default" | "eu" | "fedramp"; @@ -308,7 +308,7 @@ export type R2Objects = { } ); -export type R2Bucket = _R2Bucket & Lazy; +export type R2Bucket = _R2Bucket & R2BucketType; /** * Output returned after R2 Bucket creation/update @@ -459,15 +459,24 @@ export async function R2Bucket( apiOptions: props, name: id, binding: bucket as Omit, - properties: [ - "createMultipartUpload", - "delete", - "get", - "head", - "list", - "put", - "resumeMultipartUpload", - ], + properties: { + createMultipartUpload: true, + delete: true, + get: true, + head: true, + list: true, + put: true, + resumeMultipartUpload: (promise) => (key: string, uploadId: string) => + makeAsyncProxy( + { key, uploadId }, + promise.then((bucket) => bucket.resumeMultipartUpload(key, uploadId)), + { + uploadPart: true, + abort: true, + complete: true, + }, + ), + }, }); } diff --git a/alchemy/src/cloudflare/d1-database.ts b/alchemy/src/cloudflare/d1-database.ts index 3e11bc8de..8f0c66346 100644 --- a/alchemy/src/cloudflare/d1-database.ts +++ b/alchemy/src/cloudflare/d1-database.ts @@ -15,8 +15,8 @@ import { applyLocalD1Migrations } from "./d1-local-migrations.ts"; import { applyMigrations, listMigrationsFiles } from "./d1-migrations.ts"; import { deleteMiniflareBinding } from "./miniflare/delete.ts"; import { + makeAsyncProxy, makeAsyncProxyForBinding, - type Lazy, } from "./miniflare/node-binding.ts"; const DEFAULT_MIGRATIONS_TABLE = "d1_migrations"; @@ -176,7 +176,7 @@ export type D1Database = Pick< * The jurisdiction of the database */ jurisdiction: D1DatabaseJurisdiction; -} & Lazy; +} & D1DatabaseType; /** * Creates and manages Cloudflare D1 Databases. @@ -273,11 +273,55 @@ export async function D1Database( }, }); + function makePreparedStatementProxy( + promise: Promise, + ): D1PreparedStatement { + return makeAsyncProxy({}, promise, { + bind: + (promise) => + (...args) => + makePreparedStatementProxy( + promise.then((statement) => statement.bind(...args)), + ), + first: true, + run: true, + all: true, + raw: true, + }); + } + return makeAsyncProxyForBinding({ apiOptions: props, name: id, binding: database, - properties: ["prepare", "batch", "exec", "withSession", "dump"], + properties: { + prepare: (promise) => (query) => + makePreparedStatementProxy( + promise.then((database) => database.prepare(query)), + ), + batch: true, + exec: true, + withSession: (promise) => (constraintOrBookmark) => + makeAsyncProxy( + {}, + promise.then((database) => + database.withSession(constraintOrBookmark), + ), + { + prepare: (session) => (query) => + makePreparedStatementProxy( + session.then((session) => session.prepare(query)), + ), + batch: true, + getBookmark: () => () => { + throw new Error( + "D1DatabaseSession.getBookmark is not implemented", + ); + }, + }, + ), + dump: true, + }, }); } diff --git a/alchemy/src/cloudflare/kv-namespace.ts b/alchemy/src/cloudflare/kv-namespace.ts index d9f9a2f6b..09f613e09 100644 --- a/alchemy/src/cloudflare/kv-namespace.ts +++ b/alchemy/src/cloudflare/kv-namespace.ts @@ -15,10 +15,7 @@ import { type CloudflareApiOptions, } from "./api.ts"; import { deleteMiniflareBinding } from "./miniflare/delete.ts"; -import { - makeAsyncProxyForBinding, - type Lazy, -} from "./miniflare/node-binding.ts"; +import { makeAsyncProxyForBinding } from "./miniflare/node-binding.ts"; import { getDefaultPersistPath } from "./miniflare/paths.ts"; /** @@ -141,7 +138,7 @@ export type KVNamespace = Omit & { */ remote: boolean; }; -} & Lazy; +} & KVNamespaceType; /** * A Cloudflare KV Namespace is a key-value store that can be used to store data for your application. @@ -213,7 +210,13 @@ export async function KVNamespace( apiOptions: props, name: id, binding: namespace, - properties: ["get", "list", "put", "getWithMetadata", "delete"], + properties: { + get: true, + list: true, + put: true, + getWithMetadata: true, + delete: true, + }, }); } diff --git a/alchemy/src/cloudflare/miniflare/node-binding.ts b/alchemy/src/cloudflare/miniflare/node-binding.ts index a6d6e9356..4aa8fec0d 100644 --- a/alchemy/src/cloudflare/miniflare/node-binding.ts +++ b/alchemy/src/cloudflare/miniflare/node-binding.ts @@ -1,33 +1,25 @@ import * as mf from "miniflare"; import { Scope } from "../../scope.ts"; -import { - createCloudflareApi, - type CloudflareApi, - type CloudflareApiOptions, -} from "../api.ts"; +import { createCloudflareApi, type CloudflareApiOptions } from "../api.ts"; import type { Binding } from "../bindings.ts"; import type { Bound } from "../bound.ts"; import { buildBindings } from "./build-worker-options.ts"; import { getDefaultPersistPath } from "./paths.ts"; -type AllKeysOf = - Exclude extends never ? K : never; - export function makeAsyncProxyForBinding< B extends Extract, - const K extends readonly (keyof Lazy>)[], + P extends Properties>, >(input: { apiOptions: CloudflareApiOptions; name: string; - binding: Omit>>; - properties: AllKeysOf>, K>; + binding: Omit>; + properties: P; }): B { return makeAsyncProxy( + input.binding, async () => { const { options, remoteProxy } = await buildBindings({ - api: makeAsyncProxy(() => - createCloudflareApi(input.apiOptions), - ) as CloudflareApi, + api: makeAsyncProxy({}, () => createCloudflareApi(input.apiOptions)), name: input.name, bindings: { binding: input.binding as B, @@ -58,46 +50,48 @@ export function makeAsyncProxyForBinding< await miniflare.ready; return (await miniflare.getBindings())["binding"] as Bound; }, - input.binding, - input.properties, + input.properties as any, ) as B; } -export type Lazy = T[keyof T] extends (...args: any[]) => Promise - ? T - : { - [k in keyof T]: EnsurePromiseReturnType; - }; +type Properties = { + [K in keyof T]: T[K] extends (...args: any[]) => Promise + ? true + : (promise: Promise) => T[K]; +}; -type EnsurePromiseReturnType = T extends ( - ...args: infer Args -) => infer Return - ? (...args: Args) => Return extends Promise ? Return : Promise - : never; - -function makeAsyncProxy( - make: () => Promise, - target?: Target, - properties?: readonly (keyof Functions)[], -): Target & Lazy { - let promise: Promise | undefined; - return new Proxy(target ?? {}, { +export function makeAsyncProxy< + Target extends object, + Value, + P extends Properties>, +>( + target: Target, + get: Promise | (() => Promise), + properties?: P, +): Target & Value { + let promise = typeof get === "function" ? undefined : get; + return new Proxy(target, { get(target, prop) { - if (properties?.includes(prop as keyof Functions)) { + const property = properties?.[prop as keyof Omit]; + if (properties && !property) { return Reflect.get(target, prop); } + if (typeof property === "function") { + promise ??= typeof get === "function" ? get() : get; + return property(promise); + } return async (...args: any[]) => { - promise ??= make(); + promise ??= typeof get === "function" ? get() : get; const obj = await promise; - // @ts-expect-error - prop is a valid key of T - return obj[prop as keyof T].apply(obj, args); + // @ts-expect-error - prop is a valid key of Value + return obj[prop].apply(obj, args); }; }, has(target, prop) { return ( - properties?.includes(prop as keyof Functions) || + !!properties?.[prop as keyof Omit] || Reflect.has(target, prop) ); }, - }) as Target & Lazy; + }) as any; } diff --git a/alchemy/src/cloudflare/queue.ts b/alchemy/src/cloudflare/queue.ts index f15f9b679..a97fba168 100644 --- a/alchemy/src/cloudflare/queue.ts +++ b/alchemy/src/cloudflare/queue.ts @@ -8,10 +8,7 @@ import { type CloudflareApi, type CloudflareApiOptions, } from "./api.ts"; -import { - makeAsyncProxyForBinding, - type Lazy, -} from "./miniflare/node-binding.ts"; +import { makeAsyncProxyForBinding } from "./miniflare/node-binding.ts"; /** * Settings for a Cloudflare Queue @@ -153,7 +150,7 @@ export type Queue = Omit & { */ remote: boolean; }; -} & Lazy; +} & QueueType; /** * Creates and manages Cloudflare Queues. @@ -249,7 +246,10 @@ export async function Queue( apiOptions: props, name: id, binding: queue, - properties: ["send", "sendBatch"], + properties: { + send: true, + sendBatch: true, + }, }); } From c0b43be941024e8e6fd84cff53d94bf09c4151cf Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Thu, 11 Dec 2025 18:07:32 -0500 Subject: [PATCH 3/5] fix missing sockets --- .../miniflare/build-worker-options.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/alchemy/src/cloudflare/miniflare/build-worker-options.ts b/alchemy/src/cloudflare/miniflare/build-worker-options.ts index f9ea85b3b..7ae907486 100644 --- a/alchemy/src/cloudflare/miniflare/build-worker-options.ts +++ b/alchemy/src/cloudflare/miniflare/build-worker-options.ts @@ -73,13 +73,6 @@ export const buildWorkerOptions = async ( name: input.name, compatibilityDate: input.compatibilityDate, compatibilityFlags: input.compatibilityFlags, - unsafeDirectSockets: [ - // This matches the Wrangler configuration by exposing the default handler (e.g. `export default { fetch }`). - { - entrypoint: "default", - proxy: true, - }, - ], // This exposes the worker as a route that can be accessed by setting the MF-Route-Override header. routes: [input.name], }; @@ -122,7 +115,15 @@ export async function buildBindings(input: { options: Partial; remoteProxy: HTTPServer | undefined; }> { - const options: Partial = {}; + const options: Partial = { + unsafeDirectSockets: [ + // This matches the Wrangler configuration by exposing the default handler (e.g. `export default { fetch }`). + { + entrypoint: "default", + proxy: true, + }, + ], + }; const remoteBindings: RemoteBinding[] = []; for (const [key, binding] of Object.entries(input.bindings ?? {})) { if (typeof binding === "string") { From 4243ce1ef2f3df8d7425ef1e2da0232f25e1d66d Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Thu, 11 Dec 2025 18:27:21 -0500 Subject: [PATCH 4/5] make runtime types consistent --- alchemy/src/cloudflare/bucket-object.ts | 2 -- alchemy/src/cloudflare/bucket.ts | 5 ++--- alchemy/src/cloudflare/d1-database.ts | 5 ++--- alchemy/src/cloudflare/kv-namespace.ts | 5 ++--- alchemy/src/cloudflare/miniflare/node-binding.ts | 2 +- alchemy/src/cloudflare/queue.ts | 5 ++--- alchemy/test/cloudflare/bucket.test.ts | 5 +++-- 7 files changed, 12 insertions(+), 17 deletions(-) diff --git a/alchemy/src/cloudflare/bucket-object.ts b/alchemy/src/cloudflare/bucket-object.ts index 013b7128a..cc32848cc 100644 --- a/alchemy/src/cloudflare/bucket-object.ts +++ b/alchemy/src/cloudflare/bucket-object.ts @@ -1,4 +1,3 @@ -import type { R2PutOptions } from "@cloudflare/workers-types/experimental/index.ts"; import type { Context } from "../context.ts"; import { Resource } from "../resource.ts"; import { createCloudflareApi, type CloudflareApiOptions } from "./api.ts"; @@ -113,7 +112,6 @@ export const R2Object = Resource( // Create or update the object in the bucket const response = await props.bucket.put( props.key, - // @ts-expect-error - ReadableStream types are incompatible props.content, props.options, ); diff --git a/alchemy/src/cloudflare/bucket.ts b/alchemy/src/cloudflare/bucket.ts index 43c45ef41..d00806ece 100644 --- a/alchemy/src/cloudflare/bucket.ts +++ b/alchemy/src/cloudflare/bucket.ts @@ -1,4 +1,3 @@ -import type { R2Bucket as R2BucketType } from "@cloudflare/workers-types"; import type { R2PutOptions } from "@cloudflare/workers-types/experimental/index.ts"; import { isDeepStrictEqual } from "node:util"; import type { Context } from "../context.ts"; @@ -308,7 +307,7 @@ export type R2Objects = { } ); -export type R2Bucket = _R2Bucket & R2BucketType; +export type R2Bucket = _R2Bucket & globalThis.R2Bucket; /** * Output returned after R2 Bucket creation/update @@ -458,7 +457,7 @@ export async function R2Bucket( return makeAsyncProxyForBinding({ apiOptions: props, name: id, - binding: bucket as Omit, + binding: bucket as Omit, properties: { createMultipartUpload: true, delete: true, diff --git a/alchemy/src/cloudflare/d1-database.ts b/alchemy/src/cloudflare/d1-database.ts index 8f0c66346..dec7e6dc9 100644 --- a/alchemy/src/cloudflare/d1-database.ts +++ b/alchemy/src/cloudflare/d1-database.ts @@ -1,4 +1,3 @@ -import type { D1Database as D1DatabaseType } from "@cloudflare/workers-types"; import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { Scope } from "../scope.ts"; @@ -176,7 +175,7 @@ export type D1Database = Pick< * The jurisdiction of the database */ jurisdiction: D1DatabaseJurisdiction; -} & D1DatabaseType; +} & globalThis.D1Database; /** * Creates and manages Cloudflare D1 Databases. @@ -331,7 +330,7 @@ const _D1Database = Resource( this: Context, id: string, props: D1DatabaseProps, - ): Promise> { + ): Promise> { const databaseName = props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); const jurisdiction = props.jurisdiction ?? "default"; diff --git a/alchemy/src/cloudflare/kv-namespace.ts b/alchemy/src/cloudflare/kv-namespace.ts index 09f613e09..00ee8106e 100644 --- a/alchemy/src/cloudflare/kv-namespace.ts +++ b/alchemy/src/cloudflare/kv-namespace.ts @@ -1,4 +1,3 @@ -import type { KVNamespace as KVNamespaceType } from "@cloudflare/workers-types"; import * as mf from "miniflare"; import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; @@ -138,7 +137,7 @@ export type KVNamespace = Omit & { */ remote: boolean; }; -} & KVNamespaceType; +} & globalThis.KVNamespace; /** * A Cloudflare KV Namespace is a key-value store that can be used to store data for your application. @@ -226,7 +225,7 @@ const _KVNamespace = Resource( this: Context, id: string, props: KVNamespaceProps, - ): Promise> { + ): Promise> { const title = props.title ?? this.output?.title ?? this.scope.createPhysicalName(id); diff --git a/alchemy/src/cloudflare/miniflare/node-binding.ts b/alchemy/src/cloudflare/miniflare/node-binding.ts index 4aa8fec0d..1b0c53c67 100644 --- a/alchemy/src/cloudflare/miniflare/node-binding.ts +++ b/alchemy/src/cloudflare/miniflare/node-binding.ts @@ -8,7 +8,7 @@ import { getDefaultPersistPath } from "./paths.ts"; export function makeAsyncProxyForBinding< B extends Extract, - P extends Properties>, + const P extends Properties>, >(input: { apiOptions: CloudflareApiOptions; name: string; diff --git a/alchemy/src/cloudflare/queue.ts b/alchemy/src/cloudflare/queue.ts index a97fba168..b7dd83ee2 100644 --- a/alchemy/src/cloudflare/queue.ts +++ b/alchemy/src/cloudflare/queue.ts @@ -1,4 +1,3 @@ -import type { Queue as QueueType } from "@cloudflare/workers-types"; import type { Context } from "../context.ts"; import { Resource, ResourceKind } from "../resource.ts"; import { Scope } from "../scope.ts"; @@ -150,7 +149,7 @@ export type Queue = Omit & { */ remote: boolean; }; -} & QueueType; +} & globalThis.Queue; /** * Creates and manages Cloudflare Queues. @@ -256,7 +255,7 @@ export async function Queue( const _Queue = Resource("cloudflare::Queue", async function < T = unknown, >(this: Context>, id: string, props: QueueProps = {}): Promise< - Omit, keyof QueueType> + Omit, keyof globalThis.Queue> > { const queueName = props.name ?? this.output?.name ?? this.scope.createPhysicalName(id); diff --git a/alchemy/test/cloudflare/bucket.test.ts b/alchemy/test/cloudflare/bucket.test.ts index 262cb3020..5c8b9cb43 100644 --- a/alchemy/test/cloudflare/bucket.test.ts +++ b/alchemy/test/cloudflare/bucket.test.ts @@ -451,14 +451,15 @@ describe("R2 Bucket Resource", async () => { const testContent = "Hello, R2 Bucket Operations!"; const updatedContent = "Updated content for testing"; await bucket.delete(testKey); - let putObj = await bucket.put(testKey, testContent); + // TODO(john): this is a problem with @cloudflare/workers-types, it should not be nullable unless options.onlyIf is used + let putObj = (await bucket.put(testKey, testContent)) as R2Object; expect(putObj.size).toBeTypeOf("number"); expect(putObj.size).toEqual(testContent.length); let obj = await bucket.head(testKey); expect(obj).toBeDefined(); expect(obj?.etag).toEqual(putObj.etag); expect(obj?.size).toEqual(putObj.size); - putObj = await bucket.put(testKey, updatedContent); + putObj = (await bucket.put(testKey, updatedContent)) as R2Object; obj = await bucket.head(testKey); expect(obj?.etag).toEqual(putObj.etag); const getObj = await bucket.get(testKey); From 208f81dff9d6a3dee39fdc9213872e68a5aec458 Mon Sep 17 00:00:00 2001 From: John Royal <34844819+john-royal@users.noreply.github.com> Date: Thu, 11 Dec 2025 18:35:11 -0500 Subject: [PATCH 5/5] fix --- alchemy/src/cloudflare/miniflare/node-binding.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alchemy/src/cloudflare/miniflare/node-binding.ts b/alchemy/src/cloudflare/miniflare/node-binding.ts index 1b0c53c67..cc6e570a1 100644 --- a/alchemy/src/cloudflare/miniflare/node-binding.ts +++ b/alchemy/src/cloudflare/miniflare/node-binding.ts @@ -73,7 +73,7 @@ export function makeAsyncProxy< return new Proxy(target, { get(target, prop) { const property = properties?.[prop as keyof Omit]; - if (properties && !property) { + if (Reflect.has(target, prop) || !property) { return Reflect.get(target, prop); } if (typeof property === "function") {