diff --git a/examples/cloudflare-durable-object/package.json b/examples/cloudflare-durable-object/package.json new file mode 100644 index 0000000000..748a73f86d --- /dev/null +++ b/examples/cloudflare-durable-object/package.json @@ -0,0 +1,10 @@ +{ + "name": "cloudflare-durable-object", + "version": "0.0.0", + "dependencies": { + "sst": "^4" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240405.0" + } +} diff --git a/examples/cloudflare-durable-object/sst.config.ts b/examples/cloudflare-durable-object/sst.config.ts new file mode 100644 index 0000000000..f1b76b106a --- /dev/null +++ b/examples/cloudflare-durable-object/sst.config.ts @@ -0,0 +1,40 @@ +/// + +/** + * ## Cloudflare Durable Object + * + * This example creates a Durable Object and links it to a worker. + * + * Send a `GET` request to the `url` output. The worker calls the Durable + * Object, and the Durable Object logs the current count. + */ +export default $config({ + app(input) { + return { + name: "cloudflare-durable-object", + home: "cloudflare", + removal: input?.stage === "production" ? "retain" : "remove", + }; + }, + async run() { + const counter = new sst.cloudflare.DurableObject("Counter", { + className: "CounterTest", + }); + + const api = new sst.cloudflare.Worker("Api", { + durableObjectMigrations: [ + { + tag: "v1", + newSqliteClasses: ["CounterTest"], + }, + ], + handler: "worker.ts", + link: [counter], + url: true, + }); + + return { + url: api.url, + }; + }, +}); diff --git a/examples/cloudflare-durable-object/tsconfig.json b/examples/cloudflare-durable-object/tsconfig.json new file mode 100644 index 0000000000..071591ba65 --- /dev/null +++ b/examples/cloudflare-durable-object/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + } +} diff --git a/examples/cloudflare-durable-object/worker.ts b/examples/cloudflare-durable-object/worker.ts new file mode 100644 index 0000000000..e02d0c51d5 --- /dev/null +++ b/examples/cloudflare-durable-object/worker.ts @@ -0,0 +1,28 @@ +import { Resource } from "sst"; +import { DurableObject } from "cloudflare:workers"; + +export default { + async fetch(request: Request) { + const url = new URL(request.url); + + if (url.pathname === "/favicon.ico") { + return new Response(null, { status: 204 }); + } + + const stub = Resource.Counter.getByName("global"); + return stub.fetch("https://counter/"); + }, +}; + +export class CounterTest extends DurableObject { + async fetch() { + const current = (await this.ctx.storage.get("count")) ?? 0; + const count = current + 1; + + await this.ctx.storage.put("count", count); + + console.log("durable object hit", { count }); + + return Response.json({ count }); + } +} diff --git a/pkg/types/typescript/typescript_test.go b/pkg/types/typescript/typescript_test.go index 20cf00535a..2954dee19f 100644 --- a/pkg/types/typescript/typescript_test.go +++ b/pkg/types/typescript/typescript_test.go @@ -344,6 +344,38 @@ func TestGenerate(t *testing.T) { _, err = os.Stat(filepath.Join(ignored, "sst-env.d.ts")) assert.True(t, os.IsNotExist(err)) }) + + t.Run("cloudflare durable object binding types", func(t *testing.T) { + dir := setupProject(t, map[string]string{}) + + pkg, _ := json.Marshal(map[string]interface{}{ + "devDependencies": map[string]string{ + "@cloudflare/workers-types": "^4.0.0", + }, + }) + os.WriteFile(filepath.Join(dir, "package.json"), pkg, 0644) + + links := common.Links{ + "Counter": { + Include: []common.LinkInclude{{ + Type: "cloudflare.binding", + Other: map[string]interface{}{ + "binding": "durableObjectNamespaceBindings", + }, + }}, + }, + } + + err := typescript.Generate(dir, links) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(dir, "sst-env.d.ts")) + require.NoError(t, err) + + out := string(content) + assert.Contains(t, out, `import * as cloudflare from "@cloudflare/workers-types";`) + assert.Contains(t, out, `"Counter": cloudflare.DurableObjectNamespace`) + }) } func indexOf(s, substr string) int { diff --git a/platform/src/components/cloudflare/binding.ts b/platform/src/components/cloudflare/binding.ts index cc901601f4..3a35522fb1 100644 --- a/platform/src/components/cloudflare/binding.ts +++ b/platform/src/components/cloudflare/binding.ts @@ -74,6 +74,15 @@ export interface HyperdriveBinding { }; } +export interface DurableObjectNamespaceBinding { + type: "durableObjectNamespaceBindings"; + properties: { + className: Input; + scriptName?: Input; + environment?: Input; + }; +} + export interface VersionMetadataBinding { type: "versionMetadataBindings"; properties: Record; diff --git a/platform/src/components/cloudflare/durable-object.ts b/platform/src/components/cloudflare/durable-object.ts new file mode 100644 index 0000000000..cc879f876e --- /dev/null +++ b/platform/src/components/cloudflare/durable-object.ts @@ -0,0 +1,104 @@ +import { ComponentResourceOptions, output } from "@pulumi/pulumi"; +import { Component } from "../component.js"; +import type { Input } from "../input.js"; +import { Link } from "../link.js"; +import { binding } from "./binding.js"; + +export interface DurableObjectArgs { + /** + * The exported Durable Object class name. + */ + className: Input; +} + +/** + * Use the `DurableObject` component to register a + * [Cloudflare Durable Object](https://developers.cloudflare.com/durable-objects/) + * for a worker. + * + * Create the Durable Object and then link it to a `sst.cloudflare.Worker`. SST + * adds the Durable Object binding automatically. Configure migrations on the + * worker, similar to Wrangler. + * + * @example + * + * ```ts title="sst.config.ts" + * const counter = new sst.cloudflare.DurableObject("Counter", { + * className: "Counter", + * }); + * + * new sst.cloudflare.Worker("Api", { + * handler: "src/worker.ts", + * link: [counter], + * durableObjectMigrations: [{ + * tag: "v1", + * newSqliteClasses: ["Counter"], + * }], + * url: true, + * }); + * ``` + * + * ```ts title="src/worker.ts" + * import { Resource } from "sst"; + * import { DurableObject } from "cloudflare:workers"; + * + * export default { + * async fetch() { + * const stub = Resource.Counter.getByName("global"); + * return stub.fetch("https://counter/"); + * }, + * }; + * + * export class Counter extends DurableObject { + * async fetch() { + * return new Response("hello from the durable object"); + * } + * } + * ``` + */ +export class DurableObject extends Component implements Link.Linkable { + constructor( + name: string, + private args: DurableObjectArgs, + opts?: ComponentResourceOptions, + ) { + super(__pulumiType, name, args, opts); + } + + /** + * When you link a Durable Object to a worker, SST adds a Cloudflare Durable + * Object namespace binding. + * + * @internal + */ + getSSTLink() { + return { + properties: { + className: this.args.className, + }, + include: [ + binding({ + type: "durableObjectNamespaceBindings", + properties: { + className: this.args.className, + }, + }), + { + type: "cloudflare.durableObject", + className: this.args.className, + }, + ], + }; + } + + /** + * The exported Durable Object class name. + */ + public get className() { + return output(this.args.className); + } +} + +const __pulumiType = "sst:cloudflare:DurableObject"; +// @ts-expect-error +DurableObject.__pulumiType = __pulumiType; diff --git a/platform/src/components/cloudflare/providers/worker-url.ts b/platform/src/components/cloudflare/providers/worker-url.ts index c89ca5d430..1a6efeecc7 100644 --- a/platform/src/components/cloudflare/providers/worker-url.ts +++ b/platform/src/components/cloudflare/providers/worker-url.ts @@ -5,6 +5,7 @@ interface Inputs { accountId: string; scriptName: string; enabled: boolean; + etag?: string; } interface Outputs { @@ -15,6 +16,7 @@ export interface WorkerUrlInputs { accountId: Input; scriptName: Input; enabled: Input; + etag?: Input; } export interface WorkerUrl { diff --git a/platform/src/components/cloudflare/worker.ts b/platform/src/components/cloudflare/worker.ts index 595b23642e..c4ed089ade 100644 --- a/platform/src/components/cloudflare/worker.ts +++ b/platform/src/components/cloudflare/worker.ts @@ -27,6 +27,45 @@ import { getContentType } from "../base/base-site"; import { prefixName } from "../naming"; import { existsAsync } from "../../util/fs"; import { normalizeCompatibility } from "./helpers/compatibility.js"; +import { cfFetch } from "./helpers/fetch.js"; + +export interface WorkerDurableObjectMigration { + /** + * A unique identifier for this migration. + */ + tag: Input; + /** + * New Durable Object classes backed by KV. + */ + newClasses?: Input[]>; + /** + * New Durable Object classes backed by SQLite. + */ + newSqliteClasses?: Input[]>; + /** + * Durable Object classes to delete. + */ + deletedClasses?: Input[]>; + /** + * Durable Object classes to rename. + */ + renamedClasses?: Input< + Input<{ + from: Input; + to: Input; + }>[] + >; + /** + * Durable Object classes to transfer from another script. + */ + transferredClasses?: Input< + Input<{ + from: Input; + fromScript: Input; + to: Input; + }>[] + >; +} export interface WorkerDomainArgs { /** @@ -257,6 +296,23 @@ export interface WorkerArgs { * ``` */ environment?: Input>>; + /** + * Durable Object migrations for this worker. + * + * This follows Wrangler's migration model. Keep the full migration history + * here and SST will upload the latest step. + * + * @example + * ```js + * { + * durableObjectMigrations: [{ + * tag: "v1", + * newSqliteClasses: ["Counter"] + * }] + * } + * ``` + */ + durableObjectMigrations?: Input[]>; /** @internal */ assets?: Input<{ directory: Input; @@ -520,11 +576,12 @@ export class Worker extends Component implements Link.Linkable { aiBindings: "ai", plainTextBindings: "plain_text", secretTextBindings: "secret_text", - queueBindings: "queue", - serviceBindings: "service", - kvNamespaceBindings: "kv_namespace", - d1DatabaseBindings: "d1", - r2BucketBindings: "r2_bucket", + queueBindings: "queue", + serviceBindings: "service", + durableObjectNamespaceBindings: "durable_object_namespace", + kvNamespaceBindings: "kv_namespace", + d1DatabaseBindings: "d1", + r2BucketBindings: "r2_bucket", hyperdriveBindings: "hyperdrive", versionMetadataBindings: "version_metadata", workflowBindings: "workflow", @@ -543,6 +600,139 @@ export class Worker extends Component implements Link.Linkable { }); } + function buildDurableObjectMigrations(scriptName: string) { + return all([ + Link.getInclude<{ className: Input }>( + "cloudflare.durableObject", + args.link, + ).apply((durableObjects) => all(durableObjects.map((item) => item.className))), + output(args.durableObjectMigrations ?? []).apply((migrations) => + all( + migrations.map((migration) => + all([ + migration.tag, + output(migration.newClasses ?? []), + output(migration.newSqliteClasses ?? []), + output(migration.deletedClasses ?? []), + output(migration.renamedClasses ?? []), + output(migration.transferredClasses ?? []), + ]).apply( + ([ + tag, + newClasses, + newSqliteClasses, + deletedClasses, + renamedClasses, + transferredClasses, + ]) => ({ + tag, + newClasses, + newSqliteClasses, + deletedClasses, + renamedClasses, + transferredClasses, + }), + ), + ), + ), + ), + ]).apply(async ([durableObjects, migrations]) => { + if (durableObjects.length > 0 && migrations.length === 0) { + throw new VisibleError( + [ + "Linked Durable Objects require worker migrations.", + 'Add `durableObjectMigrations: [{ tag: "v1", newSqliteClasses: ["YourClass"] }]` to the worker.', + ].join("\n"), + ); + } + + const defined = new Set(); + for (const migration of migrations) { + for (const className of migration.newClasses) { + defined.add(className); + } + for (const className of migration.newSqliteClasses) { + defined.add(className); + } + for (const renamed of migration.renamedClasses) { + defined.delete(renamed.from); + defined.add(renamed.to); + } + for (const transferred of migration.transferredClasses) { + defined.add(transferred.to); + } + for (const className of migration.deletedClasses) { + defined.delete(className); + } + } + + const missing = durableObjects.filter((className) => !defined.has(className)); + if (missing.length > 0) { + throw new VisibleError( + `Linked Durable Objects are missing from durableObjectMigrations: ${missing.join(", ")}`, + ); + } + + const latest = migrations.at(-1); + if (!latest) { + return { + migrationTag: undefined, + migrations: {}, + }; + } + + let currentTag: string | undefined; + try { + const published = await cfFetch<{ + script: { + migration_tag?: string; + }; + }>( + `/accounts/${DEFAULT_ACCOUNT_ID}/workers/services/${scriptName}/environments/production`, + ); + currentTag = published.result.script.migration_tag; + } catch (error: any) { + const code = error?.errors?.[0]?.code; + if (code !== 10090 && code !== 10092) { + throw error; + } + } + + if (currentTag) { + const foundIndex = migrations.findIndex( + (migration) => migration.tag === currentTag, + ); + + if (foundIndex === migrations.length - 1) { + return { + migrationTag: currentTag, + migrations: {}, + }; + } + + const pending = + foundIndex === -1 ? migrations : migrations.slice(foundIndex + 1); + + return { + migrationTag: currentTag, + migrations: { + oldTag: currentTag, + newTag: latest.tag, + steps: pending.map(({ tag: _tag, ...step }) => step), + }, + }; + } + + return { + migrationTag: "", + migrations: { + newTag: latest.tag, + steps: migrations.map(({ tag: _tag, ...step }) => step), + }, + }; + }); + } + function createAwsCredentials() { return output( Link.getInclude("aws.permission", args.link), @@ -609,94 +799,105 @@ export class Worker extends Component implements Link.Linkable { } function createScript() { + const scriptName = prefixName(54, `${name}Script`).toLowerCase(); + const durableObjectMigrationState = buildDurableObjectMigrations(scriptName); const contentFilePath = build.apply((build) => path.join(build.out, build.handler), ); + const scriptArgs: cf.WorkersScriptArgs = { + // workers.dev URLs fail above 54 chars when previews are enabled + scriptName, + mainModule: "placeholder", + accountId: DEFAULT_ACCOUNT_ID, + contentFile: contentFilePath, + contentSha256: contentFilePath.apply(async (p) => + crypto + .createHash("sha256") + .update(await fs.readFile(p, "utf-8")) + .digest("hex"), + ), + compatibilityDate: compatibility.apply((value) => value.date), + compatibilityFlags: compatibility.apply((value) => value.flags), + assets: args.assets + ? output(args.assets).apply(async (assets) => { + const directory = path.isAbsolute(assets.directory) + ? assets.directory + : path.join($cli.paths.root, assets.directory); + + let headers; + let redirects; + try { + headers = await fs.readFile( + path.join(directory, "_headers"), + "utf-8", + ); + } catch (e) {} + + try { + redirects = await fs.readFile( + path.join(directory, "_redirects"), + "utf-8", + ); + } catch (e) {} + return { + directory, + config: { + headers, + redirects, + htmlHandling: assets.htmlHandling, + notFoundHandling: assets.notFoundHandling, + runWorkerFirst: assets.runWorkerFirst, + }, + }; + }) + : undefined, + + bindings: all([args.environment, iamCredentials, bindings]).apply( + ([environment, iamCredentials, bindings]) => [ + ...bindings, + ...(iamCredentials + ? [ + { + type: "plain_text", + name: "AWS_ACCESS_KEY_ID", + text: iamCredentials.id, + }, + { + type: "secret_text", + name: "AWS_SECRET_ACCESS_KEY", + text: iamCredentials.secret, + }, + ] + : []), + ...(args.assets + ? [ + { + type: "assets", + name: "ASSETS", + }, + ] + : []), + ...Object.entries(environment ?? {}).map(([key, value]) => ({ + type: "plain_text", + name: key, + text: value, + })), + ], + ), + migrations: durableObjectMigrationState.apply((state) => state.migrations), + }; + + Object.assign(scriptArgs, { + migrationTag: durableObjectMigrationState.apply( + (state) => state.migrationTag, + ), + }); + return new cf.WorkersScript( ...transform( args.transform?.worker as Transform, `${name}Script`, - { - // workers.dev URLs fail above 54 chars when previews are enabled - scriptName: prefixName(54, `${name}Script`).toLowerCase(), - mainModule: "placeholder", - accountId: DEFAULT_ACCOUNT_ID, - contentFile: contentFilePath, - contentSha256: contentFilePath.apply(async (p) => - crypto - .createHash("sha256") - .update(await fs.readFile(p, "utf-8")) - .digest("hex"), - ), - compatibilityDate: compatibility.apply((value) => value.date), - compatibilityFlags: compatibility.apply((value) => value.flags), - assets: args.assets - ? output(args.assets).apply(async (assets) => { - const directory = path.isAbsolute(assets.directory) - ? assets.directory - : path.join($cli.paths.root, assets.directory); - - let headers; - let redirects; - try { - headers = await fs.readFile( - path.join(directory, "_headers"), - "utf-8", - ); - } catch (e) {} - - try { - redirects = await fs.readFile( - path.join(directory, "_redirects"), - "utf-8", - ); - } catch (e) {} - return { - directory, - config: { - headers, - redirects, - htmlHandling: assets.htmlHandling, - notFoundHandling: assets.notFoundHandling, - runWorkerFirst: assets.runWorkerFirst, - }, - }; - }) - : undefined, - - bindings: all([args.environment, iamCredentials, bindings]).apply( - ([environment, iamCredentials, bindings]) => [ - ...bindings, - ...(iamCredentials - ? [ - { - type: "plain_text", - name: "AWS_ACCESS_KEY_ID", - text: iamCredentials.id, - }, - { - type: "secret_text", - name: "AWS_SECRET_ACCESS_KEY", - text: iamCredentials.secret, - }, - ] - : []), - ...(args.assets - ? [ - { - type: "assets", - name: "ASSETS", - }, - ] - : []), - ...Object.entries(environment ?? {}).map(([key, value]) => ({ - type: "plain_text", - name: key, - text: value, - })), - ], - ), - }, + scriptArgs, { parent, ignoreChanges: ["scriptName"] }, ), ); @@ -709,6 +910,7 @@ export class Worker extends Component implements Link.Linkable { accountId: DEFAULT_ACCOUNT_ID, scriptName: script.scriptName, enabled: urlEnabled, + etag: script.etag, }, { parent }, );