Files
actual/packages/api/browser-worker.ts
Matiss Janis Aboltins 8ad218b7d9 [AI] api: make the browser build work in consumer production bundles (#8289)
* api: make the browser build work in consumer production bundles

The browser build shipped `new Worker(new URL("./worker.js", import.meta.url))`
in dist/browser.js. A consumer bundler (Vite/Rollup) recognizes that as a worker
entry and re-bundles the already-prebuilt worker.js from scratch, which desyncs
the absurd-sql/connection RPC so `init` throws a structured-clone error. It only
worked in dev and in the package's own e2e because both serve dist verbatim.

Make the browser build fully self-contained instead:

- Inline the worker into browser.js and spawn it from a Blob URL, so consumer
  bundlers never see a worker entry to re-bundle. The worker is built as an IIFE
  (classic worker), mirroring the web app's kcab.worker.
- Embed the sql.js wasm and the default filesystem data (migrations + default
  DB) into the worker so it performs no PUBLIC_URL asset fetches:
  - loot-core sqlite gains an opt-in `setWasmBinary` (dormant for web/node);
  - the worker installs a scoped fetch shim serving the embedded data/* files
    from a sentinel base URL.
- Share the asset-collection logic between the Node disk copy and the embedded
  build via scripts/embedded-assets.mjs so the two never drift.

Result: `import '@actual-app/api'` + `init()` works in any bundler with zero
config; only COOP/COEP headers remain required (SharedArrayBuffer).

Adds an e2e (e2e/consumer + e2e/global-setup.mjs) that builds a real consumer
app for production and boots it under COOP/COEP — coverage the verbatim-dist
harness can't provide. Docs note the build is self-contained and that
cross-origin isolation is still required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [AI] api: address review feedback (guard DOM lookup, revoke blob URL on worker failure)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [AI] api: address review nitpicks (drop BodyInit cast, reword release note)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [AI] api: build the browser worker via Vite ?worker&inline

Replace the hand-rolled worker inlining (a custom Vite plugin that read the
prebuilt worker.js off disk and inlined it as a string, plus a second build
config and a build-ordering dependency) with Vite's native `?worker&inline`
import. This collapses the two browser build configs into one, removes the
`virtual:actual-worker-code` module and the manual Blob URL spawn, and drops
the worker-before-facade build step.

Vite's `?worker&inline` sub-build doesn't receive vite-plugin-node-polyfills'
global-shim injection (the plugin writes it to `build.rollupOptions`, but the
worker sub-build reads `worker.rollupOptions`), so the worker crashed on
`process is not defined`. Patch the plugin to also emit the injection on
`worker.rollupOptions` — additive and backwards compatible (the web app build,
including loot-core's nodePolyfills worker, is unaffected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [AI] api: embed browser assets via Vite, drop the embedded-assets script

Replace scripts/embedded-assets.mjs with Vite-native asset imports in the
worker entry: `?inline` for the sql.js wasm and default DB, `import.meta.glob`
(`?raw`) for the migrations. The worker builds the default-filesystem wire
format from those at module load, so the embedded set comes straight from
loot-core and can't drift.

The Node build only needs migrations + the default DB on disk (it reads them at
runtime); the old script also wrote sql-wasm.wasm and the data/ fetch tree,
which are dead now that the browser worker is self-contained. Replace
writeEmbeddedAssetsToDist with a small closeBundle copy of just those two.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [AI] api: simplify embedded data map; drop loot-core comment churn

Merge the worker's binData/textData into a single dataFiles map (Response
accepts both bytes and strings), collapsing the fetch shim's two lookups into
one. Revert the unrelated comment edits to loot-core's backend-worker.ts and
fs/index.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [AI] api: rename e2e consumer-dist to consumer/dist; drop a prose comment

Use the standard `dist` directory name for the consumer fixture's build output
(now e2e/consumer/dist), and remove the verbose comment from index.browser.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [AI] loot-core: own the default-filesystem assets; consumers stop reaching in

Add packages/loot-core/default-filesystem.mjs as the single source of truth for
loot-core's runtime assets (sql.js wasm, default DB, migrations) and the
data-file-index wire format, exported as @actual-app/core/default-filesystem.

- @actual-app/api: the browser worker embeds them via the actual-embedded-assets
  Vite plugin (which calls collectEmbeddedAssets), replacing the relative
  ?inline/?raw/import.meta.glob reaches into ../loot-core; the Node build copies
  migrations + default DB using the helper's paths. Drops the direct
  @jlongster/sql.js devDependency (loot-core resolves the wasm).
- @actual-app/web: stagePublicData copies from the helper's paths instead of
  hardcoding loot-core's tree and the sql.js wasm location.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [AI] loot-core: scan migrations once when collecting embedded assets

collectEmbeddedAssets() read the migrations directory twice — once in its
own loop and once via buildDataFileIndex(). List the names once and pass
them through, keeping the index wire-format defined in a single place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:36:21 +00:00

74 lines
2.7 KiB
TypeScript

// Web Worker entry for the browser build; owns the real loot-core backend.
//
// Fully self-contained: the sql.js wasm and the default filesystem data
// (migrations + default budget DB) are embedded at build time. The worker
// performs NO `PUBLIC_URL` asset fetches, so a consumer needs to serve no extra
// files and configure no bundler — just `import` the package and `init()`.
import * as connection from '@actual-app/core/platform/server/connection';
import { setWasmBinary } from '@actual-app/core/platform/server/sqlite';
import { handlers, init } from '@actual-app/core/server/main';
import type { InitConfig } from '@actual-app/core/server/main';
// Runtime assets, base64-embedded at build time so the worker is fully
// self-contained (no PUBLIC_URL fetches). The bytes come from loot-core's
// `default-filesystem` helper via the `actual-embedded-assets` Vite plugin, so
// they can never drift from what the Node build ships.
import {
dataFiles,
dataIndex,
wasmBase64,
} from 'virtual:actual-embedded-assets';
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
return bytes;
}
setWasmBinary(base64ToBytes(wasmBase64));
// loot-core fetches the default filesystem from `PUBLIC_URL + 'data-file-index.txt'`
// and `PUBLIC_URL + 'data/<file>'`. Point PUBLIC_URL at a sentinel origin and
// answer those requests from the embedded bytes; everything else (server sync)
// falls through to the real fetch.
const EMBEDDED_BASE = 'https://actual-embedded.invalid/';
process.env.PUBLIC_URL = EMBEDDED_BASE;
const realFetch = self.fetch.bind(self);
self.fetch = function patchedFetch(
input: RequestInfo | URL,
fetchInit?: RequestInit,
): Promise<Response> {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url;
if (url.startsWith(EMBEDDED_BASE)) {
const rel = url.slice(EMBEDDED_BASE.length);
if (rel === 'data-file-index.txt') {
return Promise.resolve(new Response(dataIndex));
}
if (rel.startsWith('data/')) {
const b64 = dataFiles[rel.slice('data/'.length)];
if (b64 != null) {
return Promise.resolve(new Response(base64ToBytes(b64)));
}
}
return Promise.resolve(new Response(null, { status: 404 }));
}
return realFetch(input, fetchInit);
} as typeof fetch;
// Worker-local handler, not part of the shared Handlers type.
(handlers as Record<string, (args?: unknown) => Promise<unknown>>)[
'api-browser/init'
] = async function (args?: unknown) {
const { config } = (args ?? {}) as { config?: InitConfig };
await init(config ?? {});
};
connection.init(self, handlers);