sproutboat 0.53.1 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The `sproutboat types` command: its arguments, the file it writes, and `--check`.
3
+ *
4
+ * Wrangler's contract (`wrangler types [path]`): the positional is the declaration file to write, default
5
+ * `worker-configuration.d.ts`; `--env-interface`, `--strict-vars`, `--include-runtime`, `--include-env` and `--check`
6
+ * behave as there. Two differences are on purpose and said in the help: `--include-runtime` references the runtime
7
+ * types shipped with the CLI instead of copying them in, and there is no `--include-main-module` (nothing here reads
8
+ * `ctx.exports`).
9
+ *
10
+ * Earlier releases took a project directory as the one positional. A positional that is an existing directory is
11
+ * still read that way.
12
+ */
13
+ import { existsSync, statSync } from "node:fs";
14
+ import { readFile, rm, writeFile } from "node:fs/promises";
15
+ import { dirname, relative, resolve, sep } from "node:path";
16
+ import type { SproutboatConfig } from "./config";
17
+ import {
18
+ exportedClasses,
19
+ generateTypes,
20
+ hashOf,
21
+ isCurrent,
22
+ LEGACY_TYPES_FILE,
23
+ LEGACY_TYPES_HEADER,
24
+ TYPES_FILE,
25
+ type TypesOptions,
26
+ } from "./types-gen";
27
+
28
+ export const TYPES_USAGE =
29
+ "types [path] [--env-interface <name>] [--no-strict-vars] [--no-include-runtime] [--no-include-env] [--check]";
30
+
31
+ export type TypesArgs = {
32
+ /** The declaration file, as typed; or the project directory when it names an existing directory. */
33
+ positional?: string;
34
+ options: Omit<TypesOptions, "command" | "mainModule">;
35
+ check: boolean;
36
+ };
37
+
38
+ type BooleanOption = "strictVars" | "includeRuntime" | "includeEnv";
39
+ const BOOLEAN_FLAGS = new Map<string, BooleanOption>([
40
+ ["--strict-vars", "strictVars"],
41
+ ["--include-runtime", "includeRuntime"],
42
+ ["--include-env", "includeEnv"],
43
+ ]);
44
+
45
+ /** Parse what is left after the global flags and `checkOptions`, where `--flag=value` is already `--flag value`. */
46
+ export function parseTypesArgs(args: readonly string[]): TypesArgs | { error: string } {
47
+ const out: TypesArgs = { options: {}, check: false };
48
+ for (let at = 0; at < args.length; at += 1) {
49
+ const arg = args[at]!;
50
+ if (arg === "--check") out.check = true;
51
+ else if (arg === "--env-interface") {
52
+ const value = args[(at += 1)];
53
+ if (value === undefined || value.startsWith("--")) return { error: "--env-interface needs a name" };
54
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value)) return { error: `"${value}" is not a valid interface name` };
55
+ out.options.envInterface = value;
56
+ } else if (BOOLEAN_FLAGS.has(arg) || (arg.startsWith("--no-") && BOOLEAN_FLAGS.has(`--${arg.slice(5)}`))) {
57
+ const negated = !BOOLEAN_FLAGS.has(arg);
58
+ const key = BOOLEAN_FLAGS.get(negated ? `--${arg.slice(5)}` : arg)!;
59
+ if (negated) {
60
+ out.options[key] = false;
61
+ continue;
62
+ }
63
+ const next = args[at + 1];
64
+ // `--strict-vars=false` arrives as `--strict-vars false`; a bare `--strict-vars` means true.
65
+ if (next === "true" || next === "false") {
66
+ out.options[key] = next === "true";
67
+ at += 1;
68
+ } else if (next === undefined || next.startsWith("--")) out.options[key] = true;
69
+ else return { error: `${arg} takes true or false, not "${next}"` };
70
+ } else if (arg.startsWith("-")) return { error: `unknown option ${arg}` };
71
+ else if (out.positional === undefined) out.positional = arg;
72
+ else return { error: `unexpected argument "${arg}"` };
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** The command line as it is recorded in the generated file's header: the flags that differ from the defaults. */
78
+ function commandLine(parsed: TypesArgs, path: string | undefined): string {
79
+ const { envInterface, strictVars, includeRuntime, includeEnv } = parsed.options;
80
+ return [
81
+ "sproutboat types",
82
+ ...(path ? [path] : []),
83
+ ...(envInterface && envInterface !== "Env" ? [`--env-interface=${envInterface}`] : []),
84
+ ...(strictVars === false ? ["--strict-vars=false"] : []),
85
+ ...(includeRuntime === false ? ["--include-runtime=false"] : []),
86
+ ...(includeEnv === false ? ["--include-env=false"] : []),
87
+ ].join(" ");
88
+ }
89
+
90
+ export type TypesPlan = {
91
+ /** Absolute path of the declaration file. */
92
+ path: string;
93
+ /** What `generateTypes` writes for it. */
94
+ text: string;
95
+ /** The positional was a project directory, which this release still reads (the file is then the default one). */
96
+ legacyDirectory: boolean;
97
+ };
98
+
99
+ /**
100
+ * The project directory and output file for a positional, and the file's text.
101
+ *
102
+ * `projectDirectory` is where `sproutboat.jsonc` is (already resolved from `-c` / `--cwd` and a directory
103
+ * positional); the output path is relative to the working directory, as in Wrangler.
104
+ */
105
+ export async function planTypes(
106
+ parsed: TypesArgs,
107
+ config: SproutboatConfig,
108
+ projectDirectory: string,
109
+ cwd: string = process.cwd(),
110
+ ): Promise<TypesPlan> {
111
+ const positional = parsed.positional;
112
+ const asDirectory = positional !== undefined && isDirectory(resolve(cwd, positional));
113
+ const path =
114
+ positional === undefined || asDirectory ? resolve(projectDirectory, TYPES_FILE) : resolve(cwd, positional);
115
+
116
+ let mainModule: TypesOptions["mainModule"];
117
+ if (/\.(?:ts|mts|tsx)$/.test(config.main)) {
118
+ const entry = resolve(projectDirectory, config.main);
119
+ try {
120
+ const classes = exportedClasses(await readFile(entry, "utf8"));
121
+ if (classes.length) {
122
+ const rel = relative(dirname(path), entry.replace(/\.[^./\\]+$/, ""))
123
+ .split(sep)
124
+ .join("/");
125
+ mainModule = { importPath: rel.startsWith(".") ? rel : `./${rel}`, classes };
126
+ }
127
+ } catch {
128
+ // No entry file yet: the bindings are still worth typing.
129
+ }
130
+ }
131
+ const text = generateTypes(config, {
132
+ ...parsed.options,
133
+ mainModule,
134
+ command: commandLine(parsed, positional !== undefined && !asDirectory ? positional : undefined),
135
+ });
136
+ return { path, text, legacyDirectory: asDirectory };
137
+ }
138
+
139
+ /** `--check`: whether the file at `path` is the one `plan` would write. Missing or hand-written is not current. */
140
+ export async function checkTypes(plan: TypesPlan): Promise<"current" | "stale" | "missing"> {
141
+ if (!existsSync(plan.path)) return "missing";
142
+ return isCurrent(await readFile(plan.path, "utf8"), plan.text) ? "current" : "stale";
143
+ }
144
+
145
+ function isDirectory(path: string): boolean {
146
+ try {
147
+ return statSync(path).isDirectory();
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+
153
+ /** Remove the file earlier releases wrote, when it is still exactly what they generated. */
154
+ export async function removeLegacyTypes(projectDir: string): Promise<boolean> {
155
+ const legacy = resolve(projectDir, LEGACY_TYPES_FILE);
156
+ try {
157
+ if (!(await readFile(legacy, "utf8")).startsWith(LEGACY_TYPES_HEADER)) return false;
158
+ await rm(legacy);
159
+ return true;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Rewrite the generated types when the config has moved on, and only when the default file is the one a plain
167
+ * `sproutboat types` wrote. Called from `dev` and `deploy` so a binding added to sproutboat.jsonc cannot sit
168
+ * unreflected in the editor. It writes only when the content differs, which keeps it out of the way of git and of
169
+ * file watchers, and leaves a file made with other flags (or by hand) alone: Wrangler never touches it either.
170
+ * Returns what it did, for the caller to print.
171
+ */
172
+ export async function refreshTypes(
173
+ projectDir: string,
174
+ config: SproutboatConfig,
175
+ ): Promise<{ wrote: boolean; removedLegacy: boolean }> {
176
+ const parsed = parseTypesArgs([]);
177
+ if ("error" in parsed) return { wrote: false, removedLegacy: false };
178
+ const plan = await planTypes(parsed, config, projectDir, projectDir);
179
+ let have: string | undefined;
180
+ try {
181
+ have = await readFile(plan.path, "utf8");
182
+ } catch {
183
+ // No file yet: only write one if the project already opted in with a tsconfig. A plain JavaScript project gets none.
184
+ if (!existsSync(resolve(projectDir, "tsconfig.json"))) return { wrote: false, removedLegacy: false };
185
+ }
186
+ if (have !== undefined) {
187
+ const customCommand = !(have.split("\n", 2)[1] ?? "").includes("`sproutboat types`");
188
+ if (hashOf(have) === undefined || customCommand || hashOf(have) === hashOf(plan.text))
189
+ return { wrote: false, removedLegacy: false };
190
+ }
191
+ await writeFile(plan.path, plan.text);
192
+ return { wrote: true, removedLegacy: await removeLegacyTypes(projectDir) };
193
+ }
package/src/types-gen.ts CHANGED
@@ -1,45 +1,66 @@
1
1
  /**
2
- * `sproutboat types` — the project's `env`, as TypeScript.
2
+ * `sproutboat types` — the project's `Env`, as TypeScript, the way `wrangler types` writes it.
3
3
  *
4
- * `env` remains available as a global in both compatibility modes. This file
5
- * gives it the project's binding names and types. From 2026-09-28 it is also
6
- * the second handler argument.
4
+ * The output is `worker-configuration.d.ts` (Wrangler's file name), a script file that declares an `Env` interface
5
+ * and `Cloudflare.Env` (what `import { env } from "cloudflare:workers"` reads) from `sproutboat.jsonc`, then
6
+ * references the ambient types shipped in `types/sproutboat.d.ts`. Binding *names* version with the project and
7
+ * binding *shapes* with the CLI. A handler is typed with `satisfies ExportedHandler<Env>`.
7
8
  *
8
- * The output declares `env` from `sproutboat.jsonc` and references the ambient
9
- * types shipped in `types/sproutboat.d.ts`, so binding *shapes* version with
10
- * the CLI and binding *names* version with the project.
11
- *
12
- * Names need no quoting: `parseConfig` already holds every binding and var to
13
- * /^[A-Z][A-Z0-9_]*$/, so all of them are plain identifiers.
9
+ * Names need no quoting: `parseConfig` already holds every binding and var to /^[A-Z][A-Z0-9_]*$/, so all of them
10
+ * are plain identifiers.
14
11
  */
12
+ import { createHash } from "node:crypto";
15
13
  import { resourceRefs, type SproutboatConfig } from "./config";
16
14
 
17
- /** The file the generator writes, relative to the project directory. */
18
- export const TYPES_FILE = "sproutboat-env.d.ts";
15
+ /** The file `sproutboat types` writes by default, relative to the project directory. */
16
+ export const TYPES_FILE = "worker-configuration.d.ts";
19
17
 
20
- const HEADER = `// Generated by \`sproutboat types\`. Do not edit.
21
- //
22
- // Regenerated whenever sproutboat.jsonc is newer than this file, so a binding
23
- // added to the config shows up in the editor without asking. Commit it: it is
24
- // part of the project's type surface, like a schema.
25
- `;
18
+ /** The file earlier releases wrote. It is only ever removed when it still carries its generated header. */
19
+ export const LEGACY_TYPES_FILE = "sproutboat-env.d.ts";
20
+ export const LEGACY_TYPES_HEADER = "// Generated by `sproutboat types`. Do not edit.";
21
+
22
+ export type TypesOptions = {
23
+ /** Name of the interface (`--env-interface`). Default `Env`. */
24
+ envInterface?: string;
25
+ /** Literal types for `vars` (`--strict-vars`, default on); off, every var is a `string`. */
26
+ strictVars?: boolean;
27
+ /** Reference the runtime types shipped with the CLI (`--include-runtime`, default on). */
28
+ includeRuntime?: boolean;
29
+ /** Write the `Env` types (`--include-env`, default on). */
30
+ includeEnv?: boolean;
31
+ /** The command as typed, for the header. */
32
+ command?: string;
33
+ /**
34
+ * The entry module, so a Durable Object binding is typed with its class and `stub.method()` is checked.
35
+ * `importPath` is relative to the output file and has no extension; only a TypeScript entry is offered.
36
+ */
37
+ mainModule?: { importPath: string; classes: readonly string[] };
38
+ };
26
39
 
27
40
  type Entry = { name: string; type: string; note?: string };
28
41
 
29
- /** Whether the binding's calls return Promises at the project's compatibility date. */
42
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
43
+
44
+ /** Whether a binding's calls return Promises at the project's compatibility date. */
30
45
  function asyncAt(config: SproutboatConfig, date: string): boolean {
31
46
  return config.compatibility_date >= date;
32
47
  }
33
48
 
34
- function entries(config: SproutboatConfig): Entry[] {
49
+ /** `export class Name` declarations of an entry module, for typing Durable Object bindings. */
50
+ export function exportedClasses(source: string): string[] {
51
+ return [...source.matchAll(/^\s*export\s+(?:abstract\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)/gm)].map(
52
+ (match) => match[1]!,
53
+ );
54
+ }
55
+
56
+ function entries(config: SproutboatConfig, options: TypesOptions): Entry[] {
35
57
  const out: Entry[] = [];
58
+ const strictVars = options.strictVars ?? true;
36
59
 
37
60
  for (const [name, value] of Object.entries(config.vars ?? {})) {
38
- out.push({ name, type: "string", note: `vars: ${JSON.stringify(value)}` });
39
- }
40
- for (const name of config.secrets ?? []) {
41
- out.push({ name, type: "string", note: "secret, set with `sproutboat secrets put`" });
61
+ out.push({ name, type: strictVars ? JSON.stringify(value) : "string" });
42
62
  }
63
+ for (const name of config.secrets ?? []) out.push({ name, type: "string", note: "secret" });
43
64
  for (const { binding } of resourceRefs(config.kv_namespaces))
44
65
  out.push({ name: binding, type: asyncAt(config, "2026-10-25") ? "KVNamespaceAsync" : "KVNamespace" });
45
66
  for (const { binding } of resourceRefs(config.d1_databases))
@@ -47,63 +68,78 @@ function entries(config: SproutboatConfig): Entry[] {
47
68
  for (const { binding } of resourceRefs(config.r2_buckets))
48
69
  out.push({ name: binding, type: asyncAt(config, "2026-10-26") ? "R2BucketAsync" : "R2Bucket" });
49
70
  for (const { binding } of resourceRefs(config.queues)) out.push({ name: binding, type: "Queue" });
50
- for (const name of config.analytics_engine_datasets ?? []) {
51
- out.push({ name, type: "AnalyticsEngineDataset" });
52
- }
71
+ for (const name of config.analytics_engine_datasets ?? []) out.push({ name, type: "AnalyticsEngineDataset" });
53
72
  for (const [binding, className] of Object.entries(config.durable_objects ?? {})) {
54
- out.push({ name: binding, type: "DurableObjectNamespace", note: `class ${className}` });
73
+ if (!asyncAt(config, "2026-10-28")) {
74
+ out.push({ name: binding, type: "DurableObjectNamespace", note: className });
75
+ continue;
76
+ }
77
+ const typed = options.mainModule?.classes.includes(className) && IDENTIFIER.test(className);
78
+ out.push({
79
+ name: binding,
80
+ type: typed
81
+ ? `DurableObjectNamespaceAsync<import(${JSON.stringify(options.mainModule!.importPath)}).${className}>`
82
+ : "DurableObjectNamespaceAsync",
83
+ note: typed ? undefined : className,
84
+ });
55
85
  }
56
86
  for (const service of config.services ?? []) {
57
- out.push({ name: service.binding, type: "Fetcher", note: `service binding: ${service.service}` });
58
- }
59
- for (const rl of config.ratelimiters ?? []) {
60
- out.push({ name: rl.binding, type: "RateLimit", note: `rate limit: ${rl.limit} per ${rl.period}s` });
87
+ out.push({
88
+ name: service.binding,
89
+ type: asyncAt(config, "2026-10-22") ? "FetcherAsync" : "Fetcher",
90
+ note: `service: ${service.service}`,
91
+ });
61
92
  }
93
+ for (const limiter of config.ratelimiters ?? []) out.push({ name: limiter.binding, type: "RateLimit" });
62
94
  if (config.assets?.binding) {
63
- out.push({ name: config.assets.binding, type: "Fetcher", note: `static assets: ${config.assets.directory}` });
64
- }
65
- if (config.version_metadata) {
66
- out.push({ name: config.version_metadata, type: "VersionMetadata" });
95
+ out.push({ name: config.assets.binding, type: asyncAt(config, "2026-10-22") ? "FetcherAsync" : "Fetcher" });
67
96
  }
97
+ if (config.version_metadata) out.push({ name: config.version_metadata, type: "VersionMetadata" });
68
98
  return out;
69
99
  }
70
100
 
101
+ const sha = (text: string) => createHash("sha256").update(text).digest("hex").slice(0, 32);
102
+
103
+ /** The hash a generated file carries in its first line, or `undefined` for anything else. */
104
+ export function hashOf(file: string): string | undefined {
105
+ return /^\/\* eslint-disable \*\/\n\/\/ Generated by Sproutboat by running `[^\n]*` \(hash: ([0-9a-f]{32})\)\n/.exec(
106
+ file,
107
+ )?.[1];
108
+ }
109
+
71
110
  /**
72
- * The contents of `sproutboat-env.d.ts` for `config`.
111
+ * The contents of `worker-configuration.d.ts` for `config`.
73
112
  *
74
- * Pure, so the test can assert on the text and `dev` can compare it against
75
- * what is already on disk without writing.
113
+ * Pure, so the tests can assert on the text and `dev` can compare it with what is on disk without writing.
76
114
  */
77
- export function generateTypes(config: SproutboatConfig): string {
78
- const rows = entries(config);
79
- const body = rows.length
80
- ? rows
81
- .map((entry) => ` ${entry.note ? `/** ${entry.note} */\n ` : ""}${entry.name}: ${entry.type};`)
82
- .join("\n")
83
- : " // No bindings declared in sproutboat.jsonc yet.";
84
-
85
- const handlerNote =
86
- config.compatibility_date >= "2026-09-28"
87
- ? "The same object is passed as the second handler argument: fetch(request, env, ctx)."
88
- : "The handler signature is fetch(request, ctx); env is only a global.";
115
+ export function generateTypes(config: SproutboatConfig, options: TypesOptions = {}): string {
116
+ const name = options.envInterface ?? "Env";
117
+ if (!IDENTIFIER.test(name)) throw new Error(`"${name}" is not a valid interface name`);
118
+ const includeRuntime = options.includeRuntime ?? true;
119
+ const includeEnv = options.includeEnv ?? true;
120
+ const legacy = config.compatibility_date < "2026-09-28";
89
121
 
90
- return `${HEADER}
91
- /// <reference types="sproutboat/types" />
92
-
93
- declare global {
94
- /**
95
- * Bindings from sproutboat.jsonc, for project "${config.name}".
96
- *
97
- * ${handlerNote}
98
- * D1 (from 2026-10-24), KV (2026-10-25) and R2 (2026-10-26) return Promises, as on
99
- * Workers, so a project at or after those dates gets the *Async types; before them
100
- * the calls are synchronous. \`await\` works at every date.
101
- */
102
- const env: {
103
- ${body}
104
- };
122
+ const parts: string[] = [];
123
+ if (includeRuntime) parts.push('/// <reference types="sproutboat/types" />');
124
+ if (includeEnv) {
125
+ const rows = entries(config, options);
126
+ const base = `__Base${name}`;
127
+ const members = rows.map((row) => `\t${row.name}: ${row.type};${row.note ? ` /* ${row.note} */` : ""}`);
128
+ parts.push(
129
+ `interface ${base} {${members.length ? `\n${members.join("\n")}\n` : ""}}`,
130
+ `declare namespace Cloudflare {\n\tinterface Env extends ${base} {}\n}`,
131
+ `interface ${name} extends ${base} {}`,
132
+ );
133
+ // Before compatibility date 2026-09-28 the handler is `fetch(request, ctx)` and `env` is only a global.
134
+ if (legacy) parts.push(`declare const env: ${name};`);
135
+ }
136
+ const body = parts.join("\n") + "\n";
137
+ const command = options.command ?? "sproutboat types";
138
+ return `/* eslint-disable */\n// Generated by Sproutboat by running \`${command}\` (hash: ${sha(body)})\n${body}`;
105
139
  }
106
140
 
107
- export {};
108
- `;
141
+ /** Whether `file` is what `generateTypes` would write for the same input, by the hash in its header. */
142
+ export function isCurrent(file: string, wanted: string): boolean {
143
+ const have = hashOf(file);
144
+ return have !== undefined && have === hashOf(wanted);
109
145
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The `cloudflare:workers` module, as the runtime provides it: the base classes Workers code extends and the
3
+ * `env` import. A script file on purpose: an ambient `declare module` is only a declaration here, not an
4
+ * augmentation. `sproutboat.d.ts` references it, so the one reference a project's generated file carries is enough.
5
+ *
6
+ * `Cloudflare.Env` is declared by the project's `worker-configuration.d.ts`.
7
+ */
8
+ /// <reference path="./sproutboat.d.ts" />
9
+
10
+ declare module "cloudflare:workers" {
11
+ /** The bindings of this project, same object as the second handler argument. */
12
+ export const env: Cloudflare.Env;
13
+
14
+ /**
15
+ * A Durable Object class, from compatibility date 2026-10-29 (2026-10-28 for the Promise storage): `this.ctx` and
16
+ * `this.env` are set, and any method is callable as `await stub.method()`.
17
+ */
18
+ export abstract class DurableObject<Bindings = Cloudflare.Env> {
19
+ constructor(ctx: DurableObjectStateAsync, env: Bindings);
20
+ protected ctx: DurableObjectStateAsync;
21
+ protected env: Bindings;
22
+ fetch?(request: Request): Response | Promise<Response>;
23
+ /** Runs after `storage.setAlarm`, with no request in flight. */
24
+ alarm?(info?: { retryCount: number; isRetry: boolean }): void | Promise<void>;
25
+ webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise<void>;
26
+ webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise<void>;
27
+ }
28
+
29
+ /**
30
+ * A Worker as a class: `export default class extends WorkerEntrypoint<Env>`. Its methods are what a service
31
+ * binding calls over RPC (from compatibility date 2026-11-18).
32
+ */
33
+ export abstract class WorkerEntrypoint<Bindings = Cloudflare.Env> {
34
+ constructor(ctx: ExecutionContext, env: Bindings);
35
+ protected ctx: ExecutionContext;
36
+ protected env: Bindings;
37
+ fetch?(request: Request): Response | Promise<Response>;
38
+ scheduled?(controller: ScheduledController): void | Promise<void>;
39
+ queue?(batch: MessageBatch): void | Promise<void>;
40
+ }
41
+
42
+ export class RpcTarget {}
43
+ }
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Shipped with the CLI rather than as a separate package, so the types always
5
5
  * describe the runtime that the installed CLI compiles. `sproutboat types`
6
- * generates a project's `sproutboat-env.d.ts`, which references this file and
7
- * declares `env` with that project's bindings.
6
+ * generates a project's `worker-configuration.d.ts`, which references this file
7
+ * and declares `Env` with that project's bindings, as `wrangler types` does.
8
8
  *
9
9
  * KV, D1 and R2 are synchronous before the compatibility date that makes each one return a
10
10
  * Promise (D1 2026-10-24, KV 2026-10-25, R2 2026-10-26): `KVNamespace`, `D1Database` and
@@ -13,9 +13,24 @@
13
13
  * project's date. `await` works at every date. Before compatibility date 2026-09-28, `env` is a
14
14
  * global only and `ctx` is the second handler argument. From that date, `env`
15
15
  * is also passed as the second argument and `ctx` as the third.
16
+ *
17
+ * Durable Objects follow the same rule: from 2026-10-28 storage and `stub.fetch()` return Promises
18
+ * (`DurableObjectNamespaceAsync`, `DurableObjectStorageAsync`, `DurableObjectStateAsync`), from
19
+ * 2026-10-29 a class that extends `DurableObject` from "cloudflare:workers" is called over RPC, and from
20
+ * 2026-10-22 `fetch()` on a service or assets binding returns a Promise (`FetcherAsync`).
16
21
  */
17
22
 
23
+ /// <reference path="./cloudflare-workers.d.ts" />
24
+
18
25
  declare global {
26
+ /**
27
+ * The project's bindings. Empty here; `sproutboat types` writes `interface Env extends ...` into
28
+ * `worker-configuration.d.ts`, which merges with this one, and `import { env } from "cloudflare:workers"` reads it.
29
+ */
30
+ namespace Cloudflare {
31
+ interface Env {}
32
+ }
33
+
19
34
  // ---------------------------------------------------------------- KV
20
35
 
21
36
  interface KVPutOptions {
@@ -262,6 +277,8 @@ declare global {
262
277
  }
263
278
 
264
279
  interface R2ObjectBodyAsync extends R2ObjectAsync {
280
+ /** Copies `httpMetadata` (content type, cache control, ...) onto `headers`, as on Workers. */
281
+ writeHttpMetadata(headers: Headers): void;
265
282
  body: ReadableStream;
266
283
  bodyUsed: boolean;
267
284
  arrayBuffer(): Promise<ArrayBuffer>;
@@ -419,6 +436,118 @@ declare global {
419
436
  getWebSocketAutoResponse(): WebSocketRequestResponsePair | null;
420
437
  }
421
438
 
439
+ // ------------------------------------- Durable Objects, from 2026-10-28
440
+
441
+ /** Workers RPC: the methods of a class, as Promise-returning stub methods. */
442
+ type RpcMethods<T> = {
443
+ [
444
+ K in keyof T as K extends
445
+ | "fetch"
446
+ | "alarm"
447
+ | "webSocketMessage"
448
+ | "webSocketClose"
449
+ | "webSocketError"
450
+ | "scheduled"
451
+ | "queue"
452
+ | "ctx"
453
+ | "env"
454
+ ? never
455
+ : T[K] extends (...args: never[]) => unknown
456
+ ? K
457
+ : never
458
+ ]: T[K] extends (...args: infer Args) => infer Result ? (...args: Args) => Promise<Awaited<Result>> : never;
459
+ };
460
+
461
+ /**
462
+ * What `namespace.get(id)` returns from 2026-10-28: `fetch()` is a Promise, and the methods of a
463
+ * class that extends `DurableObject` are callable as `await stub.method(...)` from 2026-10-29. Give
464
+ * the class as the type argument: `DurableObjectNamespaceAsync<Counter>`.
465
+ */
466
+ type DurableObjectStubAsync<T = undefined> = (T extends undefined ? unknown : RpcMethods<T>) & {
467
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
468
+ readonly id: DurableObjectId;
469
+ readonly name?: string;
470
+ };
471
+
472
+ interface DurableObjectNamespaceAsync<T = undefined> {
473
+ idFromName(name: string): DurableObjectId;
474
+ /** A 64-digit hex id, from `id.toString()`. */
475
+ idFromString(id: string): DurableObjectId;
476
+ newUniqueId(): DurableObjectId;
477
+ get(id: DurableObjectId): DurableObjectStubAsync<T>;
478
+ /** `get(idFromName(name))`, from 2026-10-29. */
479
+ getByName(name: string): DurableObjectStubAsync<T>;
480
+ }
481
+
482
+ interface DurableObjectListOptions {
483
+ prefix?: string;
484
+ start?: string;
485
+ startAfter?: string;
486
+ end?: string;
487
+ limit?: number;
488
+ reverse?: boolean;
489
+ }
490
+
491
+ interface DurableObjectTransaction {
492
+ get<T = unknown>(key: string): Promise<T | undefined>;
493
+ put<T>(key: string, value: T): Promise<void>;
494
+ delete(key: string): Promise<boolean>;
495
+ list<T = unknown>(options?: DurableObjectListOptions): Promise<Map<string, T>>;
496
+ rollback(): void;
497
+ }
498
+
499
+ type SqlStorageValue = ArrayBuffer | string | number | null;
500
+
501
+ interface SqlStorageCursor<T = Record<string, SqlStorageValue>> extends Iterable<T> {
502
+ readonly columnNames: string[];
503
+ readonly rowsRead: number;
504
+ readonly rowsWritten: number;
505
+ toArray(): T[];
506
+ one(): T;
507
+ raw<R extends SqlStorageValue[] = SqlStorageValue[]>(): IterableIterator<R>;
508
+ next(): IteratorResult<T>;
509
+ }
510
+
511
+ /** From 2026-10-29: each object has its own SQLite database. */
512
+ interface SqlStorage {
513
+ exec<T = Record<string, SqlStorageValue>>(query: string, ...bindings: unknown[]): SqlStorageCursor<T>;
514
+ readonly databaseSize: number;
515
+ }
516
+
517
+ /** Storage from 2026-10-28: every call returns a Promise, and an object keeps its own input gate. */
518
+ interface DurableObjectStorageAsync {
519
+ get<T = unknown>(key: string): Promise<T | undefined>;
520
+ get<T = unknown>(keys: string[]): Promise<Map<string, T>>;
521
+ put<T>(key: string, value: T): Promise<void>;
522
+ put(entries: Record<string, unknown>): Promise<void>;
523
+ delete(key: string): Promise<boolean>;
524
+ delete(keys: string[]): Promise<number>;
525
+ deleteAll(): Promise<void>;
526
+ list<T = unknown>(options?: DurableObjectListOptions): Promise<Map<string, T>>;
527
+ transaction<T>(closure: (txn: DurableObjectTransaction) => Promise<T>): Promise<T>;
528
+ /** Synchronous transaction over `sql`; rolls back if `closure` throws. */
529
+ transactionSync<T>(closure: () => T): T;
530
+ sync(): Promise<void>;
531
+ /** At most one alarm is pending per object; a later `setAlarm` replaces it. */
532
+ setAlarm(scheduledTime: number | Date): Promise<void>;
533
+ getAlarm(): Promise<number | null>;
534
+ deleteAlarm(): Promise<void>;
535
+ readonly sql: SqlStorage;
536
+ }
537
+
538
+ interface DurableObjectStateAsync {
539
+ readonly id: DurableObjectId;
540
+ readonly storage: DurableObjectStorageAsync;
541
+ /** Holds every other event out of the object until `callback` settles. Use it for start-up work. */
542
+ blockConcurrencyWhile<T>(callback: () => Promise<T>): Promise<T>;
543
+ waitUntil(promise: Promise<unknown>): void;
544
+ acceptWebSocket(ws: WebSocket, tags?: string[]): void;
545
+ getWebSockets(tag?: string): WebSocket[];
546
+ getTags(ws: WebSocket): string[];
547
+ setWebSocketAutoResponse(pair?: WebSocketRequestResponsePair): void;
548
+ getWebSocketAutoResponse(): WebSocketRequestResponsePair | null;
549
+ }
550
+
422
551
  // ---------------------------------------------------------- WebSockets
423
552
 
424
553
  /**
@@ -502,11 +631,20 @@ declare global {
502
631
 
503
632
  // ------------------------------------ Fetchers: services and assets
504
633
 
505
- /** A service binding, and the shape of the static-asset binding. */
634
+ /** A service binding, and the shape of the static-asset binding, before 2026-10-22. */
506
635
  interface Fetcher {
507
636
  fetch(request: Request): Response;
508
637
  }
509
638
 
639
+ /**
640
+ * From 2026-10-22 `fetch()` on a service or assets binding returns a Promise. A service binding to a
641
+ * `WorkerEntrypoint` also exposes its methods over RPC from 2026-11-18: give the class as the type
642
+ * argument, `FetcherAsync<QuoteService>`, and `await env.PEER.random()` is checked.
643
+ */
644
+ type FetcherAsync<T = undefined> = (T extends undefined ? unknown : RpcMethods<T>) & {
645
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
646
+ };
647
+
510
648
  // --------------------------------------------------------- Handlers
511
649
 
512
650
  interface ScheduledEvent {
@@ -515,6 +653,15 @@ declare global {
515
653
  scheduledTime: number;
516
654
  }
517
655
 
656
+ /** What `scheduled(controller, env, ctx)` receives, from compatibility date 2026-09-28. */
657
+ interface ScheduledController {
658
+ /** The expression that fired, as written in `triggers.crons`. */
659
+ readonly cron: string;
660
+ readonly scheduledTime: number;
661
+ /** Do not retry this run if the handler throws. */
662
+ noRetry(): void;
663
+ }
664
+
518
665
  /** Work that outlives the response, drained in-process and capped at 25s. */
519
666
  interface ExecutionContext {
520
667
  waitUntil(promise: Promise<unknown>): void;
@@ -529,6 +676,16 @@ declare global {
529
676
  queue?(batch: MessageBatch<QueueBody>, ctx: ExecutionContext): void | Promise<void>;
530
677
  }
531
678
 
679
+ /**
680
+ * The default export of a Worker, as in Workers: `satisfies ExportedHandler<Env>`. Compatibility dates
681
+ * from 2026-09-28; `Env` comes from `sproutboat types`.
682
+ */
683
+ interface ExportedHandler<Bindings = unknown, QueueBody = unknown> {
684
+ fetch?(request: Request, env: Bindings, ctx: ExecutionContext): Response | Promise<Response>;
685
+ scheduled?(controller: ScheduledController, env: Bindings, ctx: ExecutionContext): void | Promise<void>;
686
+ queue?(batch: MessageBatch<QueueBody>, env: Bindings, ctx: ExecutionContext): void | Promise<void>;
687
+ }
688
+
532
689
  /** Handler signature for compatibility dates from 2026-09-28. */
533
690
  interface WorkersHandler<Bindings = Record<string, unknown>, QueueBody = unknown> {
534
691
  fetch(request: Request, env: Bindings, ctx: ExecutionContext): Response | Promise<Response>;