Files
actual/packages/api/index.browser.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

43 lines
952 B
TypeScript

import { startBackendWorker } from '@actual-app/core/platform/client/backend-worker';
import { send } from '@actual-app/core/platform/client/connection';
import type { InitConfig } from '@actual-app/core/server/main';
import InlineWorker from './browser-worker?worker&inline';
export * from './methods';
export * as utils from './utils';
let worker: Worker | null = null;
export async function init(
config: InitConfig = {},
): Promise<{ send: typeof send }> {
worker = new InlineWorker();
try {
await startBackendWorker(worker, config);
} catch (error) {
worker.terminate();
worker = null;
throw error;
}
return { send };
}
export async function shutdown() {
if (worker) {
try {
await send('sync');
} catch {
// most likely that no budget is loaded, so the sync failed
}
try {
await send('close-budget');
} finally {
worker.terminate();
worker = null;
}
}
}