sproutboat 0.11.5 → 0.11.7

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.
package/CHANGELOG.md CHANGED
@@ -8,7 +8,26 @@ a breaking change.
8
8
  Reconstructed from git history on 2026-09-03 for everything through v0.4.11;
9
9
  maintained going forward by the `release` skill.
10
10
 
11
- ## [Unreleased]
11
+ ## [0.11.6] - 2026-09-15
12
+ ### Fixed
13
+ - The 100% CPU livelock on a `fetch` handler that resolves a promise with a
14
+ plain object (baronunread/sproutboat#168). Root cause was
15
+ `__Porffor_promise_resolve`'s `.then` duck-type probe spinning forever on a
16
+ prototype-chain fixed point instead of terminating; patched in
17
+ `@sproutboat/toolchain` alongside the other local Porffor patches. Bumped
18
+ `@sproutboat/toolchain` to `^0.4.1` to pick it up.
19
+ - `new Response(r2Object.body)` corrupted any binary R2 object (each byte
20
+ 0x80-0xFF doubled on the wire): the runtime couldn't tell opaque bytes
21
+ apart from a Latin-1-range string, so it UTF-8-encoded them a second time
22
+ (baronunread/sproutboat#177). Added `R2Object.toResponse(init?)`, which
23
+ serves the bytes unmodified. Bumped `@sproutboat/runtime` to `^0.7.1` and
24
+ `@sproutboat/wire` to `^0.6.0` to pick this up, along with the R2
25
+ file-backed storage rework (baronunread/sproutboat#56) both depend on.
26
+ - `sproutboat-env.d.ts`'s generated secret comment named the wrong command
27
+ (`secrets set`; the real one is `secrets put`), `SproutboatHandler` claimed
28
+ there is no `ctx`/`ctx.waitUntil` (shipped since #57/#171), and
29
+ `docs/standalone.md` still said a missing secret refuses the boot (0.11.1
30
+ changed that to throwing on first use instead).
12
31
 
13
32
  ## [0.11.5] - 2026-09-14
14
33
  ### Fixed
package/README.md CHANGED
@@ -150,6 +150,10 @@ files in the project and from its own `node_modules`, and may also export
150
150
  is a global, not a parameter, and every binding call is synchronous.
151
151
  [`examples/kitchen-sink/`](examples/kitchen-sink) uses every binding.
152
152
 
153
+ For objects larger than one request, use resumable R2 multipart uploads. The
154
+ [large-object guide](docs/r2-large-objects.md) covers part sizing, browser
155
+ chunking, resume state, cleanup, and the remaining download limitation.
156
+
153
157
  `sproutboat dev` runs against a real local broker, so bindings behave like
154
158
  production without a deploy — except `secrets`, which live only in the control
155
159
  plane. Put values in a `.dev.vars` file next to `sproutboat.jsonc`
package/SURFACE.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Generated by `src/surface.test.ts` from `src/surface.ts` + the pinned
4
4
  > toolchain constants. Do not edit by hand — run `UPDATE_SURFACE=1 bun test`.
5
5
 
6
- **Package:** `sproutboat` 0.11.5 · runs on Bun (use `bunx`, not `npx`)
6
+ **Package:** `sproutboat` 0.11.7 · runs on Bun (use `bunx`, not `npx`)
7
7
 
8
8
  ## Commands
9
9
 
@@ -74,7 +74,7 @@ usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev
74
74
  | | |
75
75
  | --- | --- |
76
76
  | Zig | `0.16.0` (`zig cc -target x86_64-linux-musl`, static) |
77
- | Provenance stamp | `zig-musl/0.16.0+porffor/1f4ae4a+uws/360c276d` |
77
+ | Provenance stamp | `zig-musl/0.16.0+porffor/038f415+uws/360c276d` |
78
78
  | Artifact schema | `2` |
79
79
  | Runtime | `native-fetch` |
80
80
  | Capability profile | `http-sync-v0` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.11.5",
3
+ "version": "0.11.7",
4
4
  "description": "Wrangler-shaped CLI for Sproutboat. Deploys workers to any control plane via --api-url / SPROUTBOAT_API_URL.",
5
5
  "keywords": [
6
6
  "cli",
@@ -64,9 +64,9 @@
64
64
  "@sproutboat/artifact": "^0.2.0",
65
65
  "@sproutboat/assets": "^0.2.0",
66
66
  "@sproutboat/config": "^0.3.0",
67
- "@sproutboat/runtime": "^0.6.6",
68
- "@sproutboat/toolchain": "^0.4.0",
69
- "@sproutboat/wire": "^0.5.0",
67
+ "@sproutboat/runtime": "^0.9.3",
68
+ "@sproutboat/toolchain": "^0.4.5",
69
+ "@sproutboat/wire": "^0.7.0",
70
70
  "esbuild": "^0.28.2"
71
71
  },
72
72
  "devDependencies": {
@@ -78,10 +78,10 @@
78
78
  "typescript": "7.0.2"
79
79
  },
80
80
  "optionalDependencies": {
81
- "@sproutboat/cli-darwin-arm64": "0.11.5",
82
- "@sproutboat/cli-darwin-x64": "0.11.5",
83
- "@sproutboat/cli-linux-arm64": "0.11.5",
84
- "@sproutboat/cli-linux-x64": "0.11.5"
81
+ "@sproutboat/cli-darwin-arm64": "0.11.7",
82
+ "@sproutboat/cli-darwin-x64": "0.11.7",
83
+ "@sproutboat/cli-linux-arm64": "0.11.7",
84
+ "@sproutboat/cli-linux-x64": "0.11.7"
85
85
  },
86
86
  "engines": {
87
87
  "node": ">=18"
package/src/compile.ts CHANGED
@@ -209,6 +209,7 @@ export async function compileSprout(input: CompileInput): Promise<void> {
209
209
  input.compatibilityDate,
210
210
  input.appName,
211
211
  input.assets,
212
+ input.transport,
212
213
  ),
213
214
  );
214
215
 
package/src/dev.ts CHANGED
@@ -15,7 +15,7 @@ import { mkdir, readFile, rm } from "node:fs/promises";
15
15
  import { connect } from "node:net";
16
16
  import { dirname, resolve } from "node:path";
17
17
  import { buildArtifact } from "./build";
18
- import { createBroker, listen, type Bindings, type Broker } from "./broker";
18
+ import { createBroker, listen, listenTransfers, type Bindings, type Broker } from "./broker";
19
19
  import { jsonObject, parseJsonValue } from "./json";
20
20
  import { amber, dim, leaf, ok } from "./style";
21
21
  import type { SproutboatConfig } from "./config";
@@ -97,6 +97,8 @@ export type Running = {
97
97
  /** Set before a kill we initiated, so its exit code is not reported as a crash. */
98
98
  expected: boolean;
99
99
  port: number;
100
+ /** Private direct-transfer listener, absent in test candidates. */
101
+ transferPort?: number;
100
102
  enableDispatch: () => void;
101
103
  disableDispatch: () => void;
102
104
  };
@@ -142,10 +144,13 @@ async function start(input: DevInput, port: number): Promise<Running> {
142
144
  assetsDir: existsSync(assetsDir) ? assetsDir : undefined,
143
145
  dispatchEnabled: () => dispatchEnabled,
144
146
  });
145
- let server: ReturnType<typeof listen>;
147
+ let server: ReturnType<typeof listen> | undefined;
148
+ let transferServer: ReturnType<typeof listenTransfers> | undefined;
146
149
  try {
147
150
  server = listen(broker, "127.0.0.1", 0);
151
+ transferServer = listenTransfers(broker, "127.0.0.1", 0);
148
152
  } catch (error) {
153
+ server?.stop();
149
154
  broker.close();
150
155
  throw error;
151
156
  }
@@ -156,14 +161,15 @@ async function start(input: DevInput, port: number): Promise<Running> {
156
161
  env: {
157
162
  ...process.env,
158
163
  PORT: String(port),
159
- SB_BROKER_PORT: String(server.port),
164
+ SB_BROKER_PORT: String(server!.port),
160
165
  SB_BROKER_TOKEN: "sproutboat-dev",
161
166
  },
162
167
  stdout: "inherit",
163
168
  stderr: "inherit",
164
169
  });
165
170
  } catch (error) {
166
- server.stop();
171
+ server?.stop();
172
+ transferServer?.stop();
167
173
  broker.close();
168
174
  throw error;
169
175
  }
@@ -173,11 +179,13 @@ async function start(input: DevInput, port: number): Promise<Running> {
173
179
  artifactDir,
174
180
  broker,
175
181
  stopBroker: () => {
176
- server.stop();
182
+ server?.stop();
183
+ transferServer?.stop();
177
184
  broker.close();
178
185
  },
179
186
  expected: false,
180
187
  port,
188
+ transferPort: transferServer?.port,
181
189
  enableDispatch: () => {
182
190
  dispatchEnabled = true;
183
191
  },
@@ -262,6 +270,7 @@ export async function runDev(input: DevInput): Promise<void> {
262
270
  }
263
271
  running.enableDispatch();
264
272
  let activePort = running.port;
273
+ let activeTransferPort = running.transferPort;
265
274
  // Keep the public port stable while candidates boot on private ports. This is
266
275
  // what lets a failed startup leave the last known-good process reachable.
267
276
  let proxy: ReturnType<typeof Bun.serve>;
@@ -269,9 +278,13 @@ export async function runDev(input: DevInput): Promise<void> {
269
278
  proxy = Bun.serve({
270
279
  hostname: "127.0.0.1",
271
280
  port: input.port,
272
- fetch(request) {
281
+ maxRequestBodySize: 5 * 1024 * 1024 * 1024,
282
+ fetch(request, server) {
273
283
  const target = new URL(request.url);
274
- target.host = `127.0.0.1:${activePort}`;
284
+ const directTransfer = /^\/__sb\/r2\/transfer\/[A-Z][A-Z0-9_]*\/[0-9a-f]{24}$/.test(target.pathname);
285
+ if (directTransfer) server.timeout(request, 255);
286
+ if (directTransfer && !activeTransferPort) return new Response("direct transfers unavailable", { status: 503 });
287
+ target.host = `127.0.0.1:${directTransfer ? activeTransferPort : activePort}`;
275
288
  return fetch(target, {
276
289
  method: request.method,
277
290
  headers: request.headers,
@@ -373,6 +386,7 @@ export async function runDev(input: DevInput): Promise<void> {
373
386
  current = { ...current, ...next };
374
387
  const switchedAt = now();
375
388
  activePort = candidate.port;
389
+ activeTransferPort = candidate.transferPort;
376
390
  resetWatchers();
377
391
  stop(previous);
378
392
  watchExit(running);
package/src/types-gen.ts CHANGED
@@ -34,7 +34,7 @@ function entries(config: SproutboatConfig): Entry[] {
34
34
  out.push({ name, type: "string", note: `vars: ${JSON.stringify(value)}` });
35
35
  }
36
36
  for (const name of config.secrets ?? []) {
37
- out.push({ name, type: "string", note: "secret, set with `sproutboat secrets set`" });
37
+ out.push({ name, type: "string", note: "secret, set with `sproutboat secrets put`" });
38
38
  }
39
39
  for (const { binding } of resourceRefs(config.kv_namespaces)) out.push({ name: binding, type: "KVNamespace" });
40
40
  for (const { binding } of resourceRefs(config.d1_databases)) out.push({ name: binding, type: "D1Database" });
@@ -56,20 +56,46 @@ declare global {
56
56
 
57
57
  // ---------------------------------------------------------------- R2
58
58
 
59
+ interface R2HttpMetadata {
60
+ contentType?: string;
61
+ contentDisposition?: string;
62
+ contentEncoding?: string;
63
+ contentLanguage?: string;
64
+ cacheControl?: string;
65
+ }
66
+
59
67
  interface R2Object {
60
68
  key: string;
61
69
  size: number;
62
70
  etag: string;
71
+ /** `etag`, quoted, ready for an `ETag` response header. */
72
+ httpEtag: string;
63
73
  uploaded: string;
74
+ httpMetadata: R2HttpMetadata;
64
75
  customMetadata?: Record<string, string>;
65
76
  /**
66
- * The object as a string. An object is held whole in memory on the way in
67
- * and on the way out, so keep them small.
77
+ * The object as a string. A get holds the object whole in memory, so keep
78
+ * downloads small until native download tickets are available.
79
+ *
80
+ * `new Response(obj.body, ...)` corrupts any non-ASCII/binary content: the
81
+ * runtime cannot tell these bytes apart from a string a handler built, so
82
+ * it UTF-8-encodes them a second time (baronunread/sproutboat#177). Use
83
+ * `obj.toResponse()` instead for binary content — it serves the bytes
84
+ * unmodified.
68
85
  */
69
86
  body: string;
87
+ text(): string;
88
+ json(): unknown;
89
+ /**
90
+ * A `Response` that serves `body` byte-for-byte, unlike `new
91
+ * Response(obj.body, ...)` (see `body` above). `init.headers`/`.status`
92
+ * merge in as usual; `content-type` defaults from `httpMetadata` and
93
+ * `etag` from `httpEtag` when not set.
94
+ */
95
+ toResponse(init?: { status?: number; headers?: HeadersInit }): Response;
70
96
  }
71
97
 
72
- type R2ObjectHead = Omit<R2Object, "body">;
98
+ type R2ObjectHead = Omit<R2Object, "body" | "text" | "json" | "toResponse">;
73
99
 
74
100
  interface R2Objects {
75
101
  objects: R2ObjectHead[];
@@ -78,6 +104,7 @@ declare global {
78
104
  }
79
105
 
80
106
  interface R2PutOptions {
107
+ httpMetadata?: R2HttpMetadata;
81
108
  customMetadata?: Record<string, string>;
82
109
  }
83
110
 
@@ -87,8 +114,48 @@ declare global {
87
114
  cursor?: string;
88
115
  }
89
116
 
117
+ interface R2UploadedPart {
118
+ partNumber: number;
119
+ etag: string;
120
+ }
121
+
122
+ interface R2MultipartUpload {
123
+ key: string;
124
+ uploadId: string;
125
+ uploadPart(partNumber: number, value: string): R2UploadedPart;
126
+ complete(parts: R2UploadedPart[]): R2ObjectHead;
127
+ abort(): void;
128
+ }
129
+
130
+ interface R2TransferTicket {
131
+ /** Same-origin reserved path. Send the file directly to this URL. */
132
+ url: string;
133
+ expiresAt: string;
134
+ }
135
+
136
+ interface R2UploadTicketOptions extends R2PutOptions {
137
+ /** Maximum accepted body size in bytes. Default 100 MiB. */
138
+ maxBytes?: number;
139
+ /** Ticket lifetime in seconds, clamped to 30-3600. */
140
+ expiresIn?: number;
141
+ /** Optional lowercase hexadecimal SHA-256 checksum. */
142
+ sha256?: string;
143
+ }
144
+
145
+ interface R2DownloadTicketOptions {
146
+ expiresIn?: number;
147
+ }
148
+
90
149
  interface R2Bucket {
91
150
  put(key: string, value: string, options?: R2PutOptions): void;
151
+ /** Starts a resumable upload whose individual parts stay memory-bounded. */
152
+ createMultipartUpload(key: string, options?: R2PutOptions): R2MultipartUpload;
153
+ /** Reconstructs a multipart handle after storing its key and upload id. */
154
+ resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload;
155
+ /** Broker-backed direct PUT. Not available in native standalone mode yet. */
156
+ createUploadUrl(key: string, options?: R2UploadTicketOptions): R2TransferTicket;
157
+ /** Broker-backed direct GET or HEAD, including Range. */
158
+ createDownloadUrl(key: string, options?: R2DownloadTicketOptions): R2TransferTicket;
92
159
  get(key: string): R2Object | null;
93
160
  /** Metadata without the body. */
94
161
  head(key: string): R2ObjectHead | null;
@@ -226,16 +293,22 @@ declare global {
226
293
  scheduledTime: number;
227
294
  }
228
295
 
296
+ /**
297
+ * `env` is a global, not a parameter. `ctx` is the second argument, and its
298
+ * only member is `waitUntil`: work that outlives the response, drained
299
+ * in-process and capped at 25s.
300
+ */
301
+ interface ExecutionContext {
302
+ waitUntil(promise: Promise<unknown>): void;
303
+ }
304
+
229
305
  /**
230
306
  * The default export.
231
- *
232
- * `fetch` takes only a request: `env` is a global, and there is no `ctx`, so
233
- * there is no `ctx.waitUntil`. Use a queue for work that outlives a response.
234
307
  */
235
308
  interface SproutboatHandler<QueueBody = unknown> {
236
- fetch(request: Request): Response | Promise<Response>;
237
- scheduled?(event: ScheduledEvent): void | Promise<void>;
238
- queue?(batch: MessageBatch<QueueBody>): void | Promise<void>;
309
+ fetch(request: Request, ctx: ExecutionContext): Response | Promise<Response>;
310
+ scheduled?(event: ScheduledEvent, ctx: ExecutionContext): void | Promise<void>;
311
+ queue?(batch: MessageBatch<QueueBody>, ctx: ExecutionContext): void | Promise<void>;
239
312
  }
240
313
 
241
314
  /**