sproutboat 0.10.2 → 0.11.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.
package/src/toolchain.ts CHANGED
@@ -15,7 +15,9 @@ import { homedir } from "node:os";
15
15
  import { dirname, resolve } from "node:path";
16
16
  // @ts-expect-error Bun's file loader returns the embedded asset path in a compiled executable.
17
17
  import embeddedUwsArchive from "../vendor/uwebsockets-360c276d-musl.tar.xz" with { type: "file" };
18
+ import { BEARSSL_VERSION } from "./bearssl";
18
19
  import { cachedPorfforRoot, PORFFOR_CHANNEL, PORFFOR_COMMIT } from "./porffor-toolchain";
20
+ import { SQLITE_VERSION } from "./sqlite";
19
21
 
20
22
  export const ZIG_VERSION = "0.16.0";
21
23
 
@@ -59,7 +61,7 @@ function platformKey(): ZigPlatform {
59
61
 
60
62
  export class ZigToolchainError extends Error {
61
63
  constructor(
62
- readonly kind: "download" | "integrity" | "archive" | "cache" | "unsupported",
64
+ readonly kind: "download" | "integrity" | "archive" | "cache" | "compiler" | "unsupported",
63
65
  message: string,
64
66
  ) {
65
67
  super(message);
@@ -73,6 +75,8 @@ export type EnsureZigOptions = {
73
75
  expectedSha256?: string;
74
76
  fetcher?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
75
77
  timeoutMs?: number;
78
+ /** Test hook: release acquisition always validates the cross-C++ toolchain. */
79
+ validate?: boolean;
76
80
  };
77
81
 
78
82
  async function sha256File(path: string): Promise<string> {
@@ -90,6 +94,7 @@ async function zigComplete(dir: string, key: ZigPlatform, expectedArchive: strin
90
94
  platform?: string;
91
95
  archiveSha256?: string;
92
96
  binarySha256?: string;
97
+ cValidated?: boolean;
93
98
  };
94
99
  const bin = resolve(dir, "zig");
95
100
  return (
@@ -97,6 +102,7 @@ async function zigComplete(dir: string, key: ZigPlatform, expectedArchive: strin
97
102
  manifest.platform === key &&
98
103
  manifest.archiveSha256 === expectedArchive &&
99
104
  manifest.binarySha256 !== undefined &&
105
+ manifest.cValidated !== undefined &&
100
106
  (await sha256File(bin)) === manifest.binarySha256
101
107
  );
102
108
  } catch {
@@ -144,6 +150,30 @@ async function zigDownloadUrls(
144
150
  return [official];
145
151
  }
146
152
 
153
+ /** Confirm the downloaded compiler can produce the C target Sproutboat builds. */
154
+ async function validateZigTarget(bin: string, root: string): Promise<void> {
155
+ const stage = resolve(root, `.zig-c-probe-${process.pid}-${crypto.randomUUID()}`);
156
+ try {
157
+ await mkdir(stage);
158
+ const source = resolve(stage, "main.c");
159
+ const out = resolve(stage, "probe");
160
+ await writeFile(source, "int main() { return 0; }\n");
161
+ const child = Bun.spawn([bin, "cc", "-target", "x86_64-linux-musl", "-static", source, "-o", out], {
162
+ stdout: "pipe",
163
+ stderr: "pipe",
164
+ });
165
+ const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
166
+ if (code !== 0 || !existsSync(out))
167
+ throw new ZigToolchainError(
168
+ "compiler",
169
+ `pinned Zig cannot link a linux-x86_64-musl C binary: ${stderr.trim() || `exit ${code}`}\n` +
170
+ "Set SPROUTBOAT_ZIG to a working Zig binary, then run `sproutboat toolchain doctor`.",
171
+ );
172
+ } finally {
173
+ await rm(stage, { recursive: true, force: true });
174
+ }
175
+ }
176
+
147
177
  async function downloadZig(
148
178
  urls: string[],
149
179
  path: string,
@@ -218,19 +248,40 @@ export async function ensureZig(options: EnsureZigOptions = {}): Promise<string>
218
248
  const stage = resolve(root, `.zig-${ZIG_VERSION}-${key}-${process.pid}-${crypto.randomUUID()}`);
219
249
  const fetcher = options.fetcher ?? fetch;
220
250
  const sourceUrl = options.url ?? process.env.SPROUTBOAT_ZIG_URL;
221
- const urls = sourceUrl ? [sourceUrl] : await zigDownloadUrls(key, fetcher, options.timeoutMs ?? 30_000);
222
- console.log(`Fetching Zig ${ZIG_VERSION} (${key}, one-time)...`);
251
+ // A platform package ships its own host's Zig archive right beside the CLI
252
+ // executable (scripts/build-cli.ts stages it there at publish time, the same
253
+ // compressed .tar.xz an ordinary download would fetch), so a packaged
254
+ // install needs no network for Zig -- same pattern as the vendored
255
+ // uWebSockets archive. Only the default, no-`platform`/no-`url` call (every
256
+ // real caller) takes this path; build-cli.ts's own cross-target fetches and
257
+ // this file's tests always pass one of those and go straight to the network.
258
+ const vendored =
259
+ options.platform === undefined && !sourceUrl ? resolve(dirname(process.execPath), "zig.tar.xz") : null;
260
+ const usingVendored = vendored !== null && existsSync(vendored);
261
+ console.log(
262
+ usingVendored
263
+ ? `Extracting vendored Zig ${ZIG_VERSION} (${key}, one-time)...`
264
+ : `Fetching Zig ${ZIG_VERSION} (${key}, one-time)...`,
265
+ );
223
266
  try {
224
267
  if (await zigComplete(dir, key, expected)) return resolve(dir, "zig");
225
268
  await rm(dir, { recursive: true, force: true });
226
269
  await mkdir(stage);
227
270
  const archive = resolve(stage, "zig.tar.xz");
228
- await downloadZig(urls, archive, fetcher, options.timeoutMs ?? 60_000);
271
+ if (usingVendored) {
272
+ // SAFETY: usingVendored is only true when `vendored` is the non-null path just checked.
273
+ await Bun.write(archive, Bun.file(vendored!));
274
+ } else {
275
+ // Zig's pinned archive is about 50 MB. Keep the fetch bounded while
276
+ // allowing a cold download to complete on ordinary consumer connections.
277
+ const urls = sourceUrl ? [sourceUrl] : await zigDownloadUrls(key, fetcher, options.timeoutMs ?? 30_000);
278
+ await downloadZig(urls, archive, fetcher, options.timeoutMs ?? 120_000);
279
+ }
229
280
  const actual = await sha256File(archive);
230
281
  if (actual !== expected)
231
282
  throw new ZigToolchainError(
232
283
  "integrity",
233
- `Zig archive sha256 mismatch\n expected ${expected}\n got ${actual}`,
284
+ `${usingVendored ? "vendored " : ""}Zig archive sha256 mismatch\n expected ${expected}\n got ${actual}`,
234
285
  );
235
286
  const untar = Bun.spawn(["tar", "-xJf", archive, "-C", stage, "--strip-components=1"], {
236
287
  stdout: "pipe",
@@ -243,6 +294,7 @@ export async function ensureZig(options: EnsureZigOptions = {}): Promise<string>
243
294
  const bin = resolve(stage, "zig");
244
295
  if (!existsSync(bin)) throw new ZigToolchainError("archive", "Zig archive did not contain a `zig` binary");
245
296
  await chmod(bin, 0o755);
297
+ if (options.validate !== false) await validateZigTarget(bin, root);
246
298
  await writeFile(
247
299
  resolve(stage, ".sproutboat-complete"),
248
300
  JSON.stringify({
@@ -250,6 +302,7 @@ export async function ensureZig(options: EnsureZigOptions = {}): Promise<string>
250
302
  platform: key,
251
303
  archiveSha256: actual,
252
304
  binarySha256: await sha256File(bin),
305
+ cValidated: options.validate !== false,
253
306
  }),
254
307
  { mode: 0o444 },
255
308
  );
@@ -261,6 +314,134 @@ export async function ensureZig(options: EnsureZigOptions = {}): Promise<string>
261
314
  }
262
315
  }
263
316
 
317
+ /**
318
+ * The verified, compressed Zig archive for `platform` -- not extracted, not
319
+ * necessarily this host's own. `scripts/build-cli.ts` uses this to fetch and
320
+ * stage another platform's archive into that platform's package for `--all`,
321
+ * where `ensureZig` itself cannot help: it extracts, validates, and caches for
322
+ * *this* host only, none of which makes sense for a foreign target's binary.
323
+ * Cached at `<cacheRoot>/zig-archive-<version>-<platform>.tar.xz` so a repeat
324
+ * `--all` run does not re-download all four.
325
+ */
326
+ export async function ensureZigArchive(platform: ZigPlatform, cacheRoot?: string): Promise<string> {
327
+ const root = resolve(cacheRoot ?? process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? resolve(homedir(), ".cache/sproutboat"));
328
+ const archive = resolve(root, `zig-archive-${ZIG_VERSION}-${platform}.tar.xz`);
329
+ const expected = ZIG_SHA256[platform];
330
+ if (existsSync(archive) && (await sha256File(archive)) === expected) return archive;
331
+ await mkdir(root, { recursive: true });
332
+ const stage = `${archive}.${process.pid}.${crypto.randomUUID()}`;
333
+ const urls = await zigDownloadUrls(platform, fetch, 30_000);
334
+ await downloadZig(urls, stage, fetch, 120_000);
335
+ const actual = await sha256File(stage);
336
+ if (actual !== expected) {
337
+ await rm(stage, { force: true });
338
+ throw new ZigToolchainError(
339
+ "integrity",
340
+ `Zig archive sha256 mismatch\n expected ${expected}\n got ${actual}`,
341
+ );
342
+ }
343
+ await rename(stage, archive);
344
+ return archive;
345
+ }
346
+
347
+ /** `cc [args...]`/`cxx [args...]`/`ar [args...]`, each as `[bin, ...leadingArgs]`
348
+ * ready to spread before a call's own arguments. */
349
+ export type HostCompiler = { cc: string[]; cxx: string[]; ar: string[]; usingZig: boolean };
350
+
351
+ /**
352
+ * The C/C++/archiver toolchain for a **host** build (`dev`, `build --target
353
+ * host`, and every host-native object BearSSL/SQLite/uWebSockets compile).
354
+ *
355
+ * Prefers whatever the machine already has — no download, and unchanged
356
+ * behavior for every developer with Xcode Command Line Tools / build-essential
357
+ * installed. Falls back to the pinned Zig (already vendored for the musl
358
+ * cross-target) as a cross-compiler pointed at this same host, so the packaged
359
+ * CLI needs no system C toolchain at all — Zig ships a real native codegen
360
+ * backend for the host triple, not just cross-musl.
361
+ */
362
+ export async function resolveHostCompiler(): Promise<HostCompiler> {
363
+ const cc = process.env.CC;
364
+ const cxx = process.env.CXX;
365
+ const ar = process.env.AR;
366
+ const haveCc = Boolean(cc || Bun.which("cc"));
367
+ const haveAr = Boolean(ar || Bun.which("ar"));
368
+ if (haveCc && haveAr) return { cc: [cc || "cc"], cxx: [cxx || "c++"], ar: [ar || "ar"], usingZig: false };
369
+ const zigBin = await ensureZig();
370
+ const [arch, os] = platformKey().split("-");
371
+ const triple = os === "macos" ? `${arch}-macos` : `${arch}-linux-gnu`;
372
+ return {
373
+ cc: [zigBin, "cc", "-target", triple],
374
+ cxx: [zigBin, "c++", "-target", triple],
375
+ ar: [zigBin, "ar"],
376
+ usingZig: true,
377
+ };
378
+ }
379
+
380
+ export type ToolchainDoctor = {
381
+ host: `${string}/${string}`;
382
+ cacheRoot: string;
383
+ porffor: { version: string; path: string; override: string | null; present: boolean };
384
+ zig: {
385
+ version: string;
386
+ platform: ZigPlatform | null;
387
+ path: string | null;
388
+ override: string | null;
389
+ present: boolean;
390
+ };
391
+ sqlite: { version: string; path: string; present: boolean };
392
+ bearssl: { version: string; path: string; present: boolean };
393
+ prerequisites: { cc: string | null; ar: string | null; tar: string | null; xcrun: string | null; sdk: string | null };
394
+ };
395
+
396
+ /** Inspect the selected toolchain without downloading, compiling, or mutating a cache. */
397
+ export function inspectToolchain(): ToolchainDoctor {
398
+ const cacheRoot = resolve(process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? resolve(homedir(), ".cache/sproutboat"));
399
+ const porfforOverride = process.env.SPROUTBOAT_PORFFOR_DIR ? resolve(process.env.SPROUTBOAT_PORFFOR_DIR) : null;
400
+ const zigOverride = process.env.SPROUTBOAT_ZIG ? resolve(process.env.SPROUTBOAT_ZIG) : null;
401
+ let platform: ZigPlatform | null = null;
402
+ try {
403
+ platform = platformKey();
404
+ } catch {
405
+ /* doctor reports an unsupported host instead of throwing before its diagnostics */
406
+ }
407
+ const zigPath = zigOverride ?? (platform ? resolve(cacheRoot, `zig-${ZIG_VERSION}-${platform}`, "zig") : null);
408
+ const porfforPath = porfforOverride ?? cachedPorfforRoot(cacheRoot);
409
+ const sqlitePath = resolve(cacheRoot, `sqlite-${SQLITE_VERSION}`);
410
+ const bearsslPath = resolve(cacheRoot, `bearssl-${BEARSSL_VERSION}`);
411
+ const xcrun = process.platform === "darwin" ? Bun.which("xcrun") : null;
412
+ const sdk = (() => {
413
+ if (!xcrun) return null;
414
+ const probe = Bun.spawnSync([xcrun, "--show-sdk-path"], { stdout: "pipe", stderr: "ignore" });
415
+ return probe.exitCode === 0 ? probe.stdout.toString().trim() || null : null;
416
+ })();
417
+ return {
418
+ host: `${process.arch}/${process.platform}`,
419
+ cacheRoot,
420
+ porffor: {
421
+ version: porfforVersion(),
422
+ path: porfforPath,
423
+ override: porfforOverride,
424
+ present: existsSync(resolve(porfforPath, "runtime/index.js")),
425
+ },
426
+ zig: {
427
+ version: ZIG_VERSION,
428
+ platform,
429
+ path: zigPath,
430
+ override: zigOverride,
431
+ present: Boolean(zigPath && existsSync(zigPath)),
432
+ },
433
+ sqlite: { version: SQLITE_VERSION, path: sqlitePath, present: existsSync(sqlitePath) },
434
+ bearssl: { version: BEARSSL_VERSION, path: bearsslPath, present: existsSync(bearsslPath) },
435
+ prerequisites: {
436
+ cc: Bun.which(process.env.CC ?? "cc"),
437
+ ar: Bun.which(process.env.AR ?? "ar"),
438
+ tar: Bun.which("tar"),
439
+ xcrun,
440
+ sdk,
441
+ },
442
+ };
443
+ }
444
+
264
445
  function uwsCommitFull(): string {
265
446
  try {
266
447
  const src = readFileSync(resolve(porfforRoot(), "compiler/uwebsockets.js"), "utf8");
@@ -280,6 +461,65 @@ export function uwsVendorArchive(short: string): string {
280
461
  : resolve(import.meta.dir, "..", "vendor", `uwebsockets-${short}-musl.tar.xz`);
281
462
  }
282
463
 
464
+ const UWS_REQUIRED = ["src/App.h", "uSockets/uSockets.a"];
465
+
466
+ async function uwsComplete(dir: string, command?: string[]): Promise<boolean> {
467
+ try {
468
+ // SAFETY: this manifest is private data written below. The files it names
469
+ // are fixed by this module and each recorded digest is recomputed.
470
+ const manifest = JSON.parse(await readFile(resolve(dir, ".sproutboat-complete"), "utf8")) as {
471
+ files?: Record<string, string>;
472
+ command?: string[];
473
+ };
474
+ return (
475
+ (command === undefined || JSON.stringify(manifest.command) === JSON.stringify(command)) &&
476
+ (
477
+ await Promise.all(
478
+ UWS_REQUIRED.map(async (file) => (await sha256File(resolve(dir, file))) === manifest.files?.[file]),
479
+ )
480
+ ).every(Boolean)
481
+ );
482
+ } catch {
483
+ return false;
484
+ }
485
+ }
486
+
487
+ async function writeUwsManifest(dir: string, command?: string[]): Promise<void> {
488
+ const files = Object.fromEntries(
489
+ await Promise.all(UWS_REQUIRED.map(async (file) => [file, await sha256File(resolve(dir, file))] as const)),
490
+ );
491
+ const manifest = resolve(dir, ".sproutboat-complete");
492
+ const stage = `${manifest}.${process.pid}.${crypto.randomUUID()}`;
493
+ await writeFile(stage, JSON.stringify({ files, command }), { mode: 0o444 });
494
+ await rename(stage, manifest);
495
+ }
496
+
497
+ /** Serialize cache reconstruction and recover a lock left by an interrupted build. */
498
+ async function withUwsLock<T>(dir: string, action: () => Promise<T>): Promise<T> {
499
+ await mkdir(dirname(dir), { recursive: true });
500
+ const lock = `${dir}.lock`;
501
+ const deadline = Date.now() + 30_000;
502
+ for (;;) {
503
+ try {
504
+ await mkdir(lock);
505
+ break;
506
+ } catch {
507
+ if (Date.now() > deadline) throw new UwsUnavailableError(`timed out waiting for uWebSockets cache lock ${lock}`);
508
+ try {
509
+ if (Date.now() - (await stat(lock)).mtimeMs > 5 * 60_000) await rm(lock, { recursive: true, force: true });
510
+ } catch {
511
+ /* the active publisher released the lock between stat and rm */
512
+ }
513
+ await Bun.sleep(25);
514
+ }
515
+ }
516
+ try {
517
+ return await action();
518
+ } finally {
519
+ await rm(lock, { recursive: true, force: true });
520
+ }
521
+ }
522
+
283
523
  /**
284
524
  * Seed `~/.cache/porffor/deps/uWebSockets-<commit>-musl/` with the checked-out,
285
525
  * patched, `x86_64-linux-musl`-built uWebSockets tree so Porffor's own
@@ -297,30 +537,33 @@ export async function ensureUWebSockets(): Promise<void> {
297
537
  const short = commit.slice(0, 8);
298
538
  const depsRoot = resolve(homedir(), ".cache/porffor/deps");
299
539
  const dir = resolve(depsRoot, `uWebSockets-${commit}-musl`);
300
- if (existsSync(resolve(dir, "src/App.h")) && existsSync(resolve(dir, "uSockets/uSockets.a"))) return;
301
-
302
- const archive = process.env.SPROUTBOAT_UWS_TARBALL || uwsVendorArchive(short);
303
- if (!existsSync(archive)) {
304
- throw new UwsUnavailableError(
305
- process.env.SPROUTBOAT_UWS_TARBALL
306
- ? `SPROUTBOAT_UWS_TARBALL=${archive} does not exist`
307
- : `no vendored uWebSockets archive at ${archive} (porffor pin moved? run \`bun tools/prebuild-uws.ts\`)`,
308
- );
309
- }
310
- if (!process.env.SPROUTBOAT_UWS_TARBALL) {
311
- const actual = await sha256File(archive);
312
- if (actual !== UWS_TARBALL_SHA256) {
540
+ if (await uwsComplete(dir)) return;
541
+
542
+ await withUwsLock(dir, async () => {
543
+ if (await uwsComplete(dir)) return;
544
+ const archive = process.env.SPROUTBOAT_UWS_TARBALL || uwsVendorArchive(short);
545
+ if (!existsSync(archive)) {
313
546
  throw new UwsUnavailableError(
314
- `vendored uWebSockets sha256 mismatch\n expected ${UWS_TARBALL_SHA256}\n got ${actual}`,
547
+ process.env.SPROUTBOAT_UWS_TARBALL
548
+ ? `SPROUTBOAT_UWS_TARBALL=${archive} does not exist`
549
+ : `no vendored uWebSockets archive at ${archive} (porffor pin moved? run \`bun tools/prebuild-uws.ts\`)`,
315
550
  );
316
551
  }
317
- }
318
-
319
- await extractUws(archive, dir);
552
+ if (!process.env.SPROUTBOAT_UWS_TARBALL) {
553
+ const actual = await sha256File(archive);
554
+ if (actual !== UWS_TARBALL_SHA256) {
555
+ throw new UwsUnavailableError(
556
+ `vendored uWebSockets sha256 mismatch\n expected ${UWS_TARBALL_SHA256}\n got ${actual}`,
557
+ );
558
+ }
559
+ }
560
+ await extractUws(archive, dir);
561
+ });
320
562
  }
321
563
 
322
564
  /** Unpack the vendored source tree into `dir`. */
323
565
  async function extractUws(archive: string, dir: string): Promise<void> {
566
+ await rm(dir, { recursive: true, force: true });
324
567
  await mkdir(dir, { recursive: true });
325
568
  // External programs cannot read Bun's virtual /$bunfs paths. Materialize the
326
569
  // embedded release asset inside the destination before handing it to tar.
@@ -336,6 +579,12 @@ async function extractUws(archive: string, dir: string): Promise<void> {
336
579
  await rm(dir, { recursive: true, force: true });
337
580
  throw new UwsUnavailableError(`could not extract vendored uWebSockets: ${err.trim()}`);
338
581
  }
582
+ try {
583
+ await writeUwsManifest(dir);
584
+ } catch (error) {
585
+ await rm(dir, { recursive: true, force: true });
586
+ throw new UwsUnavailableError(`could not validate vendored uWebSockets: ${String(error)}`);
587
+ }
339
588
  }
340
589
 
341
590
  /**
@@ -358,50 +607,54 @@ export async function ensureUWebSocketsHost(): Promise<void> {
358
607
  const dir = resolve(homedir(), ".cache/porffor/deps", `uWebSockets-${commit}`);
359
608
  const uSockets = resolve(dir, "uSockets");
360
609
  const archivePath = resolve(uSockets, "uSockets.a");
361
- if (existsSync(resolve(dir, "src/App.h")) && existsSync(archivePath)) return;
362
-
363
- const vendored = process.env.SPROUTBOAT_UWS_TARBALL || uwsVendorArchive(commit.slice(0, 8));
364
- if (!existsSync(vendored)) {
365
- throw new UwsUnavailableError(`no vendored uWebSockets archive at ${vendored}`);
366
- }
367
- if (!process.env.SPROUTBOAT_UWS_TARBALL) {
368
- const actual = await sha256File(vendored);
369
- if (actual !== UWS_TARBALL_SHA256) {
610
+ const { cc, ar, usingZig } = await resolveHostCompiler();
611
+ const command = [cc.join(" "), ar.join(" ")];
612
+ if (await uwsComplete(dir, command)) return;
613
+
614
+ await withUwsLock(dir, async () => {
615
+ if (await uwsComplete(dir, command)) return;
616
+ const vendored = process.env.SPROUTBOAT_UWS_TARBALL || uwsVendorArchive(commit.slice(0, 8));
617
+ if (!existsSync(vendored)) throw new UwsUnavailableError(`no vendored uWebSockets archive at ${vendored}`);
618
+ if (!process.env.SPROUTBOAT_UWS_TARBALL) {
619
+ const actual = await sha256File(vendored);
620
+ if (actual !== UWS_TARBALL_SHA256) {
621
+ throw new UwsUnavailableError(
622
+ `vendored uWebSockets sha256 mismatch\n expected ${UWS_TARBALL_SHA256}\n got ${actual}`,
623
+ );
624
+ }
625
+ }
626
+ await extractUws(vendored, dir);
627
+
628
+ // The archive carries the musl-built uSockets.a. Linking that into a host
629
+ // binary fails in a way nobody would connect to this, so it goes first.
630
+ await rm(archivePath, { force: true });
631
+
632
+ const sources = ["src/*.c", "src/eventing/*.c", "src/crypto/*.c", "src/io_uring/*.c"];
633
+ // Zig's cc needs `-fuse-ld=lld` wired up for LTO at link time; this is a
634
+ // `-c`-only compile step with no link, so LTO just isn't worth chasing here.
635
+ const flto = usingZig ? "" : "-flto ";
636
+ const compile = Bun.spawn(
637
+ ["sh", "-c", `${command[0]} -std=c11 -Isrc -DLIBUS_NO_SSL ${flto}-O3 -c ${sources.join(" ")}`],
638
+ { cwd: uSockets, stdout: "pipe", stderr: "pipe" },
639
+ );
640
+ const [ccCode, ccErr] = await Promise.all([compile.exited, new Response(compile.stderr).text()]);
641
+ if (ccCode !== 0) {
370
642
  throw new UwsUnavailableError(
371
- `vendored uWebSockets sha256 mismatch\n expected ${UWS_TARBALL_SHA256}\n got ${actual}`,
643
+ `could not compile uSockets with ${command[0]}: ${ccErr.trim().split("\n").slice(-3).join(" ")}`,
372
644
  );
373
645
  }
374
- }
375
- if (!existsSync(resolve(dir, "src/App.h"))) await extractUws(vendored, dir);
376
646
 
377
- // The archive carries the musl-built uSockets.a. Linking that into a host
378
- // binary fails in a way nobody would connect to this, so it goes first.
379
- await rm(archivePath, { force: true });
380
-
381
- const sources = ["src/*.c", "src/eventing/*.c", "src/crypto/*.c", "src/io_uring/*.c"];
382
- const cc = process.env.CC || "cc";
383
- const ar = process.env.AR || "ar";
384
- const compile = Bun.spawn(["sh", "-c", `${cc} -std=c11 -Isrc -DLIBUS_NO_SSL -flto -O3 -c ${sources.join(" ")}`], {
385
- cwd: uSockets,
386
- stdout: "pipe",
387
- stderr: "pipe",
388
- });
389
- const [ccCode, ccErr] = await Promise.all([compile.exited, new Response(compile.stderr).text()]);
390
- if (ccCode !== 0) {
391
- throw new UwsUnavailableError(
392
- `could not compile uSockets with ${cc}: ${ccErr.trim().split("\n").slice(-3).join(" ")}`,
393
- );
394
- }
395
-
396
- const archiveStep = Bun.spawn(["sh", "-c", `${ar} rvs uSockets.a *.o`], {
397
- cwd: uSockets,
398
- stdout: "pipe",
399
- stderr: "pipe",
647
+ const archiveStep = Bun.spawn(["sh", "-c", `${command[1]} rvs uSockets.a *.o`], {
648
+ cwd: uSockets,
649
+ stdout: "pipe",
650
+ stderr: "pipe",
651
+ });
652
+ const [arCode, arErr] = await Promise.all([archiveStep.exited, new Response(archiveStep.stderr).text()]);
653
+ if (arCode !== 0 || !existsSync(archivePath)) {
654
+ throw new UwsUnavailableError(`could not archive uSockets with ${command[1]}: ${arErr.trim()}`);
655
+ }
656
+ await writeUwsManifest(dir, command);
400
657
  });
401
- const [arCode, arErr] = await Promise.all([archiveStep.exited, new Response(archiveStep.stderr).text()]);
402
- if (arCode !== 0 || !existsSync(archivePath)) {
403
- throw new UwsUnavailableError(`could not archive uSockets with ${ar}: ${arErr.trim()}`);
404
- }
405
658
  }
406
659
 
407
660
  /** Directory holding node_modules/porffor (walks up from this file). */
@@ -182,9 +182,13 @@ declare global {
182
182
  * A rate-limiter binding (#69). `limit` and `period` are fixed in
183
183
  * sproutboat.jsonc; `limit({ key })` counts one call against a fixed window
184
184
  * and reports whether the key is still under the cap.
185
+ *
186
+ * `resetAt` is epoch milliseconds for when the window rolls and the count
187
+ * clears — the same for a hit or a miss. Turn a rejection into a header with
188
+ * `Retry-After: Math.ceil((resetAt - Date.now()) / 1000)`.
185
189
  */
186
190
  interface RateLimit {
187
- limit(options: { key: string }): { success: boolean };
191
+ limit(options: { key: string }): { success: boolean; resetAt: number };
188
192
  }
189
193
 
190
194
  // ------------------------------------ Crypto