diff --git a/.changeset/shared-storage-owner.md b/.changeset/shared-storage-owner.md new file mode 100644 index 00000000000..cad1231bcc3 --- /dev/null +++ b/.changeset/shared-storage-owner.md @@ -0,0 +1,7 @@ +--- +"miniflare": minor +--- + +Add experimental `unsafeSharedStorageOwner` option to share local storage across processes + +When several Miniflare instances run with resources with the same resource ID, they will now share the underlying data. diff --git a/.github/workflows/c3-e2e.yml b/.github/workflows/c3-e2e.yml index 24070c22fed..9dab2e428fa 100644 --- a/.github/workflows/c3-e2e.yml +++ b/.github/workflows/c3-e2e.yml @@ -287,10 +287,6 @@ jobs: run: pnpm run test:e2e:c3 env: NODE_VERSION: ${{ env.NODE_VERSION }} - # Some framework generators write a different pnpm version to `packageManager`. - # By default pnpm downloads and runs it. Concurrent tests can then provision - # that version in the same pnpm tool store, causing recursive installs. - pnpm_config_manage_package_manager_versions: "false" E2E_EXPERIMENTAL: ${{ matrix.experimental }} E2E_TEST_PM: ${{ matrix.pm.name }} E2E_TEST_PM_VERSION: ${{ matrix.pm.version }} diff --git a/packages/create-cloudflare/turbo.json b/packages/create-cloudflare/turbo.json index ae9209806b1..918fd28c772 100644 --- a/packages/create-cloudflare/turbo.json +++ b/packages/create-cloudflare/turbo.json @@ -28,8 +28,7 @@ "E2E_FRAMEWORK_TEMPLATE_TO_TEST", "E2E_PROJECT_PATH", "E2E_TEST_RETRIES", - "E2E_RUN_DEPLOY_TESTS", - "pnpm_config_manage_package_manager_versions" + "E2E_RUN_DEPLOY_TESTS" ], "dependsOn": ["build"], "inputs": ["e2e/**", "vitest-e2e.config.ts", "!e2e/README.md"], diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 87314a6b275..8fccc6a6937 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -1,4 +1,5 @@ import assert from "node:assert"; +import { spawn } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; @@ -57,13 +58,13 @@ import { QueuesError, R2_PLUGIN_NAME, SECRET_STORE_PLUGIN_NAME, + STREAM_PLUGIN_NAME, SERVICE_DEV_REGISTRY_PROXY, SERVICE_ENTRY, SOCKET_DEBUG_PORT, SOCKET_DEV_REGISTRY, SOCKET_ENTRY, SOCKET_ENTRY_LOCAL, - STREAM_PLUGIN_NAME, WORKFLOWS_PLUGIN_NAME, } from "./plugins"; import { RPC_PROXY_SERVICE_NAME } from "./plugins/assets/constants"; @@ -97,6 +98,7 @@ import { NoOpLog, parseWithRootPath, stripAnsi, + tryAcquireOwnerSpawnLock, } from "./shared"; import { createDurableObjectStorageHandle } from "./shared/dev-control"; import { DevRegistry, getWorkerRegistry } from "./shared/dev-registry"; @@ -114,6 +116,8 @@ import { Mutex, SharedHeaders, SiteBindings, + STORAGE_OWNER_CLIENT_PRESENCE_PREFIX, + STORAGE_OWNER_WORKER_NAME, } from "./workers"; import { ADMIN_API } from "./workers/secrets-store/constants"; import type { DispatchFetch, RequestInit } from "./http"; @@ -125,6 +129,7 @@ import type { PluginWorkerOptions, QueueConsumers, QueueProducers, + RemoteProxyConnectionString, ReplaceWorkersTypes, SharedOptions, WorkerOptions, @@ -172,6 +177,36 @@ import type { Duplex, Transform, Writable } from "node:stream"; import type { Dispatcher, Response as UndiciResponse } from "undici"; const DEFAULT_HOST = "127.0.0.1"; +// Owner-side service + socket exposing the owner's local storage entry services +// over HTTP (the remote-bindings proxy-server protocol). The client-side proxy +// service (`SERVICE_STORAGE_OWNER_PROXY`) is defined in `./plugins`, where the +// storage plugins reference it when routing bindings to the owner. +// Detached storage-owner process bootstrap. The owner runs the same built +// miniflare module (its path handed over via env), so we avoid a second build +// entry point. Kept as a constant string with no interpolation so it satisfies +// the no-unsafe-command-execution lint rule. +const STORAGE_OWNER_BOOTSTRAP = + "require(process.env.MINIFLARE_STORAGE_OWNER_MAIN).runStorageOwnerProcess()"; +const ENV_STORAGE_OWNER_MAIN = "MINIFLARE_STORAGE_OWNER_MAIN"; +const ENV_STORAGE_OWNER_CONFIG = "MINIFLARE_STORAGE_OWNER_CONFIG"; +// How long a client waits for a freshly spawned owner to register itself. +const STORAGE_OWNER_SPAWN_TIMEOUT_MS = 30_000; +const STORAGE_OWNER_POLL_MS = 50; +// Owner self-teardown tuning: a startup grace period before the owner is +// eligible to exit, and a debounce so a transient client gap (e.g. a reload) +// doesn't tear storage down. Overridable via env (read by the spawned owner +// process, which inherits the spawner's environment) primarily for tests. +const STORAGE_OWNER_STARTUP_GRACE_MS = + Number(process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS) || 10_000; +const STORAGE_OWNER_IDLE_CHECK_MS = + Number(process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS) || 1_000; +const STORAGE_OWNER_IDLE_DEBOUNCE = 3; + +// Distinguishes shared-storage client presence entries when several Miniflare +// instances live in one process (tests, the Vitest pool) and would otherwise +// collide on `process.pid`. +let storageOwnerClientCounter = 0; + function getURLSafeHost(host: string) { return net.isIPv6(host) ? `[${host}]` : host; } @@ -985,6 +1020,14 @@ export class Miniflare { readonly #webSocketExtraHeaders: WeakMap; readonly #devRegistry: DevRegistry; + // Shared-storage-owner (experimental `unsafeSharedStorageOwner`) client state. + // `#storageOwnerRoutingActive` records whether this instance actually routed + // its storage to an owner during the last assemble; if so it registers a + // presence entry (`#storageOwnerPresenceName`) in the dev registry so the + // owner can count live clients and tear itself down when none remain. + #storageOwnerRoutingActive = false; + readonly #storageOwnerPresenceName = `${STORAGE_OWNER_CLIENT_PRESENCE_PREFIX}${process.pid}-${storageOwnerClientCounter++}`; + #maybeInspectorProxyController?: InspectorProxyController; #previousRuntimeInspectorPort?: number; @@ -1937,6 +1980,28 @@ export class Miniflare { ? getExternalServiceEntrypoints(allWorkerOpts) : null; + // As a client, ensure an owner exists (spawning a detached one if needed) + // before we resolve routing below. + await this.#ensureStorageOwner(); + + // When acting as a shared-storage *client*, resolve the owner so local + // storage bindings can be routed to it (and local storage services + // skipped). `undefined` => behave normally (owner role, feature off, or + // no owner currently published). + const storageOwnerRouting = this.#getStorageOwnerRouting(); + const storageOwnerRoutePlugins = new Set(); + if (storageOwnerRouting) { + storageOwnerRoutePlugins.add(KV_PLUGIN_NAME); + storageOwnerRoutePlugins.add(R2_PLUGIN_NAME); + storageOwnerRoutePlugins.add(D1_PLUGIN_NAME); + storageOwnerRoutePlugins.add(STREAM_PLUGIN_NAME); + storageOwnerRoutePlugins.add(SECRET_STORE_PLUGIN_NAME); + storageOwnerRoutePlugins.add(IMAGES_PLUGIN_NAME); + } + // Record whether we're a routing client so `#registerWorkers` publishes a + // presence entry the owner can count. + this.#storageOwnerRoutingActive = storageOwnerRoutePlugins.size > 0; + const durableObjectClassNames = getDurableObjectClassNames(allWorkerOpts); const queueProducers = getQueueProducers(allWorkerOpts); const queueConsumers = getQueueConsumers(allWorkerOpts); @@ -2033,7 +2098,15 @@ export class Miniflare { i ); if (pluginBindings !== undefined) { - for (const binding of pluginBindings) { + for (const originalBinding of pluginBindings) { + // When routing this plugin's storage to a shared owner, let the + // plugin repoint its local storage bindings at the storage-owner + // client proxy (plugins own the knowledge of their binding shapes; + // the proxy resolves the live owner from the dev registry). + const binding = storageOwnerRoutePlugins.has(key) + ? (plugin.routeBindingToStorageOwner?.(originalBinding) ?? + originalBinding) + : originalBinding; // If this is the Workers Sites manifest, we need to add it as a // module for modules workers. For all other bindings, and in // service workers, just add to worker bindings. @@ -2109,6 +2182,13 @@ export class Miniflare { queueConsumers, devRegistryEnabled, hyperdriveProxyController: this.#hyperdriveProxyController, + storageOwnerRoutePlugins, + // Plugins not routed to the owner but still disk-backed (Cache, + // Durable Objects, Workflows) keep their storage per-instance when + // the feature is enabled, so separate processes don't contend on one + // database under the shared `resourcePersistencePath`. Applies to every + // role (client, fallback-to-local client, and owner). + isolateLocalStorage: this.#storageOwnerPersistRoot() !== undefined, }; for (const [key, plugin] of this.#mergedPluginEntries) { const workerOptions = this.#getWorkerOptsForPlugin(key, workerOpts); @@ -2193,8 +2273,19 @@ export class Miniflare { if ( this.#devRegistry.isEnabled() && externalServices && - (externalServices.size > 0 || hasQueues) + (externalServices.size > 0 || + hasQueues || + storageOwnerRoutePlugins.size > 0) ) { + // When routing storage to a shared owner, watch the owner's well-known + // registry name too, so the client's proxy worker is pushed an updated + // registry the moment the owner appears (or its debug port changes). + if (storageOwnerRoutePlugins.size > 0) { + externalServices.set(STORAGE_OWNER_WORKER_NAME, { + classNames: new Set(), + entrypoints: new Set(), + }); + } await this.#devRegistry.watch(externalServices, hasQueues); const externalObjects = Array.from(externalServices).flatMap( @@ -2209,8 +2300,8 @@ export class Miniflare { // worker has the correct registry from the moment workerd loads it. const initialRegistry = this.#devRegistry.getRegistry(); const mainModuleSource = [ - `import { ExternalQueueProxy, ExternalServiceProxy, setRegistry, createProxyDurableObjectClass } from "./dev-registry-proxy.worker.js";`, - `export { ExternalQueueProxy, ExternalServiceProxy };`, + `import { ExternalQueueProxy, ExternalServiceProxy, StorageOwnerProxy, setRegistry, createProxyDurableObjectClass } from "./dev-registry-proxy.worker.js";`, + `export { ExternalQueueProxy, ExternalServiceProxy, StorageOwnerProxy };`, `setRegistry(${JSON.stringify(initialRegistry)});`, `export default {`, ` async fetch(request, env) {`, @@ -2319,6 +2410,7 @@ export class Miniflare { durableObjectClassNames, workflowOptions: workflowOptions.size > 0 ? workflowOptions : undefined, allWorkerOpts, + storageOwnerRoutePlugins, }); for (const service of globalServices) { // Global services should all have unique names @@ -2406,6 +2498,7 @@ export class Miniflare { // This function must be run with `#runtimeMutex` held const initial = !this.#runtimeEntryURL; assert(this.#runtime !== undefined); + const runtime = this.#runtime; const configuredHost = this.#sharedOpts.core.host ?? DEFAULT_HOST; // For internal loopback communication with workerd, always use 127.0.0.1 // when localhost is configured. This prevents IPv6/IPv4 mismatch issues @@ -2489,7 +2582,7 @@ export class Miniflare { onWorkerdCrashRestart: () => this.#handleWorkerdCrash(), runtimeEnv: this.#sharedOpts.core.unsafeRuntimeEnv, }; - const maybeSocketPorts = await this.#runtime.updateConfig( + const maybeSocketPorts = await runtime.updateConfig( configBuffer, runtimeOpts, this.#workerOpts.flatMap((w) => w.core.name ?? []), @@ -2674,6 +2767,165 @@ export class Miniflare { return new URL(this.#runtimeEntryURL.toString()); } + /** + * The persist root this instance participates in as a shared storage + * owner/client, or `undefined` if the feature is off or there is nothing to + * share (pure in-memory storage). + */ + #storageOwnerPersistRoot(): string | undefined { + const core = this.#sharedOpts.core; + if (!core.unsafeSharedStorageOwner) { + return undefined; + } + // Discovery + transport ride the dev registry and the debug port, so the + // feature is a no-op without a registry. + if (!this.#devRegistry.isEnabled()) { + return undefined; + } + return core.resourcePersistencePath; + } + + /** The current owner's dev registry entry, or `undefined` if none is live. */ + #readStorageOwnerEntry(): WorkerDefinition | undefined { + return this.#devRegistry.getRegistry()[STORAGE_OWNER_WORKER_NAME]; + } + + /** + * Whether this instance (as a *client*) should route its local storage to the + * shared owner. `false` means behave normally (owner role, feature off, no + * persist root, or no owner currently registered). The owner's live address is + * resolved per-request by `StorageOwnerProxy`, so only its presence matters here. + */ + #getStorageOwnerRouting(): boolean { + const core = this.#sharedOpts.core; + const persistRoot = this.#storageOwnerPersistRoot(); + if (persistRoot === undefined || core.unsafeStorageOwnerRole === "owner") { + return false; + } + + if (this.#readStorageOwnerEntry() === undefined) { + this.#log.warn( + "Shared storage owner enabled but no owner is currently registered — " + + "using local storage for this instance" + ); + return false; + } + return true; + } + + /** + * As a client, make sure a storage owner is registered in the dev registry + * before we assemble (and therefore route to it). If none is, elect a single + * spawner via the owner spawn-lock, spawn a detached owner process, and wait + * for it to register itself. Other clients just wait. + * + * Best-effort: on any failure we log and fall back to local storage (the + * client simply won't route), so the feature degrades rather than crashes. + */ + async #ensureStorageOwner(): Promise { + const core = this.#sharedOpts.core; + const persistRoot = this.#storageOwnerPersistRoot(); + if (persistRoot === undefined || core.unsafeStorageOwnerRole === "owner") { + return; + } + if (this.#readStorageOwnerEntry() !== undefined) { + return; + } + + let lock: ReturnType; + try { + lock = tryAcquireOwnerSpawnLock(persistRoot); + // Re-check under the lock: another client may have just registered one. + if (this.#readStorageOwnerEntry() !== undefined) { + return; + } + if (lock !== undefined) { + this.#spawnStorageOwner(persistRoot); + } + // Wait for the owner (ours or another client's) to register itself. The + // registry watcher refreshes `getRegistry()` as files appear. + const deadline = Date.now() + STORAGE_OWNER_SPAWN_TIMEOUT_MS; + while ( + this.#readStorageOwnerEntry() === undefined && + Date.now() < deadline && + !this.#disposeController.signal.aborted + ) { + await new Promise((resolve) => + setTimeout(resolve, STORAGE_OWNER_POLL_MS) + ); + } + if (this.#readStorageOwnerEntry() === undefined) { + this.#log.warn( + "Timed out waiting for the shared storage owner to start — " + + "using local storage for this instance" + ); + } + } catch (e) { + this.#log.warn(`Failed to ensure a shared storage owner: ${String(e)}`); + } finally { + lock?.release(); + } + } + + /** + * Spawns a detached owner process for the persist root, hosting the storage + * resources this instance uses. The owner runs the same built miniflare + * module, registers itself in the dev registry under `STORAGE_OWNER_WORKER_NAME`, + * and self-terminates once no clients remain (see {@link runStorageOwnerProcess}). + */ + #spawnStorageOwner(persistRoot: string): void { + // Each storage plugin describes the options a spawned owner needs to stand + // up its local storage (the union of local, non-remote resources across + // this instance's workers). The owner's entry services are generic (keyed + // by `idFromName`), so they additionally serve ids declared only by other + // clients. + const ownerOptions: Record = { + resourcePersistencePath: persistRoot, + unsafeDevRegistryPath: this.#sharedOpts.core.unsafeDevRegistryPath, + modules: true, + script: + "export default { async fetch() { return new Response('miniflare storage owner', { status: 404 }); } }", + }; + for (const [key, plugin] of this.#mergedPluginEntries) { + const hosting = plugin.getStorageOwnerHosting?.( + // @ts-expect-error each plugin narrows `options` to its own schema; + // safe because `getStorageOwnerHosting` only reads its own options. + this.#workerOpts.map((o) => this.#getWorkerOptsForPlugin(key, o)) + ); + if (hosting !== undefined) { + Object.assign(ownerOptions, hosting.ownerOptions); + } + } + + const configPath = path.join( + persistRoot, + `.miniflare-owner-config-${process.pid}.json` + ); + fs.writeFileSync(configPath, JSON.stringify(ownerOptions)); + + // The owner is detached and outlives us, so it can't share our stdio. + // Redirect its output to a log file in the persist root — otherwise a + // crashing or misconfigured owner is invisible and clients just silently + // fall back to local storage. + const logPath = path.join(persistRoot, ".miniflare-owner.log"); + const logFd = fs.openSync(logPath, "a"); + try { + const child = spawn(process.execPath, ["-e", STORAGE_OWNER_BOOTSTRAP], { + detached: true, + stdio: ["ignore", logFd, logFd], + env: { + ...process.env, + [ENV_STORAGE_OWNER_MAIN]: __filename, + [ENV_STORAGE_OWNER_CONFIG]: configPath, + }, + }); + child.unref(); + } finally { + // The child has dup'd the fd; close our copy. + fs.closeSync(logFd); + } + } + async #registerWorkers(): Promise { if (!this.#devRegistry.isEnabled()) { return; @@ -2726,6 +2978,36 @@ export class Miniflare { ]); } + // As the shared-storage owner, register under the well-known name so + // clients discover our debug port (and reach our storage services over it). + // The owner's own worker is a dummy 404, so its default/user service fields + // are irrelevant — clients target specific storage services by name. + if (this.#sharedOpts.core.unsafeStorageOwnerRole === "owner") { + entries.push([ + STORAGE_OWNER_WORKER_NAME, + { + debugPortAddress, + defaultEntrypointService: getUserServiceName(), + userWorkerService: getUserServiceName(), + }, + ]); + } + + // As a shared-storage client, publish a presence entry so the owner can + // count live clients (from the registry alone) and self-terminate when the + // last one leaves. Reserved names are filtered from user-facing registry + // enumeration (see `isStorageOwnerRegistryName`). + if (this.#storageOwnerRoutingActive) { + entries.push([ + this.#storageOwnerPresenceName, + { + debugPortAddress, + defaultEntrypointService: getUserServiceName(), + userWorkerService: getUserServiceName(), + }, + ]); + } + this.#devRegistry.register(Object.fromEntries(entries)); } @@ -3391,7 +3673,8 @@ export class Miniflare { // Close the inspector proxy server if there is one await this.#maybeInspectorProxyController?.dispose(); - // Unregister workers from dev registry and stop the file watcher + // Unregister workers from dev registry and stop the file watcher. This + // also removes our shared-storage owner/client presence entry, if any. await this.#devRegistry.dispose(); // shutdown hyperdrive proxies if any exist @@ -3404,6 +3687,117 @@ export class Miniflare { } } +/** + * Entry point for the detached storage-owner process spawned by a client (see + * `Miniflare.#spawnStorageOwner`). Reads its config from a temp file named in + * the environment, starts a headless owner-role Miniflare, and self-terminates + * once no clients have been present for a debounce window (after a startup + * grace period), so storage processes don't linger. + */ +export async function runStorageOwnerProcess(): Promise { + // The owner is detached with its stdio redirected to `.miniflare-owner.log` + // in the persist root (see `#spawnStorageOwner`), so these lines are how a + // broken owner makes itself heard rather than failing silently. Writes go + // straight to the process's own stdout/stderr (there is no host `Log` here). + const tag = `[miniflare storage owner ${process.pid}]`; + const ownerLog = (message: string) => + process.stdout.write(`${tag} ${message}\n`); + const ownerError = (message: string, e: unknown) => + process.stderr.write( + `${tag} ${message} ${e instanceof Error ? (e.stack ?? e.message) : String(e)}\n` + ); + // Surface anything that would otherwise kill the process silently. + process.on("uncaughtException", (e) => { + ownerError("uncaught:", e); + process.exit(1); + }); + process.on("unhandledRejection", (e) => { + ownerError("unhandled:", e); + process.exit(1); + }); + + const configPath = process.env[ENV_STORAGE_OWNER_CONFIG]; + assert(configPath !== undefined, `${ENV_STORAGE_OWNER_CONFIG} must be set`); + const options = JSON.parse( + fs.readFileSync(configPath, "utf8") + ) as MiniflareOptions; + // The config file has served its purpose; remove it. + fs.rmSync(configPath, { force: true }); + + const persistRoot = (options as { resourcePersistencePath?: string }) + .resourcePersistencePath; + assert( + persistRoot !== undefined, + "storage owner config must set `resourcePersistencePath`" + ); + const registryPath = (options as { unsafeDevRegistryPath?: string }) + .unsafeDevRegistryPath; + assert( + registryPath !== undefined, + "storage owner config must set `unsafeDevRegistryPath`" + ); + ownerLog(`starting for persist root ${persistRoot}`); + + const mf = new Miniflare({ + ...options, + unsafeSharedStorageOwner: true, + unsafeStorageOwnerRole: "owner", + }); + + let disposing = false; + // Holder so `shutdown` (defined before the interval is created) can clear it. + const timers: { idle?: NodeJS.Timeout } = {}; + const shutdown = async (reason: string) => { + if (disposing) { + return; + } + disposing = true; + ownerLog(`shutting down (${reason})`); + if (timers.idle !== undefined) { + clearInterval(timers.idle); + } + try { + await mf.dispose(); + } finally { + process.exit(0); + } + }; + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); + + try { + await mf.ready; + } catch (e) { + ownerError("failed to start:", e); + process.exit(1); + } + ownerLog("ready"); + + // Self-teardown: once past the startup grace, exit after a debounced run of + // checks observing zero live clients. A client is any live dev-registry + // presence entry (see `#registerWorkers`); the registry's own heartbeat + + // staleness reclaim handles crashed clients that never unregistered. + const countLiveClients = () => + Object.keys(getWorkerRegistry(registryPath)).filter((name) => + name.startsWith(STORAGE_OWNER_CLIENT_PRESENCE_PREFIX) + ).length; + const startedAt = Date.now(); + let idleChecks = 0; + timers.idle = setInterval(() => { + if (Date.now() - startedAt < STORAGE_OWNER_STARTUP_GRACE_MS) { + return; + } + if (countLiveClients() === 0) { + idleChecks++; + if (idleChecks >= STORAGE_OWNER_IDLE_DEBOUNCE) { + void shutdown("no live clients"); + } + } else { + idleChecks = 0; + } + }, STORAGE_OWNER_IDLE_CHECK_MS); +} + export type { WorkerdStructuredLog } from "./plugins/core"; export interface SecretsStoreSecretAdmin { diff --git a/packages/miniflare/src/plugins/agent-memory/index.ts b/packages/miniflare/src/plugins/agent-memory/index.ts index be60145a219..3f7a30cf665 100644 --- a/packages/miniflare/src/plugins/agent-memory/index.ts +++ b/packages/miniflare/src/plugins/agent-memory/index.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - buildRemoteProxyProps, - ProxyNodeBinding, - remoteProxyClientWorker, -} from "../shared"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; +import { buildRemoteProxyProps, ProxyNodeBinding } from "../shared"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; const AgentMemoryEntrySchema = z.object({ @@ -19,9 +16,6 @@ export const AgentMemoryOptionsSchema = z.object({ export const AGENT_MEMORY_PLUGIN_NAME = "agent-memory"; -const AGENT_MEMORY_SCOPE = "agent-memory"; -const AGENT_MEMORY_REMOTE_SERVICE_NAME = `${AGENT_MEMORY_SCOPE}:remote`; - export const AGENT_MEMORY_PLUGIN: Plugin = { options: AgentMemoryOptionsSchema, bindingTypeDescription: "Agent Memory", @@ -33,7 +27,7 @@ export const AGENT_MEMORY_PLUGIN: Plugin = { return Object.entries(options.agentMemory).map(([bindingName, entry]) => ({ name: bindingName, service: { - name: AGENT_MEMORY_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps( entry.remoteProxyConnectionString, bindingName @@ -53,16 +47,7 @@ export const AGENT_MEMORY_PLUGIN: Plugin = { ]) ); }, - async getServices({ options }) { - if (!options.agentMemory || Object.keys(options.agentMemory).length === 0) { - return []; - } - - return [ - { - name: AGENT_MEMORY_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }, - ]; + async getServices() { + return []; }, }; diff --git a/packages/miniflare/src/plugins/ai-search/index.ts b/packages/miniflare/src/plugins/ai-search/index.ts index 7779c8c1a3c..559851c2158 100644 --- a/packages/miniflare/src/plugins/ai-search/index.ts +++ b/packages/miniflare/src/plugins/ai-search/index.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - buildRemoteProxyProps, - ProxyNodeBinding, - remoteProxyClientWorker, -} from "../shared"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; +import { buildRemoteProxyProps, ProxyNodeBinding } from "../shared"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; const AISearchEntrySchema = z.object({ @@ -21,9 +18,6 @@ export const AISearchOptionsSchema = z.object({ export const AI_SEARCH_PLUGIN_NAME = "ai-search"; -// One shared remote-proxy service for all AI Search bindings (config via props). -const AI_SEARCH_REMOTE_SERVICE_NAME = `${AI_SEARCH_PLUGIN_NAME}:remote`; - export const AI_SEARCH_PLUGIN: Plugin = { options: AISearchOptionsSchema, bindingTypeDescription: "AI Search", @@ -40,7 +34,7 @@ export const AI_SEARCH_PLUGIN: Plugin = { bindings.push({ name: bindingName, service: { - name: AI_SEARCH_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps( entry.remoteProxyConnectionString, bindingName @@ -63,22 +57,7 @@ export const AI_SEARCH_PLUGIN: Plugin = { return nodeBindings; }, - async getServices({ options }) { - const services: { - name: string; - worker: ReturnType; - }[] = []; - - const hasAny = - Object.keys(options.aiSearchNamespaces ?? {}).length > 0 || - Object.keys(options.aiSearchInstances ?? {}).length > 0; - if (hasAny) { - services.push({ - name: AI_SEARCH_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }); - } - - return services; + async getServices() { + return []; }, }; diff --git a/packages/miniflare/src/plugins/ai/index.ts b/packages/miniflare/src/plugins/ai/index.ts index b24616f49f4..abf11c1b80d 100644 --- a/packages/miniflare/src/plugins/ai/index.ts +++ b/packages/miniflare/src/plugins/ai/index.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - buildRemoteProxyProps, - ProxyNodeBinding, - remoteProxyClientWorker, -} from "../shared"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; +import { buildRemoteProxyProps, ProxyNodeBinding } from "../shared"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; const AISchema = z.object({ @@ -18,7 +15,6 @@ export const AIOptionsSchema = z.object({ }); export const AI_PLUGIN_NAME = "ai"; -const AI_REMOTE_SERVICE_NAME = `${AI_PLUGIN_NAME}:remote`; export const AI_PLUGIN: Plugin = { options: AIOptionsSchema, @@ -37,7 +33,7 @@ export const AI_PLUGIN: Plugin = { { name: "fetcher", service: { - name: AI_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps( options.ai.remoteProxyConnectionString, options.ai.binding @@ -57,16 +53,7 @@ export const AI_PLUGIN: Plugin = { [options.ai.binding]: new ProxyNodeBinding(), }; }, - async getServices({ options }) { - if (!options.ai) { - return []; - } - - return [ - { - name: AI_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }, - ]; + async getServices() { + return []; }, }; diff --git a/packages/miniflare/src/plugins/artifacts/index.ts b/packages/miniflare/src/plugins/artifacts/index.ts index 2ac00d49fb9..b7136b15b7d 100644 --- a/packages/miniflare/src/plugins/artifacts/index.ts +++ b/packages/miniflare/src/plugins/artifacts/index.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - buildRemoteProxyProps, - ProxyNodeBinding, - remoteProxyClientWorker, -} from "../shared"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; +import { buildRemoteProxyProps, ProxyNodeBinding } from "../shared"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; const ArtifactsSchema = z.object({ @@ -18,9 +15,6 @@ export const ArtifactsOptionsSchema = z.object({ }); export const ARTIFACTS_PLUGIN_NAME = "artifacts"; -// One shared remote-proxy service for every artifacts binding; per-binding -// config travels via props. -const ARTIFACTS_REMOTE_SERVICE_NAME = `${ARTIFACTS_PLUGIN_NAME}:remote`; export const ARTIFACTS_PLUGIN: Plugin = { options: ArtifactsOptionsSchema, @@ -33,7 +27,7 @@ export const ARTIFACTS_PLUGIN: Plugin = { return Object.entries(options.artifacts).map(([name, config]) => ({ name, service: { - name: ARTIFACTS_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps(config.remoteProxyConnectionString, name), }, })); @@ -49,16 +43,7 @@ export const ARTIFACTS_PLUGIN: Plugin = { ]) ); }, - async getServices({ options }) { - if (!options.artifacts || Object.keys(options.artifacts).length === 0) { - return []; - } - - return [ - { - name: ARTIFACTS_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }, - ]; + async getServices() { + return []; }, }; diff --git a/packages/miniflare/src/plugins/browser-rendering/index.ts b/packages/miniflare/src/plugins/browser-rendering/index.ts index 8ac011b0b28..6bb5cdaa841 100644 --- a/packages/miniflare/src/plugins/browser-rendering/index.ts +++ b/packages/miniflare/src/plugins/browser-rendering/index.ts @@ -17,11 +17,11 @@ import { import BROWSER_RENDERING_WORKER from "worker:browser-rendering/binding"; import { z } from "zod"; import { kVoid } from "../../runtime"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; import { buildRemoteProxyProps, getUserBindingServiceName, ProxyNodeBinding, - remoteProxyClientWorker, WORKER_BINDING_SERVICE_LOOPBACK, } from "../shared"; import type { Log } from "../../shared"; @@ -41,7 +41,6 @@ export const BrowserRenderingOptionsSchema = z.object({ }); export const BROWSER_RENDERING_PLUGIN_NAME = "browser-rendering"; -const BROWSER_RENDERING_REMOTE_SERVICE_NAME = `${BROWSER_RENDERING_PLUGIN_NAME}:remote`; export const BROWSER_RENDERING_PLUGIN: Plugin< typeof BrowserRenderingOptionsSchema @@ -58,7 +57,7 @@ export const BROWSER_RENDERING_PLUGIN: Plugin< name: options.browserRendering.binding, service: options.browserRendering.remoteProxyConnectionString ? { - name: BROWSER_RENDERING_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps( options.browserRendering.remoteProxyConnectionString, options.browserRendering.binding @@ -87,12 +86,7 @@ export const BROWSER_RENDERING_PLUGIN: Plugin< } if (options.browserRendering.remoteProxyConnectionString) { - return [ - { - name: BROWSER_RENDERING_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }, - ]; + return []; } return [ diff --git a/packages/miniflare/src/plugins/cache/index.ts b/packages/miniflare/src/plugins/cache/index.ts index b7e7192c151..452e75f942b 100644 --- a/packages/miniflare/src/plugins/cache/index.ts +++ b/packages/miniflare/src/plugins/cache/index.ts @@ -47,6 +47,7 @@ export const CACHE_PLUGIN: Plugin = { workerIndex, tmpPath, resourcePersistencePath, + isolateLocalStorage, }) { const cache = options.cacheAPI ?? true; @@ -84,10 +85,13 @@ export const CACHE_PLUGIN: Plugin = { if (cache) { const uniqueKey = `miniflare-${CACHE_OBJECT_CLASS_NAME}`; + // With the shared storage owner enabled, cache stays local to each + // instance so each process uses its own `tmpPath` cache and never + // contends cross-process. const persistPath = getPersistPath( CACHE_PLUGIN_NAME, tmpPath, - resourcePersistencePath + isolateLocalStorage ? undefined : resourcePersistencePath ); await fs.mkdir(persistPath, { recursive: true }); const storageService: Service = { diff --git a/packages/miniflare/src/plugins/core/constants.ts b/packages/miniflare/src/plugins/core/constants.ts index b4811ea26d9..ac5bf85351b 100644 --- a/packages/miniflare/src/plugins/core/constants.ts +++ b/packages/miniflare/src/plugins/core/constants.ts @@ -31,6 +31,8 @@ const SERVICE_CUSTOM_FETCH_PREFIX = `${CORE_PLUGIN_NAME}:custom-fetch`; // Service prefix for custom Node functions defined in `serviceBindings` option const SERVICE_CUSTOM_NODE_PREFIX = `${CORE_PLUGIN_NAME}:custom-node`; +export const SERVICE_REMOTE_BINDINGS = `${CORE_PLUGIN_NAME}:remote-bindings`; + export function getUserServiceName(workerName = "") { return `${SERVICE_USER_PREFIX}:${workerName}`; } diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 53569af0075..867a67567bc 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -33,6 +33,7 @@ import { IMAGES_PLUGIN_NAME } from "../images"; import { getR2PublicService, getR2S3Service, + R2_PLUGIN_NAME, R2_PUBLIC_SERVICE_NAME, R2_S3_SERVICE_NAME, } from "../r2"; @@ -57,6 +58,7 @@ import { OBSERVABILITY_COMPAT_FLAGS, SERVICE_ENTRY, SERVICE_LOCAL_EXPLORER, + SERVICE_REMOTE_BINDINGS, } from "./constants"; import { constructExplorerBindingMap, @@ -316,6 +318,15 @@ export const CoreSharedOptionsSchema = z.object({ // Path to the project temporary directory for plugins that need it // (e.g. `.wrangler/tmp` for email logs). Falls back to a subdirectory of tmpPath if not set. resourceTmpPath: z.string().optional(), + // Route supported local storage through a single detached "owner" process, + // so exactly one process opens the underlying SQLite/blob files. + // No-op when `resourcePersistencePath` is undefined (pure in-memory storage). + unsafeSharedStorageOwner: z.boolean().optional(), + // Internal: the role this instance plays in the shared-storage-owner + // topology. "owner" publishes itself as the storage owner; "client" (the + // default when the feature is enabled) routes storage to whichever owner is + // published. Set on the detached owner process. + unsafeStorageOwnerRole: z.enum(["owner", "client"]).optional(), // Strip the MF-DISABLE_PRETTY_ERROR header from user request stripDisablePrettyError: z.boolean().default(true), @@ -937,6 +948,14 @@ export const CORE_PLUGIN: Plugin< }); } + // Always inject the remote bindings proxy worker, without checking if any are used + // This simplifies the logic in each plugin by letting them target a well known service + // rather than each plugin having to inject it's own remote bindings proxy + services.push({ + name: SERVICE_REMOTE_BINDINGS, + worker: remoteProxyClientWorker(), + }); + return { services, extensions }; }, }; @@ -955,6 +974,8 @@ export interface GlobalServicesOptions { workflowOptions?: Map; /** All worker options for building per-worker resource bindings */ allWorkerOpts?: PluginWorkerOptions[]; + /** Storage plugins routed to a shared owner; their global services are skipped. */ + storageOwnerRoutePlugins?: Set; } export function getGlobalServices({ sharedOptions, @@ -966,6 +987,7 @@ export function getGlobalServices({ durableObjectClassNames, workflowOptions, allWorkerOpts, + storageOwnerRoutePlugins, }: GlobalServicesOptions): Service[] { // Collect list of workers we could route to, then parse and sort all routes const workerNames = [...allWorkerRoutes.keys()]; @@ -1025,11 +1047,15 @@ export function getGlobalServices({ }, }); } - const streamServiceEnabled = allWorkerOpts?.some( - (worker) => - worker.stream?.stream !== undefined && - !worker.stream.stream.remoteProxyConnectionString - ); + // When Stream is routed to a shared storage owner, the local stream service + // isn't stood up, so the entry worker must not bind it either. + const streamServiceEnabled = + !storageOwnerRoutePlugins?.has(STREAM_PLUGIN_NAME) && + allWorkerOpts?.some( + (worker) => + worker.stream?.stream !== undefined && + !worker.stream.stream.remoteProxyConnectionString + ); if (streamServiceEnabled) { serviceEntryBindings.push({ name: CoreBindings.SERVICE_STREAM, @@ -1039,14 +1065,20 @@ export function getGlobalServices({ }, }); } - const r2PublicService = getR2PublicService(allWorkerOpts ?? []); + // When R2 is routed to a shared storage owner, the local R2 storage services + // (incl. the entry service the public worker binds) aren't stood up, so skip + // the public-bucket service too. + const routeR2ToOwner = storageOwnerRoutePlugins?.has(R2_PLUGIN_NAME) === true; + const r2PublicService = routeR2ToOwner + ? undefined + : getR2PublicService(allWorkerOpts ?? []); if (r2PublicService !== undefined) { serviceEntryBindings.push({ name: CoreBindings.SERVICE_R2_PUBLIC, service: { name: R2_PUBLIC_SERVICE_NAME }, }); } - const r2S3Service = getR2S3Service(allWorkerOpts ?? []); + const r2S3Service = getR2S3Service(allWorkerOpts ?? [], routeR2ToOwner); if (r2S3Service !== undefined) { serviceEntryBindings.push({ name: CoreBindings.SERVICE_R2_S3, diff --git a/packages/miniflare/src/plugins/d1/index.ts b/packages/miniflare/src/plugins/d1/index.ts index cea6d003a81..e166a3447d3 100644 --- a/packages/miniflare/src/plugins/d1/index.ts +++ b/packages/miniflare/src/plugins/d1/index.ts @@ -3,17 +3,19 @@ import fs from "node:fs/promises"; import SCRIPT_D1_DATABASE_OBJECT from "worker:d1/database"; import { z } from "zod"; import { SharedBindings } from "../../workers"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; import { buildObjectEntryProps, buildRemoteProxyProps, + extractObjectEntryId, getMiniflareObjectBindings, getPersistPath, namespaceEntries, namespaceKeys, objectEntryWorker, ProxyNodeBinding, - remoteProxyClientWorker, SERVICE_LOOPBACK, + storageOwnerProxyDesignator, } from "../shared"; import type { Service, @@ -46,9 +48,7 @@ const D1_STORAGE_SERVICE_NAME = `${D1_PLUGIN_NAME}:storage`; const D1_DATABASE_SERVICE_PREFIX = `${D1_PLUGIN_NAME}:db`; // A single entry service shared by every *local* database. Each database's id is // supplied per-binding via `ctx.props`, so one service serves all of them. -const D1_LOCAL_ENTRY_SERVICE_NAME = `${D1_PLUGIN_NAME}:db:entry`; -// One shared remote-proxy service for all remote D1 databases (config via props). -const D1_REMOTE_SERVICE_NAME = `${D1_PLUGIN_NAME}:db:remote`; +export const D1_LOCAL_ENTRY_SERVICE_NAME = `${D1_PLUGIN_NAME}:db:entry`; const D1_DATABASE_OBJECT_CLASS_NAME = "D1DatabaseObject"; const D1_DATABASE_OBJECT: Worker_Binding_DurableObjectNamespaceDesignator = { serviceName: D1_DATABASE_SERVICE_PREFIX, @@ -71,7 +71,7 @@ export const D1_PLUGIN: Plugin = { // databases share one entry service with the id supplied via props. const serviceDesignator = remoteProxyConnectionString ? { - name: D1_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps(remoteProxyConnectionString, name), } : { @@ -107,15 +107,25 @@ export const D1_PLUGIN: Plugin = { databases.map((name) => [name, new ProxyNodeBinding()]) ); }, - async getServices({ options, tmpPath, resourcePersistencePath }) { + async getServices({ + options, + tmpPath, + resourcePersistencePath, + storageOwnerRoutePlugins, + }) { const databases = namespaceEntries(options.d1Databases); const services: Service[] = []; + // When routing local D1 to a shared storage owner, this instance must not + // stand up its own D1 storage — its bindings are repointed at the owner + // proxy by `Miniflare`. + const routeToOwner = storageOwnerRoutePlugins.has(D1_PLUGIN_NAME); + // One shared entry service for all local databases (id supplied via props). - const hasLocal = databases.some( - ([, db]) => !db.remoteProxyConnectionString - ); + const hasLocal = + !routeToOwner && + databases.some(([, db]) => !db.remoteProxyConnectionString); if (hasLocal) { services.push({ name: D1_LOCAL_ENTRY_SERVICE_NAME, @@ -123,17 +133,6 @@ export const D1_PLUGIN: Plugin = { }); } - // One shared proxy service for all remote (mixed-mode) databases. - const hasRemote = databases.some( - ([, db]) => db.remoteProxyConnectionString - ); - if (hasRemote) { - services.push({ - name: D1_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }); - } - if (hasLocal) { const uniqueKey = `miniflare-${D1_DATABASE_OBJECT_CLASS_NAME}`; const persistPath = getPersistPath( @@ -185,4 +184,62 @@ export const D1_PLUGIN: Plugin = { return services; }, + routeBindingToStorageOwner(binding) { + // The owner runs the same D1 plugin code, so its generic entry service is + // `D1_LOCAL_ENTRY_SERVICE_NAME`; the id travels as props. + const toOwner = (id: string) => + storageOwnerProxyDesignator(D1_LOCAL_ENTRY_SERVICE_NAME, undefined, { + [SharedBindings.TEXT_NAMESPACE]: id, + }); + // Pre-Wrangler-3.3 `__D1_BETA__` binding: a bare service designator. + if ("service" in binding && binding.service?.name !== undefined) { + const id = extractObjectEntryId(binding.service.props?.json); + if (id !== undefined) { + return { + name: binding.name, + service: toOwner(id), + }; + } + } + // Post-3.3 wrapped binding: rewrite the inner fetcher service designator. + if ("wrapped" in binding && binding.wrapped?.innerBindings !== undefined) { + let rewrote = false; + const innerBindings = binding.wrapped.innerBindings.map((inner) => { + if ("service" in inner && inner.service?.name !== undefined) { + const id = extractObjectEntryId(inner.service.props?.json); + if (id !== undefined) { + rewrote = true; + return { + ...inner, + service: toOwner(id), + }; + } + } + return inner; + }); + if (rewrote) { + return { + ...binding, + wrapped: { ...binding.wrapped, innerBindings }, + }; + } + } + return undefined; + }, + getStorageOwnerHosting(allOptions) { + const ids = new Set(); + for (const options of allOptions) { + for (const [, db] of namespaceEntries(options.d1Databases)) { + if (!db.remoteProxyConnectionString) { + ids.add(db.id); + } + } + } + if (ids.size === 0) { + return undefined; + } + return { + ownerOptions: { d1Databases: [...ids] }, + }; + }, }; diff --git a/packages/miniflare/src/plugins/do/index.ts b/packages/miniflare/src/plugins/do/index.ts index 8efbdeadcb6..7cf01483fa5 100644 --- a/packages/miniflare/src/plugins/do/index.ts +++ b/packages/miniflare/src/plugins/do/index.ts @@ -137,6 +137,7 @@ export const DURABLE_OBJECTS_PLUGIN: Plugin< resourcePersistencePath, durableObjectClassNames, unsafeEphemeralDurableObjects, + isolateLocalStorage, }) { // Check if we even have any Durable Object bindings, if we don't, we can // skip creating the storage directory @@ -154,10 +155,13 @@ export const DURABLE_OBJECTS_PLUGIN: Plugin< // don't need to create the storage service at all. if (unsafeEphemeralDurableObjects) return; + // Durable Objects aren't routed to the shared storage owner (a DO is + // single-owner compute, not just storage). When the owner feature is on, + // keep DO storage per-instance so separate processes don't contend. const storagePath = getPersistPath( DURABLE_OBJECTS_PLUGIN_NAME, tmpPath, - resourcePersistencePath + isolateLocalStorage ? undefined : resourcePersistencePath ); // `workerd` requires the `disk.path` to exist. Setting `recursive: true` // is like `mkdir -p`: it won't fail if the directory already exists, and it @@ -166,7 +170,7 @@ export const DURABLE_OBJECTS_PLUGIN: Plugin< return [ { // Note this service will be de-duped by name if multiple Workers create - // it. Each Worker will have the same `sharedOptions` though, so this + // it. Each Worker uses the same resource persistence path, so this // isn't a problem. name: DURABLE_OBJECTS_STORAGE_SERVICE_NAME, disk: { path: storagePath, writable: true }, diff --git a/packages/miniflare/src/plugins/email/index.ts b/packages/miniflare/src/plugins/email/index.ts index dfaedfaf3c3..dcaf1601aac 100644 --- a/packages/miniflare/src/plugins/email/index.ts +++ b/packages/miniflare/src/plugins/email/index.ts @@ -3,10 +3,10 @@ import path from "node:path"; import EMAIL_MESSAGE from "worker:email/email"; import SEND_EMAIL_BINDING from "worker:email/send_email"; import { z } from "zod"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; import { buildRemoteProxyProps, getUserBindingServiceName, - remoteProxyClientWorker, ProxyNodeBinding, } from "../shared"; import type { Service, Worker_Binding } from "../../runtime"; @@ -44,7 +44,6 @@ export const EmailOptionsSchema = z.object({ export const EMAIL_PLUGIN_NAME = "email"; const SERVICE_SEND_EMAIL_WORKER_PREFIX = `SEND-EMAIL-WORKER`; -const EMAIL_REMOTE_SERVICE_NAME = `${EMAIL_PLUGIN_NAME}:remote`; // Disk service name and binding name for writing temporary files to system temp directory const EMAIL_DISK_SERVICE_NAME = `${EMAIL_PLUGIN_NAME}:disk`; const EMAIL_DISK_BINDING_NAME = "MINIFLARE_EMAIL_DISK"; @@ -113,7 +112,7 @@ export const EMAIL_PLUGIN: Plugin = { name, service: remoteProxyConnectionString ? { - name: EMAIL_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps(remoteProxyConnectionString, name), } : { @@ -184,11 +183,9 @@ export const EMAIL_PLUGIN: Plugin = { }, })); - let hasRemote = false; for (const { name, remoteProxyConnectionString, ...config } of args.options .email?.send_email ?? []) { if (remoteProxyConnectionString) { - hasRemote = true; continue; } services.push({ @@ -216,13 +213,6 @@ export const EMAIL_PLUGIN: Plugin = { }); } - if (hasRemote) { - services.push({ - name: EMAIL_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }); - } - return services; }, diff --git a/packages/miniflare/src/plugins/flagship/index.ts b/packages/miniflare/src/plugins/flagship/index.ts index 16f0f9029bb..97c5c62e354 100644 --- a/packages/miniflare/src/plugins/flagship/index.ts +++ b/packages/miniflare/src/plugins/flagship/index.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - buildRemoteProxyProps, - ProxyNodeBinding, - remoteProxyClientWorker, -} from "../shared"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; +import { buildRemoteProxyProps, ProxyNodeBinding } from "../shared"; import type { Worker_Binding } from "../../runtime"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; @@ -19,7 +16,6 @@ export const FlagshipOptionsSchema = z.object({ }); export const FLAGSHIP_PLUGIN_NAME = "flagship"; -const FLAGSHIP_REMOTE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}:remote`; export const FLAGSHIP_PLUGIN: Plugin = { options: FlagshipOptionsSchema, @@ -33,7 +29,7 @@ export const FLAGSHIP_PLUGIN: Plugin = { ([name, config]) => ({ name, service: { - name: FLAGSHIP_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps( config.remoteProxyConnectionString, name @@ -54,15 +50,6 @@ export const FLAGSHIP_PLUGIN: Plugin = { ); }, async getServices({ options }) { - if (!options.flagship || Object.keys(options.flagship).length === 0) { - return []; - } - - return [ - { - name: FLAGSHIP_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }, - ]; + return []; }, }; diff --git a/packages/miniflare/src/plugins/images/index.ts b/packages/miniflare/src/plugins/images/index.ts index aabff361b5d..fbab3b584c0 100644 --- a/packages/miniflare/src/plugins/images/index.ts +++ b/packages/miniflare/src/plugins/images/index.ts @@ -3,6 +3,7 @@ import SCRIPT_IMAGES_SERVICE from "worker:images/images"; import SCRIPT_KV_NAMESPACE_OBJECT from "worker:kv/namespace"; import { z } from "zod"; import { SharedBindings } from "../../workers"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; import { KV_NAMESPACE_OBJECT_CLASS_NAME } from "../kv"; import { buildRemoteProxyProps, @@ -11,8 +12,8 @@ import { getUserBindingServiceName, objectEntryWorker, ProxyNodeBinding, - remoteProxyClientWorker, SERVICE_LOOPBACK, + storageOwnerProxyDesignator, WORKER_BINDING_SERVICE_LOOPBACK, } from "../shared"; import type { Service } from "../../runtime"; @@ -30,7 +31,11 @@ export const ImagesOptionsSchema = z.object({ }); export const IMAGES_PLUGIN_NAME = "images"; -const IMAGES_REMOTE_SERVICE_NAME = `${IMAGES_PLUGIN_NAME}:remote`; +// Fixed namespace backing the Images store (one per instance/owner). +const IMAGES_DATA_NAMESPACE = "images-data"; +// The object-entry service exposing the Images store. Referenced by the shared +// storage owner so it can serve a routed client's Images KV operations. +export const IMAGES_NS_DATA_SERVICE_NAME = `${IMAGES_PLUGIN_NAME}:ns:data`; export const IMAGES_PLUGIN: Plugin = { options: ImagesOptionsSchema, @@ -50,7 +55,7 @@ export const IMAGES_PLUGIN: Plugin = { name: "fetcher", service: options.images.remoteProxyConnectionString ? { - name: IMAGES_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps( options.images.remoteProxyConnectionString, options.images.binding @@ -76,20 +81,51 @@ export const IMAGES_PLUGIN: Plugin = { [options.images.binding]: new ProxyNodeBinding(), }; }, - async getServices({ options, tmpPath, resourcePersistencePath }) { + async getServices({ + options, + tmpPath, + resourcePersistencePath, + storageOwnerRoutePlugins, + }) { if (!options.images) { return []; } - if (options.images.remoteProxyConnectionString) { + // Routed to the shared storage owner: keep the transform worker local but + // repoint its backing KV store (`IMAGES_STORE`) at the owner, and skip the + // local storage/object services (the owner stands them up). The owner's + // `IMAGES_NS_DATA_SERVICE_NAME` bakes the images namespace id in, so no + // per-request id/props are needed. + if (storageOwnerRoutePlugins.has(IMAGES_PLUGIN_NAME)) { return [ { - name: IMAGES_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), + name: getUserBindingServiceName( + IMAGES_PLUGIN_NAME, + options.images.binding + ), + worker: { + compatibilityDate: "2025-04-01", + modules: [ + { name: "images.worker.js", esModule: SCRIPT_IMAGES_SERVICE() }, + ], + bindings: [ + { + name: "IMAGES_STORE", + kvNamespace: storageOwnerProxyDesignator( + IMAGES_NS_DATA_SERVICE_NAME + ), + }, + WORKER_BINDING_SERVICE_LOOPBACK, + ], + }, }, ]; } + if (options.images.remoteProxyConnectionString) { + return []; + } + const serviceName = getUserBindingServiceName( IMAGES_PLUGIN_NAME, options.images.binding @@ -141,13 +177,13 @@ export const IMAGES_PLUGIN: Plugin = { } satisfies Service; const kvNamespaceService = { - name: `${IMAGES_PLUGIN_NAME}:ns:data`, + name: IMAGES_NS_DATA_SERVICE_NAME, worker: objectEntryWorker( { serviceName: objectService.name, className: KV_NAMESPACE_OBJECT_CLASS_NAME, }, - "images-data" + IMAGES_DATA_NAMESPACE ), } satisfies Service; @@ -173,4 +209,19 @@ export const IMAGES_PLUGIN: Plugin = { return [storageService, objectService, kvNamespaceService, imagesService]; }, + getStorageOwnerHosting(allOptions) { + const hasLocal = allOptions.some( + (options) => options.images && !options.images.remoteProxyConnectionString + ); + if (!hasLocal) { + return undefined; + } + // One images store per owner (binding name irrelevant). Served via the + // fetch path like KV. The client side is handled in `getServices` (the + // transform worker stays local, only its backing KV store is repointed at + // the owner's `IMAGES_NS_DATA_SERVICE_NAME`). + return { + ownerOptions: { images: { binding: "images" } }, + }; + }, }; diff --git a/packages/miniflare/src/plugins/kv/index.ts b/packages/miniflare/src/plugins/kv/index.ts index 6da8d3a1da6..658d4629316 100644 --- a/packages/miniflare/src/plugins/kv/index.ts +++ b/packages/miniflare/src/plugins/kv/index.ts @@ -3,16 +3,19 @@ import SCRIPT_KV_NAMESPACE_OBJECT from "worker:kv/namespace"; import { z } from "zod"; import { PathSchema } from "../../shared"; import { SharedBindings } from "../../workers"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; import { + buildObjectEntryProps, buildRemoteProxyProps, + extractObjectEntryId, getMiniflareObjectBindings, getPersistPath, namespaceEntries, namespaceKeys, objectEntryWorker, ProxyNodeBinding, - remoteProxyClientWorker, SERVICE_LOOPBACK, + storageOwnerProxyDesignator, } from "../shared"; import { KV_PLUGIN_NAME } from "./constants"; import { @@ -55,9 +58,7 @@ export const KVOptionsSchema = z.object({ const SERVICE_NAMESPACE_PREFIX = `${KV_PLUGIN_NAME}:ns`; // A single entry service shared by every *local* namespace. Each namespace's id // is supplied per-binding via `ctx.props`, so one service serves all of them. -const KV_LOCAL_ENTRY_SERVICE_NAME = `${KV_PLUGIN_NAME}:ns:entry`; -// One shared remote-proxy service for all remote namespaces (config via props). -const KV_REMOTE_SERVICE_NAME = `${KV_PLUGIN_NAME}:ns:remote`; +export const KV_LOCAL_ENTRY_SERVICE_NAME = `${KV_PLUGIN_NAME}:ns:entry`; const KV_STORAGE_SERVICE_NAME = `${KV_PLUGIN_NAME}:storage`; export const KV_NAMESPACE_OBJECT_CLASS_NAME = "KVNamespaceObject"; const KV_NAMESPACE_OBJECT: Worker_Binding_DurableObjectNamespaceDesignator = { @@ -83,7 +84,7 @@ export const KV_PLUGIN: Plugin = { return { name, kvNamespace: { - name: KV_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps( namespace.remoteProxyConnectionString, name @@ -97,11 +98,7 @@ export const KV_PLUGIN: Plugin = { name, kvNamespace: { name: KV_LOCAL_ENTRY_SERVICE_NAME, - props: { - json: JSON.stringify({ - [SharedBindings.TEXT_NAMESPACE]: namespace.id, - }), - }, + props: buildObjectEntryProps(namespace.id), }, }; }); @@ -126,15 +123,26 @@ export const KV_PLUGIN: Plugin = { return bindings; }, - async getServices({ options, tmpPath, resourcePersistencePath }) { + async getServices({ + options, + tmpPath, + resourcePersistencePath, + storageOwnerRoutePlugins, + }) { const namespaces = namespaceEntries(options.kvNamespaces); const services: Service[] = []; + // When routing local KV to a shared storage owner, this instance must not + // stand up its own KV storage (disk/DO/migrations) — its bindings are + // repointed at the owner proxy by `Miniflare`. Sites are still served + // locally as they aren't routed. + const routeToOwner = storageOwnerRoutePlugins.has(KV_PLUGIN_NAME); + // One shared entry service for all local namespaces (id supplied via props). - const hasLocalNamespace = namespaces.some( - ([, ns]) => !ns.remoteProxyConnectionString - ); + const hasLocalNamespace = + !routeToOwner && + namespaces.some(([, ns]) => !ns.remoteProxyConnectionString); if (hasLocalNamespace) { services.push({ name: KV_LOCAL_ENTRY_SERVICE_NAME, @@ -142,17 +150,6 @@ export const KV_PLUGIN: Plugin = { }); } - // One shared proxy service for all remote (mixed-mode) namespaces. - const hasRemoteNamespace = namespaces.some( - ([, ns]) => ns.remoteProxyConnectionString - ); - if (hasRemoteNamespace) { - services.push({ - name: KV_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }); - } - if (hasLocalNamespace) { const uniqueKey = `miniflare-${KV_NAMESPACE_OBJECT_CLASS_NAME}`; const persistPath = getPersistPath( @@ -204,6 +201,45 @@ export const KV_PLUGIN: Plugin = { return services; }, + + routeBindingToStorageOwner(binding) { + if ("kvNamespace" in binding && binding.kvNamespace?.name !== undefined) { + const id = extractObjectEntryId(binding.kvNamespace.props?.json); + if (id !== undefined) { + return { + name: binding.name, + // The owner runs the same KV plugin code, so its generic entry + // service is `KV_LOCAL_ENTRY_SERVICE_NAME`; the id travels as props + // (read by `object-entry.worker.ts` via `ctx.props`). + kvNamespace: storageOwnerProxyDesignator( + KV_LOCAL_ENTRY_SERVICE_NAME, + undefined, + { [SharedBindings.TEXT_NAMESPACE]: id } + ), + }; + } + } + return undefined; + }, + + getStorageOwnerHosting(allOptions) { + const ids = new Set(); + for (const options of allOptions) { + for (const [, ns] of namespaceEntries(options.kvNamespaces)) { + if (!ns.remoteProxyConnectionString) { + ids.add(ns.id); + } + } + } + if (ids.size === 0) { + return undefined; + } + // The owner stands up the same generic entry service; it serves any id + // (routed by `idFromName`), so listing the ids is enough. + return { + ownerOptions: { kvNamespaces: [...ids] }, + }; + }, }; export { KV_PLUGIN_NAME }; diff --git a/packages/miniflare/src/plugins/media/index.ts b/packages/miniflare/src/plugins/media/index.ts index 5ac1fda46a2..feecb7bb661 100644 --- a/packages/miniflare/src/plugins/media/index.ts +++ b/packages/miniflare/src/plugins/media/index.ts @@ -1,13 +1,9 @@ import { z } from "zod"; -import { - buildRemoteProxyProps, - ProxyNodeBinding, - remoteProxyClientWorker, -} from "../shared"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; +import { buildRemoteProxyProps, ProxyNodeBinding } from "../shared"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; export const MEDIA_PLUGIN_NAME = "media"; -const MEDIA_REMOTE_SERVICE_NAME = `${MEDIA_PLUGIN_NAME}:remote`; const MediaSchema = z.object({ binding: z.string(), @@ -32,7 +28,7 @@ export const MEDIA_PLUGIN: Plugin = { { name: options.media.binding, service: { - name: MEDIA_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps( options.media.remoteProxyConnectionString, options.media.binding @@ -50,15 +46,6 @@ export const MEDIA_PLUGIN: Plugin = { }; }, async getServices({ options }) { - if (!options.media) { - return []; - } - - return [ - { - name: MEDIA_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }, - ]; + return []; }, }; diff --git a/packages/miniflare/src/plugins/mtls/index.ts b/packages/miniflare/src/plugins/mtls/index.ts index 1e5857e9bb9..3be02c23218 100644 --- a/packages/miniflare/src/plugins/mtls/index.ts +++ b/packages/miniflare/src/plugins/mtls/index.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - buildRemoteProxyProps, - ProxyNodeBinding, - remoteProxyClientWorker, -} from "../shared"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; +import { buildRemoteProxyProps, ProxyNodeBinding } from "../shared"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; const MtlsSchema = z.object({ @@ -18,7 +15,6 @@ export const MtlsOptionsSchema = z.object({ }); export const MTLS_PLUGIN_NAME = "mtls"; -const MTLS_REMOTE_SERVICE_NAME = `${MTLS_PLUGIN_NAME}:remote`; export const MTLS_PLUGIN: Plugin = { options: MtlsOptionsSchema, @@ -34,7 +30,7 @@ export const MTLS_PLUGIN: Plugin = { name, service: { - name: MTLS_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps(remoteProxyConnectionString, name), }, }; @@ -52,19 +48,7 @@ export const MTLS_PLUGIN: Plugin = { ]) ); }, - async getServices({ options }) { - if ( - !options.mtlsCertificates || - Object.keys(options.mtlsCertificates).length === 0 - ) { - return []; - } - - return [ - { - name: MTLS_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }, - ]; + async getServices() { + return []; }, }; diff --git a/packages/miniflare/src/plugins/pipelines/index.ts b/packages/miniflare/src/plugins/pipelines/index.ts index aa481d494ab..3b36eec05dc 100644 --- a/packages/miniflare/src/plugins/pipelines/index.ts +++ b/packages/miniflare/src/plugins/pipelines/index.ts @@ -1,10 +1,10 @@ import SCRIPT_PIPELINE_OBJECT from "worker:pipelines/pipeline"; import { z } from "zod"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; import { buildRemoteProxyProps, namespaceKeys, ProxyNodeBinding, - remoteProxyClientWorker, } from "../shared"; import type { Service } from "../../runtime"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; @@ -38,7 +38,6 @@ export const PipelineOptionsSchema = z.object({ export const PIPELINES_PLUGIN_NAME = "pipelines"; const SERVICE_PIPELINE_PREFIX = `${PIPELINES_PLUGIN_NAME}:pipeline`; -const PIPELINES_REMOTE_SERVICE_NAME = `${PIPELINES_PLUGIN_NAME}:pipeline:remote`; export const PIPELINE_PLUGIN: Plugin = { options: PipelineOptionsSchema, @@ -50,7 +49,7 @@ export const PIPELINE_PLUGIN: Plugin = { name, service: remoteProxyConnectionString ? { - name: PIPELINES_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps(remoteProxyConnectionString, name), } : { name: `${SERVICE_PIPELINE_PREFIX}:${id}` }, @@ -67,10 +66,8 @@ export const PIPELINE_PLUGIN: Plugin = { const pipelines = bindingEntries(options.pipelines); const services: Service[] = []; - let hasRemote = false; for (const [, pipeline] of pipelines) { if (pipeline.remoteProxyConnectionString) { - hasRemote = true; continue; } services.push({ @@ -87,13 +84,6 @@ export const PIPELINE_PLUGIN: Plugin = { }); } - if (hasRemote) { - services.push({ - name: PIPELINES_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }); - } - return services; }, }; diff --git a/packages/miniflare/src/plugins/r2/index.ts b/packages/miniflare/src/plugins/r2/index.ts index b9599d4feaa..71af4e66426 100644 --- a/packages/miniflare/src/plugins/r2/index.ts +++ b/packages/miniflare/src/plugins/r2/index.ts @@ -6,17 +6,19 @@ import { z } from "zod"; import { MiniflareCoreError } from "../../shared"; import { SharedBindings } from "../../workers"; import { R2S3Bindings } from "../../workers/r2/constants"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; import { buildObjectEntryProps, buildRemoteProxyProps, + extractObjectEntryId, getMiniflareObjectBindings, getPersistPath, namespaceEntries, namespaceKeys, objectEntryWorker, ProxyNodeBinding, - remoteProxyClientWorker, SERVICE_LOOPBACK, + storageOwnerProxyDesignator, } from "../shared"; import type { Service, @@ -58,9 +60,7 @@ const R2_STORAGE_SERVICE_NAME = `${R2_PLUGIN_NAME}:storage`; const R2_BUCKET_SERVICE_PREFIX = `${R2_PLUGIN_NAME}:bucket`; // A single entry service shared by every *local* bucket. Each bucket's id is // supplied per-binding via `ctx.props`, so one service serves all of them. -const R2_LOCAL_ENTRY_SERVICE_NAME = `${R2_PLUGIN_NAME}:bucket:entry`; -// One shared remote-proxy service for all remote R2 buckets (config via props). -const R2_REMOTE_SERVICE_NAME = `${R2_PLUGIN_NAME}:bucket:remote`; +export const R2_LOCAL_ENTRY_SERVICE_NAME = `${R2_PLUGIN_NAME}:bucket:entry`; export const R2_PUBLIC_SERVICE_NAME = `${R2_PLUGIN_NAME}:public`; export const R2_S3_SERVICE_NAME = `${R2_PLUGIN_NAME}:s3`; const R2_BUCKET_OBJECT_CLASS_NAME = "R2BucketObject"; @@ -110,7 +110,8 @@ export function getR2PublicService( } export function getR2S3Service( - allWorkerOpts: { r2?: z.infer }[] + allWorkerOpts: { r2?: z.infer }[], + routeToStorageOwner = false ): Service | undefined { const credentialsById: Record< string, @@ -150,10 +151,14 @@ export function getR2S3Service( const bindings = bucketIds.map((id) => ({ name: `${R2S3Bindings.BUCKET_PREFIX}${id}`, - r2Bucket: { - name: R2_LOCAL_ENTRY_SERVICE_NAME, - props: buildObjectEntryProps(id), - }, + r2Bucket: routeToStorageOwner + ? storageOwnerProxyDesignator(R2_LOCAL_ENTRY_SERVICE_NAME, undefined, { + [SharedBindings.TEXT_NAMESPACE]: id, + }) + : { + name: R2_LOCAL_ENTRY_SERVICE_NAME, + props: buildObjectEntryProps(id), + }, })); bindings.push({ name: R2S3Bindings.JSON_CREDENTIALS, @@ -180,7 +185,7 @@ export const R2_PLUGIN: Plugin = { name, r2Bucket: bucket.remoteProxyConnectionString ? { - name: R2_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps( bucket.remoteProxyConnectionString, name @@ -198,13 +203,24 @@ export const R2_PLUGIN: Plugin = { buckets.map((name) => [name, new ProxyNodeBinding()]) ); }, - async getServices({ options, tmpPath, resourcePersistencePath }) { + async getServices({ + options, + tmpPath, + resourcePersistencePath, + storageOwnerRoutePlugins, + }) { const buckets = namespaceEntries(options.r2Buckets); const services: Service[] = []; + // When routing local R2 to a shared storage owner, this instance must not + // stand up its own R2 storage — its bindings are repointed at the owner + // proxy by `Miniflare`. + const routeToOwner = storageOwnerRoutePlugins.has(R2_PLUGIN_NAME); + // One shared entry service for all local buckets (id supplied via props). - const hasLocal = buckets.some(([, b]) => !b.remoteProxyConnectionString); + const hasLocal = + !routeToOwner && buckets.some(([, b]) => !b.remoteProxyConnectionString); if (hasLocal) { services.push({ name: R2_LOCAL_ENTRY_SERVICE_NAME, @@ -212,15 +228,6 @@ export const R2_PLUGIN: Plugin = { }); } - // One shared proxy service for all remote (mixed-mode) buckets. - const hasRemote = buckets.some(([, b]) => b.remoteProxyConnectionString); - if (hasRemote) { - services.push({ - name: R2_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }); - } - if (hasLocal) { const uniqueKey = `miniflare-${R2_BUCKET_OBJECT_CLASS_NAME}`; const persistPath = getPersistPath( @@ -271,4 +278,36 @@ export const R2_PLUGIN: Plugin = { return services; }, + routeBindingToStorageOwner(binding) { + if ("r2Bucket" in binding && binding.r2Bucket?.name !== undefined) { + const id = extractObjectEntryId(binding.r2Bucket.props?.json); + if (id !== undefined) { + return { + name: binding.name, + r2Bucket: storageOwnerProxyDesignator( + R2_LOCAL_ENTRY_SERVICE_NAME, + undefined, + { [SharedBindings.TEXT_NAMESPACE]: id } + ), + }; + } + } + return undefined; + }, + getStorageOwnerHosting(allOptions) { + const ids = new Set(); + for (const options of allOptions) { + for (const [, bucket] of namespaceEntries(options.r2Buckets)) { + if (!bucket.remoteProxyConnectionString) { + ids.add(bucket.id); + } + } + } + if (ids.size === 0) { + return undefined; + } + return { + ownerOptions: { r2Buckets: [...ids] }, + }; + }, }; diff --git a/packages/miniflare/src/plugins/secret-store/index.ts b/packages/miniflare/src/plugins/secret-store/index.ts index d83809d68a7..1c579f87d03 100644 --- a/packages/miniflare/src/plugins/secret-store/index.ts +++ b/packages/miniflare/src/plugins/secret-store/index.ts @@ -12,6 +12,7 @@ import { objectEntryWorker, ProxyNodeBinding, SERVICE_LOOPBACK, + storageOwnerProxyDesignator, } from "../shared"; import type { Service, Worker_Binding } from "../../runtime"; import type { Plugin } from "../shared"; @@ -32,6 +33,9 @@ export const SECRET_STORE_PLUGIN_NAME = "secrets-store"; // A single entry service shared by every secret store. Each store_id is supplied // per-binding via `ctx.props`, so one service serves all of them. const SECRET_STORE_LOCAL_ENTRY_SERVICE_NAME = `${SECRET_STORE_PLUGIN_NAME}:ns:entry`; +// RPC entrypoint exposing a single secret. Referenced by the shared storage +// owner so it can route a client's Secrets Store binding here. +export const SECRET_STORE_SECRET_ENTRYPOINT = "SecretsStoreSecret"; export const SECRET_STORE_PLUGIN: Plugin< typeof SecretsStoreSecretsOptionsSchema @@ -53,7 +57,7 @@ export const SECRET_STORE_PLUGIN: Plugin< SECRET_STORE_PLUGIN_NAME, `${config.store_id}:${config.secret_name}` ), - entrypoint: "SecretsStoreSecret", + entrypoint: SECRET_STORE_SECRET_ENTRYPOINT, }, }; }); @@ -70,7 +74,12 @@ export const SECRET_STORE_PLUGIN: Plugin< ]) ); }, - async getServices({ options, tmpPath, resourcePersistencePath }) { + async getServices({ + options, + tmpPath, + resourcePersistencePath, + storageOwnerRoutePlugins, + }) { const configs = options.secretsStoreSecrets ? Object.values(options.secretsStoreSecrets) : []; @@ -79,6 +88,12 @@ export const SECRET_STORE_PLUGIN: Plugin< return []; } + // Routed to the shared storage owner: the owner stands up the secret + // services; this instance's bindings are repointed at the owner proxy. + if (storageOwnerRoutePlugins.has(SECRET_STORE_PLUGIN_NAME)) { + return []; + } + const persistPath = getPersistPath( SECRET_STORE_PLUGIN_NAME, tmpPath, @@ -163,4 +178,53 @@ export const SECRET_STORE_PLUGIN: Plugin< return [...secretServices, entryService, storageService, objectService]; }, + routeBindingToStorageOwner(binding) { + // Per-secret RPC service. The owner exposes each secret under the same + // service name (derived from `:`, not the binding + // key), so repoint at the client proxy targeting that same service + + // entrypoint — reached natively over the owner's debug port. + if ("service" in binding && binding.service?.name !== undefined) { + return { + name: binding.name, + service: storageOwnerProxyDesignator( + binding.service.name, + binding.service.entrypoint + ), + }; + } + return undefined; + }, + getStorageOwnerHosting(allOptions) { + // Dedupe by ":" across all workers. + const secrets = new Map< + string, + { store_id: string; secret_name: string } + >(); + for (const options of allOptions) { + if (!options.secretsStoreSecrets) { + continue; + } + for (const { store_id, secret_name } of Object.values( + options.secretsStoreSecrets + )) { + secrets.set(`${store_id}:${secret_name}`, { store_id, secret_name }); + } + } + if (secrets.size === 0) { + return undefined; + } + return { + // Recreate each secret resource so the owner stands up the matching + // per-secret service (its name derives from `:`, + // so it matches what the client targets; the record keys are arbitrary). + ownerOptions: { + secretsStoreSecrets: Object.fromEntries( + [...secrets.entries()].map(([resource, secret]) => [ + `owner:${resource}`, + secret, + ]) + ), + }, + }; + }, }; diff --git a/packages/miniflare/src/plugins/shared/constants.ts b/packages/miniflare/src/plugins/shared/constants.ts index 0a4d6585516..239cbc33a61 100644 --- a/packages/miniflare/src/plugins/shared/constants.ts +++ b/packages/miniflare/src/plugins/shared/constants.ts @@ -1,6 +1,11 @@ import SCRIPT_OBJECT_ENTRY from "worker:shared/object-entry"; import SCRIPT_REMOTE_PROXY_CLIENT from "worker:shared/remote-proxy-client"; -import { CoreBindings, SharedBindings } from "../../workers"; +import { + CoreBindings, + SharedBindings, + STORAGE_OWNER_CLIENT_ENTRYPOINT, +} from "../../workers"; +import { getUserServiceName } from "../core"; import type { RemoteProxyConnectionString } from "."; import type { Worker, @@ -150,6 +155,28 @@ export function buildRemoteProxyProps( }; } +// Builds a binding designator routing a storage op through the client-side +// storage-owner proxy (the `StorageOwnerProxy` entrypoint on the dev-registry +// proxy service). The proxy resolves the owner from the dev registry and, over +// its debug port, `getEntrypoint`s the named owner service — forwarding +// `userProps` into the callee's `ctx.props` (e.g. the resource id read by +// `object-entry.worker.ts`). `ownerService` / `ownerEntrypoint` are the owner's +// real storage service coordinates (the owner runs the same plugin code, so +// these names match); the plugin supplies them in `routeBindingToStorageOwner`. +export function storageOwnerProxyDesignator( + ownerService: string, + ownerEntrypoint?: string, + userProps?: Record +): { name: string; entrypoint: string; props: { json: string } } { + return { + name: getUserServiceName(SERVICE_DEV_REGISTRY_PROXY), + entrypoint: STORAGE_OWNER_CLIENT_ENTRYPOINT, + props: { + json: JSON.stringify({ ownerService, ownerEntrypoint, userProps }), + }, + }; +} + // Value of `unsafeUniqueKey` that forces the use of "colo local" ephemeral // namespaces. These namespaces only provide a `get(id: string): Fetcher` method // and construct objects without a `state` parameter. See the schema for details: diff --git a/packages/miniflare/src/plugins/shared/index.ts b/packages/miniflare/src/plugins/shared/index.ts index a23ef3af187..d7236922310 100644 --- a/packages/miniflare/src/plugins/shared/index.ts +++ b/packages/miniflare/src/plugins/shared/index.ts @@ -78,6 +78,17 @@ export interface PluginServicesOptions< // messages to a consumer in another `wrangler dev` process. devRegistryEnabled: boolean; hyperdriveProxyController: HyperdriveProxyController; + // Plugin names (e.g. "kv") whose *local* storage is being routed to a shared + // storage owner process. Plugins listed here should skip standing up their + // local storage services (disk/DO/migrations); their bindings are rewritten + // to the storage-owner proxy by `Miniflare`. + storageOwnerRoutePlugins: Set; + // When the shared storage owner feature is enabled, plugins that aren't + // routed to the owner but still persist to `resourcePersistencePath` (Cache, + // Durable Objects, Workflows) keep their storage per-instance (under + // `tmpPath`) instead of the shared root, so separate processes don't contend + // on one database. + isolateLocalStorage: boolean; } export interface ServicesExtensions { @@ -85,6 +96,16 @@ export interface ServicesExtensions { extensions: Extension[]; } +// How a shared storage owner should host one plugin's local storage. Returned by +// `PluginBase.getStorageOwnerHosting`. +export interface StorageOwnerHosting { + // Option fragment merged into the detached owner process's `MiniflareOptions` + // so it stands up the corresponding local storage services. The owner runs + // the same plugin code, so those services get the same (deterministic) names + // the client targets over the debug port in `routeBindingToStorageOwner`. + ownerOptions: Record; +} + export interface PluginBase< Options extends z.ZodType, SharedOptions extends z.ZodType | undefined, @@ -104,6 +125,25 @@ export interface PluginBase< getExtensions?(options: { options: z.infer[]; }): Awaitable; + // Shared storage owner (experimental `unsafeSharedStorageOwner`) hooks. Only + // implemented by plugins whose local storage can be routed to a single owner + // process. `Miniflare` owns the process/presence/routing orchestration; these + // let each plugin own the knowledge of its own binding + resource shapes. + + // Rewrite one of this plugin's *local* storage bindings so the op is served by + // the owner process (via the client-side storage-owner proxy, which reaches + // the owner's storage service over the debug port), or return `undefined` to + // leave `binding` unchanged. Called for each binding this plugin emits when + // its storage is routed to an owner. + routeBindingToStorageOwner?( + binding: Worker_Binding + ): Worker_Binding | undefined; + // Given every worker's options for this plugin, describe how a shared owner + // should host its local storage, or `undefined` if there's nothing local to + // share. Used to configure the spawned owner process. + getStorageOwnerHosting?( + allOptions: z.infer[] + ): StorageOwnerHosting | undefined; } export type Plugin< diff --git a/packages/miniflare/src/plugins/stream/index.ts b/packages/miniflare/src/plugins/stream/index.ts index 3ef0fc25809..0ec80153515 100644 --- a/packages/miniflare/src/plugins/stream/index.ts +++ b/packages/miniflare/src/plugins/stream/index.ts @@ -3,13 +3,14 @@ import BINDING_SCRIPT from "worker:stream/binding"; import OBJECT_SCRIPT from "worker:stream/object"; import { z } from "zod"; import { SharedBindings } from "../../workers"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; import { buildRemoteProxyProps, getMiniflareObjectBindings, getPersistPath, getUserBindingServiceName, ProxyNodeBinding, - remoteProxyClientWorker, + storageOwnerProxyDesignator, WORKER_BINDING_SERVICE_LOOPBACK, } from "../shared"; import type { Service } from "../../runtime"; @@ -27,10 +28,13 @@ export const StreamOptionsSchema = z.object({ }); export const STREAM_PLUGIN_NAME = "stream"; -const STREAM_REMOTE_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:remote`; const STREAM_STORAGE_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:storage`; const STREAM_OBJECT_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:object`; export const STREAM_OBJECT_CLASS_NAME = "StreamObject"; +// The RPC entrypoint service exposing the stream store. Referenced by the +// shared storage owner so it can route a client's Stream binding here. +export const STREAM_BINDING_SERVICE_NAME = `${STREAM_PLUGIN_NAME}:service`; +export const STREAM_BINDING_ENTRYPOINT = "StreamBinding"; export const STREAM_COMPAT_DATE = "2026-03-23"; @@ -47,15 +51,15 @@ export const STREAM_PLUGIN: Plugin = { name: options.stream.binding, service: options.stream.remoteProxyConnectionString ? { - name: STREAM_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps( options.stream.remoteProxyConnectionString, options.stream.binding ), } : { - name: getUserBindingServiceName(STREAM_PLUGIN_NAME, "service"), - entrypoint: "StreamBinding", + name: STREAM_BINDING_SERVICE_NAME, + entrypoint: STREAM_BINDING_ENTRYPOINT, }, }, ]; @@ -68,18 +72,25 @@ export const STREAM_PLUGIN: Plugin = { [options.stream.binding]: new ProxyNodeBinding(), }; }, - async getServices({ options, tmpPath, resourcePersistencePath }) { + async getServices({ + options, + tmpPath, + resourcePersistencePath, + storageOwnerRoutePlugins, + }) { if (!options.stream) { return []; } + // Routed to the shared storage owner: the owner stands up the stream + // store and entrypoint; this instance's binding is repointed at the owner + // proxy by `Miniflare`, so skip standing up local storage here. + if (storageOwnerRoutePlugins.has(STREAM_PLUGIN_NAME)) { + return []; + } + if (options.stream.remoteProxyConnectionString) { - return [ - { - name: STREAM_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }, - ]; + return []; } const persistPath = getPersistPath( @@ -161,4 +172,37 @@ export const STREAM_PLUGIN: Plugin = { return [storageService, objectService, bindingService]; }, + routeBindingToStorageOwner(binding) { + // A single per-instance store accessed over RPC. Repoint the whole binding + // at the owner's stream RPC entrypoint (reached natively over the debug + // port by the client proxy); the owner hosts it under the canonical + // `STREAM_BINDING_SERVICE_NAME` / `STREAM_BINDING_ENTRYPOINT`. + if ( + "service" in binding && + binding.service?.name === STREAM_BINDING_SERVICE_NAME && + binding.service.entrypoint === STREAM_BINDING_ENTRYPOINT + ) { + return { + name: binding.name, + service: storageOwnerProxyDesignator( + STREAM_BINDING_SERVICE_NAME, + STREAM_BINDING_ENTRYPOINT + ), + }; + } + return undefined; + }, + getStorageOwnerHosting(allOptions) { + const hasLocal = allOptions.some( + (options) => options.stream && !options.stream.remoteProxyConnectionString + ); + if (!hasLocal) { + return undefined; + } + // One stream store per owner; the binding name is irrelevant (the owner + // exposes it under the canonical service/entrypoint above). + return { + ownerOptions: { stream: { binding: "stream" } }, + }; + }, }; diff --git a/packages/miniflare/src/plugins/vectorize/index.ts b/packages/miniflare/src/plugins/vectorize/index.ts index f3cf83657ca..73f71201240 100644 --- a/packages/miniflare/src/plugins/vectorize/index.ts +++ b/packages/miniflare/src/plugins/vectorize/index.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - buildRemoteProxyProps, - ProxyNodeBinding, - remoteProxyClientWorker, -} from "../shared"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; +import { buildRemoteProxyProps, ProxyNodeBinding } from "../shared"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; const VectorizeSchema = z.object({ @@ -18,7 +15,6 @@ export const VectorizeOptionsSchema = z.object({ }); export const VECTORIZE_PLUGIN_NAME = "vectorize"; -const VECTORIZE_REMOTE_SERVICE_NAME = `${VECTORIZE_PLUGIN_NAME}:remote`; export const VECTORIZE_PLUGIN: Plugin = { options: VectorizeOptionsSchema, @@ -38,7 +34,7 @@ export const VECTORIZE_PLUGIN: Plugin = { { name: "fetcher", service: { - name: VECTORIZE_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps( remoteProxyConnectionString, name @@ -74,16 +70,7 @@ export const VECTORIZE_PLUGIN: Plugin = { ]) ); }, - async getServices({ options }) { - if (!options.vectorize || Object.keys(options.vectorize).length === 0) { - return []; - } - - return [ - { - name: VECTORIZE_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }, - ]; + async getServices() { + return []; }, }; diff --git a/packages/miniflare/src/plugins/websearch/index.ts b/packages/miniflare/src/plugins/websearch/index.ts index db4ab15160c..6d8a27e19e4 100644 --- a/packages/miniflare/src/plugins/websearch/index.ts +++ b/packages/miniflare/src/plugins/websearch/index.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - buildRemoteProxyProps, - ProxyNodeBinding, - remoteProxyClientWorker, -} from "../shared"; +import { SERVICE_REMOTE_BINDINGS } from "../core"; +import { buildRemoteProxyProps, ProxyNodeBinding } from "../shared"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; const WebsearchEntrySchema = z.object({ @@ -18,9 +15,6 @@ export const WebsearchOptionsSchema = z.object({ export const WEBSEARCH_PLUGIN_NAME = "websearch"; -const WEBSEARCH_SCOPE = "websearch"; -const WEBSEARCH_REMOTE_SERVICE_NAME = `${WEBSEARCH_SCOPE}:remote`; - export const WEBSEARCH_PLUGIN: Plugin = { options: WebsearchOptionsSchema, bindingTypeDescription: "Web Search", @@ -36,7 +30,7 @@ export const WEBSEARCH_PLUGIN: Plugin = { bindings.push({ name: bindingName, service: { - name: WEBSEARCH_REMOTE_SERVICE_NAME, + name: SERVICE_REMOTE_BINDINGS, props: buildRemoteProxyProps( entry.remoteProxyConnectionString, bindingName @@ -56,19 +50,7 @@ export const WEBSEARCH_PLUGIN: Plugin = { return nodeBindings; }, - async getServices({ options }) { - const services: { - name: string; - worker: ReturnType; - }[] = []; - - if (Object.keys(options.websearch ?? {}).length > 0) { - services.push({ - name: WEBSEARCH_REMOTE_SERVICE_NAME, - worker: remoteProxyClientWorker(), - }); - } - - return services; + async getServices() { + return []; }, }; diff --git a/packages/miniflare/src/plugins/workflows/index.ts b/packages/miniflare/src/plugins/workflows/index.ts index e6c8b0108bd..5b1d0ccb6cc 100644 --- a/packages/miniflare/src/plugins/workflows/index.ts +++ b/packages/miniflare/src/plugins/workflows/index.ts @@ -104,14 +104,17 @@ export const WORKFLOWS_PLUGIN: Plugin< async getServices({ options, + sharedOptions, tmpPath, resourcePersistencePath, - sharedOptions, + isolateLocalStorage, }) { + // Workflows aren't routed to the shared storage owner; keep their storage + // per-instance when the feature is on so separate processes don't contend. const persistPath = getPersistPath( WORKFLOWS_PLUGIN_NAME, tmpPath, - resourcePersistencePath + isolateLocalStorage ? undefined : resourcePersistencePath ); await fs.mkdir(persistPath, { recursive: true }); // each workflow should get its own storage service diff --git a/packages/miniflare/src/shared/index.ts b/packages/miniflare/src/shared/index.ts index b1c29594045..0d5ccd1c484 100644 --- a/packages/miniflare/src/shared/index.ts +++ b/packages/miniflare/src/shared/index.ts @@ -2,5 +2,6 @@ export * from "./error"; export * from "./event"; export * from "./log"; export * from "./matcher"; +export * from "./storage-owner"; export * from "./streams"; export * from "./types"; diff --git a/packages/miniflare/src/shared/storage-owner.ts b/packages/miniflare/src/shared/storage-owner.ts new file mode 100644 index 00000000000..393b75b7791 --- /dev/null +++ b/packages/miniflare/src/shared/storage-owner.ts @@ -0,0 +1,109 @@ +import { + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +// Election lock for the shared "central storage owner" feature. Discovery, +// liveness, and client presence all now ride the dev registry (see +// `STORAGE_OWNER_WORKER_NAME` and `StorageOwnerProxy`); the only thing the +// registry can't provide is a mutex, so a single lock file still serialises +// which client spawns the (one-per-registry) owner process. +// +// /.miniflare-owner.lock - transient lock serialising owner election + +const OWNER_SPAWN_LOCK_FILE = ".miniflare-owner.lock"; + +// A lock whose mtime is older than this is considered stale and reclaimable. +export const OWNER_STALE_MS = 30_000; +const OWNER_LOCK_RETRY_MS = 50; + +/** + * Returns whether a process with the given pid is currently alive. + * + * `process.kill(pid, 0)` sends no signal but performs the permission/existence + * check: it throws `ESRCH` if the process does not exist, and `EPERM` if it + * exists but we lack permission to signal it (still alive). + */ +export function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (e) { + return (e as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function ownerSpawnLockPath(lockDir: string): string { + return path.join(lockDir, OWNER_SPAWN_LOCK_FILE); +} + +/** Handle for a held owner-election lock. */ +export interface OwnerSpawnLock { + release(): void; +} + +/** + * Attempts to acquire the per-registry election lock so that exactly one client + * spawns the owner process. `lockDir` should be a directory scoped to the dev + * registry but *outside* the watched registry directory (so the registry's own + * stale-file cleanup never touches it). Returns a release handle on success, or + * `undefined` if another live process currently holds it. + * + * A lock whose mtime is stale or whose pid is dead is reclaimed. + */ +export function tryAcquireOwnerSpawnLock( + lockDir: string +): OwnerSpawnLock | undefined { + mkdirSync(lockDir, { recursive: true }); + const lockPath = ownerSpawnLockPath(lockDir); + try { + writeFileSync(lockPath, String(process.pid), { flag: "wx" }); + return { release: () => rmSync(lockPath, { force: true }) }; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EEXIST") { + throw e; + } + } + // Lock exists — reclaim if stale or owned by a dead process. + if (isOwnerSpawnLockStale(lockPath)) { + rmSync(lockPath, { force: true }); + try { + writeFileSync(lockPath, String(process.pid), { flag: "wx" }); + return { release: () => rmSync(lockPath, { force: true }) }; + } catch { + return undefined; + } + } + return undefined; +} + +function isOwnerSpawnLockStale(lockPath: string): boolean { + let stats; + try { + stats = statSync(lockPath, { throwIfNoEntry: false }); + } catch { + return true; + } + if (stats === undefined) { + return true; + } + if (stats.mtime.getTime() < Date.now() - OWNER_STALE_MS) { + return true; + } + let pid: number; + try { + pid = Number(readFileSync(lockPath, { encoding: "utf8" }).trim()); + } catch { + return true; + } + return !isProcessAlive(pid); +} + +export { OWNER_LOCK_RETRY_MS }; diff --git a/packages/miniflare/src/workers/core/constants.ts b/packages/miniflare/src/workers/core/constants.ts index 3e49866d37b..04ea3970bc0 100644 --- a/packages/miniflare/src/workers/core/constants.ts +++ b/packages/miniflare/src/workers/core/constants.ts @@ -97,6 +97,35 @@ export const CoreBindings = { SERVICE_OBSERVABILITY_COLLECTOR: "MINIFLARE_OBSERVABILITY_COLLECTOR", } as const; +// Shared storage owner (experimental `unsafeSharedStorageOwner`). +// +// The detached owner process registers itself in the dev registry under this +// well-known worker name; clients look it up to find the owner's debug port and +// reach its storage services over Cap'n Proto RPC (see `StorageOwnerProxy` in +// `dev-registry-proxy.worker.ts`). Policy is one owner per dev registry. +export const STORAGE_OWNER_WORKER_NAME = "__miniflare_shared_storage_owner__"; +// Each client routing storage to the owner registers a presence entry under this +// prefix (+ its pid) so the owner can tell — from the registry alone — when no +// clients remain and it can tear itself down. +export const STORAGE_OWNER_CLIENT_PRESENCE_PREFIX = + "__miniflare_storage_owner_client__:"; +// Named entrypoint (on the dev-registry-proxy service) that routed storage +// bindings target; forwards to the owner's storage service via the debug port. +export const STORAGE_OWNER_CLIENT_ENTRYPOINT = "StorageOwnerProxy"; + +/** + * Whether a dev registry entry name is one of the shared-storage-owner's + * internal reservations (the owner itself, or a client presence marker) rather + * than a real user worker. Consumers that enumerate the registry as "running + * workers" should skip these. + */ +export function isStorageOwnerRegistryName(name: string): boolean { + return ( + name === STORAGE_OWNER_WORKER_NAME || + name.startsWith(STORAGE_OWNER_CLIENT_PRESENCE_PREFIX) + ); +} + export const ProxyOps = { // Get the target or a property of the target GET: "GET", diff --git a/packages/miniflare/src/workers/core/dev-registry-proxy.worker.ts b/packages/miniflare/src/workers/core/dev-registry-proxy.worker.ts index 8fc05d10d87..11e10d1e505 100644 --- a/packages/miniflare/src/workers/core/dev-registry-proxy.worker.ts +++ b/packages/miniflare/src/workers/core/dev-registry-proxy.worker.ts @@ -1,6 +1,6 @@ import { WorkerEntrypoint } from "cloudflare:workers"; import { getQueueServiceName, HEADER_QUEUE_NAME } from "../queues/constants"; -import { CorePaths } from "./constants"; +import { CorePaths, STORAGE_OWNER_WORKER_NAME } from "./constants"; import { findQueueConsumer, resolveTarget, @@ -187,3 +187,89 @@ export class ExternalServiceProxy extends WorkerEntrypoint { } } } + +/** + * Props carried by a storage binding routed to the shared storage owner. The + * proxy resolves the owner from the dev registry (by its well-known name) and + * `getEntrypoint`s the named owner service over the debug port, forwarding + * `userProps` into the callee's `ctx.props` (e.g. the resource id read by + * `object-entry.worker.ts`). + */ +interface StorageProps { + /** The workerd service name on the owner process to target. */ + ownerService: string; + /** Optional named entrypoint of that service (RPC-type resources). */ + ownerEntrypoint?: string; + /** Props forwarded to the owner service as `ctx.props`. */ + userProps?: Record; +} + +/** + * Client-side proxy for the shared storage owner. Every routed storage binding + * (KV / R2 / D1 / Images fetch, Streams / Secrets RPC) points here; the proxy + * connects to the owner's debug port and forwards both fetch and arbitrary RPC + * calls to the owner's real storage service. Resolved lazily per use so the + * owner restarting (new debug port) is picked up automatically. + */ +export class StorageOwnerProxy extends WorkerEntrypoint { + _cachedFetcher: Fetcher | undefined; + _cachedDebugPortAddress: string | undefined; + + _resolve(): Fetcher | null { + const target = resolveTarget(STORAGE_OWNER_WORKER_NAME); + if (!target || !target.debugPortAddress) { + this._cachedFetcher = undefined; + this._cachedDebugPortAddress = undefined; + return null; + } + if ( + this._cachedFetcher && + target.debugPortAddress === this._cachedDebugPortAddress + ) { + return this._cachedFetcher; + } + const client = this.env.DEV_REGISTRY_DEBUG_PORT.connect( + target.debugPortAddress + ); + const fetcher = client.getEntrypoint( + this.ctx.props.ownerService, + this.ctx.props.ownerEntrypoint, + this.ctx.props.userProps + ); + this._cachedFetcher = fetcher; + this._cachedDebugPortAddress = target.debugPortAddress; + return fetcher; + } + + constructor(ctx: ExecutionContext, env: Env) { + super(ctx, env); + + return new Proxy(this, { + get(target, prop) { + if (Reflect.has(target, prop)) { + return Reflect.get(target, prop); + } + const fetcher = target._resolve(); + if (!fetcher) { + // Return a function-that-throws rather than throwing in the get + // trap: workerd probes properties (fetch, etc.) and throwing here + // would crash those internal checks. + return () => { + throw new Error(workerNotFoundMessage(STORAGE_OWNER_WORKER_NAME)); + }; + } + return Reflect.get(fetcher, prop); + }, + }); + } + + fetch(request: Request): Promise | Response { + const fetcher = this._resolve(); + if (!fetcher) { + return new Response(workerNotFoundMessage(STORAGE_OWNER_WORKER_NAME), { + status: 503, + }); + } + return fetcher.fetch(request); + } +} diff --git a/packages/miniflare/src/workers/core/storage-owner-server.worker.ts b/packages/miniflare/src/workers/core/storage-owner-server.worker.ts new file mode 100644 index 00000000000..7d7d80e1ae7 --- /dev/null +++ b/packages/miniflare/src/workers/core/storage-owner-server.worker.ts @@ -0,0 +1,61 @@ +import { SharedHeaders } from "../shared/constants"; +import { + BindingError, + createRemoteBindingsProxyServer, +} from "../shared/remote-bindings-proxy-server"; + +// Owner-side server for the shared "central storage owner". Runs in the detached +// owner process and exposes the owner's local storage over the remote-bindings +// boundary, reusing the shared proxy server (`remote-bindings-proxy-server.ts`). +// +// Clients route their storage bindings through the shared remote-proxy client +// worker (`remote-proxy-client.worker.ts`) pointed at this server's address: +// - Fetch (KV / R2 / D1 / Images): `MF-Binding` is a ":" key. The +// type selects the matching object-entry service (bound by `Miniflare`); the +// id is forwarded via `MF-Storage-Owner-Namespace` so a single shared entry +// service resolves any resource — including ids only declared by other +// clients — without a per-id binding. +// - JSRPC (Streams / Secrets Store): the `MF-Binding` query param selects the +// bound entrypoint service directly. + +type Env = Record; + +export default createRemoteBindingsProxyServer({ + resolveRpcBinding(request, env) { + // For RPC the client (`makeRemoteProxyStub`) puts the binding key in the URL. + const bindingKey = new URL(request.url).searchParams.get("MF-Binding"); + if (!bindingKey) { + throw new BindingError("missing MF-Binding"); + } + const target = env[bindingKey]; + if (!target) { + throw new BindingError( + `storage binding "${bindingKey}" not served by owner` + ); + } + return target; + }, + resolveFetchBinding(request, env) { + // Fetch types (KV/R2/D1/Images) are served by one generic entry service per + // type; the resource id is forwarded separately so a single binding serves + // any id. + const bindingKey = request.headers.get("MF-Binding"); + if (!bindingKey) { + throw new BindingError("missing MF-Binding"); + } + const sep = bindingKey.indexOf(":"); + const type = sep === -1 ? bindingKey : bindingKey.slice(0, sep); + const id = sep === -1 ? "" : bindingKey.slice(sep + 1); + const fetcher = env[type]; + if (!fetcher) { + throw new BindingError(`storage type "${type}" not served by owner`); + } + return { + fetcher, + rewriteHeaders(headers) { + // Tell the shared object-entry service which resource this op targets. + headers.set(SharedHeaders.STORAGE_OWNER_NAMESPACE, id); + }, + }; + }, +}); diff --git a/packages/miniflare/src/workers/local-explorer/aggregation.ts b/packages/miniflare/src/workers/local-explorer/aggregation.ts index 3e5ca07106b..8bae77d0d8b 100644 --- a/packages/miniflare/src/workers/local-explorer/aggregation.ts +++ b/packages/miniflare/src/workers/local-explorer/aggregation.ts @@ -6,7 +6,7 @@ */ import { env } from "cloudflare:workers"; -import { CorePaths } from "../core"; +import { CorePaths, isStorageOwnerRegistryName } from "../core"; import type { WorkerRegistry } from "../../shared/dev-registry-types"; import type { AppContext } from "./common"; @@ -28,7 +28,9 @@ function getPeerDebugPortAddresses( ): string[] { const selfSet = new Set(selfWorkerNames); const addresses = Object.entries(registry) - .filter(([name]) => !selfSet.has(name)) + // Skip the shared-storage owner + client presence entries — they aren't + // real user workers and their debug ports don't serve the explorer API. + .filter(([name]) => !selfSet.has(name) && !isStorageOwnerRegistryName(name)) .map(([, def]) => def.debugPortAddress) .filter((addr): addr is string => typeof addr === "string"); // A single Miniflare process with multiple workers registers multiple diff --git a/packages/miniflare/src/workers/shared/constants.ts b/packages/miniflare/src/workers/shared/constants.ts index ef7e61a4331..5b2fb43f992 100644 --- a/packages/miniflare/src/workers/shared/constants.ts +++ b/packages/miniflare/src/workers/shared/constants.ts @@ -1,5 +1,6 @@ export const SharedHeaders = { LOG_LEVEL: "MF-Log-Level", + STORAGE_OWNER_NAMESPACE: "MF-Storage-Owner-Namespace", } as const; export const SharedBindings = { diff --git a/packages/miniflare/src/workers/shared/object-entry.worker.ts b/packages/miniflare/src/workers/shared/object-entry.worker.ts index 5ce89db88f8..86bf7d1c86c 100644 --- a/packages/miniflare/src/workers/shared/object-entry.worker.ts +++ b/packages/miniflare/src/workers/shared/object-entry.worker.ts @@ -13,9 +13,11 @@ interface Env { export default >{ async fetch(request, env, ctx) { - // Prefer the namespace passed at runtime via `ctx.props` (props-based - // model: one entry service serves many namespaces). Fall back to the - // static binding for callers that still bake the namespace in. + // Resolve the namespace, in priority order: + // 1. `ctx.props` — props-based model: one entry service serves many + // namespaces. Also how the shared storage owner supplies the resource + // id per-request (forwarded via the debug port's `getEntrypoint` props). + // 2. The static binding — legacy per-resource model. const name = ctx.props[SharedBindings.TEXT_NAMESPACE] ?? env[SharedBindings.TEXT_NAMESPACE]; diff --git a/packages/miniflare/src/workers/shared/remote-bindings-proxy-server.ts b/packages/miniflare/src/workers/shared/remote-bindings-proxy-server.ts new file mode 100644 index 00000000000..bbc6f5ce3af --- /dev/null +++ b/packages/miniflare/src/workers/shared/remote-bindings-proxy-server.ts @@ -0,0 +1,124 @@ +import { newWorkersRpcResponse } from "capnweb"; +import { pipeSocketOverWebSocket } from "./remote-bindings-utils"; + +// Shared server for the remote-bindings boundary. It terminates proxied fetch, +// capnweb JSRPC, and raw TCP connect calls made by the remote-proxy client worker +// (`remote-proxy-client.worker.ts`) and dispatches them onto locally-bound +// services. Used by the @cloudflare/remote-bindings proxy server +// (`packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts`), +// which exposes a session's remote bindings to a local workerd instance. The +// consumer supplies its own binding-resolution strategy; the wire protocol is +// shared by both servers. + +/** Thrown by a resolver when a requested binding is not served. Yields a 400. */ +export class BindingError extends Error {} + +type RpcTarget = Parameters[1]; + +export type RemoteBindingsProxyConfig = { + /** Resolve the capnweb RPC target for a JSRPC (WebSocket) request. */ + resolveRpcBinding: (request: Request, env: Env) => RpcTarget; + /** + * Resolve the fetcher for a plain fetch or raw TCP connect request, plus an + * optional hook to rewrite reconstructed fetch headers before forwarding. + */ + resolveFetchBinding: ( + request: Request, + env: Env + ) => { fetcher: Fetcher; rewriteHeaders?: (headers: Headers) => void }; + /** Override JSRPC detection (defaults to `isJsRpcRequest`). */ + isJsRpc?: (request: Request) => boolean; +}; + +/** capnweb sessions arrive as a WebSocket upgrade carrying an MF-Binding query. */ +export function isJsRpcRequest(request: Request): boolean { + return ( + request.headers.has("Upgrade") && + new URL(request.url).searchParams.has("MF-Binding") + ); +} + +/** + * Raw TCP tunnels arrive as WebSocket upgrades carrying the destination in + * `MF-Connect-Address`. + */ +export function isConnectRequest(request: Request): boolean { + return ( + request.headers.get("Upgrade") === "websocket" && + request.headers.has("MF-Connect-Address") + ); +} + +function handleConnectRequest( + request: Request, + env: Env, + config: RemoteBindingsProxyConfig +): Response { + const address = request.headers.get("MF-Connect-Address"); + if (address === null) { + return new Response("Missing MF-Connect-Address header", { status: 400 }); + } + + const { fetcher } = config.resolveFetchBinding(request, env); + + const { 0: client, 1: server } = new WebSocketPair(); + server.accept(); + + const socket = fetcher.connect(address); + pipeSocketOverWebSocket(socket, server).catch(() => {}); + + return new Response(null, { status: 101, webSocket: client }); +} + +export function createRemoteBindingsProxyServer( + config: RemoteBindingsProxyConfig +): ExportedHandler { + const isJsRpc = config.isJsRpc ?? isJsRpcRequest; + return { + async fetch(request, env) { + try { + if (isConnectRequest(request)) { + return handleConnectRequest(request, env, config); + } + + if (isJsRpc(request)) { + return await newWorkersRpcResponse( + request, + config.resolveRpcBinding(request, env) + ); + } + + const { fetcher, rewriteHeaders } = config.resolveFetchBinding( + request, + env + ); + + const originalHeaders = new Headers(); + for (const [name, value] of request.headers) { + if (name.startsWith("mf-header-")) { + originalHeaders.set(name.slice("mf-header-".length), value); + } else if (name === "upgrade") { + // The `Upgrade` header needs to be special-cased to prevent: + // TypeError: Worker tried to return a WebSocket in a response to + // a request which did not contain the header "Upgrade: websocket" + originalHeaders.set(name, value); + } + } + rewriteHeaders?.(originalHeaders); + + return await fetcher.fetch( + request.headers.get("MF-URL") ?? "http://example.com", + new Request(request, { + redirect: "manual", + headers: originalHeaders, + }) + ); + } catch (e) { + if (e instanceof BindingError) { + return new Response(e.message, { status: 400 }); + } + return new Response((e as Error).message, { status: 500 }); + } + }, + }; +} diff --git a/packages/miniflare/test/persist-sharing.spec.ts b/packages/miniflare/test/persist-sharing.spec.ts new file mode 100644 index 00000000000..9ac19b0066e --- /dev/null +++ b/packages/miniflare/test/persist-sharing.spec.ts @@ -0,0 +1,2564 @@ +// Validates whether the existing `resourcePersistencePath` option is sufficient to +// make storage bindings behave as singletons across multiple Miniflare +// instances (e.g. separate `wrangler dev` / `vite dev` sessions). +// +// Each `Miniflare` instance spawns its own `workerd` subprocess, so two +// instances in the same Node process still exercise genuine cross-process +// access to the same on-disk SQLite databases and blob files. +// +// These tests deliberately use NO special concurrency handling (no busy-timeout +// retries, no sticky blobs, no owner election). They characterise the +// out-of-the-box behaviour of a shared `resourcePersistencePath`. + +import { Miniflare } from "miniflare"; +import { afterEach, describe, test } from "vitest"; +import { useTmp } from "./test-shared"; +import type { MiniflareOptions } from "miniflare"; + +const COMPAT_DATE = "2024-11-01"; + +// When set, route storage through a single shared owner process (all instances +// sharing a `resourcePersistencePath` elect one owner). Tests branch on this where +// shared-owner semantics intentionally differ from plain `resourcePersistencePath` +// (notably: cache is kept per-instance, not shared). +const sharedOwner = true; + +const NOOP_SCRIPT = `export default { async fetch() { return new Response("ok"); } };`; + +interface MakeOptions { + root: string; + name?: string; + kvId?: string; + r2Bucket?: string; + d1Id?: string; + script?: string; + durableObjects?: Record; + /** Configure a Stream binding (`STREAM`). */ + stream?: boolean; + /** Configure an Images binding (`IMAGES`). */ + images?: boolean; + /** Configure a Secrets Store secret binding (`SECRET`). */ + secret?: { store_id: string; secret_name: string }; +} + +const instances: Miniflare[] = []; + +function make({ + root, + name, + kvId, + r2Bucket, + d1Id, + script, + durableObjects, + stream, + images, + secret, +}: MakeOptions): Miniflare { + const opts: MiniflareOptions = { + name, + resourcePersistencePath: root, + modules: true, + script: script ?? NOOP_SCRIPT, + compatibilityDate: COMPAT_DATE, + kvNamespaces: kvId !== undefined ? { KV: kvId } : {}, + r2Buckets: r2Bucket !== undefined ? { R2: r2Bucket } : {}, + d1Databases: d1Id !== undefined ? { DB: d1Id } : {}, + durableObjects, + ...(stream ? { stream: { binding: "STREAM" } } : {}), + ...(images ? { images: { binding: "IMAGES" } } : {}), + ...(secret ? { secretsStoreSecrets: { SECRET: secret } } : {}), + unsafeSharedStorageOwner: sharedOwner || undefined, + unsafeDevRegistryPath: sharedOwner ? `${root}/.registry` : undefined, + }; + const mf = new Miniflare(opts); + instances.push(mf); + return mf; +} + +afterEach(async () => { + // Dispose in reverse creation order; tolerate already-disposed instances. + const toDispose = instances.splice(0, instances.length).reverse(); + for (const mf of toDispose) { + try { + await mf.dispose(); + } catch {} + } +}); + +describe.sequential("resourcePersistencePath sharing", () => { + // --------------------------------------------------------------- Host API + // One smoke test that the documented host-side helper API (getKVNamespace, + // etc.) also observes shared storage across instances. The worker-driven + // tests below exercise the real binding code paths in depth. + describe("host API access", () => { + test("host-side getKVNamespace in B sees a write from A", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ root, name: "a", kvId: "ns" }); + const b = make({ root, name: "b", kvId: "ns" }); + await a.ready; + await b.ready; + const kvA = await a.getKVNamespace("KV"); + const kvB = await b.getKVNamespace("KV"); + + await kvA.put("key", "value-from-a"); + expect(await kvB.get("key")).toBe("value-from-a"); + }); + }); + + // --------------------------------------------------------------- Isolation + // Storage must be shared ONLY when the same id/path is used. These guard the + // keying / persist-path scheme against accidental collisions or over-sharing. + describe("isolation (not shared)", () => { + test("KV: different namespace ids are not shared", async ({ expect }) => { + const root = await useTmp(); + const a = make({ root, name: "a", kvId: "ns-a" }); + const b = make({ root, name: "b", kvId: "ns-b" }); + await a.ready; + await b.ready; + const kvA = await a.getKVNamespace("KV"); + const kvB = await b.getKVNamespace("KV"); + + await kvA.put("k", "v"); + expect(await kvB.get("k")).toBe(null); + }); + + test("KV: different resourcePersistencePath is not shared", async ({ + expect, + }) => { + const rootA = await useTmp(); + const rootB = await useTmp(); + const a = make({ root: rootA, name: "a", kvId: "ns" }); + const b = make({ root: rootB, name: "b", kvId: "ns" }); + await a.ready; + await b.ready; + const kvA = await a.getKVNamespace("KV"); + const kvB = await b.getKVNamespace("KV"); + + await kvA.put("k", "v"); + expect(await kvB.get("k")).toBe(null); + }); + + test("R2: different bucket names are not shared", async ({ expect }) => { + const root = await useTmp(); + const a = make({ root, name: "a", r2Bucket: "bucket-a" }); + const b = make({ root, name: "b", r2Bucket: "bucket-b" }); + await a.ready; + await b.ready; + const r2A = await a.getR2Bucket("R2"); + const r2B = await b.getR2Bucket("R2"); + + await r2A.put("obj", "data"); + expect(await r2B.head("obj")).toBe(null); + }); + + test("D1: different database ids are not shared", async ({ expect }) => { + const root = await useTmp(); + const a = make({ root, name: "a", d1Id: "db-a" }); + const b = make({ root, name: "b", d1Id: "db-b" }); + await a.ready; + await b.ready; + const dbA = await a.getD1Database("DB"); + const dbB = await b.getD1Database("DB"); + + await dbA.exec("CREATE TABLE t (x INTEGER);"); + await dbA.prepare("INSERT INTO t (x) VALUES (1)").run(); + // B's database should not have the table at all. + await expect(dbB.prepare("SELECT * FROM t").all()).rejects.toThrow(); + }); + }); + + // ---------------------------------------------------------- Runtime code paths + describe("runtime code paths", () => { + const STORAGE_WORKER_SCRIPT = ` + export default { + async fetch(request, env) { + const url = new URL(request.url); + const key = url.searchParams.get("key") ?? "key"; + if (url.pathname === "/kv") { + if (request.method === "PUT") { + await env.KV.put(key, await request.text(), { + metadata: { source: url.searchParams.get("source") ?? "worker" }, + }); + return new Response("ok"); + } + const result = await env.KV.getWithMetadata(key); + return Response.json(result); + } + + if (url.pathname === "/r2") { + if (request.method === "PUT") { + await env.R2.put(key, await request.text(), { + customMetadata: { source: url.searchParams.get("source") ?? "worker" }, + }); + return new Response("ok"); + } + const object = await env.R2.get(key); + if (object === null) { + return Response.json(null); + } + return Response.json({ + value: await object.text(), + customMetadata: object.customMetadata, + }); + } + + if (url.pathname === "/d1/init") { + await env.DB.exec("CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT)"); + return new Response("ok"); + } + if (url.pathname === "/d1/insert") { + await env.DB.prepare("INSERT INTO items (name) VALUES (?)") + .bind(url.searchParams.get("name")) + .run(); + return new Response("ok"); + } + if (url.pathname === "/d1") { + return Response.json(await env.DB.prepare("SELECT name FROM items ORDER BY name").all()); + } + + if (url.pathname === "/cache") { + const cacheUrl = url.searchParams.get("url") ?? "http://example.com/cache"; + if (request.method === "PUT") { + await caches.default.put( + cacheUrl, + new Response(await request.text(), { + headers: { "Cache-Control": "max-age=3600" }, + }) + ); + return new Response("ok"); + } + const response = await caches.default.match(cacheUrl); + return new Response(response === undefined ? "" : await response.text()); + } + + return new Response("not found", { status: 404 }); + } + }; + `; + + test("Worker handlers share KV, R2, D1 and Cache state", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ + root, + name: "a", + kvId: "ns", + r2Bucket: "bucket", + d1Id: "db", + script: STORAGE_WORKER_SCRIPT, + }); + const b = make({ + root, + name: "b", + kvId: "ns", + r2Bucket: "bucket", + d1Id: "db", + script: STORAGE_WORKER_SCRIPT, + }); + await a.ready; + await b.ready; + + await ( + await a.dispatchFetch("http://x/kv?key=from-worker&source=a", { + method: "PUT", + body: "kv-value", + }) + ).text(); + const kvResult = (await ( + await b.dispatchFetch("http://x/kv?key=from-worker") + ).json()) as { value: string | null; metadata: unknown }; + expect(kvResult).toMatchObject({ + value: "kv-value", + metadata: { source: "a" }, + }); + + await ( + await a.dispatchFetch("http://x/r2?key=from-worker&source=a", { + method: "PUT", + body: "r2-value", + }) + ).text(); + const r2Result = (await ( + await b.dispatchFetch("http://x/r2?key=from-worker") + ).json()) as { value: string; customMetadata: unknown }; + expect(r2Result).toEqual({ + value: "r2-value", + customMetadata: { source: "a" }, + }); + + await (await a.dispatchFetch("http://x/d1/init")).text(); + await (await a.dispatchFetch("http://x/d1/insert?name=a")).text(); + await (await b.dispatchFetch("http://x/d1/insert?name=b")).text(); + const d1Result = (await ( + await a.dispatchFetch("http://x/d1") + ).json()) as { results: { name: string }[] }; + expect(d1Result.results).toEqual([{ name: "a" }, { name: "b" }]); + + await ( + await a.dispatchFetch( + "http://x/cache?url=http://example.com/from-worker", + { + method: "PUT", + body: "cache-value", + } + ) + ).text(); + const cacheFromB = await ( + await b.dispatchFetch( + "http://x/cache?url=http://example.com/from-worker" + ) + ).text(); + // Cache is intentionally NOT shared through the storage owner: each + // instance keeps its own local cache (evictions are recoverable), so in + // shared-owner mode B does not observe A's cache write. + expect(cacheFromB).toBe(sharedOwner ? "" : "cache-value"); + }); + + // Regression test: once two instances share a persist root, concurrent writes + // from user Worker code used to surface as 500 responses from the KV simulator + // (cross-process SQLITE_BUSY / read-only fallback). They must now all succeed. + test("Worker handlers can concurrently write shared KV without failed responses", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ + root, + name: "a", + kvId: "ns", + script: STORAGE_WORKER_SCRIPT, + }); + const b = make({ + root, + name: "b", + kvId: "ns", + script: STORAGE_WORKER_SCRIPT, + }); + await a.ready; + await b.ready; + + const N = 50; + const writes: Promise<{ key: string; status: number; body: string }>[] = + []; + for (let i = 0; i < N; i++) { + const keyA = `worker-a-${i}`; + const keyB = `worker-b-${i}`; + writes.push( + a + .dispatchFetch(`http://x/kv?key=${keyA}&source=a`, { + method: "PUT", + body: "v", + }) + .then(async (response) => ({ + key: keyA, + status: response.status, + body: await response.text(), + })) + ); + writes.push( + b + .dispatchFetch(`http://x/kv?key=${keyB}&source=b`, { + method: "PUT", + body: "v", + }) + .then(async (response) => ({ + key: keyB, + status: response.status, + body: await response.text(), + })) + ); + } + + const results = await Promise.all(writes); + expect(results.filter((result) => result.status !== 200)).toEqual([]); + + for (const { key } of results) { + const result = (await ( + await b.dispatchFetch(`http://x/kv?key=${key}`) + ).json()) as { value: string | null }; + expect(result.value).toBe("v"); + } + }); + + const DO_WRITES_STORAGE_SCRIPT = ` + export class StorageWriter { + constructor(state, env) { + this.env = env; + } + + async fetch(request) { + const url = new URL(request.url); + const key = url.searchParams.get("key") ?? "from-do"; + + if (url.pathname === "/do/kv") { + if (request.method === "PUT") { + await this.env.KV.put(key, await request.text()); + return new Response("ok"); + } + return new Response((await this.env.KV.get(key)) ?? ""); + } + + if (url.pathname === "/do/r2") { + if (request.method === "PUT") { + await this.env.R2.put(key, await request.text()); + return new Response("ok"); + } + const object = await this.env.R2.get(key); + return new Response(object === null ? "" : await object.text()); + } + + if (url.pathname === "/do/d1/init") { + await this.env.DB.exec("CREATE TABLE IF NOT EXISTS do_items (id INTEGER PRIMARY KEY, name TEXT)"); + return new Response("ok"); + } + if (url.pathname === "/do/d1/insert") { + await this.env.DB.prepare("INSERT INTO do_items (name) VALUES (?)") + .bind(url.searchParams.get("name")) + .run(); + return new Response("ok"); + } + if (url.pathname === "/do/d1") { + return Response.json(await this.env.DB.prepare("SELECT name FROM do_items ORDER BY name").all()); + } + + if (request.method === "PUT") { + await this.env.KV.put(key, await request.text()); + return new Response("ok"); + } + return new Response((await this.env.KV.get(key)) ?? ""); + } + } + + export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname.startsWith("/do")) { + const id = env.WRITER.idFromName("writer"); + return env.WRITER.get(id).fetch(request); + } + if (url.pathname === "/r2") { + const object = await env.R2.get(url.searchParams.get("key") ?? "from-do"); + return new Response(object === null ? "" : await object.text()); + } + if (url.pathname === "/d1") { + return Response.json(await env.DB.prepare("SELECT name FROM do_items ORDER BY name").all()); + } + const key = url.searchParams.get("key") ?? "from-do"; + return new Response((await env.KV.get(key)) ?? ""); + } + }; + `; + + test("Durable Object code can write shared KV, R2 and D1 read by another instance", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ + root, + name: "a", + kvId: "ns", + r2Bucket: "bucket", + d1Id: "db", + script: DO_WRITES_STORAGE_SCRIPT, + durableObjects: { WRITER: "StorageWriter" }, + }); + const b = make({ + root, + name: "b", + kvId: "ns", + r2Bucket: "bucket", + d1Id: "db", + script: DO_WRITES_STORAGE_SCRIPT, + durableObjects: { WRITER: "StorageWriter" }, + }); + await a.ready; + await b.ready; + + await ( + await a.dispatchFetch("http://x/do/kv?key=from-do", { + method: "PUT", + body: "written-by-do", + }) + ).text(); + + expect( + await (await b.dispatchFetch("http://x/?key=from-do")).text() + ).toBe("written-by-do"); + expect( + await (await b.dispatchFetch("http://x/do/kv?key=from-do")).text() + ).toBe("written-by-do"); + + await ( + await a.dispatchFetch("http://x/do/r2?key=from-do", { + method: "PUT", + body: "r2-written-by-do", + }) + ).text(); + expect( + await (await b.dispatchFetch("http://x/r2?key=from-do")).text() + ).toBe("r2-written-by-do"); + + await (await a.dispatchFetch("http://x/do/d1/init")).text(); + await (await a.dispatchFetch("http://x/do/d1/insert?name=do-a")).text(); + const d1Result = (await ( + await b.dispatchFetch("http://x/d1") + ).json()) as { results: { name: string }[] }; + expect(d1Result.results).toEqual([{ name: "do-a" }]); + }); + + // Same regression as above, but through a user Durable Object writing to a + // shared KV binding. This exercises user code -> DO -> binding -> simulator + // instead of direct Worker -> binding -> simulator calls. + test("Durable Object handlers can concurrently write shared KV without failed responses", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ + root, + name: "a", + kvId: "ns", + script: DO_WRITES_STORAGE_SCRIPT, + durableObjects: { WRITER: "StorageWriter" }, + }); + const b = make({ + root, + name: "b", + kvId: "ns", + script: DO_WRITES_STORAGE_SCRIPT, + durableObjects: { WRITER: "StorageWriter" }, + }); + await a.ready; + await b.ready; + + const N = 50; + const writes: Promise<{ key: string; status: number; body: string }>[] = + []; + for (let i = 0; i < N; i++) { + const keyA = `do-a-${i}`; + const keyB = `do-b-${i}`; + writes.push( + a + .dispatchFetch(`http://x/do/kv?key=${keyA}`, { + method: "PUT", + body: "v", + }) + .then(async (response) => ({ + key: keyA, + status: response.status, + body: await response.text(), + })) + ); + writes.push( + b + .dispatchFetch(`http://x/do/kv?key=${keyB}`, { + method: "PUT", + body: "v", + }) + .then(async (response) => ({ + key: keyB, + status: response.status, + body: await response.text(), + })) + ); + } + + const results = await Promise.all(writes); + expect(results.filter((result) => result.status !== 200)).toEqual([]); + + for (const { key } of results) { + expect( + await (await b.dispatchFetch(`http://x/?key=${key}`)).text() + ).toBe("v"); + } + }); + }); + + // ------------------------------------------- Worker-driven storage edge cases + // These exercise reads and writes performed entirely *inside* Worker code (via + // the `env.KV` / `env.R2` / `env.DB` bindings and the `caches` API), rather + // than through Miniflare's host-side helper APIs. This goes through the real + // runtime binding -> simulator -> shared SQLite path. All operations are + // sequenced (A completes before B reads) so they characterise cross-process + // visibility, not concurrency (which is covered separately above). + describe("worker-driven storage edge cases", () => { + const EDGE_WORKER_SCRIPT = ` + export default { + async fetch(request, env) { + const url = new URL(request.url); + const p = url.pathname; + const q = url.searchParams; + const key = q.get("key") ?? "key"; + + // -------------------------------------------------------- KV + if (p === "/kv/put") { + const opts = {}; + const meta = q.get("meta"); + if (meta) opts.metadata = JSON.parse(meta); + const ttl = q.get("ttl"); + if (ttl) opts.expirationTtl = Number(ttl); + await env.KV.put(key, await request.text(), opts); + return new Response("ok"); + } + if (p === "/kv/get") { + const res = await env.KV.getWithMetadata(key); + return Response.json({ value: res.value, metadata: res.metadata }); + } + if (p === "/kv/delete") { + await env.KV.delete(key); + return new Response("ok"); + } + if (p === "/kv/list") { + const res = await env.KV.list({ prefix: q.get("prefix") ?? undefined }); + return Response.json({ keys: res.keys.map((k) => k.name).sort() }); + } + if (p === "/kv/ryow") { + // Read-your-writes within a single request. + await env.KV.put(key, await request.text()); + return new Response((await env.KV.get(key)) ?? ""); + } + + // -------------------------------------------------------- R2 + if (p === "/r2/put") { + await env.R2.put(key, await request.text(), { + httpMetadata: { contentType: q.get("ct") ?? "text/plain" }, + customMetadata: { src: q.get("src") ?? "x" }, + }); + return new Response("ok"); + } + if (p === "/r2/get") { + const obj = await env.R2.get(key); + if (obj === null) return Response.json(null); + return Response.json({ + value: await obj.text(), + size: obj.size, + contentType: obj.httpMetadata?.contentType ?? null, + customMetadata: obj.customMetadata ?? null, + }); + } + if (p === "/r2/delete") { + await env.R2.delete(key); + return new Response("ok"); + } + if (p === "/r2/list") { + const res = await env.R2.list({ prefix: q.get("prefix") ?? undefined }); + return Response.json({ keys: res.objects.map((o) => o.key).sort() }); + } + + // -------------------------------------------------------- D1 + if (p === "/d1/exec") { + await env.DB.exec(await request.text()); + return new Response("ok"); + } + if (p === "/d1/insert") { + await env.DB.prepare("INSERT INTO edge (name, src) VALUES (?, ?)") + .bind(q.get("name"), q.get("src") ?? "x") + .run(); + return new Response("ok"); + } + if (p === "/d1/batch") { + const names = (q.get("names") ?? "").split(",").filter(Boolean); + await env.DB.batch( + names.map((n) => + env.DB.prepare("INSERT INTO edge (name, src) VALUES (?, ?)").bind(n, q.get("src") ?? "x") + ) + ); + return new Response("ok"); + } + if (p === "/d1/all") { + const { results } = await env.DB.prepare( + "SELECT name, src FROM edge ORDER BY name" + ).all(); + return Response.json(results); + } + if (p === "/d1/count") { + const row = await env.DB.prepare("SELECT COUNT(*) AS c FROM edge").first(); + return Response.json({ c: row.c }); + } + + // ----------------------------------------------------- Cache + if (p === "/cache/put") { + const cache = q.get("name") ? await caches.open(q.get("name")) : caches.default; + await cache.put( + q.get("url"), + new Response(await request.text(), { + headers: { "Cache-Control": "max-age=3600" }, + }) + ); + return new Response("ok"); + } + if (p === "/cache/get") { + const cache = q.get("name") ? await caches.open(q.get("name")) : caches.default; + const res = await cache.match(q.get("url")); + return new Response(res === undefined ? "" : await res.text()); + } + if (p === "/cache/delete") { + const cache = q.get("name") ? await caches.open(q.get("name")) : caches.default; + return new Response(String(await cache.delete(q.get("url")))); + } + + // ----- Mixed: multiple binding types in a single request ----- + if (p === "/mixed/write") { + const tag = q.get("tag") ?? "t"; + await env.KV.put("mixed:" + tag, "kv-" + tag); + await env.R2.put("mixed:" + tag, "r2-" + tag); + await env.DB.prepare("INSERT INTO edge (name, src) VALUES (?, ?)") + .bind("mixed:" + tag, "mixed") + .run(); + return new Response("ok"); + } + if (p === "/mixed/read") { + const tag = q.get("tag") ?? "t"; + const kv = await env.KV.get("mixed:" + tag); + const r2obj = await env.R2.get("mixed:" + tag); + const r2 = r2obj === null ? null : await r2obj.text(); + const row = await env.DB.prepare("SELECT name FROM edge WHERE name = ?") + .bind("mixed:" + tag) + .first(); + return Response.json({ kv, r2, d1: row ? row.name : null }); + } + + return new Response("not found", { status: 404 }); + } + }; + `; + + function makeEdge(root: string, name: string) { + return make({ + root, + name, + kvId: "ns", + r2Bucket: "bucket", + d1Id: "db", + script: EDGE_WORKER_SCRIPT, + }); + } + + async function text(mf: Miniflare, path: string, body?: string) { + const res = await mf.dispatchFetch(`http://x${path}`, { + method: body === undefined ? "GET" : "PUT", + body, + }); + return res.text(); + } + + async function json( + mf: Miniflare, + path: string, + body?: string + ): Promise { + const res = await mf.dispatchFetch(`http://x${path}`, { + method: body === undefined ? "GET" : "PUT", + body, + }); + return (await res.json()) as T; + } + + // ----------------------------------------------------------------- KV + test("KV: worker write in B is read by worker in A", async ({ expect }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(b, "/kv/put?key=k", "from-b-worker"); + const got = await json<{ value: string | null }>(a, "/kv/get?key=k"); + expect(got.value).toBe("from-b-worker"); + }); + + test("KV: worker overwrite in A is observed by worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/kv/put?key=k", "v1"); + expect( + (await json<{ value: string | null }>(b, "/kv/get?key=k")).value + ).toBe("v1"); + await text(a, "/kv/put?key=k", "v2"); + expect( + (await json<{ value: string | null }>(b, "/kv/get?key=k")).value + ).toBe("v2"); + }); + + test("KV: worker delete in B is observed by worker in A", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/kv/put?key=k", "v"); + expect( + (await json<{ value: string | null }>(b, "/kv/get?key=k")).value + ).toBe("v"); + await text(b, "/kv/delete?key=k"); + expect( + (await json<{ value: string | null }>(a, "/kv/get?key=k")).value + ).toBe(null); + }); + + test("KV: worker-written metadata is read by worker in other instance", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text( + a, + `/kv/put?key=k&meta=${encodeURIComponent('{"hello":"world"}')}`, + "v" + ); + const got = await json<{ value: string | null; metadata: unknown }>( + b, + "/kv/get?key=k" + ); + expect(got.value).toBe("v"); + expect(got.metadata).toEqual({ hello: "world" }); + }); + + test("KV: worker list reflects keys written by workers in both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/kv/put?key=edge:a", "1"); + await text(b, "/kv/put?key=edge:b", "2"); + const listed = await json<{ keys: string[] }>(b, "/kv/list?prefix=edge:"); + expect(listed.keys).toEqual(["edge:a", "edge:b"]); + }); + + test("KV: read-your-writes within a single worker request, then visible cross-instance", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + // The same request both writes and reads back the value. + expect(await text(a, "/kv/ryow?key=k", "self-read")).toBe("self-read"); + // And it is durable for another process. + expect( + (await json<{ value: string | null }>(b, "/kv/get?key=k")).value + ).toBe("self-read"); + }); + + test("KV: worker write with expirationTtl is immediately visible cross-instance", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/kv/put?key=k&ttl=3600", "ttl-value"); + expect( + (await json<{ value: string | null }>(b, "/kv/get?key=k")).value + ).toBe("ttl-value"); + }); + + // ----------------------------------------------------------------- R2 + test("R2: worker write in A (httpMetadata + customMetadata) read by worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/r2/put?key=obj&ct=application/json&src=a", '{"x":1}'); + const got = await json<{ + value: string; + contentType: string | null; + customMetadata: Record | null; + }>(b, "/r2/get?key=obj"); + expect(got.value).toBe('{"x":1}'); + expect(got.contentType).toBe("application/json"); + expect(got.customMetadata).toEqual({ src: "a" }); + }); + + test("R2: worker write of a large body (blob store) read by worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + const big = "z".repeat(2 * 1024 * 1024); // 2 MiB + await text(a, "/r2/put?key=big", big); + const got = await json<{ value: string; size: number }>( + b, + "/r2/get?key=big" + ); + expect(got.size).toBe(big.length); + expect(got.value.length).toBe(big.length); + }); + + test("R2: worker delete in B is observed by worker in A", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/r2/put?key=obj", "data"); + expect( + (await json<{ value: string } | null>(b, "/r2/get?key=obj"))?.value + ).toBe("data"); + await text(b, "/r2/delete?key=obj"); + expect(await json(a, "/r2/get?key=obj")).toBe(null); + }); + + test("R2: worker list reflects objects written by workers in both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text(a, "/r2/put?key=p/a", "1"); + await text(b, "/r2/put?key=p/b", "2"); + const listed = await json<{ keys: string[] }>(a, "/r2/list?prefix=p/"); + expect(listed.keys).toEqual(["p/a", "p/b"]); + }); + + // ----------------------------------------------------------------- D1 + test("D1: schema + rows created by worker in A are queried by worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS edge (id INTEGER PRIMARY KEY, name TEXT, src TEXT)" + ); + await text(a, "/d1/insert?name=row-a&src=a"); + const rows = await json<{ name: string; src: string }[]>(b, "/d1/all"); + expect(rows).toEqual([{ name: "row-a", src: "a" }]); + }); + + test("D1: worker batch insert in A is visible to worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS edge (id INTEGER PRIMARY KEY, name TEXT, src TEXT)" + ); + await text(a, "/d1/batch?names=b1,b2,b3&src=a"); + expect((await json<{ c: number }>(b, "/d1/count")).c).toBe(3); + }); + + test("D1: rows inserted by workers in both instances are all visible", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS edge (id INTEGER PRIMARY KEY, name TEXT, src TEXT)" + ); + await text(a, "/d1/insert?name=from-a&src=a"); + await text(b, "/d1/insert?name=from-b&src=b"); + const rows = await json<{ name: string; src: string }[]>(a, "/d1/all"); + expect(rows.map((r) => r.name)).toEqual(["from-a", "from-b"]); + }); + + // -------------------------------------------------------------- Cache + test("Cache: named cache populated by worker in A is matched by worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + const cacheUrl = "http://example.com/edge-named"; + await text( + a, + `/cache/put?name=edge&url=${encodeURIComponent(cacheUrl)}`, + "named!" + ); + // Cache is per-instance under the storage owner: B has its own local + // cache and does not observe A's write. + expect( + await text( + b, + `/cache/get?name=edge&url=${encodeURIComponent(cacheUrl)}` + ) + ).toBe(sharedOwner ? "" : "named!"); + }); + + test("Cache: worker delete in B is observed by worker in A", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + const cacheUrl = "http://example.com/edge-delete"; + await text(a, `/cache/put?url=${encodeURIComponent(cacheUrl)}`, "cached"); + if (sharedOwner) { + // Per-instance cache: B never sees A's write, so there is nothing for + // B to delete on A's behalf. Just assert the isolation. + expect( + await text(b, `/cache/get?url=${encodeURIComponent(cacheUrl)}`) + ).toBe(""); + return; + } + expect( + await text(b, `/cache/get?url=${encodeURIComponent(cacheUrl)}`) + ).toBe("cached"); + expect( + await text(b, `/cache/delete?url=${encodeURIComponent(cacheUrl)}`) + ).toBe("true"); + expect( + await text(a, `/cache/get?url=${encodeURIComponent(cacheUrl)}`) + ).toBe(""); + }); + + // ------------------------------------------------- Mixed / cross-type + test("Mixed: worker in A writes KV + R2 + D1 in one request, worker in B reads all", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + await text( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS edge (id INTEGER PRIMARY KEY, name TEXT, src TEXT)" + ); + await text(a, "/mixed/write?tag=one"); + const got = await json<{ + kv: string | null; + r2: string | null; + d1: string | null; + }>(b, "/mixed/read?tag=one"); + expect(got).toEqual({ + kv: "kv-one", + r2: "r2-one", + d1: "mixed:one", + }); + }); + + test("Mixed: the same key name in KV and R2 stays type-isolated across instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeEdge(root, "a"); + const b = makeEdge(root, "b"); + await a.ready; + await b.ready; + + // Write only to KV under a shared key name in A. + await text(a, "/kv/put?key=shared-name", "kv-only"); + // B sees it in KV... + expect( + (await json<{ value: string | null }>(b, "/kv/get?key=shared-name")) + .value + ).toBe("kv-only"); + // ...but the same name in R2 is independent and absent. + expect(await json(b, "/r2/get?key=shared-name")).toBe(null); + }); + }); + + // ----------------------------------- Worker-driven concurrency & stress tests + // Weirder edge cases and stress tests, all driven through Worker code (the real + // binding -> simulator -> shared SQLite path). These exercise genuine + // cross-process contention, and several assert correctness properties that + // only hold with proper SQLite WAL + busy-handling. They REQUIRE the workerd + // fixes for shared local-disk SQLite, so run them with a patched binary via + // MINIFLARE_WORKERD_PATH until those land in the pinned workerd. + describe("worker-driven concurrency & stress", () => { + const STRESS_WORKER_SCRIPT = ` + export default { + async fetch(request, env) { + const url = new URL(request.url); + const p = url.pathname; + const q = url.searchParams; + + // ---------------------------------------------------- KV + if (p === "/kv/put") { + await env.KV.put(q.get("key"), await request.text()); + return new Response("ok"); + } + if (p === "/kv/get") { + const v = await env.KV.get(q.get("key")); + return new Response(v === null ? "" : v); + } + if (p === "/kv/delete") { + await env.KV.delete(q.get("key")); + return new Response("ok"); + } + if (p === "/kv/append") { + // Deliberately non-atomic read-modify-write: demonstrates that + // lost updates are possible across processes without transactions. + const cur = (await env.KV.get(q.get("key"))) ?? ""; + await env.KV.put(q.get("key"), cur + q.get("c")); + return new Response("ok"); + } + if (p === "/kv/bulkput") { + const n = Number(q.get("n")); + const prefix = q.get("prefix"); + for (let i = 0; i < n; i++) { + await env.KV.put(prefix + String(i).padStart(5, "0"), "v"); + } + return new Response("ok"); + } + if (p === "/kv/listcount") { + const prefix = q.get("prefix") ?? undefined; + let cursor = undefined; + const names = new Set(); + for (;;) { + const res = await env.KV.list({ prefix, cursor, limit: 1000 }); + for (const k of res.keys) names.add(k.name); + if (res.list_complete) break; + cursor = res.cursor; + } + return Response.json({ count: names.size }); + } + if (p === "/kv/putbin") { + const len = Number(q.get("len")); + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = i % 256; + await env.KV.put(q.get("key"), bytes); + return new Response("ok"); + } + if (p === "/kv/getbin") { + const buf = await env.KV.get(q.get("key"), "arrayBuffer"); + if (buf === null) return Response.json({ len: -1, ok: false }); + const bytes = new Uint8Array(buf); + let ok = true; + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] !== i % 256) { + ok = false; + break; + } + } + return Response.json({ len: bytes.length, ok }); + } + + // ---------------------------------------------------- D1 + if (p === "/d1/exec") { + await env.DB.exec(await request.text()); + return new Response("ok"); + } + if (p === "/d1/incr") { + // Atomic, single-statement increment: must never lose updates, + // even under cross-process concurrency. + await env.DB.prepare( + "INSERT INTO counter (id, v) VALUES (?, 1) ON CONFLICT(id) DO UPDATE SET v = v + 1" + ) + .bind(q.get("key")) + .run(); + return new Response("ok"); + } + if (p === "/d1/value") { + const row = await env.DB.prepare("SELECT v FROM counter WHERE id = ?") + .bind(q.get("key")) + .first(); + return Response.json({ v: row ? row.v : null }); + } + if (p === "/d1/insert") { + await env.DB.prepare("INSERT INTO log (src) VALUES (?)") + .bind(q.get("src")) + .run(); + return new Response("ok"); + } + if (p === "/d1/bulkinsert") { + const n = Number(q.get("n")); + const stmt = env.DB.prepare("INSERT INTO log (src) VALUES (?)"); + const batch = []; + for (let i = 0; i < n; i++) batch.push(stmt.bind(q.get("src"))); + await env.DB.batch(batch); + return new Response("ok"); + } + if (p === "/d1/stats") { + const c = await env.DB.prepare("SELECT COUNT(*) AS c FROM log").first(); + const d = await env.DB.prepare( + "SELECT COUNT(DISTINCT id) AS d FROM log" + ).first(); + const m = await env.DB.prepare("SELECT MAX(id) AS m FROM log").first(); + return Response.json({ count: c.c, distinctIds: d.d, maxId: m.m }); + } + + // ---------------------------------------------------- R2 + if (p === "/r2/put") { + await env.R2.put(q.get("key"), await request.text()); + return new Response("ok"); + } + if (p === "/r2/get") { + const o = await env.R2.get(q.get("key")); + return new Response(o === null ? "" : await o.text()); + } + if (p === "/r2/putbig") { + await env.R2.put(q.get("key"), "x".repeat(Number(q.get("len")))); + return new Response("ok"); + } + if (p === "/r2/size") { + const o = await env.R2.get(q.get("key")); + return Response.json({ size: o === null ? -1 : o.size }); + } + + // ---------------------------------------------------- Cache + if (p === "/cache/put") { + await caches.default.put( + q.get("url"), + new Response(await request.text(), { + headers: { "Cache-Control": "max-age=3600" }, + }) + ); + return new Response("ok"); + } + if (p === "/cache/get") { + const res = await caches.default.match(q.get("url")); + return new Response(res === undefined ? "" : await res.text()); + } + + // ------------- Touch every binding type in one request ------------- + if (p === "/touchall") { + const tag = q.get("tag"); + await env.DB.exec( + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + await env.KV.put("touch:" + tag, "v"); + await env.R2.put("touch:" + tag, "v"); + await env.DB.prepare("INSERT INTO log (src) VALUES (?)").bind(tag).run(); + await caches.default.put( + "http://x/touch/" + tag, + new Response("v", { headers: { "Cache-Control": "max-age=3600" } }) + ); + return new Response("ok"); + } + + // ------------- D1: tagged big batch + tagged count ------------- + if (p === "/d1/bigbatch") { + const n = Number(q.get("n")); + const stmt = env.DB.prepare("INSERT INTO log (src) VALUES (?)"); + const batch = []; + for (let i = 0; i < n; i++) batch.push(stmt.bind(q.get("tag"))); + await env.DB.batch(batch); + return new Response("ok"); + } + if (p === "/d1/counttag") { + const row = await env.DB.prepare( + "SELECT COUNT(*) AS c FROM log WHERE src = ?" + ) + .bind(q.get("tag")) + .first(); + return Response.json({ c: row.c }); + } + + // ------------- D1: bank-transfer conservation invariant ------------- + if (p === "/bank/init") { + await env.DB.exec( + "CREATE TABLE IF NOT EXISTS accounts (id TEXT PRIMARY KEY, balance INTEGER)" + ); + await env.DB.prepare( + "INSERT OR IGNORE INTO accounts (id, balance) VALUES ('x', 1000), ('y', 1000)" + ).run(); + return new Response("ok"); + } + if (p === "/bank/transfer") { + const amt = Number(q.get("amt")); + // A single atomic batch (transaction): debit one, credit the other. + await env.DB.batch([ + env.DB.prepare("UPDATE accounts SET balance = balance - ? WHERE id = ?").bind( + amt, + q.get("from") + ), + env.DB.prepare("UPDATE accounts SET balance = balance + ? WHERE id = ?").bind( + amt, + q.get("to") + ), + ]); + return new Response("ok"); + } + if (p === "/bank/total") { + const row = await env.DB.prepare("SELECT SUM(balance) AS s FROM accounts").first(); + return Response.json({ total: row.s }); + } + + return new Response("not found", { status: 404 }); + } + }; + `; + + function makeStress(root: string, name: string) { + return make({ + root, + name, + kvId: "ns", + r2Bucket: "bucket", + d1Id: "db", + script: STRESS_WORKER_SCRIPT, + }); + } + + async function fire( + mf: Miniflare, + path: string, + body?: string + ): Promise { + const res = await mf.dispatchFetch(`http://x${path}`, { + method: body === undefined ? "GET" : "PUT", + body, + }); + await res.text(); + return res.status; + } + + async function bodyOf(mf: Miniflare, path: string): Promise { + return (await mf.dispatchFetch(`http://x${path}`)).text(); + } + + async function jsonOf(mf: Miniflare, path: string): Promise { + return (await (await mf.dispatchFetch(`http://x${path}`)).json()) as T; + } + + // Start two instances sharing `root` and wait for both to be ready. + async function startPair(root: string): Promise<[Miniflare, Miniflare]> { + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await Promise.all([a.ready, b.ready]); + return [a, b]; + } + + // Start `count` instances sharing `root` (named p0..pN) and wait for ready. + async function startMany( + root: string, + count: number + ): Promise { + const mfs = Array.from({ length: count }, (_unused, i) => + makeStress(root, `p${i}`) + ); + await Promise.all(mfs.map((mf) => mf.ready)); + return mfs; + } + + describe.each([ + { instances: 4, per: 25 }, + { instances: 6, per: 20 }, + { instances: 10, per: 30 }, + ])( + "stress: $instances instances concurrently write distinct KV keys", + ({ instances, per }) => { + test("all present with exact values", async ({ expect }) => { + const root = await useTmp(); + const mfs = await startMany(root, instances); + + const ops = mfs.flatMap((mf, idx) => + Array.from({ length: per }, (_unused, i) => + fire(mf, `/kv/put?key=dk-${idx}-${i}`, `val-${idx}-${i}`) + ) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // A fresh reader process sees every key with the exact value written. + const reader = makeStress(root, "reader"); + await reader.ready; + const total = instances * per; + expect( + ( + await jsonOf<{ count: number }>( + reader, + "/kv/listcount?prefix=dk-" + ) + ).count + ).toBe(total); + const bad: string[] = []; + for (let idx = 0; idx < instances; idx++) { + for (let i = 0; i < per; i++) { + const v = await bodyOf(reader, `/kv/get?key=dk-${idx}-${i}`); + if (v !== `val-${idx}-${i}`) { + bad.push(`dk-${idx}-${i}=${v}`); + } + } + } + expect(bad).toEqual([]); + }); + } + ); + + describe.each([ + { kind: "KV", put: "/kv/put", get: "/kv/get", n: 50 }, + { kind: "R2", put: "/r2/put", get: "/r2/get", n: 40 }, + ])("stress: concurrent writes to the SAME $kind key", ({ put, get, n }) => { + test("never error and converge", async ({ expect }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + const ops: Promise[] = []; + const candidates = new Set(); + for (let i = 0; i < n; i++) { + const va = `a-${i}`; + const vb = `b-${i}`; + candidates.add(va); + candidates.add(vb); + ops.push(fire(a, `${put}?key=hot`, va)); + ops.push(fire(b, `${put}?key=hot`, vb)); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // After writes settle, both processes read the SAME, valid final value. + const fromA = await bodyOf(a, `${get}?key=hot`); + const fromB = await bodyOf(b, `${get}?key=hot`); + expect(fromA).toBe(fromB); + expect(candidates.has(fromA)).toBe(true); + }); + }); + + test("correctness: concurrent atomic D1 increments across instances lose no updates", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + await fire( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS counter (id TEXT PRIMARY KEY, v INTEGER)" + ); + + const PER = 50; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + ops.push(fire(a, "/d1/incr?key=hot")); + ops.push(fire(b, "/d1/incr?key=hot")); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // Each increment is a single atomic statement, so no updates are lost + // even though two processes raced -- the total must be exact. + expect((await jsonOf<{ v: number }>(a, "/d1/value?key=hot")).v).toBe( + PER * 2 + ); + expect((await jsonOf<{ v: number }>(b, "/d1/value?key=hot")).v).toBe( + PER * 2 + ); + }); + + test("hazard: concurrent non-atomic KV read-modify-write may lose updates (documented)", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + const PER = 30; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + ops.push(fire(a, "/kv/append?key=acc&c=a")); + ops.push(fire(b, "/kv/append?key=acc&c=b")); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // KV has no transactions, so read-modify-write races CAN lose updates: the + // final string is some valid interleaving no longer than the total number + // of appends. We only assert no corruption / no errors and a sane length. + const finalA = await bodyOf(a, "/kv/get?key=acc"); + const finalB = await bodyOf(b, "/kv/get?key=acc"); + expect(finalA).toBe(finalB); + expect(finalA.length).toBeGreaterThan(0); + expect(finalA.length).toBeLessThanOrEqual(PER * 2); + expect(/^[ab]*$/.test(finalA)).toBe(true); + }); + + test("correctness: concurrent D1 AUTOINCREMENT inserts keep unique ids and exact count", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeStress(root, "a"); + const b = makeStress(root, "b"); + const c = makeStress(root, "c"); + await Promise.all([a.ready, b.ready, c.ready]); + await fire( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + + const PER = 30; + const writers: [Miniflare, string][] = [ + [a, "a"], + [b, "b"], + [c, "c"], + ]; + const ops: Promise[] = []; + for (const [mf, src] of writers) { + for (let i = 0; i < PER; i++) { + ops.push(fire(mf, `/d1/insert?src=${src}`)); + } + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const total = PER * writers.length; + const stats = await jsonOf<{ + count: number; + distinctIds: number; + maxId: number; + }>(a, "/d1/stats"); + expect(stats.count).toBe(total); + expect(stats.distinctIds).toBe(total); + expect(stats.maxId).toBe(total); + }); + + test("edge: KV list pagination across instances returns every key (>1000)", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + // Each instance bulk-writes 600 keys under a shared prefix. + expect(await fire(a, "/kv/bulkput?prefix=page:a-&n=600")).toBe(200); + expect(await fire(b, "/kv/bulkput?prefix=page:b-&n=600")).toBe(200); + + // Listing (with cursor paging) from either instance sees all 1200. + expect( + (await jsonOf<{ count: number }>(a, "/kv/listcount?prefix=page:")).count + ).toBe(1200); + expect( + (await jsonOf<{ count: number }>(b, "/kv/listcount?prefix=page:")).count + ).toBe(1200); + }); + + test("edge: binary KV values with NUL/high bytes round-trip via workers cross-instance", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + expect(await fire(a, "/kv/putbin?key=bin&len=4096")).toBe(200); + const got = await jsonOf<{ len: number; ok: boolean }>( + b, + "/kv/getbin?key=bin" + ); + expect(got.len).toBe(4096); + expect(got.ok).toBe(true); + }); + + test("edge: empty KV and R2 values round-trip (distinct from missing)", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + await fire(a, "/kv/put?key=empty", ""); + await fire(a, "/r2/put?key=empty", ""); + // Empty string is a real value, not . + expect(await bodyOf(b, "/kv/get?key=empty")).toBe(""); + expect(await bodyOf(b, "/r2/get?key=empty")).toBe(""); + // A genuinely-missing key is still . + expect(await bodyOf(b, "/kv/get?key=missing")).toBe(""); + }); + + test("edge: an instance restart mid-stream keeps and continues shared writes", async ({ + expect, + }) => { + const root = await useTmp(); + let a = makeStress(root, "a"); + const b = makeStress(root, "b"); + await a.ready; + await b.ready; + + await fire(a, "/kv/put?key=k1", "from-a1"); + await fire(b, "/kv/put?key=k2", "from-b"); + + // Tear down A while B keeps the shared database open, then bring up a + // fresh A pointed at the same persistence dir (cold reopen of an + // actively-held WAL database by a new process). + await a.dispose(); + a = makeStress(root, "a"); + await a.ready; + + expect(await bodyOf(a, "/kv/get?key=k1")).toBe("from-a1"); + expect(await bodyOf(a, "/kv/get?key=k2")).toBe("from-b"); + await fire(a, "/kv/put?key=k3", "from-a2"); + expect(await bodyOf(b, "/kv/get?key=k3")).toBe("from-a2"); + }); + + test("stress: interleaved concurrent multi-type writes from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + await fire( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + + const PER = 20; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + ops.push(fire(a, `/kv/put?key=mt:a:${i}`, "v")); + ops.push(fire(b, `/kv/put?key=mt:b:${i}`, "v")); + ops.push(fire(a, `/r2/put?key=mt:a:${i}`, "v")); + ops.push(fire(b, `/r2/put?key=mt:b:${i}`, "v")); + ops.push(fire(a, "/d1/insert?src=a")); + ops.push(fire(b, "/d1/insert?src=b")); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + expect( + (await jsonOf<{ count: number }>(a, "/kv/listcount?prefix=mt:")).count + ).toBe(PER * 2); + expect((await jsonOf<{ count: number }>(b, "/d1/stats")).count).toBe( + PER * 2 + ); + expect(await bodyOf(b, `/r2/get?key=mt:a:0`)).toBe("v"); + expect(await bodyOf(a, `/r2/get?key=mt:b:0`)).toBe("v"); + }); + + test("edge: rapid delete/recreate race on one KV key stays consistent", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + const N = 40; + const ops: Promise[] = []; + for (let i = 0; i < N; i++) { + ops.push(fire(a, "/kv/put?key=race", "v")); + ops.push(fire(b, "/kv/delete?key=race")); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // Whatever the interleaving, both processes must agree on the final state + // and it must be either the value or absent (never corrupt). + const fromA = await bodyOf(a, "/kv/get?key=race"); + const fromB = await bodyOf(b, "/kv/get?key=race"); + expect(fromA).toBe(fromB); + expect(fromA === "v" || fromA === "").toBe(true); + }); + + test("edge: concurrent CREATE TABLE IF NOT EXISTS from both instances at cold start", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + const ddl = + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)"; + // Both processes race to create the schema on a freshly-created database. + const created = await Promise.all([ + fire(a, "/d1/exec", ddl), + fire(b, "/d1/exec", ddl), + ]); + expect(created.filter((s) => s !== 200)).toEqual([]); + + await fire(a, "/d1/insert?src=a"); + await fire(b, "/d1/insert?src=b"); + expect((await jsonOf<{ count: number }>(a, "/d1/stats")).count).toBe(2); + }); + + test("stress: concurrent R2 writes to distinct keys from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + const PER = 40; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + ops.push(fire(a, `/r2/put?key=r2:a:${i}`, "v")); + ops.push(fire(b, `/r2/put?key=r2:b:${i}`, "v")); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const misses: string[] = []; + for (let i = 0; i < PER; i++) { + if ((await bodyOf(b, `/r2/get?key=r2:a:${i}`)) !== "v") + misses.push(`a:${i}`); + if ((await bodyOf(a, `/r2/get?key=r2:b:${i}`)) !== "v") + misses.push(`b:${i}`); + } + expect(misses).toEqual([]); + }); + + test("stress: concurrent large R2 bodies (blob store) from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + const LEN = 256 * 1024; // 256 KiB each + const PER = 8; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + ops.push(fire(a, `/r2/putbig?key=blob:a:${i}&len=${LEN}`)); + ops.push(fire(b, `/r2/putbig?key=blob:b:${i}&len=${LEN}`)); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + for (let i = 0; i < PER; i++) { + expect( + (await jsonOf<{ size: number }>(b, `/r2/size?key=blob:a:${i}`)).size + ).toBe(LEN); + expect( + (await jsonOf<{ size: number }>(a, `/r2/size?key=blob:b:${i}`)).size + ).toBe(LEN); + } + }); + + test("stress: concurrent Cache writes to distinct URLs from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + const PER = 40; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + const ua = encodeURIComponent(`http://example.com/c/a/${i}`); + const ub = encodeURIComponent(`http://example.com/c/b/${i}`); + ops.push(fire(a, `/cache/put?url=${ua}`, "v")); + ops.push(fire(b, `/cache/put?url=${ub}`, "v")); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // Per-instance cache under the storage owner: each instance reads back + // only its own writes. Without the owner (shared persist root), cache is + // shared, so each instance also sees the other's writes. + const misses: string[] = []; + for (let i = 0; i < PER; i++) { + const ua = encodeURIComponent(`http://example.com/c/a/${i}`); + const ub = encodeURIComponent(`http://example.com/c/b/${i}`); + const reader = sharedOwner ? a : b; + if ((await bodyOf(reader, `/cache/get?url=${ua}`)) !== "v") + misses.push(`a:${i}`); + const readerB = sharedOwner ? b : a; + if ((await bodyOf(readerB, `/cache/get?url=${ub}`)) !== "v") + misses.push(`b:${i}`); + } + expect(misses).toEqual([]); + }); + + test("stress: concurrent D1 batch transactions from both instances keep exact count", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + await fire( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + + const BATCHES = 10; + const ROWS = 10; + const ops: Promise[] = []; + for (let i = 0; i < BATCHES; i++) { + ops.push(fire(a, `/d1/bulkinsert?n=${ROWS}&src=a`)); + ops.push(fire(b, `/d1/bulkinsert?n=${ROWS}&src=b`)); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const total = BATCHES * ROWS * 2; + const stats = await jsonOf<{ count: number; distinctIds: number }>( + b, + "/d1/stats" + ); + expect(stats.count).toBe(total); + expect(stats.distinctIds).toBe(total); + }); + + test("stress: D1 readers running concurrently with cross-process writers never error", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + await fire( + a, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + + const PER = 40; + const writes: Promise[] = []; + const reads: Promise[] = []; + for (let i = 0; i < PER; i++) { + writes.push(fire(a, "/d1/insert?src=a")); + writes.push(fire(b, "/d1/insert?src=b")); + // Reads from both processes, interleaved with the writes. + reads.push(fire(a, "/d1/stats")); + reads.push(fire(b, "/d1/stats")); + } + const [writeStatuses, readStatuses] = await Promise.all([ + Promise.all(writes), + Promise.all(reads), + ]); + expect(writeStatuses.filter((s) => s !== 200)).toEqual([]); + expect(readStatuses.filter((s) => s !== 200)).toEqual([]); + expect((await jsonOf<{ count: number }>(a, "/d1/stats")).count).toBe( + PER * 2 + ); + }); + + test("stress: cold-start race touching KV + R2 + D1 + Cache concurrently from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + // The very first thing both processes do is hit every binding type at + // once -- this maximises the cold-start open/transition races across all + // of the freshly-created databases simultaneously. + const PER = 20; + const ops: Promise[] = []; + for (let i = 0; i < PER; i++) { + ops.push(fire(a, `/touchall?tag=a-${i}`)); + ops.push(fire(b, `/touchall?tag=b-${i}`)); + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + expect( + (await jsonOf<{ count: number }>(b, "/kv/listcount?prefix=touch:")) + .count + ).toBe(PER * 2); + expect((await jsonOf<{ count: number }>(a, "/d1/stats")).count).toBe( + PER * 2 + ); + expect(await bodyOf(b, "/r2/get?key=touch:a-0")).toBe("v"); + // Cache is per-instance under the storage owner, so A reads back its own + // cached tag rather than B's. Without the owner the cache is shared. + const cacheTag = sharedOwner ? "a-0" : "b-0"; + expect( + await bodyOf( + a, + `/cache/get?url=${encodeURIComponent("http://x/touch/" + cacheTag)}` + ) + ).toBe("v"); + }); + + test("stress: concurrent overlapping put/delete/get on a shared KV key space", async ({ + expect, + }) => { + const root = await useTmp(); + const [a, b] = await startPair(root); + + // Both processes interleave puts, deletes and gets over the same small set + // of keys. Nothing may error and reads must always return a valid state. + const KEYS = 8; + const ROUNDS = 20; + const ops: Promise[] = []; + const badReads: string[] = []; + const readCheck = async (mf: Miniflare, key: string) => { + const v = await bodyOf(mf, `/kv/get?key=${key}`); + if (v !== "v" && v !== "") badReads.push(v); + return 200; + }; + for (let r = 0; r < ROUNDS; r++) { + for (let k = 0; k < KEYS; k++) { + const key = `mix-${k}`; + ops.push(fire(a, `/kv/put?key=${key}`, "v")); + ops.push(fire(b, `/kv/delete?key=${key}`)); + ops.push(readCheck(a, key)); + ops.push(readCheck(b, key)); + } + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + expect(badReads).toEqual([]); + }); + + // ------------------------------------------------------------------- + // High concurrency: many independent workerd processes (each Miniflare + // instance is its own process) hammering one shared store at once. These + // assert exact correctness, not just absence of errors. + // ------------------------------------------------------------------- + + test("high concurrency: 8 processes increment one D1 counter, total is exact", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = await startMany(root, 8); + await fire( + mfs[0], + "/d1/exec", + "CREATE TABLE IF NOT EXISTS counter (id TEXT PRIMARY KEY, v INTEGER)" + ); + + const PER = 25; + const ops = mfs.flatMap((mf) => + Array.from({ length: PER }, () => fire(mf, "/d1/incr?key=hot")) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const total = mfs.length * PER; + // Every process must observe the same exact total -- no lost updates. + for (const mf of mfs) { + expect((await jsonOf<{ v: number }>(mf, "/d1/value?key=hot")).v).toBe( + total + ); + } + }); + + test("high concurrency: 8 processes AUTOINCREMENT insert, ids unique and count exact", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = await startMany(root, 8); + await fire( + mfs[0], + "/d1/exec", + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + + const PER = 25; + const ops = mfs.flatMap((mf, idx) => + Array.from({ length: PER }, () => fire(mf, `/d1/insert?src=p${idx}`)) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const total = mfs.length * PER; + const stats = await jsonOf<{ + count: number; + distinctIds: number; + maxId: number; + }>(mfs[3], "/d1/stats"); + expect(stats.count).toBe(total); + expect(stats.distinctIds).toBe(total); + expect(stats.maxId).toBe(total); // contiguous: no gaps, no reused ids + }); + + test("high concurrency: many processes increment many disjoint D1 rows, each exact", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = await startMany(root, 6); + await fire( + mfs[0], + "/d1/exec", + "CREATE TABLE IF NOT EXISTS counter (id TEXT PRIMARY KEY, v INTEGER)" + ); + + const ROWS = 5; + const PER = 10; + // Every process increments every counter PER times -> high contention on + // all rows simultaneously. + const ops = mfs.flatMap((mf) => + Array.from({ length: ROWS * PER }, (_unused, i) => + fire(mf, `/d1/incr?key=c${i % ROWS}`) + ) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const expected = mfs.length * PER; + for (let r = 0; r < ROWS; r++) { + expect( + (await jsonOf<{ v: number }>(mfs[0], `/d1/value?key=c${r}`)).v + ).toBe(expected); + } + }); + + test("high concurrency: concurrent bank transfers across processes conserve the total", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = await startMany(root, 6); + await fire(mfs[0], "/bank/init"); + + const PER = 20; + const ops = mfs.flatMap((mf, idx) => + Array.from({ length: PER }, (_unused, i) => { + // Alternate direction so debits and credits interleave heavily. + const [from, to] = (idx + i) % 2 === 0 ? ["x", "y"] : ["y", "x"]; + return fire(mf, `/bank/transfer?from=${from}&to=${to}&amt=1`); + }) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // Each transfer is an atomic batch, so the invariant total == 2000 must + // hold exactly regardless of interleaving across processes. + for (const mf of mfs) { + expect((await jsonOf<{ total: number }>(mf, "/bank/total")).total).toBe( + 2000 + ); + } + }); + + test("high concurrency: many processes overwrite one KV key, all agree on a real final value", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = await startMany(root, 8); + + const PER = 20; + const candidates = new Set(); + const ops = mfs.flatMap((mf, idx) => + Array.from({ length: PER }, (_unused, i) => { + const v = `p${idx}-${i}`; + candidates.add(v); + return fire(mf, "/kv/put?key=hot", v); + }) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // After settling, every process reads the identical final value and it is + // one of the values that was actually written (no torn/garbage value). + const finals = await Promise.all( + mfs.map((mf) => bodyOf(mf, "/kv/get?key=hot")) + ); + expect(new Set(finals).size).toBe(1); + expect(candidates.has(finals[0])).toBe(true); + }); + + test("concurrency: a D1 batch is atomic to other processes (never a partial count)", async ({ + expect, + }) => { + const root = await useTmp(); + const writer = makeStress(root, "writer"); + const reader = makeStress(root, "reader"); + await writer.ready; + await reader.ready; + await fire( + writer, + "/d1/exec", + "CREATE TABLE IF NOT EXISTS log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT)" + ); + + const N = 300; + // Kick off the big batch without awaiting, then poll the count from the + // OTHER process while it runs. `finally` guarantees the loop terminates + // even if the batch request rejects. + let done = false; + const batch = fire(writer, `/d1/bigbatch?tag=BIG&n=${N}`).finally(() => { + done = true; + }); + const observed: number[] = []; + while (!done) { + observed.push( + (await jsonOf<{ c: number }>(reader, "/d1/counttag?tag=BIG")).c + ); + } + expect(await batch).toBe(200); + observed.push( + (await jsonOf<{ c: number }>(reader, "/d1/counttag?tag=BIG")).c + ); + + // Other processes may only ever see the batch as not-yet-applied (0) or + // fully applied (N) -- never a partial, mid-transaction count. + expect(observed.every((c) => c === 0 || c === N)).toBe(true); + expect(observed.at(-1)).toBe(N); + }); + + test("high concurrency: 6 processes race cold-start touching all binding types", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = await startMany(root, 6); + + const PER = 10; + // First operation each process performs hits every binding type at once. + const ops = mfs.flatMap((mf, idx) => + Array.from({ length: PER }, (_unused, i) => + fire(mf, `/touchall?tag=p${idx}-${i}`) + ) + ); + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + const total = mfs.length * PER; + expect( + (await jsonOf<{ count: number }>(mfs[0], "/kv/listcount?prefix=touch:")) + .count + ).toBe(total); + expect((await jsonOf<{ count: number }>(mfs[1], "/d1/stats")).count).toBe( + total + ); + }); + + test("high concurrency: writers and a deleter race one key set; final state is consistent", async ({ + expect, + }) => { + const root = await useTmp(); + const mfs = await startMany(root, 6); + + const KEYS = 6; + const ROUNDS = 15; + // Processes 0..4 write the keys; process 5 deletes them; all concurrent. + const ops: Promise[] = []; + for (let r = 0; r < ROUNDS; r++) { + for (let k = 0; k < KEYS; k++) { + for (let idx = 0; idx < mfs.length - 1; idx++) { + ops.push(fire(mfs[idx], `/kv/put?key=race-${k}`, "v")); + } + ops.push(fire(mfs[mfs.length - 1], `/kv/delete?key=race-${k}`)); + } + } + expect((await Promise.all(ops)).filter((s) => s !== 200)).toEqual([]); + + // Every process must agree on the final state of each key, and it must be + // a valid value or absent (never corrupt). + for (let k = 0; k < KEYS; k++) { + const finals = await Promise.all( + mfs.map((mf) => bodyOf(mf, `/kv/get?key=race-${k}`)) + ); + expect(new Set(finals).size).toBe(1); + expect(finals[0] === "v" || finals[0] === "").toBe(true); + } + }); + }); + + // ------------------------------------------------------------- Cross-cutting + describe("cross-cutting", () => { + test("three instances share the same store", async ({ expect }) => { + const root = await useTmp(); + const a = make({ root, name: "a", kvId: "ns" }); + const b = make({ root, name: "b", kvId: "ns" }); + const c = make({ root, name: "c", kvId: "ns" }); + await a.ready; + await b.ready; + await c.ready; + const kvA = await a.getKVNamespace("KV"); + const kvC = await c.getKVNamespace("KV"); + + await kvA.put("k", "v"); + expect(await kvC.get("k")).toBe("v"); + await kvC.put("k2", "v2"); + expect(await (await b.getKVNamespace("KV")).get("k2")).toBe("v2"); + }); + + test("data survives one instance being disposed mid-run", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ root, name: "a", kvId: "ns" }); + const b = make({ root, name: "b", kvId: "ns" }); + await a.ready; + await b.ready; + const kvA = await a.getKVNamespace("KV"); + + await kvA.put("k", "written-by-a"); + // Dispose A; B should keep reading the same dataset uninterrupted. + await a.dispose(); + + const kvB = await b.getKVNamespace("KV"); + expect(await kvB.get("k")).toBe("written-by-a"); + await kvB.put("k2", "written-by-b"); + expect(await kvB.get("k2")).toBe("written-by-b"); + }); + + test("data persists across a full restart (new instance, same root)", async ({ + expect, + }) => { + const root = await useTmp(); + const first = make({ root, name: "a", kvId: "ns" }); + await first.ready; + const kv1 = await first.getKVNamespace("KV"); + await kv1.put("k", "persisted"); + await first.dispose(); + + const second = make({ root, name: "a", kvId: "ns" }); + await second.ready; + const kv2 = await second.getKVNamespace("KV"); + expect(await kv2.get("k")).toBe("persisted"); + }); + + test("mixed: shared KV (same id) but isolated R2 (different bucket)", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ + root, + name: "a", + kvId: "ns", + r2Bucket: "bucket-a", + }); + const b = make({ + root, + name: "b", + kvId: "ns", + r2Bucket: "bucket-b", + }); + await a.ready; + await b.ready; + + const kvA = await a.getKVNamespace("KV"); + const kvB = await b.getKVNamespace("KV"); + const r2A = await a.getR2Bucket("R2"); + const r2B = await b.getR2Bucket("R2"); + + await kvA.put("k", "shared"); + await r2A.put("obj", "isolated"); + + expect(await kvB.get("k")).toBe("shared"); + expect(await r2B.head("obj")).toBe(null); + }); + }); + + // ----------------------------------------------------- Concurrency / SQLITE_BUSY + // Concurrent cross-process writes must neither fail (no SQLITE_BUSY / read-only + // surfacing as errors) nor corrupt the store: every committed write must remain + // readable, and counts must stay exact. + describe("concurrent writes", () => { + test("KV: concurrent writes to distinct keys from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ root, name: "a", kvId: "ns" }); + const b = make({ root, name: "b", kvId: "ns" }); + await a.ready; + await b.ready; + const kvA = await a.getKVNamespace("KV"); + const kvB = await b.getKVNamespace("KV"); + + const N = 50; + const ops: Promise<{ ok: boolean; key: string }>[] = []; + for (let i = 0; i < N; i++) { + const keyA = `a-${i}`; + const keyB = `b-${i}`; + ops.push( + kvA + .put(keyA, "v") + .then(() => ({ ok: true, key: keyA })) + .catch(() => ({ ok: false, key: keyA })) + ); + ops.push( + kvB + .put(keyB, "v") + .then(() => ({ ok: true, key: keyB })) + .catch(() => ({ ok: false, key: keyB })) + ); + } + const results = await Promise.all(ops); + expect(results.filter((r) => !r.ok)).toEqual([]); + expect(results.some((r) => r.ok)).toBe(true); + + // Integrity: every write that reported success must be readable. + for (const r of results.filter((r) => r.ok)) { + expect(await kvA.get(r.key)).toBe("v"); + } + }); + + test("D1: concurrent inserts into the same table from both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = make({ root, name: "a", d1Id: "db" }); + const b = make({ root, name: "b", d1Id: "db" }); + await a.ready; + await b.ready; + const dbA = await a.getD1Database("DB"); + const dbB = await b.getD1Database("DB"); + + await dbA.exec( + "CREATE TABLE log (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT);" + ); + + const N = 40; + const ops: Promise[] = []; + for (let i = 0; i < N; i++) { + ops.push( + dbA + .prepare("INSERT INTO log (src) VALUES ('a')") + .run() + .then(() => true) + .catch(() => false) + ); + ops.push( + dbB + .prepare("INSERT INTO log (src) VALUES ('b')") + .run() + .then(() => true) + .catch(() => false) + ); + } + const results = await Promise.all(ops); + const succeeded = results.filter(Boolean).length; + expect(succeeded).toBe(results.length); + expect(succeeded).toBeGreaterThan(0); + + // Integrity: row count equals number of successful inserts (no + // corruption, no phantom/lost committed rows). + const { results: rows } = await dbA + .prepare("SELECT COUNT(*) as c FROM log") + .all<{ c: number }>(); + expect(rows[0].c).toBe(succeeded); + }); + }); + + // --------------------------------------------------- Durable Objects (out of scope) + // Documents the behaviour of sharing a DO via resourcePersistencePath. DOs are out + // of scope for the singleton feature; this test records what happens today. + describe("Durable Objects (behaviour documentation)", () => { + const DO_SCRIPT = ` + export class Counter { + constructor(state) { this.state = state; } + async fetch(request) { + const url = new URL(request.url); + if (request.method === "POST") { + await this.state.storage.put("v", url.searchParams.get("v")); + return new Response("ok"); + } + const v = await this.state.storage.get("v"); + return new Response(String(v ?? "")); + } + } + export default { + async fetch(request, env) { + const id = env.COUNTER.idFromName("singleton"); + return env.COUNTER.get(id).fetch(request); + } + }; + `; + + function makeDO(root: string, name: string) { + const mf = new Miniflare({ + name, + resourcePersistencePath: root, + modules: true, + script: DO_SCRIPT, + compatibilityDate: COMPAT_DATE, + durableObjects: { COUNTER: "Counter" }, + }); + instances.push(mf); + return mf; + } + + test("DO storage is not live-shared between running instances", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeDO(root, "a"); + const b = makeDO(root, "b"); + await a.ready; + await b.ready; + + await ( + await a.dispatchFetch("http://x/?v=42", { method: "POST" }) + ).text(); + const res = await b.dispatchFetch("http://x/"); + const text = await res.text(); + // User Durable Objects are intentionally out of scope for shared local + // binding singletons. Two running instances can have independent live + // actors even when pointed at the same persistence directory. + expect(text).toBe(""); + }); + + const DO_OUTPUT_GATE_SCRIPT = ` + export class GateCounter { + constructor(state) { this.state = state; } + + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/write-and-wait") { + await this.state.storage.put("v", "pending"); + await new Promise((resolve) => setTimeout(resolve, 500)); + return new Response("done"); + } + + if (url.pathname === "/write-and-throw") { + await this.state.storage.put("v", "thrown"); + throw new Error("boom"); + } + + const v = await this.state.storage.get("v"); + return new Response(String(v ?? "")); + } + } + + export default { + async fetch(request, env) { + const id = env.COUNTER.idFromName("singleton"); + return env.COUNTER.get(id).fetch(request); + } + }; + `; + + function makeGateDO(root: string) { + const mf = new Miniflare({ + // The default DO uniqueKey is `${name}-${className}`. Using the same + // name in both Miniflare instances deliberately points both live + // actors at the same persisted DO storage. + name: "same-worker", + resourcePersistencePath: root, + modules: true, + script: DO_OUTPUT_GATE_SCRIPT, + compatibilityDate: COMPAT_DATE, + durableObjects: { COUNTER: "GateCounter" }, + }); + instances.push(mf); + return mf; + } + + test("DO output gate does not block another process reading the same DO storage", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeGateDO(root); + const b = makeGateDO(root); + await a.ready; + await b.ready; + + const pendingWrite = a.dispatchFetch("http://x/write-and-wait"); + const writeCompleted = await Promise.race([ + pendingWrite.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 100)), + ]); + expect(writeCompleted).toBe(false); + + // Output gates block A's outgoing response while the write is flushed, but + // they are local to A's live actor. B is a separate workerd process with a + // separate live actor, so it can observe the shared SQLite state before A's + // request has completed. + expect(await (await b.dispatchFetch("http://x/read")).text()).toBe( + "pending" + ); + expect(await (await pendingWrite).text()).toBe("done"); + }); + + test("DO storage writes are not rolled back when the request throws", async ({ + expect, + }) => { + const root = await useTmp(); + const a = makeGateDO(root); + const b = makeGateDO(root); + await a.ready; + await b.ready; + + const failed = await a.dispatchFetch("http://x/write-and-throw"); + expect(failed.status).toBe(500); + await failed.text(); + + // Output gates are durability/visibility gates, not request-scoped + // transactions. A write that completed before user code threw can still be + // committed and subsequently observed by another process. + expect(await (await b.dispatchFetch("http://x/read")).text()).toBe( + "thrown" + ); + }); + }); + + // --------------------------------------------- Extra routed storage types + // Streams, Secrets Store and Images also share their backing storage across + // instances (via the owner when enabled; via the persist root otherwise). + describe("extra storage types", () => { + test("Stream: a video uploaded by a worker in A is listed by a worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const WORKER = `export default { + async fetch(request, env) { + if (request.method === "PUT") { + const body = new Response(new Uint8Array([0,1,2,3,4,5,6,7])).body; + const video = await env.STREAM.upload(body, {}); + return Response.json({ id: video.id }); + } + const videos = await env.STREAM.videos.list(); + return Response.json({ count: videos.length }); + } + }`; + const a = make({ root, name: "a", stream: true, script: WORKER }); + const b = make({ root, name: "b", stream: true, script: WORKER }); + await a.ready; + await b.ready; + + const put = (await ( + await a.dispatchFetch("http://x/", { method: "PUT" }) + ).json()) as { id: string }; + expect(put.id).toBeTruthy(); + + expect( + ( + (await (await b.dispatchFetch("http://x/")).json()) as { + count: number; + } + ).count + ).toBe(1); + }); + + test("Secrets Store: a secret created via A is read by a worker in B", async ({ + expect, + }) => { + const root = await useTmp(); + const secret = { store_id: "store", secret_name: "api_key" }; + const WORKER = `export default { + async fetch(request, env) { + try { return new Response(await env.SECRET.get()); } + catch (e) { return new Response(e.message, { status: 404 }); } + } + }`; + const a = make({ root, name: "a", secret, script: WORKER }); + const b = make({ root, name: "b", secret, script: WORKER }); + await a.ready; + await b.ready; + + await ( + await a.getSecretsStoreSecretAPI("SECRET") + )().create("shared-secret"); + + expect(await (await b.dispatchFetch("http://x/")).text()).toBe( + "shared-secret" + ); + }); + + test("Images: the binding is usable in both instances", async ({ + expect, + }) => { + const root = await useTmp(); + const WORKER = `export default { + async fetch(_request, env) { + return new Response(typeof env.IMAGES.info); + } + }`; + const a = make({ root, name: "a", images: true, script: WORKER }); + const b = make({ root, name: "b", images: true, script: WORKER }); + await a.ready; + await b.ready; + + expect(await (await a.dispatchFetch("http://x/")).text()).toBe( + "function" + ); + expect(await (await b.dispatchFetch("http://x/")).text()).toBe( + "function" + ); + }); + }); +}); diff --git a/packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts b/packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts index 44fbe11dfa6..622dc730f32 100644 --- a/packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts +++ b/packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts @@ -1,7 +1,7 @@ import path from "node:path"; // The relay helper under unit test. It is the same implementation bundled into -// miniflare's dist and mirrored (byte-for-byte, comments aside) into the edge -// ProxyServerWorker. It is resolved via a vitest alias (see vitest.config.mts) +// Miniflare's dist and imported by the edge ProxyServerWorker through the shared +// proxy-server factory. It is resolved via a vitest alias (see vitest.config.mts) // rather than a real path so that its worker-typed source isn't pulled into the // node-side tsconfig, which excludes `src/workers/**`. tsc has no matching path // mapping, hence the expected error below. diff --git a/packages/miniflare/test/storage-owner.spec.ts b/packages/miniflare/test/storage-owner.spec.ts new file mode 100644 index 00000000000..c14d2151ff9 --- /dev/null +++ b/packages/miniflare/test/storage-owner.spec.ts @@ -0,0 +1,618 @@ +import { readFileSync, utimesSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { + getWorkerRegistry, + isProcessAlive, + Miniflare, + OWNER_STALE_MS, + STORAGE_OWNER_CLIENT_PRESENCE_PREFIX, + STORAGE_OWNER_WORKER_NAME, + tryAcquireOwnerSpawnLock, +} from "miniflare"; +import { describe, it, vi } from "vitest"; +import { useTmp } from "./test-shared"; + +// A pid that is essentially guaranteed not to exist on the host. +const DEAD_PID = 0x7fffffff; + +/** The live shared-storage owner's dev registry entry, if any. */ +function readOwnerEntry(registryPath: string) { + return getWorkerRegistry(registryPath)[STORAGE_OWNER_WORKER_NAME]; +} + +/** Number of live shared-storage client presence entries in the registry. */ +function countClientPresence(registryPath: string): number { + return Object.keys(getWorkerRegistry(registryPath)).filter((name) => + name.startsWith(STORAGE_OWNER_CLIENT_PRESENCE_PREFIX) + ).length; +} + +/** Recover the detached owner's pid from its log file (best-effort, tests only). */ +function readOwnerPidFromLog(persistRoot: string): number | undefined { + try { + const log = readFileSync( + path.join(persistRoot, ".miniflare-owner.log"), + "utf8" + ); + const match = log.match(/storage owner (\d+)/); + return match ? Number(match[1]) : undefined; + } catch { + return undefined; + } +} + +describe("isProcessAlive", () => { + it("reports the current process as alive", ({ expect }) => { + expect(isProcessAlive(process.pid)).toBe(true); + }); + it("reports a non-existent process as dead", ({ expect }) => { + expect(isProcessAlive(DEAD_PID)).toBe(false); + }); + it("treats invalid pids as dead", ({ expect }) => { + expect(isProcessAlive(0)).toBe(false); + expect(isProcessAlive(-1)).toBe(false); + }); +}); + +describe("owner spawn lock", () => { + it("grants the lock to a single acquirer", async ({ expect }) => { + const lockDir = await useTmp(); + const first = tryAcquireOwnerSpawnLock(lockDir); + expect(first).toBeDefined(); + const second = tryAcquireOwnerSpawnLock(lockDir); + expect(second).toBeUndefined(); + first?.release(); + const third = tryAcquireOwnerSpawnLock(lockDir); + expect(third).toBeDefined(); + third?.release(); + }); + + it("reclaims a lock held by a dead process", async ({ expect }) => { + const lockDir = await useTmp(); + // Simulate a crashed holder by writing a dead pid into the lock file. + writeFileSync( + path.join(lockDir, ".miniflare-owner.lock"), + String(DEAD_PID) + ); + const lock = tryAcquireOwnerSpawnLock(lockDir); + expect(lock).toBeDefined(); + lock?.release(); + }); + + it("reclaims a stale lock", async ({ expect }) => { + const lockDir = await useTmp(); + const lockPath = path.join(lockDir, ".miniflare-owner.lock"); + writeFileSync(lockPath, String(process.pid)); + const old = new Date(Date.now() - OWNER_STALE_MS - 60_000); + utimesSync(lockPath, old, old); + const lock = tryAcquireOwnerSpawnLock(lockDir); + expect(lock).toBeDefined(); + lock?.release(); + }); +}); + +describe.sequential("owner presence integration", () => { + it("an owner-role instance registers itself in the dev registry and removes it on dispose", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const owner = new Miniflare({ + unsafeSharedStorageOwner: true, + unsafeStorageOwnerRole: "owner", + resourcePersistencePath: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + modules: true, + kvNamespaces: ["NS"], + script: + "export default { async fetch() { return new Response('owner'); } }", + }); + await owner.ready; + + const entry = readOwnerEntry(registryPath); + expect(entry).toBeDefined(); + expect(entry?.debugPortAddress).toMatch(/^127\.0\.0\.1:\d+$/); + + await owner.dispose(); + expect(readOwnerEntry(registryPath)).toBeUndefined(); + }); + + it("a client-role instance registers a presence entry and removes it on dispose", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const common = { + unsafeSharedStorageOwner: true, + resourcePersistencePath: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + kvNamespaces: ["NS"], + script: "export default { async fetch() { return new Response('ok'); } }", + }; + const owner = new Miniflare({ ...common, unsafeStorageOwnerRole: "owner" }); + await owner.ready; + const client = new Miniflare({ + ...common, + unsafeStorageOwnerRole: "client", + }); + + try { + await client.ready; + + await vi.waitFor( + () => expect(countClientPresence(registryPath)).toBe(1), + { + timeout: 5_000, + interval: 100, + } + ); + + await client.dispose(); + expect(countClientPresence(registryPath)).toBe(0); + } finally { + await client.dispose().catch(() => {}); + await owner.dispose(); + } + }); + + it("routes a client's KV through the owner so storage is shared", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const KV_WORKER = `export default { + async fetch(request, env) { + const url = new URL(request.url); + const key = url.searchParams.get("key") ?? "k"; + if (request.method === "PUT") { + await env.NS.put(key, await request.text()); + return new Response("ok"); + } + const val = await env.NS.get(key); + return new Response(val ?? ""); + } + }`; + const common = { + unsafeSharedStorageOwner: true, + resourcePersistencePath: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + kvNamespaces: ["NS"], + script: KV_WORKER, + }; + + const owner = new Miniflare({ ...common, unsafeStorageOwnerRole: "owner" }); + await owner.ready; + const client = new Miniflare({ + ...common, + unsafeStorageOwnerRole: "client", + }); + + try { + await client.ready; + + // Write through the client (which routes to the owner). + const putRes = await client.dispatchFetch("http://x/?key=greeting", { + method: "PUT", + body: "hello-from-client", + }); + expect(await putRes.text()).toBe("ok"); + + // The owner can read what the client wrote → storage is shared. + const ownerRes = await owner.dispatchFetch("http://x/?key=greeting"); + expect(await ownerRes.text()).toBe("hello-from-client"); + + // And the client can read it back through the proxy. + const clientRes = await client.dispatchFetch("http://x/?key=greeting"); + expect(await clientRes.text()).toBe("hello-from-client"); + } finally { + await client.dispose(); + await owner.dispose(); + } + }); + + it("routes a client's R2 and D1 through the owner so storage is shared", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const WORKER = `export default { + async fetch(request, env) { + const url = new URL(request.url); + const kind = url.searchParams.get("kind"); + if (kind === "r2") { + if (request.method === "PUT") { + await env.BUCKET.put("obj", await request.text()); + return new Response("ok"); + } + const o = await env.BUCKET.get("obj"); + return new Response(o ? await o.text() : ""); + } + // d1 + if (request.method === "PUT") { + await env.DB.prepare("CREATE TABLE IF NOT EXISTS t(v TEXT)").run(); + await env.DB.prepare("INSERT INTO t(v) VALUES (?)").bind(await request.text()).run(); + return new Response("ok"); + } + const { results } = await env.DB.prepare("SELECT v FROM t").all(); + return new Response(JSON.stringify(results.map((r) => r.v))); + } + }`; + const common = { + unsafeSharedStorageOwner: true, + resourcePersistencePath: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + r2Buckets: ["BUCKET"], + d1Databases: ["DB"], + script: WORKER, + }; + const owner = new Miniflare({ ...common, unsafeStorageOwnerRole: "owner" }); + await owner.ready; + const client = new Miniflare({ + ...common, + unsafeStorageOwnerRole: "client", + }); + + try { + await client.ready; + + // R2: client write → owner read. + expect( + await ( + await client.dispatchFetch("http://x/?kind=r2", { + method: "PUT", + body: "r2-from-client", + }) + ).text() + ).toBe("ok"); + expect( + await (await owner.dispatchFetch("http://x/?kind=r2")).text() + ).toBe("r2-from-client"); + + // D1: client write → owner read. + expect( + await ( + await client.dispatchFetch("http://x/?kind=d1", { + method: "PUT", + body: "d1-from-client", + }) + ).text() + ).toBe("ok"); + expect( + await (await owner.dispatchFetch("http://x/?kind=d1")).text() + ).toBe(JSON.stringify(["d1-from-client"])); + } finally { + await client.dispose(); + await owner.dispose(); + } + }); + + it("routes a client's Stream through the owner over RPC so storage is shared", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + // Exercises the JSRPC path of the owner boundary (native RPC over the debug + // port), including nested RpcTargets (`videos.list()`). + const WORKER = `export default { + async fetch(request, env) { + try { + if (request.method === "PUT") { + const body = new Response( + new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) + ).body; + const video = await env.STREAM.upload(body, {}); + return Response.json({ id: video.id }); + } + const videos = await env.STREAM.videos.list(); + return Response.json({ count: videos.length }); + } catch (e) { + return Response.json({ error: String(e && e.stack || e) }, { status: 500 }); + } + } + }`; + const common = { + unsafeSharedStorageOwner: true, + resourcePersistencePath: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + stream: { binding: "STREAM" }, + script: WORKER, + }; + const owner = new Miniflare({ ...common, unsafeStorageOwnerRole: "owner" }); + await owner.ready; + const client = new Miniflare({ + ...common, + unsafeStorageOwnerRole: "client", + }); + + try { + await client.ready; + + // Client uploads a video (RPC through the owner)... + const put = (await ( + await client.dispatchFetch("http://x/", { method: "PUT" }) + ).json()) as { id: string }; + expect(put.id).toBeTruthy(); + + // ...and the owner sees it (shared store), proving the RPC round-trip + // and the shared backing storage. + expect( + ( + (await (await owner.dispatchFetch("http://x/")).json()) as { + count: number; + } + ).count + ).toBe(1); + } finally { + await client.dispose(); + await owner.dispose(); + } + }); + + it("routes a client's Secrets Store secret through the owner over RPC", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const WORKER = `export default { + async fetch(request, env) { + try { + return new Response(await env.SECRET.get()); + } catch (e) { + return new Response(e.message, { status: 404 }); + } + } + }`; + const common = { + unsafeSharedStorageOwner: true, + resourcePersistencePath: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + secretsStoreSecrets: { + SECRET: { store_id: "store_a", secret_name: "api_key" }, + }, + script: WORKER, + }; + const owner = new Miniflare({ ...common, unsafeStorageOwnerRole: "owner" }); + await owner.ready; + const client = new Miniflare({ + ...common, + unsafeStorageOwnerRole: "client", + }); + + try { + await client.ready; + + // Seed the secret value on the owner (which holds the local store)... + await ( + await owner.getSecretsStoreSecretAPI("SECRET") + )().create("super-secret"); + + // ...and the client reads it back over the routed RPC binding. + expect(await (await client.dispatchFetch("http://x/")).text()).toBe( + "super-secret" + ); + } finally { + await client.dispose(); + await owner.dispose(); + } + }); + + it("routes a client's Images store to the owner without dangling services", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const common = { + unsafeSharedStorageOwner: true, + resourcePersistencePath: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + images: { binding: "IMAGES" }, + script: + "export default { async fetch(_request, env) { return new Response(typeof env.IMAGES.info); } }", + }; + const owner = new Miniflare({ ...common, unsafeStorageOwnerRole: "owner" }); + const client = new Miniflare({ + ...common, + unsafeStorageOwnerRole: "client", + }); + try { + // Both reaching `ready` proves the routed client doesn't reference a + // local images storage service it no longer stands up (the owner does), + // and the transform worker + its routed `IMAGES_STORE` binding resolve. + await owner.ready; + await client.ready; + expect(await (await client.dispatchFetch("http://x/")).text()).toBe( + "function" + ); + } finally { + await client.dispose(); + await owner.dispose(); + } + }); + + it("auto-spawns a detached owner, routes to it, and tears it down when idle", async ({ + expect, + }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + // Shrink the owner's teardown timings (inherited by the spawned process). + const prevGrace = process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS; + const prevCheck = process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS; + process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS = "500"; + process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS = "200"; + + let ownerPid: number | undefined; + const client = new Miniflare({ + // No role set → behaves as a client and auto-spawns an owner. + unsafeSharedStorageOwner: true, + resourcePersistencePath: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + kvNamespaces: ["NS"], + script: `export default { + async fetch(request, env) { + if (request.method === "PUT") { + await env.NS.put("k", await request.text()); + return new Response("ok"); + } + return new Response((await env.NS.get("k")) ?? ""); + } + }`, + }); + + try { + await client.ready; + + // An owner was auto-spawned and registered itself in the dev registry. + expect(readOwnerEntry(registryPath)).toBeDefined(); + ownerPid = readOwnerPidFromLog(persistRoot); + expect(ownerPid).toBeDefined(); + expect(ownerPid).not.toBe(process.pid); // a separate process + + // Storage works through the routed proxy. + await ( + await client.dispatchFetch("http://x/", { + method: "PUT", + body: "via-auto-owner", + }) + ).text(); + const got = await client.dispatchFetch("http://x/"); + expect(await got.text()).toBe("via-auto-owner"); + + // Disposing the only client should let the owner self-terminate. + await client.dispose(); + await vi.waitFor( + () => expect(readOwnerEntry(registryPath)).toBeUndefined(), + { timeout: 15_000, interval: 200 } + ); + } finally { + await client.dispose().catch(() => {}); + // Safety net: ensure the detached owner isn't leaked if assertions failed. + if (ownerPid !== undefined && isProcessAlive(ownerPid)) { + try { + process.kill(ownerPid); + } catch {} + } + process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS = prevGrace; + process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS = prevCheck; + } + }); + + it("lets many client instances write one D1 concurrently without contention", async ({ + expect, + }) => { + // This is the scenario that produces cross-process SQLITE_BUSY today: many + // processes opening the same SQLite file. With a shared owner, only the + // owner opens it, so concurrent writes from all clients succeed. + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const prevGrace = process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS; + const prevCheck = process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS; + process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS = "500"; + process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS = "200"; + + const N = 3; // client instances + const M = 20; // inserts per client + const WORKER = `export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.searchParams.get("init") === "1") { + await env.DB.prepare("CREATE TABLE IF NOT EXISTS t(v INTEGER)").run(); + return new Response("ok"); + } + if (request.method === "PUT") { + await env.DB.prepare("INSERT INTO t(v) VALUES (1)").run(); + return new Response("ok"); + } + const row = await env.DB.prepare("SELECT COUNT(*) AS c FROM t").first(); + return new Response(String(row.c)); + } + }`; + const make = () => + new Miniflare({ + unsafeSharedStorageOwner: true, + resourcePersistencePath: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + compatibilityDate: "2025-01-01", + modules: true, + d1Databases: ["DB"], + script: WORKER, + }); + + const clients = Array.from({ length: N }, make); + let ownerPid: number | undefined; + try { + await Promise.all(clients.map((c) => c.ready)); + expect(readOwnerEntry(registryPath)).toBeDefined(); + ownerPid = readOwnerPidFromLog(persistRoot); + + // Create the table once, then hammer it concurrently from every client. + await clients[0].dispatchFetch("http://x/?init=1").then((r) => r.text()); + + const results = await Promise.all( + clients.flatMap((c) => + Array.from({ length: M }, async () => { + const r = await c.dispatchFetch("http://x/", { method: "PUT" }); + await r.text(); // consume body + return r.status; + }) + ) + ); + // No request failed (e.g. with a 500 from SQLITE_BUSY). + expect(results.every((s) => s === 200)).toBe(true); + + // All writes landed — no lost updates, no contention failures. + const count = await clients[0] + .dispatchFetch("http://x/") + .then((r) => r.text()); + expect(count).toBe(String(N * M)); + } finally { + await Promise.all(clients.map((c) => c.dispose().catch(() => {}))); + if (ownerPid !== undefined && isProcessAlive(ownerPid)) { + try { + process.kill(ownerPid); + } catch {} + } + process.env.MINIFLARE_STORAGE_OWNER_GRACE_MS = prevGrace; + process.env.MINIFLARE_STORAGE_OWNER_IDLE_CHECK_MS = prevCheck; + } + }); + + it("does nothing when the feature flag is off", async ({ expect }) => { + const persistRoot = await useTmp(); + const registryPath = await useTmp(); + const mf = new Miniflare({ + resourcePersistencePath: persistRoot, + unsafeDevRegistryPath: registryPath, + compatibilityFlags: ["experimental"], + modules: true, + script: + "export default { async fetch() { return new Response('plain'); } }", + }); + await mf.ready; + expect(readOwnerEntry(registryPath)).toBeUndefined(); + expect(countClientPresence(registryPath)).toBe(0); + await mf.dispose(); + }); +}); diff --git a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts index 055bfeda91d..da036e579c2 100644 --- a/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts +++ b/packages/remote-bindings/templates/remoteBindings/ProxyServerWorker.ts @@ -1,5 +1,11 @@ -import { newWorkersRpcResponse } from "capnweb"; import { EmailMessage } from "cloudflare:email"; +// The remote-bindings boundary's server logic is shared with Miniflare's storage +// owner. Import the single implementation from Miniflare (a build-time +// dependency; esbuild bundles it into this template) rather than duplicating it. +import { + BindingError, + createRemoteBindingsProxyServer, +} from "../../../miniflare/src/workers/shared/remote-bindings-proxy-server"; type Env = Record; @@ -11,12 +17,6 @@ type SendEmailInput = "EmailMessage::raw": ReadableStream; }; -class BindingNotFoundError extends Error { - constructor(name?: string) { - super(`Binding ${name ? `"${name}"` : ""} not found`); - } -} - /** * For most bindings, we expose them as * - RPC stubs directly to capnweb, or @@ -40,12 +40,12 @@ function getExposedJSRPCBinding(request: Request, env: Env) { const url = new URL(request.url); const bindingName = url.searchParams.get("MF-Binding"); if (!bindingName) { - throw new BindingNotFoundError(); + throw new BindingError("Binding not found"); } const targetBinding = env[bindingName]; if (!targetBinding) { - throw new BindingNotFoundError(bindingName); + throw new BindingError(`Binding "${bindingName}" not found`); } if (targetBinding.constructor.name === "SendEmail") { @@ -79,15 +79,15 @@ function getExposedJSRPCBinding(request: Request, env: Env) { return targetBinding; } -function getExposedFetcher(request: Request, env: Env) { +function getExposedFetcher(request: Request, env: Env): Fetcher { const bindingName = request.headers.get("MF-Binding"); if (!bindingName) { - throw new BindingNotFoundError(); + throw new BindingError("Binding not found"); } const targetBinding = env[bindingName]; if (!targetBinding) { - throw new BindingNotFoundError(bindingName); + throw new BindingError(`Binding "${bindingName}" not found`); } // Special case the Dispatch Namespace binding because it has a top-level synchronous .get() call @@ -101,250 +101,9 @@ function getExposedFetcher(request: Request, env: Env) { return targetBinding as Fetcher; } -/** - * This Worker can proxy two types of remote binding: - * 1. "raw" bindings, where this Worker has been configured to pass through the raw - * fetch from a local workerd instance to the relevant binding - * 2. JSRPC bindings, where this Worker uses capnweb to proxy RPC - * communication in userland. This is always over a WebSocket connection - */ -function isJSRPCBinding(request: Request): boolean { - const url = new URL(request.url); - return request.headers.has("Upgrade") && url.searchParams.has("MF-Binding"); -} - -/** - * A raw TCP tunnel request from the local proxy client's `connect` handler: a - * WebSocket upgrade carrying the target address in `MF-Connect-Address`. We open - * the binding's socket (routing through the VPC tunnel) and relay bytes between - * it and the WebSocket in both directions. - */ -function isConnectBinding(request: Request): boolean { - return ( - request.headers.get("Upgrade") === "websocket" && - request.headers.has("MF-Connect-Address") - ); -} - -function handleConnect(request: Request, env: Env): Response { - const address = request.headers.get("MF-Connect-Address"); - if (address === null) { - return new Response("Missing MF-Connect-Address header", { status: 400 }); - } - const fetcher = getExposedFetcher(request, env) as Fetcher; - - const { 0: client, 1: server } = new WebSocketPair(); - server.accept(); - - const socket = fetcher.connect(address); - // Relay runs for the lifetime of the tunnel; failures are surfaced by closing - // the WebSocket with code 1011 (see pipeSocketOverWebSocket). - pipeSocketOverWebSocket(socket, server).catch(() => {}); - - return new Response(null, { status: 101, webSocket: client }); -} - -/** - * Clamp a WebSocket close reason to the protocol's 123-byte limit. - * - * `WebSocket.close(code, reason)` requires the reason to be at most 123 UTF-8 - * bytes. Slicing by JavaScript string length (UTF-16 code units) can both - * overshoot the byte budget and split a multi-byte character, so truncate on a - * UTF-8 byte boundary instead. - */ -function truncateCloseReason(reason: string): string { - const bytes = new TextEncoder().encode(reason); - if (bytes.length <= 123) { - return reason; - } - // Back off to a UTF-8 character boundary: step left over any trailing - // continuation bytes (10xxxxxx) so we never cut a multi-byte sequence. - let end = 123; - while (end > 0 && ((bytes[end] ?? 0) & 0b1100_0000) === 0b1000_0000) { - end--; - } - return new TextDecoder().decode(bytes.subarray(0, end)); -} - -/** - * Relay raw TCP bytes between a `connect()` socket and a WebSocket in both - * directions, propagating close and error state. Mirrors the helper in the - * Miniflare proxy client (packages/miniflare/src/workers/shared/remote-bindings-utils.ts); - * this file is bundled standalone for the edge, so the logic is duplicated. Keep - * the two implementations byte-for-byte identical (comments aside). - * - * Both directions are torn down together: when one side finishes — a WebSocket - * close/error, or the socket reaching EOF / erroring — the opposite direction is - * actively cancelled (`reader.cancel()` for the socket read, settling the - * promise for the WebSocket wait), so neither a parked read nor a never-arriving - * close event can leak the relay or the underlying socket. - * - * Note: WebSockets have no backpressure signal, and there is no TCP half-close — - * when either direction ends the tunnel is torn down in full (the WebSocket is - * closed and the socket's writable side is closed). - */ -async function pipeSocketOverWebSocket( - socket: Socket, - ws: WebSocket -): Promise { - const writer = socket.writable.getWriter(); - const reader = socket.readable.getReader(); - - let wsClosed = false; - function closeWebSocket(code: number, reason?: string) { - if (wsClosed) { - return; - } - wsClosed = true; - try { - ws.close( - code, - reason === undefined ? undefined : truncateCloseReason(reason) - ); - } catch { - // Already closing/closed. - } - } - - // ws -> socket writes are serialised through this chain to preserve byte - // order. The writable side is closed exactly once (guarded by `writerClosed`) - // so both teardown paths below can invoke it idempotently. - let writeChain = Promise.resolve(); - let writerClosed = false; - function closeWriter(): Promise { - if (writerClosed) { - return Promise.resolve(); - } - writerClosed = true; - return writeChain.then(() => writer.close()); - } - - // `fromWebSocket` settles from the ws close/error events, or is settled by the - // socket -> ws direction once it has closed the ws itself (in which case no - // inbound close event will arrive to settle it). - let resolveFromWs!: () => void; - let rejectFromWs!: (reason: unknown) => void; - const fromWebSocket = new Promise((resolve, reject) => { - resolveFromWs = resolve; - rejectFromWs = reject; - }); - - // WebSocket -> socket. Message events aren't awaited by the runtime, so writes - // are serialised through the promise chain to preserve byte order. - ws.addEventListener("message", (event) => { - const chunk = - typeof event.data === "string" - ? new TextEncoder().encode(event.data) - : new Uint8Array(event.data); - writeChain = writeChain - .then(() => writer.write(chunk)) - .catch((error) => { - // A socket write failed: tear the tunnel down rather than leaving the - // rejection unhandled. Close the ws (1011), cancel the opposite read - // direction, and reject so the caller sees the failure. - closeWebSocket( - 1011, - (error as Error)?.message ?? "socket write failed" - ); - reader.cancel().catch(() => {}); - rejectFromWs(error); - }); - }); - ws.addEventListener("close", (event) => { - wsClosed = true; - // Actively cancel the opposite direction so a parked `reader.read()` can't - // keep the socket -> ws relay (and this whole pipe) alive forever. - reader.cancel().catch(() => {}); - // Close code 1011 signals the remote end errored. - if (event.code === 1011) { - rejectFromWs( - new Error(event.reason || "Remote tunnel closed with an error") - ); - return; - } - // Flush any queued writes, then close the writable side so the caller sees - // EOF. - closeWriter().then(resolveFromWs, rejectFromWs); - }); - ws.addEventListener("error", () => { - wsClosed = true; - reader.cancel().catch(() => {}); - rejectFromWs(new Error("Tunnel WebSocket errored")); - }); - - // socket -> WebSocket. On EOF close cleanly (1000); on read error close with - // 1011 and reject so the caller sees the failure. - const toWebSocket = (async () => { - try { - for (;;) { - const { value, done } = await reader.read(); - if (done) { - break; - } - // Re-check after the parked read: the ws may have closed while we were - // waiting. If so, terminate quietly instead of erroring on `send`. - if (wsClosed) { - break; - } - ws.send( - value.buffer.slice( - value.byteOffset, - value.byteOffset + value.byteLength - ) - ); - } - closeWebSocket(1000); - } catch (error) { - closeWebSocket(1011, (error as Error)?.message ?? "socket read failed"); - throw error; - } finally { - reader.releaseLock(); - // We closed (or observed the close of) the ws on this side, so no inbound - // close event will arrive to settle `fromWebSocket`. Close the writable - // side and settle it here; idempotent with the ws `close` handler above. - closeWriter().then(resolveFromWs, rejectFromWs); - } - })(); - - await Promise.all([toWebSocket, fromWebSocket]); -} - -export default { - async fetch(request, env) { - try { - if (isConnectBinding(request)) { - return handleConnect(request, env); - } else if (isJSRPCBinding(request)) { - return await newWorkersRpcResponse( - request, - getExposedJSRPCBinding(request, env) - ); - } else { - const fetcher = getExposedFetcher(request, env); - const originalHeaders = new Headers(); - for (const [name, value] of request.headers) { - if (name.startsWith("mf-header-")) { - originalHeaders.set(name.slice("mf-header-".length), value); - } else if (name === "upgrade") { - // The `Upgrade` header needs to be special-cased to prevent: - // TypeError: Worker tried to return a WebSocket in a response to a request which did not contain the header "Upgrade: websocket" - originalHeaders.set(name, value); - } - } - - return await fetcher.fetch( - request.headers.get("MF-URL") ?? "http://example.com", - new Request(request, { - redirect: "manual", - headers: originalHeaders, - }) - ); - } - } catch (e) { - if (e instanceof BindingNotFoundError) { - return new Response(e.message, { status: 400 }); - } - return new Response((e as Error).message, { status: 500 }); - } - }, -} satisfies ExportedHandler; +export default createRemoteBindingsProxyServer({ + resolveRpcBinding: getExposedJSRPCBinding, + resolveFetchBinding: (request, env) => ({ + fetcher: getExposedFetcher(request, env), + }), +}); diff --git a/turbo.json b/turbo.json index 361fa6851f9..c7a82e0edc3 100644 --- a/turbo.json +++ b/turbo.json @@ -12,6 +12,7 @@ "DOCKER_HOST", "LOCAL_TESTS_WITHOUT_DOCKER", "MINIFLARE_CACHE_DIR", + "MINIFLARE_TEST_SHARED_OWNER", "NODE_EXTRA_CA_CERTS", "PWD", "TEST_REPORT_PATH",