swift-rust 1.2.1 → 1.4.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/bin/build.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  } from "node:fs";
14
14
  import { join, resolve, dirname, sep } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
+ import { createRequire } from "node:module";
16
17
 
17
18
  const cwd = process.cwd();
18
19
  const APP_DIR_CANDIDATES = [resolve(cwd, "src", "app"), resolve(cwd, "app")];
@@ -22,6 +23,260 @@ const OUT_DIR = resolve(cwd, ".vercel", "output");
22
23
  const STATIC_DIR = join(OUT_DIR, "static");
23
24
  const RUNTIME_DIR = resolve(fileURLToPath(import.meta.url), "..", "runtime");
24
25
 
26
+ const ROUTING_EXTS = [".tsx", ".ts", ".jsx", ".js"];
27
+
28
+ // Scan a file's leading directives for a runtime / guard opt-in.
29
+ function scanFileDirectives(file) {
30
+ const out = { runtime: null, guard: false };
31
+ try {
32
+ for (const line of readFileSync(file, "utf8").split("\n")) {
33
+ const t = line.trim();
34
+ if (!t || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) continue;
35
+ const m = t.match(/^["']use (bun|edge|node|worker|guard)["'];?$/);
36
+ if (m) {
37
+ if (m[1] === "guard") out.guard = true;
38
+ else if (!out.runtime) out.runtime = m[1];
39
+ continue;
40
+ }
41
+ if (/^["']use [a-z]+["'];?$/.test(t)) continue;
42
+ break;
43
+ }
44
+ } catch {}
45
+ return out;
46
+ }
47
+
48
+ // Source-level detection of a route's runtime/guard opt-in (page + layouts),
49
+ // so dynamic routes are emitted even when a build-time GET redirects.
50
+ function scanDirectives(pageFile) {
51
+ const { layouts } = collectChainFiles(pageFile);
52
+ let runtime = null;
53
+ let guard = false;
54
+ for (const f of [pageFile, ...layouts]) {
55
+ const d = scanFileDirectives(f);
56
+ if (d.runtime && !runtime) runtime = d.runtime;
57
+ if (d.guard) guard = true;
58
+ }
59
+ return { runtime, guardEnabled: guard };
60
+ }
61
+
62
+ function findRoutingFile(dir, base) {
63
+ for (const ext of ROUTING_EXTS) {
64
+ const f = join(dir, `${base}${ext}`);
65
+ if (existsSync(f)) return f;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ // Collect the routing files along a page's dir chain (outer → inner) plus the
71
+ // URL pattern segments (with [param] markers preserved). Mirrors the dev
72
+ // server's pipeline so the emitted function has the same behavior.
73
+ function collectChainFiles(pageFile) {
74
+ const layouts = []; // { file, slots: [{ name, file }] }
75
+ const proxies = [];
76
+ const schemas = [];
77
+ const guards = [];
78
+ const loaders = [];
79
+ let actionFile = null;
80
+ let queryFile = null;
81
+ let stateFile = null;
82
+ let seoFile = null;
83
+ let i18nFile = null;
84
+ let revalidateFile = null;
85
+ const pattern = [];
86
+ const rel = dirname(pageFile).slice(APP_DIR.length).replace(/^[/\\]/, "");
87
+ const segs = rel ? rel.split(sep) : [];
88
+ if (APP_DIR.endsWith(`${sep}app`) && dirname(APP_DIR).endsWith(`${sep}src`)) {
89
+ const rp = findRoutingFile(dirname(APP_DIR), "proxy");
90
+ if (rp) proxies.push(rp);
91
+ }
92
+ const slotsFor = (d) => {
93
+ const out = [];
94
+ try {
95
+ for (const e of readdirSync(d, { withFileTypes: true })) {
96
+ if (e.isDirectory() && e.name.startsWith("@")) {
97
+ const sd = join(d, e.name);
98
+ const file = findRoutingFile(sd, "page") || findRoutingFile(sd, "fragment") || findRoutingFile(sd, "default");
99
+ if (file) out.push({ name: e.name.slice(1), file });
100
+ }
101
+ }
102
+ } catch {}
103
+ return out;
104
+ };
105
+ let dir = APP_DIR;
106
+ const scan = (d, isLeaf) => {
107
+ const l = findRoutingFile(d, "layout");
108
+ if (l) layouts.push({ file: l, slots: slotsFor(d) });
109
+ const p = findRoutingFile(d, "proxy");
110
+ if (p) proxies.push(p);
111
+ const s = findRoutingFile(d, "schema");
112
+ if (s) schemas.push(s);
113
+ const g = findRoutingFile(d, "guard");
114
+ if (g) guards.push(g);
115
+ const ld = findRoutingFile(d, "loader");
116
+ if (ld) loaders.push(ld);
117
+ const q = findRoutingFile(d, "query");
118
+ if (q) queryFile = q;
119
+ const st = findRoutingFile(d, "state");
120
+ if (st) stateFile = st;
121
+ const se = findRoutingFile(d, "seo");
122
+ if (se) seoFile = se;
123
+ const i = findRoutingFile(d, "i18n");
124
+ if (i) i18nFile = i;
125
+ const rv = findRoutingFile(d, "revalidate");
126
+ if (rv) revalidateFile = rv;
127
+ if (isLeaf) {
128
+ const a = findRoutingFile(d, "action");
129
+ if (a) actionFile = a;
130
+ }
131
+ };
132
+ scan(dir, segs.length === 0);
133
+ for (let i = 0; i < segs.length; i++) {
134
+ dir = join(dir, segs[i]);
135
+ pattern.push(segs[i]);
136
+ scan(dir, i === segs.length - 1);
137
+ }
138
+ return { layouts, proxies, schemas, guards, loaders, actionFile, queryFile, stateFile, seoFile, i18nFile, revalidateFile, pattern };
139
+ }
140
+
141
+ function isClientPageFile(file) {
142
+ return scanFileDirectivesRaw(file).includes("client");
143
+ }
144
+ function scanFileDirectivesRaw(file) {
145
+ const found = [];
146
+ try {
147
+ for (const line of readFileSync(file, "utf8").split("\n")) {
148
+ const t = line.trim();
149
+ if (!t || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) continue;
150
+ const m = t.match(/^["']use ([a-z]+)["'];?$/);
151
+ if (m) {
152
+ found.push(m[1]);
153
+ continue;
154
+ }
155
+ break;
156
+ }
157
+ } catch {}
158
+ return found;
159
+ }
160
+
161
+ // Build a client-island bundle for a "use client" page (served by the dev
162
+ // server during build) and write it to the static output. Returns its URL.
163
+ async function buildIslandAsset(pageFile) {
164
+ try {
165
+ const res = await fetch(`http://${HOST}:${PORT}/_swift-rust/island.js?p=${encodeURIComponent(pageFile)}`);
166
+ if (!res.ok) return null;
167
+ const code = await res.text();
168
+ const rel = `_swift-rust/island/${simpleHash(pageFile)}.js`;
169
+ writeRawFile(STATIC_DIR, rel, code);
170
+ return `/${rel}`;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ // Generate + bundle a Vercel function (.func) for one dynamic route.
177
+ async function emitFunction({ route, funcRel, pageFile, runtime, head, guardEnabled, isClient, islandSrc }) {
178
+ if (typeof Bun === "undefined" || typeof Bun.build !== "function") return false;
179
+ const f = collectChainFiles(pageFile);
180
+ const fnCore = join(RUNTIME_DIR, "fn-core.mjs");
181
+ const imports = [`import { makeRouteHandler } from ${JSON.stringify(fnCore)};`];
182
+ let uid = 0;
183
+ const ns = (file) => {
184
+ const name = `__m${uid++}`;
185
+ imports.push(`import * as ${name} from ${JSON.stringify(file)};`);
186
+ return name;
187
+ };
188
+ const arr = (files) => `[${files.map((file) => ns(file)).join(", ")}]`;
189
+ const opt = (file) => (file ? ns(file) : "undefined");
190
+
191
+ // IMPORTANT: resolve every ns()/arr()/opt() (which append to `imports`)
192
+ // BEFORE joining `imports` — otherwise body-only modules go unimported.
193
+ const pageNs = ns(pageFile);
194
+ const layoutsArr = `[${f.layouts.map((l) => ns(l.file)).join(", ")}]`;
195
+ const slotsArr = `[${f.layouts.map((l) => `[${l.slots.map((s) => `{ name: ${JSON.stringify(s.name)}, mod: ${ns(s.file)} }`).join(", ")}]`).join(", ")}]`;
196
+ const proxiesArr = arr(f.proxies);
197
+ const schemasArr = arr(f.schemas);
198
+ const guardsArr = arr(f.guards);
199
+ const loadersArr = arr(f.loaders);
200
+ const actionNs = opt(f.actionFile);
201
+ const queryNs = opt(f.queryFile);
202
+ const stateNs = opt(f.stateFile);
203
+ const seoNs = opt(f.seoFile);
204
+ const i18nNs = opt(f.i18nFile);
205
+ const revNs = opt(f.revalidateFile);
206
+
207
+ const entry =
208
+ `${imports.join("\n")}\n` +
209
+ `const handler = makeRouteHandler({\n` +
210
+ ` page: ${pageNs}, layouts: ${layoutsArr}, layoutMetas: ${layoutsArr}, slots: ${slotsArr},\n` +
211
+ ` proxies: ${proxiesArr}, schemas: ${schemasArr}, guards: ${guardsArr}, guardEnabled: ${guardEnabled ? "true" : "false"},\n` +
212
+ ` action: ${actionNs}, loaders: ${loadersArr}, query: ${queryNs}, state: ${stateNs},\n` +
213
+ ` seo: ${seoNs}, i18n: ${i18nNs}, revalidate: ${revNs},\n` +
214
+ ` isClient: ${isClient ? "true" : "false"}, islandSrc: ${JSON.stringify(islandSrc || "")},\n` +
215
+ ` pattern: ${JSON.stringify(f.pattern)}, runtime: ${JSON.stringify(runtime)}, head: ${JSON.stringify(head)},\n` +
216
+ `});\n` +
217
+ (runtime === "bun" ? `export default { fetch: handler };\n` : `export default handler;\n`);
218
+
219
+ const rel = funcRel || (route === "/" ? "index.func" : `${route.replace(/^\//, "")}.func`);
220
+ const funcDir = join(OUT_DIR, "functions", rel);
221
+ mkdirSync(funcDir, { recursive: true });
222
+ const entryTmp = join(dirname(pageFile), `.__sr_fn_${process.pid}_${simpleHash(rel)}.js`);
223
+ writeFileSync(entryTmp, entry);
224
+ try {
225
+ const isEdge = runtime === "edge";
226
+ const result = await Bun.build({
227
+ entrypoints: [entryTmp],
228
+ target: isEdge ? "browser" : "node",
229
+ format: "esm",
230
+ minify: true,
231
+ external: isEdge ? ["react-dom/server"] : [],
232
+ });
233
+ if (!result.success) throw new AggregateError(result.logs, "function bundle failed");
234
+ const code = await result.outputs[0].text();
235
+ writeFileSync(join(funcDir, "index.js"), code);
236
+ const vcConfig = isEdge
237
+ ? { runtime: "edge", entrypoint: "index.js" }
238
+ : { runtime: "nodejs22.x", handler: "index.js", launcherType: "Nodejs", supportsResponseStreaming: true };
239
+ writeFileSync(join(funcDir, ".vc-config.json"), `${JSON.stringify(vcConfig, null, 2)}\n`);
240
+ return true;
241
+ } finally {
242
+ try {
243
+ unlinkSync(entryTmp);
244
+ } catch {}
245
+ }
246
+ }
247
+
248
+ // Vercel runs functions on Bun when vercel.json sets bunVersion. Ensure it's
249
+ // present (preserving the rest of the file) so 'use bun' routes run on Bun.
250
+ function ensureBunVersion() {
251
+ const file = resolve(cwd, "vercel.json");
252
+ let json = {};
253
+ if (existsSync(file)) {
254
+ try {
255
+ json = JSON.parse(readFileSync(file, "utf8"));
256
+ } catch {
257
+ json = {};
258
+ }
259
+ }
260
+ if (json.bunVersion) return;
261
+ json.bunVersion = "1.x";
262
+ writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`);
263
+ process.stdout.write(` ${paint("cyan", "•")} set ${paint("bold", 'vercel.json "bunVersion": "1.x"')} for Bun functions\n`);
264
+ }
265
+
266
+ // Locate the bundled local fonts (installed @swift-rust/font, else monorepo).
267
+ function resolveLocalFontDir() {
268
+ const candidates = [];
269
+ try {
270
+ const req = createRequire(import.meta.url);
271
+ const pkg = req.resolve("@swift-rust/font/package.json");
272
+ candidates.push(join(dirname(pkg), "src", "local"), join(dirname(pkg), "dist", "local"));
273
+ } catch {}
274
+ candidates.push(
275
+ resolve(fileURLToPath(import.meta.url), "..", "..", "..", "..", "packages", "font", "src", "local"),
276
+ );
277
+ return candidates.find((d) => existsSync(d)) ?? null;
278
+ }
279
+
25
280
  const PORT_START = parseInt(process.env.SWIFT_RUST_BUILD_PORT || "47321", 10);
26
281
  const HOST = "127.0.0.1";
27
282
  let PORT = PORT_START;
@@ -57,6 +312,8 @@ function discoverPages(dir, base = "") {
57
312
  if (!existsSync(dir)) return pages;
58
313
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
59
314
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
315
+ // @slot dirs (parallel routes) are not URL segments — composed into layouts.
316
+ if (entry.name.startsWith("@")) continue;
60
317
  const full = join(dir, entry.name);
61
318
  if (entry.isDirectory()) {
62
319
  const catchAll = entry.name.match(CATCH_PARAM);
@@ -180,7 +437,13 @@ async function fetchRoute(pathname, timeoutMs = ROUTE_TIMEOUT_MS) {
180
437
  signal: controller.signal,
181
438
  redirect: "manual",
182
439
  });
183
- return { status: res.status, body: await res.text() };
440
+ return {
441
+ status: res.status,
442
+ body: await res.text(),
443
+ runtime: res.headers.get("x-swift-rust-runtime") || "bun",
444
+ dynamic: res.headers.get("x-swift-rust-dynamic") === "1",
445
+ guardEnabled: res.headers.get("x-swift-rust-guard") === "1",
446
+ };
184
447
  } finally {
185
448
  clearTimeout(timer);
186
449
  }
@@ -258,7 +521,7 @@ async function localizeIslands(html) {
258
521
  return out;
259
522
  }
260
523
 
261
- function writeConfigJson(outDir, _hasPublic) {
524
+ function writeConfigJson(outDir, _hasPublic, fnRoutes = []) {
262
525
  // Build Output API v3 config. Only schema-valid fields here — unknown
263
526
  // top-level fields or route properties are rejected at "Deploying outputs".
264
527
  const config = {
@@ -266,6 +529,8 @@ function writeConfigJson(outDir, _hasPublic) {
266
529
  routes: [
267
530
  { src: "/_swift-rust/static/(.*)", headers: { "Cache-Control": "public, max-age=31536000, immutable" } },
268
531
  { src: "/fonts/(.*)", headers: { "Cache-Control": "public, max-age=31536000, immutable" } },
532
+ // [param] / catch-all routes → their request-time function.
533
+ ...fnRoutes,
269
534
  { handle: "filesystem" },
270
535
  { src: "^(.*)$", status: 404, dest: "/404.html" },
271
536
  ],
@@ -313,10 +578,12 @@ async function main() {
313
578
  for (const r of routes) staticRoutes.push(r);
314
579
  process.stdout.write(` ${paint("dim", "•")} catch-all ${paint("cyan", ca.base + "/[...]")}: ${paint("bold", String(routes.length))}\n`);
315
580
  }
581
+ const paramFns = []; // dynamic pages with no static params → request-time functions
316
582
  for (const dyn of dynamics) {
317
583
  const params = await enumerateParams(dyn);
318
584
  if (params.length === 0) {
319
- process.stdout.write(` ${paint("dim", "•")} dynamic ${paint("cyan", dyn.base + "/[" + dyn.paramName + "]")}: ${paint("yellow", "needs serverless function (skipped for v0.1.0)")}\n`);
585
+ paramFns.push(dyn);
586
+ process.stdout.write(` ${paint("dim", "•")} dynamic ${paint("cyan", dyn.base + "/[" + dyn.paramName + "]")}: ${paint("cyan", "→ function")}\n`);
320
587
  continue;
321
588
  }
322
589
  const routes = routesFromParams(dyn.base, dyn.paramName, params);
@@ -324,6 +591,10 @@ async function main() {
324
591
  process.stdout.write(` ${paint("dim", "•")} dynamic ${paint("cyan", dyn.base + "/[" + dyn.paramName + "]")}: ${paint("bold", String(routes.length))}\n`);
325
592
  }
326
593
 
594
+ // route → page file (for static-type routes that may opt into a runtime fn).
595
+ const routeToFile = new Map();
596
+ for (const p of pages) if (p.type === "static") routeToFile.set(p.route, p.file);
597
+
327
598
  const allRoutes = [...new Set(staticRoutes)].sort();
328
599
  process.stdout.write(` ${paint("dim", "•")} total: ${paint("bold", String(allRoutes.length))}\n\n`);
329
600
 
@@ -336,18 +607,64 @@ async function main() {
336
607
  let okCount = 0;
337
608
  let skipCount = 0;
338
609
  let failCount = 0;
610
+ let fnCount = 0;
611
+ let usesBunFns = false;
612
+ let fallbackHead = null;
613
+ const fnConfigRoutes = [];
339
614
  try {
340
615
  await waitForServer();
341
616
  process.stdout.write(` ${paint("green", "✓")} server ready\n\n`);
342
617
 
343
618
  for (const route of allRoutes) {
344
619
  try {
345
- const { status, body } = await fetchRoute(route);
620
+ const { status, body, runtime, dynamic, guardEnabled } = await fetchRoute(route);
621
+ const pageFile = routeToFile.get(route);
622
+ const cleaned = status === 200 ? rewriteImageUrls(await localizeIslands(stripHmrScript(body))) : null;
623
+ if (cleaned && fallbackHead == null) {
624
+ fallbackHead = (cleaned.match(/<head>([\s\S]*?)<\/head>/i) || [, ""])[1];
625
+ }
626
+
627
+ // Decide if this route is a request-time function. Prefer the live
628
+ // headers (200 render); else fall back to a source directive scan so
629
+ // guarded routes that redirect a build-time GET still get a function.
630
+ let fnRuntime = null;
631
+ let fnGuard = false;
632
+ let fnHead = null;
633
+ if (dynamic && pageFile) {
634
+ fnRuntime = runtime;
635
+ fnGuard = guardEnabled;
636
+ fnHead = (cleaned?.match(/<head>([\s\S]*?)<\/head>/i) || [, ""])[1];
637
+ } else if (pageFile && status !== 404) {
638
+ const d = scanDirectives(pageFile);
639
+ if (d.runtime) {
640
+ fnRuntime = d.runtime;
641
+ fnGuard = d.guardEnabled;
642
+ fnHead = fallbackHead || "";
643
+ }
644
+ }
645
+
646
+ if (fnRuntime && pageFile) {
647
+ let emitted = false;
648
+ const isClient = isClientPageFile(pageFile);
649
+ const islandSrc = isClient ? await buildIslandAsset(pageFile) : null;
650
+ try {
651
+ emitted = await emitFunction({ route, pageFile, runtime: fnRuntime, head: fnHead, guardEnabled: fnGuard, isClient, islandSrc });
652
+ } catch (e) {
653
+ process.stdout.write(` ${paint("dim", `fn emit failed: ${e?.message || e}`)}\n`);
654
+ }
655
+ if (emitted) {
656
+ fnCount++;
657
+ if (fnRuntime === "bun") usesBunFns = true;
658
+ process.stdout.write(` ${paint("cyan", "ƒ")} ${route} ${paint("yellow", `[${fnRuntime}]`)}\n`);
659
+ continue;
660
+ }
661
+ }
662
+
346
663
  if (status === 200) {
347
- const cleaned = rewriteImageUrls(await localizeIslands(stripHmrScript(body)));
348
664
  writeStaticFile(STATIC_DIR, route, cleaned);
349
665
  okCount++;
350
- process.stdout.write(` ${paint("green", "✓")} ${route}\n`);
666
+ const rt = runtime && runtime !== "bun" ? ` ${paint("yellow", `[${runtime}]`)}` : "";
667
+ process.stdout.write(` ${paint("green", "✓")} ${route}${rt}\n`);
351
668
  } else if (status === 404) {
352
669
  skipCount++;
353
670
  process.stdout.write(` ${paint("dim", "○")} ${route} ${paint("dim", "(404, skipped)")}\n`);
@@ -363,6 +680,32 @@ async function main() {
363
680
  }
364
681
  }
365
682
 
683
+ // [param] / catch-all routes with no static params → one request-time
684
+ // function each, matched via a config.json route.
685
+ for (const dyn of paramFns) {
686
+ const pageFile = dyn.file;
687
+ const relDir = dirname(pageFile).slice(APP_DIR.length).replace(/^[/\\]/, "").split(sep).join("/");
688
+ const funcRel = `${relDir}.func`;
689
+ const { runtime: dirRt, guardEnabled: dirGuard } = scanDirectives(pageFile);
690
+ const runtime = dirRt || "bun";
691
+ const isClient = isClientPageFile(pageFile);
692
+ const islandSrc = isClient ? await buildIslandAsset(pageFile) : null;
693
+ let emitted = false;
694
+ try {
695
+ emitted = await emitFunction({ funcRel, pageFile, runtime, head: fallbackHead || "", guardEnabled: dirGuard, isClient, islandSrc });
696
+ } catch (e) {
697
+ process.stdout.write(` ${paint("dim", `fn emit failed: ${e?.message || e}`)}\n`);
698
+ }
699
+ if (emitted) {
700
+ fnCount++;
701
+ if (runtime === "bun") usesBunFns = true;
702
+ // src regex from the pattern segments; dest is the literal function path.
703
+ const src = `^/${relDir.split("/").map((s) => (s.startsWith("[...") ? "(.*)" : s.startsWith("[") ? "([^/]+)" : s)).join("/")}/?$`;
704
+ fnConfigRoutes.push({ src, dest: `/${relDir}` });
705
+ process.stdout.write(` ${paint("cyan", "ƒ")} /${relDir} ${paint("yellow", `[${runtime}]`)}\n`);
706
+ }
707
+ }
708
+
366
709
  if (failCount > 0) {
367
710
  const tail = tailDevLog(30);
368
711
  if (tail.length) {
@@ -412,16 +755,38 @@ async function main() {
412
755
  }
413
756
  }
414
757
 
415
- writeConfigJson(OUT_DIR, hasPublic);
758
+ // Client navigator runtime (SPA navigation). The rendered HTML references
759
+ // /_swift-rust/navigator.js; emit it as a static asset so deployed sites
760
+ // get client-side navigation too.
761
+ const navSrc = join(RUNTIME_DIR, "navigator.js");
762
+ if (existsSync(navSrc)) {
763
+ writeRawFile(STATIC_DIR, "_swift-rust/navigator.js", readFileSync(navSrc));
764
+ process.stdout.write(` ${paint("green", "✓")} _swift-rust/navigator.js\n`);
765
+ }
766
+
767
+ // Bundled local fonts — the dev server serves these from
768
+ // /_swift-rust/fonts/; emit them so deployed @font-face URLs resolve.
769
+ const fontDir = resolveLocalFontDir();
770
+ if (fontDir) {
771
+ cpSync(fontDir, join(STATIC_DIR, "_swift-rust", "fonts"), { recursive: true });
772
+ process.stdout.write(` ${paint("green", "✓")} _swift-rust/fonts/\n`);
773
+ }
774
+
775
+ writeConfigJson(OUT_DIR, hasPublic, fnConfigRoutes);
416
776
 
417
777
  const total = Date.now() - start;
418
778
  const outRel = OUT_DIR.startsWith(cwd + sep) ? OUT_DIR.slice(cwd.length + 1) : OUT_DIR;
419
779
  const skippedPart = skipCount > 0 ? paint("dim", `${skipCount} skipped`) : "";
420
780
  const failedPart = failCount > 0 ? paint("yellow", `${failCount} failed`) : paint("dim", "0 failed");
421
- const summary = ` ${paint("green", "✓")} ${okCount} ok ${skippedPart} ${failedPart}\n`;
781
+ const fnPart = fnCount > 0 ? ` ${paint("cyan", `${fnCount} function${fnCount === 1 ? "" : "s"}`)}` : "";
782
+ const summary = ` ${paint("green", "✓")} ${okCount} ok ${skippedPart} ${failedPart}${fnPart}\n`;
422
783
  process.stdout.write(`\n ${paint("bold", "done")} ${paint("dim", "in " + fmtMs(total))}\n`);
423
784
  process.stdout.write(summary);
424
- process.stdout.write(` ${paint("dim", "output: " + outRel)}\n\n`);
785
+ process.stdout.write(` ${paint("dim", "output: " + outRel)}\n`);
786
+ if (usesBunFns) {
787
+ ensureBunVersion();
788
+ }
789
+ process.stdout.write("\n");
425
790
 
426
791
  const treatFailuresAsWarning = okCount > 0;
427
792
  process.exit(treatFailuresAsWarning || failCount === 0 ? 0 : 1);