@frockbot/cloudflare 0.0.0 → 0.7.292
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/build-artifact.ts +130 -0
- package/build-flutter-web.ts +508 -0
- package/deployment-config/README.md +414 -0
- package/deployment-config/cli.ts +228 -0
- package/deployment-config/generate.ts +689 -0
- package/deployment-config/jsonc.ts +18 -0
- package/deployment-config/profile-schema.generated.ts +391 -0
- package/deployment-config/profile.schema.json +358 -0
- package/deployment-config/profile.ts +124 -0
- package/migrations/0001_better_auth.sql +15 -0
- package/migrations/0002_drop_account_issuer.sql +8 -0
- package/package.json +61 -5
- package/release-version.ts +20 -0
- package/src/account-admission.ts +179 -0
- package/src/account-deletion.ts +274 -0
- package/src/admin-entrypoint.ts +194 -0
- package/src/admin-identities.ts +26 -0
- package/src/audit.ts +103 -0
- package/src/auth-package.access.ts +24 -0
- package/src/auth-package.ts +36 -0
- package/src/avatar-state-cleanup.ts +107 -0
- package/src/billing-computer.ts +267 -0
- package/src/billing-readiness.ts +21 -0
- package/src/billing.ts +461 -0
- package/src/bot-capabilities.ts +480 -0
- package/src/bot-recovery.ts +1 -0
- package/src/bot-state-channel.ts +702 -0
- package/src/bot-state.ts +3995 -0
- package/src/bot-template-cleanup.ts +98 -0
- package/src/bot-title-cleanup.ts +104 -0
- package/src/brand-icon.png +0 -0
- package/src/brand-logo.ts +2 -0
- package/src/brand.ts +36 -0
- package/src/client-compatibility.ts +51 -0
- package/src/compaction-announcement-cleanup.ts +113 -0
- package/src/computer-egress.ts +110 -0
- package/src/computer-host.ts +92 -0
- package/src/computer-screenshot-cleanup.ts +78 -0
- package/src/contracts.ts +832 -0
- package/src/debug.ts +272 -0
- package/src/default-packages-marker-cleanup.ts +39 -0
- package/src/deployment-policy-admin-host.ts +121 -0
- package/src/deployment-policy.ts +426 -0
- package/src/directory-profile-cleanup.ts +101 -0
- package/src/durable-rpc.ts +753 -0
- package/src/durable-session.ts +21 -0
- package/src/entry-boundary.ts +103 -0
- package/src/frock-ai.ts +338 -0
- package/src/gateway.ts +1392 -0
- package/src/group-chat.ts +736 -0
- package/src/hidden-bot-notifications-cleanup.ts +32 -0
- package/src/index.ts +2798 -0
- package/src/insights.ts +15 -0
- package/src/machine-messages-cleanup.ts +121 -0
- package/src/machine-socket.ts +164 -0
- package/src/memory-records.ts +181 -0
- package/src/memory.ts +145 -0
- package/src/model-rates.ts +252 -0
- package/src/native-auth.ts +1006 -0
- package/src/native-sessions.ts +121 -0
- package/src/notification-state-cleanup.ts +18 -0
- package/src/ollama-web-search-cleanup.ts +69 -0
- package/src/package-page-shapes-cleanup.ts +190 -0
- package/src/plugin-egress.ts +31 -0
- package/src/plugin-page-route.ts +72 -0
- package/src/plugin-panels-cleanup.ts +101 -0
- package/src/prepared-input-cleanup.ts +105 -0
- package/src/production-secrets.ts +484 -0
- package/src/project-cleanup.ts +123 -0
- package/src/project-events-cleanup.ts +152 -0
- package/src/publication-state-cleanup.ts +92 -0
- package/src/push.ts +498 -0
- package/src/request-body.ts +165 -0
- package/src/routine-state-cleanup.ts +57 -0
- package/src/search.ts +88 -0
- package/src/sidebar-label-cleanup.ts +165 -0
- package/src/skill-index-cleanup.ts +53 -0
- package/src/supersede-cleanup.ts +162 -0
- package/src/test-chat-cleanup.ts +93 -0
- package/src/uploads.ts +486 -0
- package/src/user-application.ts +1068 -0
- package/src/user-configuration.ts +4491 -0
- package/src/voice-assistant.ts +4999 -0
- package/src/voice-dictation.ts +743 -0
- package/src/working-context-cleanup.ts +96 -0
- package/src/workspace.ts +201 -0
- package/wrangler.jsonc +385 -0
- 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
|
+
);
|