diff --git a/bun.lock b/bun.lock index cc56b7fd4ec..b5da555c079 100644 --- a/bun.lock +++ b/bun.lock @@ -971,10 +971,10 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", - "@textjs/app": "1.5.0", - "@textjs/core": "1.5.0", - "@textjs/opencode": "1.5.0", - "@textjs/react": "1.5.0", + "@textjs/app": "1.6.0", + "@textjs/core": "1.6.0", + "@textjs/opencode": "1.6.0", + "@textjs/react": "1.6.0", "fuzzysort": "3.1.0", "nitro": "3.0.260903-beta", "number-flow": "0.6.2", @@ -2557,13 +2557,13 @@ "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.8", "", {}, "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA=="], - "@textjs/app": ["@textjs/app@1.5.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "@textjs/core": "1.5.0", "@textjs/opencode": "1.5.0", "@textjs/react": "1.5.0", "@vitejs/plugin-react": "^4.5.2", "vite": "^6.3.5", "zod": "^4" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, "bin": { "text": "bin/text.js" } }, "sha512-+FFKhriOCzrIvn2/Qa7CxTKkRwASf8+y+EVbDEIZln1nDgJSgr9t4vtbv1Is+HKoe8tK0CZhXGzN0Mqyb8WnbA=="], + "@textjs/app": ["@textjs/app@1.6.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "@textjs/core": "1.6.0", "@textjs/opencode": "1.6.0", "@textjs/react": "1.6.0", "@vitejs/plugin-react": "^4.5.2", "vite": "^6.3.5", "zod": "^4" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, "bin": { "text": "bin/text.js" } }, "sha512-aK0NR5IQQTDpBTG4FXVVIal2KfuYlT+O/B/Yle51oDodf/HdAQq7cYbEBSiBXkeGnS3aUiRJw2OE/+tFQYLC6g=="], - "@textjs/core": ["@textjs/core@1.5.0", "", { "dependencies": { "yoga-layout": "^3.2.1" } }, "sha512-1JAXz2KS/VytPhd92AmvebghOPMQKxKpAzvIhiWI/x9hKEUR4pAmug9cov7l7BUycXiNowxRIfJicJhBNgUIYA=="], + "@textjs/core": ["@textjs/core@1.6.0", "", { "dependencies": { "yoga-layout": "3.2.1" } }, "sha512-ubN6p4PAjQpurUlIP/EyA/2pEGgB/UXb3sMCTDmj3nqtw9dGBbiOEzwuLCc232Nqk5VuaAE8VcZHG/Wvs7GVFw=="], - "@textjs/opencode": ["@textjs/opencode@1.5.0", "", { "bin": { "textjs-opencode": "bin/textjs-opencode.js" } }, "sha512-eSX6nkFmbmOhWJSxDTNrLBy216M0cGECetCb3pkAw8s4HGEqEDPqLOjFtQX9neHwxAqgq2H4eHA3+0clDWtyHA=="], + "@textjs/opencode": ["@textjs/opencode@1.6.0", "", { "bin": { "textjs-opencode": "bin/textjs-opencode.js" } }, "sha512-Aa6Z8rDQxZAHFFKpAzNhb34Y90Ko7gyhOpkDxjdv2LeisZLtKuggL1nZW7s4AkVHpe858rTgqkoc2PVlwUA3WQ=="], - "@textjs/react": ["@textjs/react@1.5.0", "", { "dependencies": { "@textjs/core": "1.5.0", "react-reconciler": "^0.34.0", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-wSSZDC7P3kW4fbJoyvFuXBypFwyOJ7qOYk3DYnI6eVezOaQ5pkgG5KWSneApVXsg0q+RAo68MXFJTcJfI46N/g=="], + "@textjs/react": ["@textjs/react@1.6.0", "", { "dependencies": { "@textjs/core": "1.6.0", "react-reconciler": "^0.34.0", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-KBNGzUBgyV0v+vLQ02M8Efew2SPujL8L9UUDrhFQv4CZdFP/OxJ/JNi+V2KTj6MQanIRypZqm8TRNdP+ZtKdsQ=="], "@trickfilm400/rollup-plugin-off-main-thread": ["@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1", "", { "dependencies": { "ejs": "^3.1.10", "json5": "^2.2.3", "magic-string": "^0.30.21", "string.prototype.matchall": "^4.0.12" } }, "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q=="], diff --git a/services/site/package.json b/services/site/package.json index 3b46760b888..bc39886d996 100644 --- a/services/site/package.json +++ b/services/site/package.json @@ -27,10 +27,10 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", - "@textjs/app": "1.5.0", - "@textjs/core": "1.5.0", - "@textjs/opencode": "1.5.0", - "@textjs/react": "1.5.0", + "@textjs/app": "1.6.0", + "@textjs/core": "1.6.0", + "@textjs/opencode": "1.6.0", + "@textjs/react": "1.6.0", "fuzzysort": "3.1.0", "nitro": "3.0.260903-beta", "number-flow": "0.6.2", diff --git a/services/site/public/_headers b/services/site/public/_headers index 99ad35f8af4..55351f52e6c 100644 --- a/services/site/public/_headers +++ b/services/site/public/_headers @@ -12,6 +12,9 @@ Cache-Control: public, max-age=31536000, immutable /textjs/picker-* Cache-Control: public, max-age=31536000, immutable +# Yoga's WebAssembly, named by a hash of its bytes (textjs/script/build.ts). +/textjs/yoga-* + Cache-Control: public, max-age=31536000, immutable # Link preview cards, named by a hash of the PNG (script/og.ts). /og/* Cache-Control: public, max-age=31536000, immutable diff --git a/services/site/textjs/script/build.ts b/services/site/textjs/script/build.ts index 9eeaee8c22f..8e4eccc11c4 100644 --- a/services/site/textjs/script/build.ts +++ b/services/site/textjs/script/build.ts @@ -6,6 +6,7 @@ // anomalyco/textjs (MIT), which isn't published to npm; it is downloaded at the pinned commit into // .upstream/. If the download fails the build still writes the picker, and try() falls back to a // link to textjs.org/opencode. +import { createHash } from "node:crypto" import { mkdir, rm } from "node:fs/promises" import path from "node:path" @@ -14,6 +15,13 @@ const root = path.resolve(import.meta.dir, "..") const upstream = path.join(root, ".upstream") const out = path.resolve(root, "../public/textjs") +// Text.js 1.6 loads Yoga from a .wasm file and the bidi engine from its own chunk under the +// `@textjs/client` resolve condition, which `text build` sets. Bun's bundler leaves the loader's +// `new URL("@textjs/core/yoga.wasm", import.meta.url)` alone, so the entries load the file +// themselves (`initYoga({ wasm })`) from a name hashed by its contents, defined into them below. +const yoga = await Bun.file(new URL(import.meta.resolve("@textjs/core/yoga.wasm"))).bytes() +const yogaName = `yoga-${createHash("sha256").update(yoga).digest("hex").slice(0, 8)}.wasm` + const lite = await fetchUpstream().then( () => true, (error) => { @@ -29,22 +37,30 @@ const result = await Bun.build({ format: "esm", splitting: true, minify: true, + conditions: ["@textjs/client"], naming: { entry: "[name]-[hash].[ext]", chunk: "chunk-[hash].[ext]" }, - define: { "process.env.NODE_ENV": '"production"' }, + define: { + "process.env.NODE_ENV": '"production"', + __TEXTJS_YOGA__: JSON.stringify(`./${yogaName}`), + }, }) if (!result.success) throw new AggregateError(result.logs, "textjs build failed") +await Bun.write(path.join(out, yogaName), yoga) const entries = result.outputs.filter((output) => output.kind === "entry-point") const manifest = Object.fromEntries( entries.map((entry) => [path.basename(entry.path).split("-")[0], path.basename(entry.path)]), ) await Bun.write(path.join(out, "manifest.json"), JSON.stringify(manifest)) -// What each app downloads: its entry plus every chunk it imports statically. +// What each app downloads: its entry, every chunk it imports statically, and then the chunks it +// fetches on demand (the bidi engine, the session dialogs), plus Yoga's wasm. const sizes = new Map( await Promise.all( result.outputs.map(async (output) => { - const text = await Bun.file(output.path).text() + const bytes = await Bun.file(output.path).bytes() + const text = new TextDecoder().decode(bytes) const imports = [...text.matchAll(/from"\.\/([\w-]+\.js)"/g)].map((match) => match[1]) - return [path.basename(output.path), { bytes: text.length, gzip: Bun.gzipSync(text).length, imports }] as const + const lazy = [...text.matchAll(/import\("\.\/([\w-]+\.js)"\)/g)].map((match) => match[1]) + return [path.basename(output.path), { bytes: bytes.length, gzip: Bun.gzipSync(text).length, imports, lazy }] as const }), ), ) @@ -54,11 +70,21 @@ const closure = (file: string, seen = new Set()): Set => { sizes.get(file)?.imports.forEach((next) => closure(next, seen)) return seen } +const lazyClosure = (eager: string[]): Set => { + const seen = new Set() + const known = new Set(eager) + for (const file of eager) sizes.get(file)?.lazy.forEach((next) => closure(next, seen)) + return new Set([...seen].filter((file) => !known.has(file))) +} for (const [name, file] of Object.entries(manifest)) { const files = [...closure(file)] - const kb = (key: "bytes" | "gzip") => - (files.reduce((sum, f) => sum + (sizes.get(f)?.[key] ?? 0), 0) / 1024).toFixed(1) - console.log(`textjs: ${name} ${kb("bytes")} KB, ${kb("gzip")} KB gzip (${files.length} files)`) + const kb = (key: "bytes" | "gzip", list: string[]) => + (list.reduce((sum, f) => sum + (sizes.get(f)?.[key] ?? 0), 0) / 1024).toFixed(1) + console.log(`textjs: ${name} ${kb("bytes", files)} KB, ${kb("gzip", files)} KB gzip (${files.length} files)`) + const lazy = [...lazyClosure(files)] + if (lazy.length) console.log(`textjs: ${name} +${kb("gzip", lazy)} KB gzip on demand (${lazy.join(", ")})`) + const yogaKb = `${(yoga.length / 1024).toFixed(1)} KB, ${(Bun.gzipSync(yoga).length / 1024).toFixed(1)} KB gzip` + console.log(`textjs: ${name} +${yogaKb} yoga (${yogaName})`) } async function fetchUpstream() { diff --git a/services/site/textjs/src/lite.tsx b/services/site/textjs/src/lite.tsx index a0a88389a09..93e4c4d5eda 100644 --- a/services/site/textjs/src/lite.tsx +++ b/services/site/textjs/src/lite.tsx @@ -1,3 +1,4 @@ +import { initYoga } from "@textjs/core" import { renderToConsole } from "@textjs/react" import { EmbeddedOpenCode } from "../.upstream/opencode-console/embed" import { createDemoClient } from "../.upstream/opencode-console/lib/demo" @@ -6,7 +7,13 @@ import { createDemoClient } from "../.upstream/opencode-console/lib/demo" // in-memory backend scripts the agent, so nothing runs and nothing is installed. The source comes // from anomalyco/textjs (apps/opencode-console) at the commit script/build.ts pins. +// Text.js 1.6 fetches Yoga's WebAssembly instead of carrying it inline. The build names the file +// after its contents (script/build.ts), and Bun doesn't resolve the loader's default +// `new URL("@textjs/core/yoga.wasm", import.meta.url)`, so load it from next to the chunks. +declare const __TEXTJS_YOGA__: string + export async function start() { + await initYoga({ wasm: new URL(__TEXTJS_YOGA__, import.meta.url) }) // The app sets its own theme (OpenCode dark), like the native TUI. const root = await renderToConsole( root.unmount()} />, { responsive: true, diff --git a/services/site/textjs/src/picker.tsx b/services/site/textjs/src/picker.tsx index 7866605f776..459442392ef 100644 --- a/services/site/textjs/src/picker.tsx +++ b/services/site/textjs/src/picker.tsx @@ -1,11 +1,17 @@ +import { initYoga } from "@textjs/core" import { Box, renderToConsole, Select, Text, useInput, type SelectOption } from "@textjs/react" // opencode.install.pick(): the install commands and desktop builds as a Text.js list in the // DevTools console. The site passes the options, so this bundle holds no site data. +// Text.js 1.6 fetches Yoga's WebAssembly instead of carrying it inline; script/build.ts hashes the +// file and defines its name. See src/lite.tsx. +declare const __TEXTJS_YOGA__: string + type Input = { options: SelectOption[]; selected?: string; dark: boolean } -export function pick(input: Input) { +export async function pick(input: Input) { + await initYoga({ wasm: new URL(__TEXTJS_YOGA__, import.meta.url) }) return new Promise((resolve) => { const done = (value?: string) => { root.then((app) => app.unmount())