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 },
);