Skip to content
Merged
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
69 changes: 69 additions & 0 deletions alchemy/src/cloudflare/artifacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Properties for creating an Artifacts binding.
*
* Artifacts namespaces are created implicitly by Cloudflare when the first
* repository is created in the namespace, so this binding does not provision
* or delete a namespace resource.
*/
export interface ArtifactsProps {
/**
* Artifacts namespace name.
*/
namespace: string;

dev?: {
/**
* Whether to run the Artifacts binding remotely in local development.
*
* Artifacts do not have a local emulator, so generated Wrangler config
* defaults this binding to remote mode.
*
* @default true
*/
remote?: boolean;
};
}

/**
* Cloudflare Artifacts binding.
*
* Artifacts namespaces are implicit containers for repositories. Use this
* helper to bind a Worker to a namespace; repository lifecycle should be
* managed through the Artifacts runtime API, REST API, or Git endpoint.
*
* @example
* ```ts
* import { Artifacts, Worker } from "alchemy/cloudflare";
*
* await Worker("site-editor", {
* entrypoint: "./src/worker.ts",
* bindings: {
* SITE_ARTIFACTS: Artifacts({ namespace: "my-sites" }),
* },
* });
* ```
*
* @see https://developers.cloudflare.com/artifacts/
*/
export interface Artifacts {
type: "artifacts";
namespace: string;
dev?: {
remote?: boolean;
};
}

export function Artifacts(props: string | ArtifactsProps): Artifacts {
if (typeof props === "string") {
return {
type: "artifacts",
namespace: props,
};
}

return {
type: "artifacts",
namespace: props.namespace,
dev: props.dev,
};
}
15 changes: 15 additions & 0 deletions alchemy/src/cloudflare/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Ai } from "./ai.ts";
import type { AiSearchNamespace } from "./ai-search-namespace.ts";
import type { AiSearch } from "./ai-search.ts";
import type { AnalyticsEngineDataset } from "./analytics-engine.ts";
import type { Artifacts } from "./artifacts.ts";
import type { Assets } from "./assets.ts";
import type { Bound } from "./bound.ts";
import type { BrowserRendering } from "./browser-rendering.ts";
Expand Down Expand Up @@ -54,6 +55,7 @@ export type Binding =
| AiSearch
| AiSearchNamespace
| Assets
| Artifacts
| Container
| CloudflareSecret
| CloudflareSecretRef
Expand Down Expand Up @@ -127,6 +129,7 @@ export type WorkerBindingSpec =
| WorkerBindingAiSearch
| WorkerBindingAiSearchNamespace
| WorkerBindingAnalyticsEngine
| WorkerBindingArtifacts
| WorkerBindingAssets
| WorkerBindingBrowserRendering
| WorkerBindingD1
Expand Down Expand Up @@ -206,6 +209,18 @@ export interface WorkerBindingAnalyticsEngine {
dataset: string;
}

/**
* Artifacts binding type
*/
export interface WorkerBindingArtifacts {
/** The name of the binding */
name: string;
/** Type identifier for Artifacts binding */
type: "artifacts";
/** Artifacts namespace name */
namespace: string;
}

/**
* Assets binding type
*/
Expand Down
3 changes: 3 additions & 0 deletions alchemy/src/cloudflare/bound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Ai as _Ai } from "./ai.ts";
import type { AiSearchNamespace as _AiSearchNamespace } from "./ai-search-namespace.ts";
import type { AiSearch as _AiSearch } from "./ai-search.ts";
import type { AnalyticsEngineDataset as _AnalyticsEngineDataset } from "./analytics-engine.ts";
import type { Artifacts as _Artifacts } from "./artifacts.ts";
import type { Assets } from "./assets.ts";
import type { Binding, Json, Self } from "./bindings.ts";
import type { BrowserRendering } from "./browser-rendering.ts";
Expand Down Expand Up @@ -61,6 +62,8 @@ export type Bound<T extends Binding> = T extends _AiSearch
? BoundWorker<RPC>
: T extends _Worker<any, infer RPC> | WorkerRef<infer RPC>
? BoundWorker<RPC>
: T extends _Artifacts
? Artifacts
: T extends { type: "service" }
? Service
: T extends _R2Bucket
Expand Down
1 change: 1 addition & 0 deletions alchemy/src/cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from "./api-gateway-operation.ts";
export * from "./api-schema.ts";
export * from "./api-shield.ts";
export * from "./api.ts";
export * from "./artifacts.ts";
export * from "./assets.ts";
export * from "./astro/astro.ts";
export * from "./bindings.ts";
Expand Down
5 changes: 5 additions & 0 deletions alchemy/src/cloudflare/miniflare/build-worker-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ export const buildWorkerOptions = async (
};
break;
}
case "artifacts": {
throw new Error(
"Artifacts bindings are not supported in Alchemy local dev yet. Deploy the Worker or use generated Wrangler config with remote Artifacts bindings.",
);
}
case "assets": {
options.assets = {
binding: key,
Expand Down
6 changes: 6 additions & 0 deletions alchemy/src/cloudflare/worker-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,12 @@ export async function prepareWorkerMetadata(
jurisdiction:
binding.jurisdiction === "default" ? undefined : binding.jurisdiction,
});
} else if (binding.type === "artifacts") {
meta.bindings.push({
type: "artifacts",
name: bindingName,
namespace: binding.namespace,
});
} else if (binding.type === "send_email") {
meta.bindings.push({
type: "send_email",
Expand Down
19 changes: 19 additions & 0 deletions alchemy/src/cloudflare/wrangler.json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,11 @@ async function processBindings(
const kvNamespaces: WranglerJsonConfig["kv_namespaces"] = [];
const durableObjects: WranglerJsonConfig["durable_objects"]["bindings"] = [];
const r2Buckets: WranglerJsonConfig["r2_buckets"] = [];
const artifacts: {
binding: string;
namespace: string;
remote?: boolean;
}[] = [];
const services: WranglerJsonConfig["services"] = [];
const sendEmailBindings: WranglerJsonConfig["send_email"] = [];
const secrets: string[] = [];
Expand Down Expand Up @@ -422,6 +427,12 @@ async function processBindings(
allowed_sender_addresses: binding.allowedSenderAddresses,
...(binding.dev?.remote ? { remote: true } : {}),
});
} else if (binding.type === "artifacts") {
artifacts.push({
binding: bindingName,
namespace: binding.namespace,
remote: binding.dev?.remote ?? true,
});
} else if (binding.type === "secret") {
// Secret binding
secrets.push(bindingName);
Expand Down Expand Up @@ -598,6 +609,14 @@ async function processBindings(
spec.r2_buckets = r2Buckets;
}

if (artifacts.length > 0) {
(
spec as WranglerJsonSpec & {
artifacts?: typeof artifacts;
}
).artifacts = artifacts;
}

if (services.length > 0) {
spec.services = services;
}
Expand Down
156 changes: 156 additions & 0 deletions alchemy/test/cloudflare/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect } from "vitest";
import { alchemy } from "../../src/alchemy.ts";
import { AnalyticsEngineDataset } from "../../src/cloudflare/analytics-engine.ts";
import { createCloudflareApi } from "../../src/cloudflare/api.ts";
import { Artifacts } from "../../src/cloudflare/artifacts.ts";
import { Assets } from "../../src/cloudflare/assets.ts";
import { Self } from "../../src/cloudflare/bindings.ts";
import { DurableObjectNamespace } from "../../src/cloudflare/durable-object-namespace.ts";
Expand Down Expand Up @@ -37,6 +38,8 @@ import "../../src/test/vitest.ts";
const ENABLE_WFP_TESTS = process.env.CLOUDFLARE_ACCOUNT_ENABLE_WFP !== "false";
const ENABLE_PAID_TESTS =
process.env.CLOUDFLARE_ACCOUNT_ENABLE_PAID !== "false";
const ENABLE_ARTIFACTS_TESTS =
process.env.CLOUDFLARE_ACCOUNT_ENABLE_ARTIFACTS !== "false";

const test = alchemy.test(import.meta, {
prefix: BRANCH_PREFIX,
Expand Down Expand Up @@ -1403,6 +1406,159 @@ describe("Worker Resource", () => {
}
}, 60000); // Increase timeout for Worker operations

test.skipIf(!ENABLE_ARTIFACTS_TESTS)(
"create and test worker with Artifacts binding",
async (scope) => {
const workerName = `${BRANCH_PREFIX}-test-worker-artifacts-binding`;
const namespace = `${BRANCH_PREFIX}-test-artifacts`;
const repoName = `${BRANCH_PREFIX}-test-worker-artifacts-repo`;

let worker: Worker | undefined;

try {
worker = await Worker(workerName, {
name: workerName,
script: `
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);

if (url.pathname !== "/check-binding") {
return new Response("Artifacts Worker is running!", {
status: 200,
headers: { "Content-Type": "text/plain" }
});
}

if (!env.ARTIFACTS) {
return Response.json({
success: false,
hasBinding: false
}, { status: 500 });
}

const repoName = ${JSON.stringify(repoName)};

try {
await env.ARTIFACTS.delete(repoName);

const created = await env.ARTIFACTS.create(repoName, {
description: "Alchemy Artifacts binding test"
});
const repo = await env.ARTIFACTS.get(repoName);
const deleted = await env.ARTIFACTS.delete(repoName);

return Response.json({
success: true,
hasBinding: true,
bindingType: typeof env.ARTIFACTS,
created: {
name: created.name,
remote: created.remote,
hasToken: typeof created.token === "string" && created.token.length > 0
},
info: {
name: repo.name,
remote: repo.remote,
defaultBranch: repo.defaultBranch,
readOnly: repo.readOnly
},
deleted
});
} catch (error) {
try {
await env.ARTIFACTS.delete(repoName);
} catch {}

return Response.json({
success: false,
hasBinding: true,
error: error?.message ?? String(error),
code: error?.code,
name: error?.name
}, { status: 500 });
}
}
};
`,
format: "esm",
url: true,
bindings: {
ARTIFACTS: Artifacts({ namespace }),
},
adopt: true,
});

expect(worker.id).toBeTruthy();
expect(worker.name).toEqual(workerName);
expect(worker.bindings?.ARTIFACTS).toBeDefined();
expect(worker.url).toBeTruthy();

const data = await waitFor(
async () => {
const response = await fetch(`${worker!.url}/check-binding`);
if (!response.ok) {
return {
success: false,
status: response.status,
body: await response.text(),
};
}
return (await response.json()) as {
success: boolean;
hasBinding: boolean;
bindingType: string;
created: {
name: string;
remote: string;
hasToken: boolean;
};
info: {
name: string;
remote: string;
defaultBranch: string;
readOnly: boolean;
};
deleted: boolean;
};
},
(data) =>
data.success === true &&
"created" in data &&
data.created.name === repoName,
{ timeoutMs: 30_000, intervalMs: 1_000 },
);

if (!data.success || !("created" in data)) {
throw new Error(
`Artifacts binding check failed: ${JSON.stringify(data)}`,
);
}

expect(data.success).toEqual(true);
expect(data.hasBinding).toEqual(true);
expect(data.bindingType).toEqual("object");
expect(data.created.name).toEqual(repoName);
expect(data.created.remote).toContain(
`artifacts.cloudflare.net/${namespace}/${repoName}.git`,
);
expect(data.created.hasToken).toEqual(true);
expect(data.info).toMatchObject({
name: repoName,
readOnly: false,
});
expect(data.info.remote).toContain(
`artifacts.cloudflare.net/${namespace}/${repoName}.git`,
);
expect(data.deleted).toEqual(true);
} finally {
await destroy(scope);
await assertWorkerDoesNotExist(api, workerName);
}
},
60000,
);

test("can bind to a worker referenced by name", async (scope) => {
const workerName = `${BRANCH_PREFIX}-test-worker-bind-by-name`;
const workerName2 = `${BRANCH_PREFIX}-test-worker-bind-by-name-2`;
Expand Down
Loading