@frockbot/cloudflare 0.0.0 → 0.7.291

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.
Files changed (88) hide show
  1. package/build-artifact.ts +130 -0
  2. package/build-flutter-web.ts +508 -0
  3. package/deployment-config/README.md +414 -0
  4. package/deployment-config/cli.ts +228 -0
  5. package/deployment-config/generate.ts +689 -0
  6. package/deployment-config/jsonc.ts +18 -0
  7. package/deployment-config/profile-schema.generated.ts +391 -0
  8. package/deployment-config/profile.schema.json +358 -0
  9. package/deployment-config/profile.ts +124 -0
  10. package/migrations/0001_better_auth.sql +15 -0
  11. package/migrations/0002_drop_account_issuer.sql +8 -0
  12. package/package.json +61 -5
  13. package/release-version.ts +20 -0
  14. package/src/account-admission.ts +179 -0
  15. package/src/account-deletion.ts +274 -0
  16. package/src/admin-entrypoint.ts +194 -0
  17. package/src/admin-identities.ts +26 -0
  18. package/src/audit.ts +103 -0
  19. package/src/auth-package.access.ts +24 -0
  20. package/src/auth-package.ts +36 -0
  21. package/src/avatar-state-cleanup.ts +107 -0
  22. package/src/billing-computer.ts +267 -0
  23. package/src/billing-readiness.ts +21 -0
  24. package/src/billing.ts +461 -0
  25. package/src/bot-capabilities.ts +480 -0
  26. package/src/bot-recovery.ts +1 -0
  27. package/src/bot-state-channel.ts +702 -0
  28. package/src/bot-state.ts +3995 -0
  29. package/src/bot-template-cleanup.ts +98 -0
  30. package/src/bot-title-cleanup.ts +104 -0
  31. package/src/brand-icon.png +0 -0
  32. package/src/brand-logo.ts +2 -0
  33. package/src/brand.ts +36 -0
  34. package/src/client-compatibility.ts +51 -0
  35. package/src/compaction-announcement-cleanup.ts +113 -0
  36. package/src/computer-egress.ts +110 -0
  37. package/src/computer-host.ts +92 -0
  38. package/src/computer-screenshot-cleanup.ts +78 -0
  39. package/src/contracts.ts +832 -0
  40. package/src/debug.ts +272 -0
  41. package/src/default-packages-marker-cleanup.ts +39 -0
  42. package/src/deployment-policy-admin-host.ts +121 -0
  43. package/src/deployment-policy.ts +426 -0
  44. package/src/directory-profile-cleanup.ts +101 -0
  45. package/src/durable-rpc.ts +753 -0
  46. package/src/durable-session.ts +21 -0
  47. package/src/entry-boundary.ts +103 -0
  48. package/src/frock-ai.ts +338 -0
  49. package/src/gateway.ts +1392 -0
  50. package/src/group-chat.ts +736 -0
  51. package/src/hidden-bot-notifications-cleanup.ts +32 -0
  52. package/src/index.ts +2798 -0
  53. package/src/insights.ts +15 -0
  54. package/src/machine-messages-cleanup.ts +121 -0
  55. package/src/machine-socket.ts +164 -0
  56. package/src/memory-records.ts +181 -0
  57. package/src/memory.ts +145 -0
  58. package/src/model-rates.ts +252 -0
  59. package/src/native-auth.ts +1006 -0
  60. package/src/native-sessions.ts +121 -0
  61. package/src/notification-state-cleanup.ts +18 -0
  62. package/src/ollama-web-search-cleanup.ts +69 -0
  63. package/src/package-page-shapes-cleanup.ts +190 -0
  64. package/src/plugin-egress.ts +31 -0
  65. package/src/plugin-page-route.ts +72 -0
  66. package/src/plugin-panels-cleanup.ts +101 -0
  67. package/src/prepared-input-cleanup.ts +105 -0
  68. package/src/production-secrets.ts +484 -0
  69. package/src/project-cleanup.ts +123 -0
  70. package/src/project-events-cleanup.ts +152 -0
  71. package/src/publication-state-cleanup.ts +92 -0
  72. package/src/push.ts +498 -0
  73. package/src/request-body.ts +165 -0
  74. package/src/routine-state-cleanup.ts +57 -0
  75. package/src/search.ts +88 -0
  76. package/src/sidebar-label-cleanup.ts +165 -0
  77. package/src/skill-index-cleanup.ts +53 -0
  78. package/src/supersede-cleanup.ts +162 -0
  79. package/src/test-chat-cleanup.ts +93 -0
  80. package/src/uploads.ts +486 -0
  81. package/src/user-application.ts +1068 -0
  82. package/src/user-configuration.ts +4491 -0
  83. package/src/voice-assistant.ts +4999 -0
  84. package/src/voice-dictation.ts +743 -0
  85. package/src/working-context-cleanup.ts +96 -0
  86. package/src/workspace.ts +201 -0
  87. package/wrangler.jsonc +385 -0
  88. package/README.md +0 -3
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The application artifact: `src/user-application.ts` bundled with a brand, for
3
+ * the Worker to load from R2.
4
+ *
5
+ * bun build-artifact.ts [--brand <module>] [--dist <directory>]
6
+ *
7
+ * `--dist` is where `build-flutter-web.ts` staged the client and where
8
+ * `artifacts/foundation-v1.mjs` is written: this package's own `dist` by
9
+ * default, and a white-label's own directory when it builds from the published
10
+ * package ([ADR 0038](../../docs/adr/0038-white-label-deployments.md) §4).
11
+ */
12
+ import { readFile, rm } from "node:fs/promises";
13
+ import { dirname, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { decodeBrandV1 } from "@frockbot/core/contracts";
16
+
17
+ const root = fileURLToPath(new URL(".", import.meta.url));
18
+
19
+ /** The value after `flag`, or undefined when the flag is absent. */
20
+ function flagValue(flag: string): string | undefined {
21
+ const index = process.argv.indexOf(flag);
22
+ if (index === -1) return undefined;
23
+ const value = process.argv[index + 1];
24
+ if (!value || value.startsWith("--"))
25
+ throw new Error(`${flag} names nothing`);
26
+ return value;
27
+ }
28
+
29
+ const dist = resolve(flagValue("--dist") ?? resolve(root, "dist"));
30
+ const outdir = resolve(dist, "artifacts");
31
+ await rm(outdir, { recursive: true, force: true });
32
+
33
+ // The document the artifact renders names the client's payload, which is
34
+ // content-addressed under `/_flutter/<buildHash>/`; `build-flutter-web.ts`
35
+ // stages it and writes the hash here.
36
+ let flutterBuild: string;
37
+ try {
38
+ flutterBuild = (
39
+ JSON.parse(await readFile(resolve(dist, "flutter-web.json"), "utf8")) as {
40
+ buildHash: string;
41
+ }
42
+ ).buildHash;
43
+ } catch (error) {
44
+ throw new Error("Flutter web client was not built", { cause: error });
45
+ }
46
+
47
+ // The brand this artifact is built with: `--brand <module>` names a
48
+ // deployment's own (the profile's `brand`, resolved), and without it `#brand`
49
+ // resolves through the package import to FrockBot's. The artifact is bundled
50
+ // here rather than by wrangler, so the profile's alias never reaches it; the
51
+ // plugin below is that alias for this build. The brand is validated here, where
52
+ // a build can still refuse it: every look through the ThemeDocument decoder
53
+ // and its contrast floor. Its icon is a path beside the brand module, which the
54
+ // shell serves as the site icon.
55
+ const brandFlag = flagValue("--brand");
56
+ const brandModule =
57
+ brandFlag === undefined
58
+ ? fileURLToPath(import.meta.resolve("#brand"))
59
+ : resolve(brandFlag);
60
+ const brand = decodeBrandV1(
61
+ ((await import(brandModule)) as { BRAND_V1?: unknown }).BRAND_V1,
62
+ );
63
+ const clientIcon = await readFile(
64
+ resolve(dirname(brandModule), brand.iconPng),
65
+ "base64",
66
+ );
67
+
68
+ const result = await Bun.build({
69
+ entrypoints: [resolve(root, "src/user-application.ts")],
70
+ outdir,
71
+ // The Worker uses nodejs_compat, and hosted runtime Contributions may import
72
+ // supported Node built-ins (for example the Computer host SDK's node:net shim).
73
+ target: "node",
74
+ format: "esm",
75
+ naming: "foundation-v1.mjs",
76
+ minify: true,
77
+ sourcemap: "external",
78
+ packages: "bundle",
79
+ plugins: [
80
+ {
81
+ name: "brand",
82
+ setup(build) {
83
+ build.onResolve({ filter: /^#brand$/ }, () => ({ path: brandModule }));
84
+ },
85
+ },
86
+ ],
87
+ define: {
88
+ __FROCKBOT_FLUTTER_BUILD__: JSON.stringify(flutterBuild),
89
+ __FROCKBOT_CLIENT_ICON__: JSON.stringify(clientIcon),
90
+ // The module identity this bundle reports to itself.
91
+ //
92
+ // A Worker Loader module has no file URL, so `import.meta.url` is
93
+ // `undefined` inside the isolate. That is fatal rather than cosmetic: the
94
+ // provider catalog reaches the model provider SDKs (`openai`,
95
+ // `@anthropic-ai/sdk`, `google-auth-library` and the CommonJS packages
96
+ // underneath them), and for those Bun emits
97
+ // `var require = createRequire(import.meta.url)` as the bundle's *first*
98
+ // top-level statement. `createRequire(undefined)` throws
99
+ // `TypeError: The argument 'path' must be a file URL object, a file URL
100
+ // string, or an absolute path string`, so the artifact never finishes
101
+ // evaluating and every request answers `Failed to start Worker`.
102
+ //
103
+ // Naming the bundle here is what a module loaded from a file would have
104
+ // had. The `require` it builds is only ever called from inside the lazy
105
+ // CommonJS wrappers of those SDKs' Node-only code paths — none of which an
106
+ // artifact takes — and when one is, it reaches the `nodejs_compat`
107
+ // built-ins exactly as any other `require` in the Worker does.
108
+ "import.meta.url": JSON.stringify("file:///frockbot/foundation-v1.mjs"),
109
+ },
110
+ });
111
+
112
+ if (result.success) {
113
+ const artifact = result.outputs.find((output) =>
114
+ output.path.endsWith(".mjs"),
115
+ );
116
+ if (!artifact) throw new Error("user application artifact was not emitted");
117
+ // The `import.meta.url` substitution above is load-bearing, and a bundler
118
+ // that stopped honouring it would leave an artifact that builds, ships, and
119
+ // then fails to evaluate in every isolate that loads it. That is only
120
+ // visible in the browser end-to-end job, hours later, as
121
+ // `Failed to start Worker`. Read the bytes back instead.
122
+ if ((await readFile(artifact.path, "utf8")).includes("import.meta.url"))
123
+ throw new Error(
124
+ "user application artifact still reads import.meta.url, which is undefined in a Worker Loader isolate",
125
+ );
126
+ process.stdout.write(`Built ${artifact.path} (${artifact.size} bytes)\n`);
127
+ } else {
128
+ for (const log of result.logs) process.stderr.write(`${String(log)}\n`);
129
+ process.exitCode = 1;
130
+ }
@@ -0,0 +1,508 @@
1
+ /**
2
+ * The Flutter client, built for the browser and staged where the Worker's
3
+ * static assets are uploaded from.
4
+ *
5
+ * `FROCKBOT_ORIGIN` is deliberately left undefined: the browser build talks to
6
+ * the origin it was served from, which is what a deployment, a `wrangler dev`
7
+ * on an arbitrary port and the e2e harness all need. CanvasKit is built local
8
+ * (`--no-web-resources-cdn`) so the app origin's `script-src 'self'` stays
9
+ * true and the engine is never fetched from gstatic. Rive Native's WebAssembly
10
+ * runtime is staged here for the same reason and named by
11
+ * `RIVE_NATIVE_WASM_HOST`; see `stageRiveWasm`. So are the engine's fallback
12
+ * fonts, which it otherwise fetches from gstatic; see `stageFallbackFonts`.
13
+ *
14
+ * Everything is staged under `_flutter/<buildHash>/`, so every URL the
15
+ * document names is content-addressed and can be served `immutable`. The
16
+ * document itself is rendered by the application artifact, which reads the
17
+ * same hash from `dist/flutter-web.json`.
18
+ *
19
+ * bun build-flutter-web.ts [--app <directory>] [--dist <directory>]
20
+ *
21
+ * `--app` is the Flutter application to build — `apps/native` by default, and
22
+ * a white-label's own thin application, carrying its own client brand, when it
23
+ * builds from the published package ([ADR 0038](../../docs/adr/0038-white-label-deployments.md)
24
+ * §4). `--dist` is where the client is staged: `<dist>/web` is what the Worker
25
+ * uploads and `<dist>/flutter-web.json` is what `build-artifact.ts --dist`
26
+ * reads, this package's own `dist` by default.
27
+ */
28
+ import { createHash } from "node:crypto";
29
+ import {
30
+ cp,
31
+ mkdir,
32
+ readdir,
33
+ readFile,
34
+ rename,
35
+ rm,
36
+ stat,
37
+ writeFile,
38
+ } from "node:fs/promises";
39
+ import { join, relative, resolve } from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+ import { releaseVersion } from "./release-version.ts";
42
+
43
+ const root = fileURLToPath(new URL(".", import.meta.url));
44
+
45
+ /** The value after `flag`, or undefined when the flag is absent. */
46
+ function flagValue(flag: string): string | undefined {
47
+ const index = process.argv.indexOf(flag);
48
+ if (index === -1) return undefined;
49
+ const value = process.argv[index + 1];
50
+ if (!value || value.startsWith("--"))
51
+ throw new Error(`${flag} names nothing`);
52
+ return value;
53
+ }
54
+
55
+ const nativeRoot = resolve(flagValue("--app") ?? resolve(root, "../native"));
56
+ const dist = resolve(flagValue("--dist") ?? resolve(root, "dist"));
57
+ const flutterOut = resolve(nativeRoot, "build/web");
58
+ const assetsRoot = resolve(dist, "web");
59
+ const clientManifest = resolve(dist, "flutter-web.json");
60
+ const payloadPrefix = "_flutter";
61
+
62
+ /**
63
+ * What the browser never asks for.
64
+ *
65
+ * The `.symbols` files are the engine's debug symbol maps — 7 MB of them, read
66
+ * by a stack-trace symbolizer nobody runs here. The rest are the pieces of
67
+ * Flutter's own page: this deployment renders its document from the
68
+ * application artifact, so the template index, the PWA manifest and its icons,
69
+ * and the service worker that `--pwa-strategy=none` leaves empty have no
70
+ * reader.
71
+ */
72
+ const OMITTED = new Set([
73
+ ".last_build_id",
74
+ "version.json",
75
+ "index.html",
76
+ "manifest.json",
77
+ "flutter_service_worker.js",
78
+ "favicon.png",
79
+ ]);
80
+
81
+ async function emittedFiles(directory: string): Promise<string[]> {
82
+ const entries = await readdir(directory, {
83
+ withFileTypes: true,
84
+ recursive: true,
85
+ });
86
+ return entries
87
+ .filter((entry) => entry.isFile())
88
+ .map((entry) =>
89
+ relative(flutterOut, join(entry.parentPath, entry.name)).replaceAll(
90
+ "\\",
91
+ "/",
92
+ ),
93
+ )
94
+ .filter(
95
+ (path) =>
96
+ !path.endsWith(".symbols") &&
97
+ !path.startsWith("icons/") &&
98
+ !OMITTED.has(path),
99
+ )
100
+ .sort();
101
+ }
102
+
103
+ const BUILD_FLAGS = [
104
+ "--release",
105
+ "--pwa-strategy=none",
106
+ "--no-web-resources-cdn",
107
+ ];
108
+
109
+ /**
110
+ * Rive Native's WebAssembly runtime, served from this origin.
111
+ *
112
+ * `rive_native` otherwise fetches `wasm/rive_native.js` from jsdelivr as the
113
+ * app starts, which the app origin's `script-src 'self' 'wasm-unsafe-eval'`
114
+ * refuses. Its loader appends a `<script>` and awaits the `load` event, so a
115
+ * blocked script is not a failure it ever sees: `RiveNative.init()` never
116
+ * settles, `main()` never reaches `runApp`, and the window stays blank. That
117
+ * is what turned `Main` red on 2026-09-16 — every browser end-to-end test
118
+ * timed out waiting for a shell that was still awaiting a script the browser
119
+ * had already refused.
120
+ *
121
+ * The payload is staged under its own version rather than the Flutter build
122
+ * hash, because that hash is only known after a build which has to be told
123
+ * this URL first. A version is just as immutable a name.
124
+ */
125
+ const RIVE_WASM_PACKAGE = "@rive-app/flutter-native-wasm";
126
+ // Resolved rather than joined onto a `node_modules`, because the workspace
127
+ // install is free to hoist the package to the repository root instead.
128
+ const riveManifest = Bun.resolveSync(`${RIVE_WASM_PACKAGE}/package.json`, root);
129
+ const rivePackageRoot = resolve(riveManifest, "..");
130
+
131
+ /** The wasm version the resolved `rive_native` links against, from its source. */
132
+ async function riveWasmVersion(): Promise<string> {
133
+ const packageConfig = JSON.parse(
134
+ await readFile(
135
+ resolve(nativeRoot, ".dart_tool/package_config.json"),
136
+ "utf8",
137
+ ),
138
+ ) as { packages?: { name?: string; rootUri?: string }[] };
139
+ const entry = packageConfig.packages?.find(
140
+ (candidate) => candidate.name === "rive_native",
141
+ );
142
+ if (!entry?.rootUri?.startsWith("file:")) {
143
+ throw new Error(
144
+ `${relative(root, resolve(nativeRoot, ".dart_tool/package_config.json"))} does not resolve rive_native ` +
145
+ "to a file: root.",
146
+ );
147
+ }
148
+ const packageRoot = fileURLToPath(entry.rootUri);
149
+ const source = await readFile(
150
+ resolve(packageRoot, "lib/src/wasm_version.dart"),
151
+ "utf8",
152
+ );
153
+ const version = /wasmVersion\s*=\s*'([^']+)'/u.exec(source);
154
+ if (!version) {
155
+ throw new Error("rive_native's wasm_version.dart states no wasmVersion.");
156
+ }
157
+ return version[1]!;
158
+ }
159
+
160
+ /**
161
+ * Copy the runtime into `dist/web`, and answer the URL prefix it is served at.
162
+ *
163
+ * The staged version has to be the one `rive_native` asks for: it names the
164
+ * exports the Dart side links against, so a mismatch is a blank artboard at
165
+ * runtime rather than an error at build time. The two are pinned in different
166
+ * files — `pubspec.lock` and `package.json` — so the drift is checked here
167
+ * rather than assumed.
168
+ */
169
+ async function stageRiveWasm(expected: string): Promise<string> {
170
+ const installed = (
171
+ JSON.parse(await readFile(riveManifest, "utf8")) as { version?: string }
172
+ ).version;
173
+ if (installed !== expected) {
174
+ throw new Error(
175
+ `rive_native expects ${RIVE_WASM_PACKAGE}@${expected}, but ${installed} is installed. ` +
176
+ "Update that dependency in apps/cloudflare/package.json to match.",
177
+ );
178
+ }
179
+ const staged = resolve(assetsRoot, "rive", expected);
180
+ for (const directory of ["wasm", "wasm_compatibility"]) {
181
+ await cp(resolve(rivePackageRoot, directory), resolve(staged, directory), {
182
+ recursive: true,
183
+ force: true,
184
+ });
185
+ }
186
+ return `/rive/${expected}/`;
187
+ }
188
+
189
+ /**
190
+ * The Noto faces the engine falls back to, served from this origin.
191
+ *
192
+ * When the app's own fonts lack a glyph — "♯" in a chat message is enough —
193
+ * the engine fetches a Noto face that has it from `fontFallbackBaseUrl`, which
194
+ * defaults to fonts.gstatic.com. The app document's `connect-src 'self'`
195
+ * refuses that, so the glyph was never drawn. `web/flutter_bootstrap.js`
196
+ * points the engine at `fallback-fonts/` beside the build instead, and this
197
+ * stages every file the engine's own fallback table can ask for there: which
198
+ * one a message needs is not knowable ahead of time, and the whole table is
199
+ * about 22 MB. The table is read from the SDK doing the build, so a Flutter
200
+ * upgrade that rolls the fonts rolls these with it.
201
+ *
202
+ * Each file's gstatic path carries its version, so a download is cached
203
+ * outside `dist/` and fetched once per version rather than once per build.
204
+ */
205
+ const FALLBACK_FONTS = "fallback-fonts";
206
+ const FALLBACK_FONT_ORIGIN = "https://fonts.gstatic.com/s/";
207
+ const fallbackFontCache = resolve(
208
+ root,
209
+ "node_modules/.cache/flutter-fallback-fonts",
210
+ );
211
+
212
+ async function fallbackFontPaths(): Promise<string[]> {
213
+ const version = JSON.parse(
214
+ Bun.spawnSync({
215
+ cmd: ["flutter", "--version", "--machine"],
216
+ cwd: nativeRoot,
217
+ }).stdout.toString(),
218
+ ) as { flutterRoot?: string };
219
+ if (!version.flutterRoot) {
220
+ throw new Error("`flutter --version --machine` names no flutterRoot.");
221
+ }
222
+ const table = await readFile(
223
+ resolve(
224
+ version.flutterRoot,
225
+ "bin/cache/flutter_web_sdk/lib/_engine/engine/font_fallback_data.dart",
226
+ ),
227
+ "utf8",
228
+ );
229
+ const paths = [
230
+ ...new Set(
231
+ [
232
+ ...table.matchAll(/'([a-z0-9]+\/v\d+\/[\w.-]+\.(?:woff2|ttf|otf))'/gu),
233
+ ].map((match) => match[1]!),
234
+ ),
235
+ ].sort();
236
+ // A table this build cannot read would stage nothing and quietly put every
237
+ // fallback glyph back to undrawn.
238
+ if (paths.length < 100) {
239
+ throw new Error(
240
+ `Read only ${paths.length} fallback fonts from the Flutter web SDK; ` +
241
+ "its font_fallback_data.dart has changed shape.",
242
+ );
243
+ }
244
+ return paths;
245
+ }
246
+
247
+ async function downloadFallbackFont(path: string): Promise<void> {
248
+ const cached = resolve(fallbackFontCache, path);
249
+ if (await stat(cached).catch(() => undefined)) return;
250
+ for (let attempt = 1; ; attempt++) {
251
+ try {
252
+ const response = await fetch(`${FALLBACK_FONT_ORIGIN}${path}`);
253
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
254
+ const body = new Uint8Array(await response.arrayBuffer());
255
+ await mkdir(resolve(cached, ".."), { recursive: true });
256
+ // Written aside and renamed, so an interrupted build never leaves a
257
+ // truncated font the next one would take as cached.
258
+ await writeFile(`${cached}.partial`, body);
259
+ await rename(`${cached}.partial`, cached);
260
+ return;
261
+ } catch (error) {
262
+ if (attempt >= 3) {
263
+ throw new Error(
264
+ `Could not fetch fallback font ${path}: ${String(error)}`,
265
+ );
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ /** Copy the fallback fonts into the payload, and answer their staged paths. */
272
+ async function stageFallbackFonts(payload: string): Promise<string[]> {
273
+ const paths = await fallbackFontPaths();
274
+ const queue = [...paths];
275
+ await Promise.all(
276
+ Array.from({ length: 16 }, async () => {
277
+ for (let path = queue.pop(); path; path = queue.pop()) {
278
+ await downloadFallbackFont(path);
279
+ }
280
+ }),
281
+ );
282
+ for (const path of paths) {
283
+ await cp(
284
+ resolve(fallbackFontCache, path),
285
+ resolve(payload, FALLBACK_FONTS, path),
286
+ { force: true, recursive: true },
287
+ );
288
+ }
289
+ return paths.map((path) => `${FALLBACK_FONTS}/${path}`);
290
+ }
291
+
292
+ /**
293
+ * The Dart sources, the assets, and the page template the build reads: the
294
+ * application's and those of the client package it depends on.
295
+ *
296
+ * `build/` and `.dart_tool/` are the build's own outputs, and the platform
297
+ * directories belong to the phone builds; nothing under them reaches the web
298
+ * bundle.
299
+ */
300
+ const SOURCE_ROOTS = [
301
+ "lib",
302
+ "web",
303
+ "assets",
304
+ "vendor",
305
+ "packages/frockbot_client/lib",
306
+ "packages/frockbot_client/assets",
307
+ ];
308
+ const SOURCE_FILES = [
309
+ "pubspec.yaml",
310
+ "pubspec.lock",
311
+ "packages/frockbot_client/pubspec.yaml",
312
+ ];
313
+
314
+ async function sourceFingerprint(defines: string[]): Promise<string> {
315
+ const digest = createHash("sha256");
316
+ // The flags and this script are part of what the output is: a change to
317
+ // either produces a different bundle from the same Dart. So are the
318
+ // defines, such as the Rive runtime's URL, which a version bump moves and
319
+ // the bundle has baked in.
320
+ digest.update(BUILD_FLAGS.join(" "));
321
+ digest.update(defines.join(" "));
322
+ digest.update(await readFile(fileURLToPath(import.meta.url)));
323
+ // The toolchain too — a Flutter upgrade rewrites the engine even though no
324
+ // file in this repository moved.
325
+ const version = Bun.spawnSync({
326
+ cmd: ["flutter", "--version", "--machine"],
327
+ cwd: nativeRoot,
328
+ });
329
+ digest.update(version.stdout);
330
+
331
+ const paths: string[] = [];
332
+ for (const directory of SOURCE_ROOTS) {
333
+ const absolute = resolve(nativeRoot, directory);
334
+ const there = await stat(absolute).catch(() => undefined);
335
+ if (!there?.isDirectory()) continue;
336
+ for (const entry of await readdir(absolute, {
337
+ withFileTypes: true,
338
+ recursive: true,
339
+ })) {
340
+ if (!entry.isFile()) continue;
341
+ paths.push(join(entry.parentPath, entry.name));
342
+ }
343
+ }
344
+ for (const file of SOURCE_FILES) paths.push(resolve(nativeRoot, file));
345
+ paths.sort();
346
+ for (const path of paths) {
347
+ digest.update(relative(nativeRoot, path).replaceAll("\\", "/"));
348
+ digest.update(await readFile(path).catch(() => Buffer.alloc(0)));
349
+ }
350
+ return digest.digest("hex");
351
+ }
352
+
353
+ /**
354
+ * Whether the staged bundle was built from exactly these sources.
355
+ *
356
+ * `flutter build web --release` is not incremental — dart2js runs whole, for
357
+ * about a minute, whether or not a line changed — and the browser end-to-end
358
+ * harness builds the client before every run. Fingerprinting the inputs turns
359
+ * the second run of an unchanged tree into a no-op. `FROCKBOT_FORCE_CLIENT_BUILD`
360
+ * builds anyway.
361
+ */
362
+ async function stagedIsCurrent(fingerprint: string): Promise<boolean> {
363
+ if (process.env.FROCKBOT_FORCE_CLIENT_BUILD) return false;
364
+ const manifest = await readFile(clientManifest, "utf8").catch(
365
+ () => undefined,
366
+ );
367
+ if (!manifest) return false;
368
+ let parsed: { sourceHash?: unknown; buildHash?: unknown; files?: unknown };
369
+ try {
370
+ parsed = JSON.parse(manifest) as typeof parsed;
371
+ } catch {
372
+ return false;
373
+ }
374
+ if (parsed.sourceHash !== fingerprint) return false;
375
+ if (typeof parsed.buildHash !== "string" || !Array.isArray(parsed.files)) {
376
+ return false;
377
+ }
378
+ // The manifest is only a promise about `dist/web`; a half-deleted staging
379
+ // directory has to rebuild rather than serve a document naming files that
380
+ // are not there.
381
+ const staged = resolve(assetsRoot, payloadPrefix, parsed.buildHash);
382
+ for (const path of parsed.files as string[]) {
383
+ if (!(await stat(resolve(staged, path)).catch(() => undefined))) {
384
+ return false;
385
+ }
386
+ }
387
+ return true;
388
+ }
389
+
390
+ /**
391
+ * Run a `flutter` subcommand, and fail the build if it did.
392
+ *
393
+ * A failed command leaves whatever the last successful one wrote in place —
394
+ * a stale `package_config.json`, a stale `build/web` — so an unread exit code
395
+ * is how a build that never happened gets staged and announced as one.
396
+ */
397
+ function flutter(...args: string[]): void {
398
+ const run = Bun.spawnSync({
399
+ cmd: ["flutter", ...args],
400
+ cwd: nativeRoot,
401
+ stdout: "inherit",
402
+ stderr: "inherit",
403
+ });
404
+ if (run.exitCode !== 0) {
405
+ throw new Error(`\`flutter ${args.join(" ")}\` exited ${run.exitCode}.`);
406
+ }
407
+ }
408
+
409
+ // `flutter build` resolves the pub dependencies itself, but the wasm version
410
+ // has to be read out of the resolved `rive_native` before the build, because
411
+ // the build is what carries the URL it produces.
412
+ flutter("pub", "get");
413
+ const riveVersion = await riveWasmVersion();
414
+ // The tag `release.yml` builds is the app's version; any other build has none.
415
+ const release = releaseVersion(process.env.FROCKBOT_RELEASE)?.release;
416
+ const defines = [
417
+ ...(release ? [`--dart-define=FROCKBOT_RELEASE=${release}`] : []),
418
+ `--dart-define=RIVE_NATIVE_WASM_HOST=/rive/${riveVersion}/`,
419
+ ];
420
+
421
+ const fingerprint = await sourceFingerprint(defines);
422
+ if (await stagedIsCurrent(fingerprint)) {
423
+ // The staging directory is rewritten whole by a build, so the runtime is
424
+ // copied on the skipping path too rather than only alongside one.
425
+ await stageRiveWasm(riveVersion);
426
+ process.stdout.write(
427
+ "The Flutter web client is already built from these sources; skipping.\n",
428
+ );
429
+ process.exit(0);
430
+ }
431
+
432
+ flutter("build", "web", ...BUILD_FLAGS, ...defines);
433
+
434
+ const files = await emittedFiles(flutterOut);
435
+ if (
436
+ !files.includes("flutter_bootstrap.js") ||
437
+ !files.includes("main.dart.js")
438
+ ) {
439
+ throw new Error("Flutter web build did not emit an entry point");
440
+ }
441
+
442
+ // The define actually reached the compiler. A dart-define that stops being
443
+ // read — a renamed key, a flag dropped from the command — leaves the jsdelivr
444
+ // default compiled in, and the only symptom is a blank window in a browser
445
+ // whose policy refuses it. Better to fail here than to ship that.
446
+ const entry = await readFile(resolve(flutterOut, "main.dart.js"), "utf8");
447
+ if (entry.includes("cdn.jsdelivr.net")) {
448
+ throw new Error(
449
+ "The web bundle still names cdn.jsdelivr.net, which the app origin's " +
450
+ "`script-src 'self'` refuses. RIVE_NATIVE_WASM_HOST did not take.",
451
+ );
452
+ }
453
+ // Same for the fallback fonts: a bootstrap that stopped naming them leaves
454
+ // the engine on its gstatic default, which `connect-src 'self'` refuses.
455
+ const bootstrap = await readFile(
456
+ resolve(flutterOut, "flutter_bootstrap.js"),
457
+ "utf8",
458
+ );
459
+ if (!bootstrap.includes("fontFallbackBaseUrl")) {
460
+ throw new Error(
461
+ "The built flutter_bootstrap.js sets no fontFallbackBaseUrl, so the " +
462
+ "engine would fetch fallback fonts from gstatic. Did " +
463
+ "apps/native/web/flutter_bootstrap.js stop being the template?",
464
+ );
465
+ }
466
+
467
+ const digest = createHash("sha256");
468
+ for (const path of files) {
469
+ digest.update(path);
470
+ digest.update(
471
+ createHash("sha256")
472
+ .update(await readFile(resolve(flutterOut, path)))
473
+ .digest(),
474
+ );
475
+ }
476
+ const buildHash = digest.digest("hex").slice(0, 32);
477
+
478
+ await rm(assetsRoot, { recursive: true, force: true });
479
+ const payload = resolve(assetsRoot, payloadPrefix, buildHash);
480
+ await mkdir(payload, { recursive: true });
481
+ for (const path of files) {
482
+ await cp(resolve(flutterOut, path), resolve(payload, path), {
483
+ force: true,
484
+ // `recursive` creates the intermediate directories a nested asset needs.
485
+ recursive: true,
486
+ });
487
+ }
488
+
489
+ const fallbackFonts = await stageFallbackFonts(payload);
490
+ const riveHost = await stageRiveWasm(riveVersion);
491
+
492
+ // Every URL under either prefix carries a version — the build hash, or Rive's
493
+ // own — so a browser may keep it for a year: a new build is a new path, not a
494
+ // new body at the same one.
495
+ await writeFile(
496
+ resolve(assetsRoot, "_headers"),
497
+ `/${payloadPrefix}/*\n cache-control: public, max-age=31536000, immutable\n` +
498
+ `${riveHost}*\n cache-control: public, max-age=31536000, immutable\n`,
499
+ );
500
+ await writeFile(
501
+ clientManifest,
502
+ `${JSON.stringify({ schemaVersion: 1, buildHash, sourceHash: fingerprint, files: [...files, ...fallbackFonts] }, null, 2)}\n`,
503
+ );
504
+
505
+ process.stdout.write(
506
+ `Built the Flutter web client at /${payloadPrefix}/${buildHash}/ (${files.length} files, ${fallbackFonts.length} fallback fonts)\n` +
507
+ `Staged Rive Native's wasm runtime at ${riveHost}\n`,
508
+ );