Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/local-artifacts-binding.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 77 additions & 0 deletions packages/miniflare/src/plugins/artifacts/LOCAL_DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -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:<port>/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.
11 changes: 7 additions & 4 deletions packages/miniflare/src/plugins/artifacts/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
4 changes: 2 additions & 2 deletions packages/miniflare/src/plugins/artifacts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ type ArtifactsBinding = Extract<MiniflareBinding, { type: "artifacts" }>;
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<string> {
Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 14 additions & 21 deletions packages/miniflare/test/plugins/artifacts/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down Expand Up @@ -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/
);
Expand Down
14 changes: 14 additions & 0 deletions packages/remote-bindings/src/maybe-start-or-update-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down
180 changes: 180 additions & 0 deletions packages/vite-plugin-cloudflare/src/__tests__/artifacts-local.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("node:fs")>();
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<typeof original.readFileSync>[0],
options: Parameters<typeof original.readFileSync>[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();
}
}
);
Loading
Loading