diff --git a/.changeset/local-artifacts-binding.md b/.changeset/local-artifacts-binding.md new file mode 100644 index 00000000000..fc0176047ab --- /dev/null +++ b/.changeset/local-artifacts-binding.md @@ -0,0 +1,9 @@ +--- +"miniflare": minor +"wrangler": minor +"@cloudflare/workers-utils": minor +--- + +Enable local Artifacts by default across Wrangler, Vite, and Workers Vitest + +Artifacts bindings now use the Miniflare simulator and persistent local repositories by default in development. Set `remote: true` on the binding to opt into the deployed resource. Local development does not require Cloudflare credentials, but does require Git 2.32 or newer on the host. diff --git a/packages/miniflare/src/plugins/artifacts/LOCAL_DEVELOPMENT.md b/packages/miniflare/src/plugins/artifacts/LOCAL_DEVELOPMENT.md new file mode 100644 index 00000000000..7a0e8286930 --- /dev/null +++ b/packages/miniflare/src/plugins/artifacts/LOCAL_DEVELOPMENT.md @@ -0,0 +1,77 @@ +# Develop with local Artifacts + +## Prerequisite: Git + +This first version of the local Artifacts emulator requires Git 2.32 or newer installed globally on your development machine, with `git` available on the PATH of the process starting Wrangler, Vite, or Workers Vitest. Run `git --version` in that environment before starting the dev server or tests. If Git is missing, cannot run, or is older than 2.32, local Artifacts fails at startup with installation or upgrade instructions instead of waiting for a repository operation. Restart your dev tool after installing Git so it picks up the updated PATH. + +Git 2.32 provides `GIT_CONFIG_GLOBAL`, which the emulator uses to isolate host Git settings. Use an up-to-date, security-patched release. + +A binding with `"remote": true` does not start the local emulator and does not need local Git. Production Artifacts also does not require Git on your machine. We plan to open-source a JavaScript Git engine in the future; it is not part of this initial local release, which uses native Git. + +## Configure a binding + +For a Wrangler project, add an Artifacts binding to `wrangler.jsonc`: + +```jsonc +{ + "name": "example-worker", + "main": "src/index.ts", + "compatibility_date": "2026-09-03", + "artifacts": [{ "binding": "REPOS", "namespace": "examples" }], +} +``` + +To try this unreleased feature from the Workers SDK checkout, install its dependencies and build Miniflare and Wrangler, then start the checkout's CLI from the app directory: + +```sh +# From the workers-sdk root: +pnpm install --frozen-lockfile +pnpm --filter miniflare build +pnpm --filter wrangler build + +# From your app directory, using the absolute path to this checkout: +node /path/to/workers-sdk/packages/wrangler/bin/wrangler.js dev --port=0 --inspector-port=0 +``` + +Open the URL on Wrangler's `Ready on` line. With `@cloudflare/vite-plugin`, use the same Wrangler config with `cloudflare()` in your Vite config and start the Vite dev server. In Workers Vitest, point `cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })` at this config. These paths share the Miniflare implementation. To force local behavior explicitly, add `"remote": false` to the binding. The absence of `remote` is also local; this differs from older development behavior that implicitly used a remote Artifacts resource. Do not rely on production repositories being available locally. + +A Worker can create and inspect a local repository: + +```ts +const created = await env.REPOS.create("demo"); +const repo = await env.REPOS.get("demo"); +const listing = await env.REPOS.list(); +const info = await repo.info(); +// created.remote is a loopback HTTP Git URL; created.token is a secret. +``` + +To use Git smart HTTP, use the returned `remote` URL and a repository token. If `git clone` prompts for a username and password, enter any username (for example, `git`) and use `created.token` as the password. This is an Artifacts repository token, **not** a GitHub or Cloudflare account password. A configured Git credential helper may remember a password entered at the prompt. To avoid that, supply a bearer token for one Git command without storing it in source or in a Git remote URL: + +```sh +read -s REPO_TOKEN # Paste the token returned by create(); input is hidden. +printf '\n' +# Replace the URL with the `remote` value returned by create() or info(). +GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader \ + GIT_CONFIG_VALUE_0="Authorization: Bearer $REPO_TOKEN" \ + git clone 'http://127.0.0.1:/git/examples/demo.git' +unset REPO_TOKEN +``` + +After pushing `hello.txt` to `main`, read it in the Worker with `await repo.readFile({ ref: "main", path: "hello.txt" })` (a `Blob`, or `null` when absent). The initial create token permits writes. Its plaintext is returned only once; if you lose it, use `repo.createToken("read")` for cloning or `repo.createToken("write")` for pushing and save the returned `plaintext` securely. Revoke tokens that are no longer needed. Avoid printing tokens or committing them to config. A plain request without a token cannot clone a private local repository. Git's URL uses an ephemeral loopback port; obtain the current URL after restarting the dev server instead of hardcoding it. + +## Storage and network boundaries + +Local repositories and metadata persist under the dev tool's Miniflare resource persistence path (for example, Wrangler's `.wrangler/state/` when persistence is enabled). Use the same project root and persistence settings to keep local data across reloads and restarts. Disabling persistence (for example, Vite's `persistState: false`) makes the data temporary. Local and deployed Artifacts are separate: local create, push, and delete operations do not update remote resources. To opt in to a remote binding, set `"remote": true` on that Artifacts binding and configure the credentials required by your development tool. Remote mode uses a Cloudflare proxy, not the local Git store; do not assume local data migrates to it. + +To reset local state, stop the dev server first, back up any repositories you need, then delete the `artifacts/` directory inside that dev tool's resource persistence path. Older draft persisted Artifacts layouts are not automatically migrated and may require this backup-and-reset procedure. Do not delete unrelated binding data in the same persistence directory. + +A local binding does not contact Cloudflare for repository operations. **Explicit imports from HTTPS Git sources are different:** they make outbound network requests from your development machine to the supplied host, including private-network hosts. Import does not follow HTTP redirects; supply the final HTTPS Git URL so credentials cannot be redirected to another destination. Imports can require authentication and may fail due to TLS certificates, connectivity, or upstream Git permissions. For a private Git CA, set `GIT_SSL_CAINFO` to the trusted CA file in the dev tool's host environment before starting the dev server. Do not disable certificate verification. After a successful import, URL credentials are omitted from the local repository metadata and Git config. In-flight process-crash recovery is not yet validated; treat partial local storage after an interrupted import as sensitive if the URL contained credentials. Treat the import URL as a secret while calling `import()` and avoid logging it. Avoid importing untrusted or private URLs in examples or tests. No remote resources are contacted by the local-only examples above. + +## Troubleshooting and limits + +- If startup says local Artifacts requires Git, install Git globally, check `git --version` in the dev tool's environment, and restart the tool with `git` on PATH. +- If startup rejects an older storage layout, back it up and reset only the Artifacts persistence directory; a restart alone will not convert it. +- If Git asks for a username and password, use any username and the repository token as the password. If Git reports unauthorized, check the repository, scope, expiry, and revocation; do not use a Cloudflare or GitHub account password. +- If startup reports `Address already in use`, the dev or inspector port may be occupied. Pass `--port=0 --inspector-port=0` and use the printed `Ready on` URL. +- If a Git URL stops responding after restart, get the new `remote` from the binding; the loopback listener is tied to the dev process. +- Local development is not a replica of production placement or infrastructure. The local Git endpoint is HTTP on loopback only, and local state and tokens do not transfer to a remote namespace. Miniflare Artifacts, Wrangler dev, and Vite local fixtures have passed Windows CI; Workers Vitest's Artifacts fixture has not been verified on Windows. diff --git a/packages/miniflare/src/plugins/artifacts/README.md b/packages/miniflare/src/plugins/artifacts/README.md index 59fd851347f..0206901459e 100644 --- a/packages/miniflare/src/plugins/artifacts/README.md +++ b/packages/miniflare/src/plugins/artifacts/README.md @@ -1,13 +1,16 @@ # Artifacts in Miniflare +For setup, persistence, Git authentication, and local/remote behavior, see +[Local development](./LOCAL_DEVELOPMENT.md). + The Artifacts plugin implements the local Artifacts binding in workerd. This first version requires Git 2.32 or newer installed globally on the host and available on PATH. Miniflare checks the Git version at local startup and gives installation or upgrade instructions if needed. A Node worker thread runs the -native Git backend. Set `dev.remote` to `false` explicitly to select local -storage; an omitted setting retains remote binding behavior in this stage. -We plan to open-source a JavaScript Git engine in the future; it is not -included in this version. +native Git backend. Unless `dev.remote` is explicitly `true`, an Artifacts +binding uses local storage rather than contacting Cloudflare. We plan to +open-source a JavaScript Git engine in the future; it is not included in this +version. ## Local startup diff --git a/packages/miniflare/src/plugins/artifacts/index.ts b/packages/miniflare/src/plugins/artifacts/index.ts index e39ce376c81..90b1eceffc4 100644 --- a/packages/miniflare/src/plugins/artifacts/index.ts +++ b/packages/miniflare/src/plugins/artifacts/index.ts @@ -29,7 +29,7 @@ type ArtifactsBinding = Extract; type ArtifactsEntry = [name: string, binding: ArtifactsBinding]; function isLocalArtifactsBinding(binding: ArtifactsBinding): boolean { - return binding.dev?.remote === false; + return binding.dev?.remote !== true; } function getLocalNamespaceNames(bindings: ArtifactsEntry[]): Set { @@ -60,7 +60,7 @@ export const ARTIFACTS_PLUGIN: Plugin = { getBindings(options) { return getEnvBindingsOfType(options.config, "artifacts").map( ([name, binding]) => { - // In this stage, only explicit remote: false opts into local storage. + // Omitted remote and false use local storage; true opts into the remote proxy. if (isLocalArtifactsBinding(binding)) { validateLocalNamespace(binding.namespace); return { diff --git a/packages/miniflare/test/plugins/artifacts/index.spec.ts b/packages/miniflare/test/plugins/artifacts/index.spec.ts index a4a7fa51e0c..c46b6c0b3a9 100644 --- a/packages/miniflare/test/plugins/artifacts/index.spec.ts +++ b/packages/miniflare/test/plugins/artifacts/index.spec.ts @@ -262,19 +262,10 @@ test("artifacts: config update closes retired sidecars but abort preserves activ } }); -test("artifacts: omitted remote preserves remote routing without host Git", async ({ - expect, -}) => { - const root = await useTmp(); - const previousPath = process.env.PATH; - process.env.PATH = root; - try { - const mf = new Miniflare(options("test", "REPOS", null)); - useDispose(mf); - await expect(mf.ready).resolves.toBeDefined(); - } finally { - process.env.PATH = previousPath; - } +test("artifacts: omitted remote remains offline", async ({ expect }) => { + const mf = new Miniflare(options("test", "REPOS", null)); + useDispose(mf); + expect((await rpc(mf, "create", ["offline"])).name).toBe("offline"); }); test("artifacts: rejects invalid local namespace before starting services", async ({ @@ -899,14 +890,16 @@ test("artifacts: missing Git gives an actionable error during local startup", as const previousPath = process.env.PATH; process.env.PATH = root; try { - const mf = new Miniflare(options()); - await expect(mf.ready).rejects.toThrow( - /Local Artifacts requires Git installed on the host and available on PATH.*Install Git.*git --version.*restart/i - ); - // dispose() preserves the startup error after cleaning up the instance. - await expect(mf.dispose()).rejects.toThrow( - /Local Artifacts requires Git installed on the host and available on PATH/ - ); + for (const remote of [false, null]) { + const mf = new Miniflare(options("test", "REPOS", remote)); + await expect(mf.ready).rejects.toThrow( + /Local Artifacts requires Git installed on the host and available on PATH.*Install Git.*git --version.*restart/i + ); + // dispose() preserves the startup error after cleaning up the instance. + await expect(mf.dispose()).rejects.toThrow( + /Local Artifacts requires Git installed on the host and available on PATH/ + ); + } await expect(startGitSidecar(path.join(root, "repos"))).rejects.toThrow( /Local Artifacts requires Git installed on the host and available on PATH/ ); diff --git a/packages/remote-bindings/src/maybe-start-or-update-session.test.ts b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts index f5d6601ab59..d0fdd8f343b 100644 --- a/packages/remote-bindings/src/maybe-start-or-update-session.test.ts +++ b/packages/remote-bindings/src/maybe-start-or-update-session.test.ts @@ -35,6 +35,20 @@ describe("maybeStartOrUpdateRemoteProxySession", () => { } ); + it("keeps Artifacts offline unless remote is explicitly enabled", ({ + expect, + }) => { + const local = { type: "artifacts" as const, namespace: "local" }; + const remote = { + type: "artifacts" as const, + namespace: "remote", + remote: true, + }; + expect(pickRemoteBindings({ LOCAL: local, REMOTE: remote })).toEqual({ + REMOTE: remote, + }); + }); + it("updates an existing session when all remote bindings are removed", async ({ expect, }) => { diff --git a/packages/vite-plugin-cloudflare/src/__tests__/artifacts-local.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/artifacts-local.spec.ts new file mode 100644 index 00000000000..cd12ff13f92 --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/__tests__/artifacts-local.spec.ts @@ -0,0 +1,180 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath as pathFromUrl } from "node:url"; +import { promisify } from "node:util"; +import { removeDirSync } from "@cloudflare/workers-utils"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { createServer } from "vite"; +import { afterEach, test, vi } from "vitest"; +import { cloudflare } from "../index"; + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + const { fileURLToPath } = await import("node:url"); + const source = fileURLToPath(new URL("../workers/", import.meta.url)); + const built = fileURLToPath(new URL("../../dist/workers/", import.meta.url)); + return { + ...original, + readFileSync( + file: Parameters[0], + options: Parameters[1] + ) { + if ( + typeof file === "string" && + file.startsWith(source) && + file.endsWith(".js") + ) { + file = built + file.slice(source.length); + } + return original.readFileSync(file, options); + }, + }; +}); + +const git = promisify(execFile); + +runInTempDir(); +afterEach(() => vi.unstubAllEnvs()); + +test.for([undefined, false] as const)( + "Vite dev serves local Artifacts without credentials (remote=%s)", + async (remote, { expect, onTestFinished }) => { + vi.stubEnv("CLOUDFLARE_API_TOKEN", undefined); + vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", undefined); + // On Windows, the system temp directory can use an 8.3 path (RUNNER~1). + // Vite resolves that entry as /index.js, but cannot load it from the + // virtual Worker module. Keep the fixture under the checkout instead. + const fixtureDir = fs.mkdtempSync( + path.join( + fs.realpathSync(path.dirname(pathFromUrl(import.meta.url))), + "artifacts-local-" + ) + ); + onTestFinished(() => removeDirSync(fixtureDir)); + fs.writeFileSync( + path.join(fixtureDir, "package.json"), + JSON.stringify({ type: "module" }) + ); + fs.writeFileSync( + path.join(fixtureDir, "wrangler.jsonc"), + JSON.stringify({ + name: "vite-artifacts-local", + main: "index.js", + compatibility_date: "2026-09-03", + artifacts: [ + { + binding: "REPOS", + namespace: "examples", + ...(remote === undefined ? {} : { remote }), + }, + ], + }) + ); + fs.writeFileSync( + path.join(fixtureDir, "index.js"), + `export default { + async fetch(request, env) { + if (new URL(request.url).pathname === "/create") return Response.json(await env.REPOS.create("demo")); + if (new URL(request.url).pathname === "/list") return Response.json(await env.REPOS.list()); + const repo = await env.REPOS.get("demo"); + if (new URL(request.url).pathname === "/file") { + const file = await repo.readFile({ ref: "main", path: "hello.txt" }); + return new Response(file ?? "missing", { status: file ? 200 : 404 }); + } + return Response.json(await repo.info()); + } +};` + ); + const server = await createServer({ + root: fixtureDir, + configFile: false, + logLevel: "silent", + server: { port: 0 }, + plugins: [ + cloudflare({ + configPath: path.join(fixtureDir, "wrangler.jsonc"), + inspectorPort: false, + persistState: false, + }), + ], + }); + let gitUrl: string | undefined; + try { + await server.listen(); + const url = server.resolvedUrls?.local[0]; + if (!url) { + throw new Error("Vite server did not provide a local URL"); + } + const created = (await (await fetch(new URL("create", url))).json()) as { + remote: string; + token: string; + }; + gitUrl = created.remote; + expect(new URL(gitUrl).hostname).toBe("127.0.0.1"); + const directory = path.join(fixtureDir, "git-fixture"); + await git("git", ["init", "--initial-branch=main", directory]); + fs.writeFileSync(path.join(directory, "hello.txt"), "hello artifacts\n"); + // Avoid inheriting host Git credentials or Cloudflare environment. + const inherited = Object.fromEntries( + [ + "PATH", + "SystemRoot", + "WINDIR", + "PATHEXT", + "TMP", + "TEMP", + "TMPDIR", + "LANG", + ].map((key) => [key, process.env[key]]) + ); + const gitOptions = { + cwd: directory, + env: { + ...inherited, + GIT_AUTHOR_NAME: "Example", + GIT_AUTHOR_EMAIL: "example@example.invalid", + GIT_COMMITTER_NAME: "Example", + GIT_COMMITTER_EMAIL: "example@example.invalid", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: path.join(directory, "absent-global-gitconfig"), + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "credential.helper", + GIT_CONFIG_VALUE_0: "", + GIT_TERMINAL_PROMPT: "0", + }, + }; + await git("git", ["add", "hello.txt"], gitOptions); + await git("git", ["commit", "-m", "fixture"], gitOptions); + await git( + "git", + [ + "-c", + `http.extraHeader=Authorization: Bearer ${created.token}`, + "push", + gitUrl, + "main", + ], + gitOptions + ); + expect(await (await fetch(new URL("file", url))).text()).toBe( + "hello artifacts\n" + ); + const listed = (await (await fetch(new URL("list", url))).json()) as { + repos: unknown[]; + }; + expect(listed.repos).toHaveLength(1); + const info = (await (await fetch(new URL("info", url))).json()) as { + name: string; + }; + expect(info.name).toBe("demo"); + } finally { + await server.close(); + } + if (gitUrl) { + await expect( + fetch(gitUrl + "/info/refs?service=git-upload-pack") + ).rejects.toThrow(); + } + } +); diff --git a/packages/vitest-plugin/test/artifacts-local.test.ts b/packages/vitest-plugin/test/artifacts-local.test.ts new file mode 100644 index 00000000000..ce29ce16bf4 --- /dev/null +++ b/packages/vitest-plugin/test/artifacts-local.test.ts @@ -0,0 +1,48 @@ +import dedent from "ts-dedent"; +import { test, vitestConfig } from "./helpers"; + +test.for([undefined, false] as const)( + "Workers Vitest uses local Artifacts without credentials (remote=%s)", + async (remote, { expect, seed, vitestRun }) => { + await seed({ + "vitest.config.mts": vitestConfig({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + "wrangler.jsonc": JSON.stringify({ + name: "artifacts-test-worker", + main: "./index.ts", + compatibility_date: "2026-09-03", + artifacts: [ + { + binding: "REPOS", + namespace: "examples", + ...(remote === undefined ? {} : { remote }), + }, + ], + }), + "index.ts": dedent` + export default { + async fetch(request, env) { + return Response.json(await env.REPOS.list()); + } + }; + `, + "index.test.ts": dedent` + import { env } from "cloudflare:test"; + import { it } from "vitest"; + + it("creates, gets and lists a repository locally", async ({ expect }) => { + const created = await env.REPOS.create("demo"); + expect(new URL(created.remote).hostname).toBe("127.0.0.1"); + const repo = await env.REPOS.get("demo"); + expect((await repo.info()).name).toBe("demo"); + expect((await env.REPOS.list()).repos).toHaveLength(1); + expect(await repo.readFile({ ref: "main", path: "missing.txt" })).toBeNull(); + }); + `, + }); + + const result = await vitestRun(); + await expect(result.exitCode, result.stderr).resolves.toBe(0); + } +); diff --git a/packages/workers-utils/src/config/binding-local-support.ts b/packages/workers-utils/src/config/binding-local-support.ts index 724bb7f2619..636a8a8d96a 100644 --- a/packages/workers-utils/src/config/binding-local-support.ts +++ b/packages/workers-utils/src/config/binding-local-support.ts @@ -58,6 +58,7 @@ const BINDING_LOCAL_SUPPORT: Record< // TODO: Miniflare currently ignores `remote: true` on queues, tracked in #13727. queue: "local-and-remote", flagship: "local-and-remote", + artifacts: "local-and-remote", vectorize: "remote", mtls_certificate: "remote", @@ -70,7 +71,6 @@ const BINDING_LOCAL_SUPPORT: Record< ai_search_namespace: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", media: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", - artifacts: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", vpc_service: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", vpc_network: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", agent_memory: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", diff --git a/packages/workers-utils/src/print-bindings.ts b/packages/workers-utils/src/print-bindings.ts index f07de4ce3bb..dd13beca9ee 100644 --- a/packages/workers-utils/src/print-bindings.ts +++ b/packages/workers-utils/src/print-bindings.ts @@ -457,12 +457,14 @@ export function printBindings( if (artifacts.length > 0) { output.push( - ...artifacts.map(({ binding, namespace }) => { + ...artifacts.map(({ binding, namespace, remote }) => { return { name: binding, type: getBindingTypeFriendlyName("artifacts"), value: namespace, - mode: getMode({ isSimulatedLocally: false }), + mode: getMode({ + isSimulatedLocally: context.remoteBindingsDisabled || !remote, + }), }; }) ); diff --git a/packages/workers-utils/tests/config/binding-local-support.test.ts b/packages/workers-utils/tests/config/binding-local-support.test.ts index 63c5eb6b989..2f7ee953d98 100644 --- a/packages/workers-utils/tests/config/binding-local-support.test.ts +++ b/packages/workers-utils/tests/config/binding-local-support.test.ts @@ -90,6 +90,12 @@ describe("validateBindingRemoteSetting", () => { expect(() => validateBindingRemoteSetting("flagship", undefined, warn) ).not.toThrow(); + expect(() => + validateBindingRemoteSetting("artifacts", false, warn) + ).not.toThrow(); + expect(() => + validateBindingRemoteSetting("artifacts", undefined, warn) + ).not.toThrow(); expect(warn).not.toHaveBeenCalled(); }); }); diff --git a/packages/workers-utils/tests/print-bindings.test.ts b/packages/workers-utils/tests/print-bindings.test.ts index ccefdc7ff2d..f29177ab10b 100644 --- a/packages/workers-utils/tests/print-bindings.test.ts +++ b/packages/workers-utils/tests/print-bindings.test.ts @@ -80,6 +80,30 @@ describe("printBindings", () => { expect(output).toMatch(/K2 Stream\s+not supported/); }); + test.for([ + { label: "default", remote: undefined, disabled: false, mode: "local" }, + { label: "explicit local", remote: false, disabled: false, mode: "local" }, + { label: "explicit remote", remote: true, disabled: false, mode: "remote" }, + { label: "remote disabled", remote: true, disabled: true, mode: "local" }, + ])( + "labels Artifacts bindings as $mode for $label", + ({ remote, disabled, mode }, { expect }) => { + const output = captureBindings( + { + REPOS: { + type: "artifacts", + namespace: "review", + ...(remote === undefined ? {} : { remote }), + }, + }, + { local: true, remoteBindingsDisabled: disabled } + ); + expect(output).toMatch( + new RegExp(`env\\.REPOS \\(review\\)\\s+Artifacts\\s+${mode}`) + ); + } + ); + test.for([{ class_name: "Sandbox" }, { name: "Sandbox" }])( "prints Durable Object-managed containers using their configured identity %j", (identity, { expect }) => { diff --git a/packages/wrangler/src/__tests__/dev/artifacts-local.test.ts b/packages/wrangler/src/__tests__/dev/artifacts-local.test.ts new file mode 100644 index 00000000000..98c5c1ad879 --- /dev/null +++ b/packages/wrangler/src/__tests__/dev/artifacts-local.test.ts @@ -0,0 +1,206 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; +import { fetch } from "undici"; +import { afterEach, describe, it, vi } from "vitest"; +import { mockConsoleMethods } from "../helpers/mock-console"; +import { runWrangler } from "../helpers/run-wrangler"; +import type { StartDevOptions } from "../../dev"; + +const git = promisify(execFile); +let stopDev: (() => Promise) | undefined; +vi.mock("../../dev/start-dev", async () => { + const actual = await vi.importActual( + "../../dev/start-dev" + ); + return { + ...actual, + async startDev(options: StartDevOptions) { + const result = await actual.startDev(options); + stopDev = () => result.devEnv.teardown(); + return result; + }, + }; +}); + +const worker = `export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/version") return new Response("initial"); + if (url.pathname === "/create") return Response.json(await env.REPOS.create("demo")); + if (url.pathname === "/list") return Response.json(await env.REPOS.list()); + const repo = await env.REPOS.get("demo"); + if (url.pathname === "/file") { + const file = await repo.readFile({ ref: "main", path: "hello.txt" }); + return new Response(file ?? "missing", { status: file ? 200 : 404 }); + } + return Response.json(await repo.info()); + } +};`; + +type Created = { remote: string; token: string }; + +async function startLocalDev(remote?: false) { + await seed({ + "wrangler.jsonc": JSON.stringify({ + name: "artifacts-local-dev", + main: "index.js", + compatibility_date: "2026-09-03", + artifacts: [ + { + binding: "REPOS", + namespace: "examples", + ...(remote === undefined ? {} : { remote }), + }, + ], + }), + "index.js": worker, + }); + const previousOutput = output.out; + const stopped = runWrangler("dev --port=0 --inspector-port=0", { + WRANGLER_CI_DISABLE_CONFIG_WATCHING: "false", + CLOUDFLARE_API_TOKEN: undefined, + CLOUDFLARE_ACCOUNT_ID: undefined, + }); + + const match = await Promise.race([ + vi.waitUntil( + () => + output.out + .slice(previousOutput.length) + .match(/Ready on (?http:\/\/[^:]+:\d+)/), + { timeout: 15_000 } + ), + stopped.then(() => { + throw new Error("Wrangler dev exited before ready"); + }), + ]).catch((error: unknown) => { + throw new Error( + `Wrangler did not become ready: ${output.out}\n${output.err}`, + { cause: error } + ); + }); + const url = match?.groups?.url; + if (!url) { + throw new Error(`No local dev URL: ${output.out}`); + } + return { url, stopped }; +} + +const output = mockConsoleMethods(); + +async function stopRunningDev(): Promise { + await stopDev?.(); + stopDev = undefined; +} + +async function request(url: string, endpoint: string): Promise { + const response = await fetch(url + endpoint); + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + return (await response.json()) as T; +} + +describe.sequential("Wrangler dev local Artifacts", () => { + runInTempDir(); + + afterEach(async () => { + await stopRunningDev(); + vi.unstubAllEnvs(); + }); + + it.for([undefined, false] as const)( + "creates, pushes, reads and survives reload (remote=%s)", + async (remote, { expect }) => { + const first = await startLocalDev(remote); + let gitUrl = ""; + try { + const created = await request(first.url, "/create"); + gitUrl = created.remote; + expect(new URL(created.remote).hostname).toBe("127.0.0.1"); + const directory = path.resolve("git-fixture"); + await git("git", ["init", "--initial-branch=main", directory]); + await writeFile(path.join(directory, "hello.txt"), "hello artifacts\n"); + // Do not inherit credential helpers, Cloudflare tokens or Git overrides. + const inherited = Object.fromEntries( + [ + "PATH", + "SystemRoot", + "WINDIR", + "PATHEXT", + "TMP", + "TEMP", + "TMPDIR", + "LANG", + ].map((key) => [key, process.env[key]]) + ); + const env = { + ...inherited, + GIT_AUTHOR_NAME: "Example", + GIT_AUTHOR_EMAIL: "example@example.invalid", + GIT_COMMITTER_NAME: "Example", + GIT_COMMITTER_EMAIL: "example@example.invalid", + GIT_CONFIG_NOSYSTEM: "1", + // Git for Windows cannot use /dev/null as a global config file. + GIT_CONFIG_GLOBAL: path.join(directory, "absent-global-gitconfig"), + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "credential.helper", + GIT_CONFIG_VALUE_0: "", + GIT_TERMINAL_PROMPT: "0", + }; + await git("git", ["add", "hello.txt"], { cwd: directory, env }); + await git("git", ["commit", "-m", "fixture"], { cwd: directory, env }); + await git( + "git", + [ + "-c", + `http.extraHeader=Authorization: Bearer ${created.token}`, + "push", + created.remote, + "main", + ], + { cwd: directory, env } + ); + expect(await (await fetch(first.url + "/file")).text()).toBe( + "hello artifacts\n" + ); + expect( + (await request<{ repos: unknown[] }>(first.url, "/list")).repos + ).toHaveLength(1); + await seed({ "index.js": worker.replace("initial", "reloaded") }); + await vi.waitFor( + async () => { + expect(await (await fetch(first.url + "/version")).text()).toBe( + "reloaded" + ); + }, + { timeout: 15_000 } + ); + expect(await (await fetch(first.url + "/file")).text()).toBe( + "hello artifacts\n" + ); + } finally { + await stopRunningDev(); + await first.stopped; + } + await expect( + fetch(gitUrl + "/info/refs?service=git-upload-pack") + ).rejects.toThrow(); + const second = await startLocalDev(remote); + try { + expect( + (await request<{ name: string }>(second.url, "/info")).name + ).toBe("demo"); + expect(await (await fetch(second.url + "/file")).text()).toBe( + "hello artifacts\n" + ); + } finally { + await stopRunningDev(); + await second.stopped; + } + } + ); +}); diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts index 129d755caca..f8e2e1269f0 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts @@ -523,12 +523,14 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { { binding: "MY_ARTIFACTS", namespace: "default", + remote: true, }, ], }, expectedProxyWorkerBindings: { MY_ARTIFACTS: { namespace: "default", + remote: true, type: "artifacts", }, }, @@ -671,6 +673,36 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { await wranglerStopped; }); + it("keeps Artifacts local when remote is omitted", async ({ expect }) => { + await seed({ + "wrangler.jsonc": JSON.stringify({ + name: "worker", + main: "index.js", + compatibility_date: "2025-01-01", + artifacts: [{ binding: "MY_ARTIFACTS", namespace: "default" }], + }), + "index.js": `export default { fetch() { return new Response("hello") } }`, + }); + const wranglerStopped = runWrangler("dev --port=0 --inspector-port=0"); + await vi.waitFor(() => expect(std.out).toMatch(/Ready/), { + timeout: devReadyTimeout, + }); + expect(startRemoteProxySessionCallCount).toBe(0); + expect(proxyWorkerBindings).toBeUndefined(); + expect(workerOptions).toEqual([ + expect.objectContaining({ + artifacts: { + MY_ARTIFACTS: { + namespace: "default", + remoteProxyConnectionString: undefined, + }, + }, + }), + ]); + await stopWrangler(); + await wranglerStopped; + }); + it("should not recreate the remote proxy session on reload when auth has not changed", async () => { const { maybeStartOrUpdateRemoteProxySession } = await import("../../api/remoteBindings"); diff --git a/packages/wrangler/src/__tests__/print-bindings.test.ts b/packages/wrangler/src/__tests__/print-bindings.test.ts index 8723079ca5d..68ab3548f5e 100644 --- a/packages/wrangler/src/__tests__/print-bindings.test.ts +++ b/packages/wrangler/src/__tests__/print-bindings.test.ts @@ -176,16 +176,27 @@ describe("printBindings -- Flagship bindings", () => { }); describe("printBindings -- Artifacts bindings", () => { - it("shows Artifacts bindings", ({ expect }) => { - const output = callPrintBindings({ - MY_ARTIFACTS: { - type: "artifacts", - namespace: "default", - }, - }); - - expect(output).toContain("MY_ARTIFACTS"); - expect(output).toContain("Artifacts"); - expect(output).toContain("default"); - }); + it.for([ + { label: "default", remote: undefined, mode: "local" }, + { label: "explicit local", remote: false, mode: "local" }, + { label: "explicit remote", remote: true, mode: "remote" }, + ])( + "shows $label Artifacts bindings as $mode", + ({ remote, mode }, { expect }) => { + const output = callPrintBindings( + { + MY_ARTIFACTS: { + type: "artifacts", + namespace: "default", + ...(remote === undefined ? {} : { remote }), + }, + }, + true + ); + + expect(output).toMatch( + new RegExp(`env\\.MY_ARTIFACTS \\(default\\)\\s+Artifacts\\s+${mode}`) + ); + } + ); }); diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index da6c613fc7d..b0ffa13e563 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -960,7 +960,9 @@ export function buildMiniflareBindingOptions( binding.binding, { namespace: binding.namespace, - remoteProxyConnectionString, + remoteProxyConnectionString: binding.remote + ? remoteProxyConnectionString + : undefined, }, ]) ), diff --git a/turbo.json b/turbo.json index 5684358336c..a4ee832daa8 100644 --- a/turbo.json +++ b/turbo.json @@ -12,6 +12,8 @@ "DOCKER_HOST", "LOCAL_TESTS_WITHOUT_DOCKER", "MINIFLARE_CACHE_DIR", + "pnpm_config_pm_on_fail", + "pnpm_config_verify_deps_before_run", "NODE_EXTRA_CA_CERTS", "PWD", "TEST_REPORT_PATH",