feat(mobile): the in-process engine ships as a lazy chunk at chrome58, and a device starts on it (#4727)

* feat(mobile): bundle the in-process engine as a lazily-fetched chunk

`praisonai/mobile` is now a dependency and reaches the webview as a chunk
behind one literal `import()` in engines/praisonai-ts/load-agent.ts. The
shell that loads at first paint is unchanged -- 66.6kB (app.js 65.9kB plus
esbuild's 0.6kB interop helper) of a 400kB budget -- and the engine's
1361.4kB (zod 426k, ai 276k, @ai-sdk/openai 166k, @ai-sdk/google 142k,
openai 102k, praisonai 79k) is fetched only when the in-process engine
first runs.

Two budgets replace SIZE_BUDGET_BYTES, because the two populations are paid
for at different moments: SHELL_BUDGET_BYTES (400kB, the entry chunk plus
its static import graph) and LAZY_BUDGET_BYTES (1500kB, everything behind
an import(); ~10% above the measured engine so one more provider is a
decision). Both are pinned, both are proven to fail, and a fixture proves
neither is the other in disguise.

The gate learns three things it could not see: an unresolvable bare import
is now named by the gate instead of aborting the build inside esbuild (the
plugin probes resolution and leaves failures external); a CLI-only external
(chalk, boxen, ora, cli-table3, figlet) imported STATICALLY fails, because
external + static is import-time fatal; and praisonai reaching any eager
chunk fails at one byte rather than at 400kB.

The Chrome target is DERIVED and no longer a literal: the floor from
tauri.conf.json through ANDROID_WEBVIEW_FLOOR, raised to chrome63 because
import() is Chrome 63 (measured: below it esbuild lowers the dynamic import
and the whole engine lands in the shell, 1486.8kB), raised to chrome89 for
as long as the installed praisonai/mobile carries top-level await (the esm
shim in 1.7.4, probed per file at load; removed upstream in praisonai-ts
#4720, awaiting a release).

tools/bundle-target.test.mjs's first test is RED ON PURPOSE. It says which
of the two things holds the target above the declared chrome58: the engine
layer clears itself on the praisonai bump; the split layer (chrome63) is a
minSdkVersion decision this change does not make.

Refs #4673.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* feat(mobile): the in-process engine ships, and a device starts on it

`praisonai` is now `file:../praisonai-ts` rather than a registry pin: the
top-level-await fix that makes `praisonai/mobile` buildable for the
chrome58 WebView (praisonai-ts #4720) lives in this repo, so the engine
the app ships is the one in the same commit, and a change to praisonai-ts's
esm shim fails mobile's CI on the PR that makes it. CI builds praisonai-ts
before `npm ci` here.

The bundle gate follows the link honestly: bare imports are resolved from
THIS package first and the importer second, because peers are the
consumer's to provide and Node's real-path resolution through a link cannot
see them (@ai-sdk/cohere, which praisonai imports and never declares, was
reported unresolved); and "unresolved" is read off the metafile -- the
plugin's own probe from the importer -- instead of re-resolved from the
entry, where praisonai's own dependencies would look missing.

The esbuild target is chrome58, DERIVED from tauri.conf.json through
ANDROID_WEBVIEW_FLOOR, and the real build succeeds there: shell 72.0kB
(app.js 70.1kB + 1.9kB helper) of 400kB, lazy 1459.6kB of 1600kB. One
construct is kept above the floor on purpose -- import() is Chrome 63 and
esbuild would otherwise lower it into a static import and put the whole
engine in the shell (measured: 1486.8kB) -- so bundle() tells esbuild the
target supports it, and bundle-target.test.mjs pins that the override is
load-bearing, that the split floor really is 63, and, with a parser rather
than a regex, that no chunk (engine included) carries syntax the floor
cannot parse. No red-on-purpose test remains.

The engine is wired end to end. `defaultEngineIdFor("tauri")` is
ENGINE_PRAISONAI_TS (web keeps remote-http); it is in the picker's choices
again, with the stale comment saying why it was removed rewritten to say
why it is back; `settingDefsFor(kind)` gives boot the platform's defs so
Settings shows the engine that answers; and a mount-level test drives a
message through the real composer, engine, controller and transcript on
platform "tauri" with empty storage into a scripted Agent handed in through
the `loadAgent` seam -- no network, no key.

Known gap, stated not solved: the in-process Agent has no API-key setting
on a device yet, so a real first turn reaches the model layer and fails
recoverably for want of a key.

Refs #4673.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(mobile): the workflow did not parse, so no mobile job ran on this PR

`- name: Build praisonai-ts (the file: dependency)` is not valid YAML: the
unquoted `file:` reads as a key separator, so the whole file failed to load
at that line. GitHub skips a workflow it cannot parse -- silently, with no
annotation and no failing check.

The effect was that this PR, which rewrites the bundler and adds the engine
dependency, ran ZERO mobile jobs: no `check (node ...)`, no `rust shell`, no
`upstream parity`, no `cross-check`. `gh pr checks 4727` listed only the
repo-wide jobs, and nothing said why.

Both occurrences are quoted now, and `yaml.safe_load` accepts the file.

That the gate can vanish this quietly is the finding, not the typo -- the
same shape as the path filter that made `upstream parity` blind to
praisonai-ts (#4689). A workflow-lint step would catch the class; noted for
follow-up rather than smuggled into this PR.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(mobile): upstream-parity also needs the file: dependency built

With `"praisonai": "file:../praisonai-ts"`, every job that runs `npm ci` in
praisonai-mobile first needs praisonai-ts installed and built. Three jobs got
that step; `upstream-parity` did not, and it failed on this PR the moment the
workflow started parsing again.

Worth stating plainly: the failure was real CI feedback, not drift. The
checker itself passes -- the real Agent still satisfies PraisonAgent -- but
the job could not reach the point of running it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Mervin PraisonandClaude Opus 5 authored and GitHub committed 2026-09-03 08:02:52 +01:00
1 parent 19b065220e
commit 6885c12766
16 files changed
+1251 -271

No files matched your search

+47 -5
View File
@@ -36,6 +36,10 @@ on:
# drift landed on main unchecked. Verified on 71197fa80, which edited
# src/praisonai-ts/src/agent/simple.ts: no parity check ran on it.
- 'src/praisonai-ts/src/**'
# And the pieces of praisonai-ts that decide what `praisonai/mobile`
# RESOLVES to through the file: link -- the esm shim shapes dist/esm.
- 'src/praisonai-ts/scripts/**'
- 'src/praisonai-ts/package.json'
pull_request:
paths: *watched
@@ -66,6 +70,17 @@ jobs:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: src/praisonai-mobile/package-lock.json
# `praisonai` is a `file:` link to ../praisonai-ts (see praisonai-mobile's
# package.json for why), so its dist/ must exist before this package can
# resolve `praisonai/mobile`. `npm install`, not `npm ci`: praisonai-ts
# gitignores its lockfile. `--legacy-peer-deps` for the
# bedrock-agentcore -> @strands-agents/sdk peer conflict, exactly as
# praisonai-ts-webview.yml does.
- name: "Build praisonai-ts (the file: dependency)"
working-directory: src/praisonai-ts
run: |
npm install --no-audit --no-fund --legacy-peer-deps
npm run build
- run: npm ci
- name: Typecheck
run: npm run typecheck
@@ -85,11 +100,11 @@ jobs:
# rather than buried in another job's log.
#
# It needs only THIS package's install. praisonai-ts's own dependencies are
# neither needed nor installable -- `npm install` there fails with an
# ERESOLVE peer conflict (bedrock-agentcore -> @strands-agents/sdk) -- and
# every member the check looks at is declared locally in simple.ts. That is
# verified rather than assumed: with praisonai-ts's node_modules moved
# aside, injected drift is still caught as TS2741.
# not needed here (the `check` job installs them, with --legacy-peer-deps,
# to build the file: dependency): every member the check looks at is
# declared locally in simple.ts. That is verified rather than assumed:
# with praisonai-ts's node_modules moved aside, injected drift is still
# caught as TS2741.
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
@@ -98,6 +113,11 @@ jobs:
node-version: '22.18'
cache: npm
cache-dependency-path: src/praisonai-mobile/package-lock.json
- name: "Build praisonai-ts (the file: dependency)"
working-directory: src/praisonai-ts
run: |
npm install --no-audit --no-fund --legacy-peer-deps
npm run build
- run: npm ci
- name: Does the real Agent still satisfy PraisonAgent?
# Skips loudly if the praisonai-ts checkout is missing, rather than
@@ -144,6 +164,17 @@ jobs:
# generate_context! expands frontendDist (../dist) at COMPILE time, and
# dist/ is gitignored -- absent on a fresh checkout. Without this the
# crate cannot compile and neither test nor clippy below ever runs.
# `praisonai` is a `file:` link to ../praisonai-ts (see praisonai-mobile's
# package.json for why), so its dist/ must exist before this package can
# resolve `praisonai/mobile`. `npm install`, not `npm ci`: praisonai-ts
# gitignores its lockfile. `--legacy-peer-deps` for the
# bedrock-agentcore -> @strands-agents/sdk peer conflict, exactly as
# praisonai-ts-webview.yml does.
- name: "Build praisonai-ts (the file: dependency)"
working-directory: src/praisonai-ts
run: |
npm install --no-audit --no-fund --legacy-peer-deps
npm run build
- name: Build the webview (creates ../dist for generate_context!)
working-directory: src/praisonai-mobile
run: |
@@ -203,6 +234,17 @@ jobs:
# generate_context! expands frontendDist at COMPILE time and dist/ is
# gitignored, so the crate cannot be checked without this -- the same
# reason the job above needs it.
# `praisonai` is a `file:` link to ../praisonai-ts (see praisonai-mobile's
# package.json for why), so its dist/ must exist before this package can
# resolve `praisonai/mobile`. `npm install`, not `npm ci`: praisonai-ts
# gitignores its lockfile. `--legacy-peer-deps` for the
# bedrock-agentcore -> @strands-agents/sdk peer conflict, exactly as
# praisonai-ts-webview.yml does.
- name: "Build praisonai-ts (the file: dependency)"
working-directory: src/praisonai-ts
run: |
npm install --no-audit --no-fund --legacy-peer-deps
npm run build
- name: Build the webview (creates ../dist for generate_context!)
working-directory: src/praisonai-mobile
run: |
+81 -25
View File
@@ -7,24 +7,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { defaultEngineIdFor, stopNotice } from "./main.ts";
import { stopNotice } from "./main.ts";
import { defaultEngineIdFor } from "./registry.ts";
import { en } from "../../ui/src/i18n/strings.ts";
test("every platform's first-launch default is the remote engine, for now", () => {
// Tempting to default a device to the in-process engine -- a phone cannot
// reach `http://127.0.0.1:8765` and the cleartext localhost address is
// refused by iOS ATS and Android -- but the in-process engine's module is
// ABSENT from the shipping webview: build-webview.mjs bundles only
// `main.ts` into `dist/app.js`, and the engine reaches praisonai-ts through a
// runtime-computed import left outside that bundle (#4437). So defaulting a
// device to it would swap the "not answering" warning (which names Settings
// as the fix) for a first prompt that fails "unavailable in this build" with
// no recovery -- the very brick registry.ts keeps the engine out of the
// picker to avoid. Until #4437 ships praisonai-ts inside the webview, remote
// is the honest default. `Platform["kind"]` also cannot tell desktop Tauri
// (`cargo tauri dev`) from a phone, so a `kind === "tauri"` default would
// strand the desktop/dev flow too.
assert.equal(defaultEngineIdFor("tauri"), ENGINE_REMOTE_HTTP);
test("a device's first-launch default is the in-process engine; the web's is remote", () => {
// A phone cannot reach `http://127.0.0.1:8765`, and the cleartext localhost
// address is refused by iOS ATS and Android -- so a remote default on a
// device was a first prompt that failed with a status code. The in-process
// engine ships as a lazy chunk beside app.js now, so it is the one choice
// that works with nothing configured, and the picker offers it. The web
// keeps the remote engine: a server is what a browser tab has.
// `Platform["kind"]` cannot tell desktop Tauri (`cargo tauri dev`) from a
// phone, so desktop starts in-process too and switches in Settings.
assert.equal(defaultEngineIdFor("tauri"), ENGINE_PRAISONAI_TS);
assert.equal(defaultEngineIdFor("web"), ENGINE_REMOTE_HTTP);
});
@@ -95,15 +91,17 @@ test("the real composition root offers the in-process engine", async () => {
assert.equal(ids[0], ENGINE_REMOTE_HTTP, "the remote engine must stay the default");
});
test("the in-process engine, when its module cannot load, fails RECOVERABLY", async () => {
// The factory's dynamic import resolves at run time from a computed
// specifier, and where praisonai-ts is not on disk -- the shipping webview
// bundles only dist/ (#4437), and this test runs with the same absence -- it
// rejects. Constructing the engine must still succeed (create() opens no
// upstream), and the failure must arrive as a single recoverable `error`
// event through engine.ts's run loop, never as an unhandled rejection that
// takes down the turn opaquely. That is the named, on-screen failure
// engines.ts argues for.
test("the in-process engine, when its chunk cannot load, fails RECOVERABLY", async () => {
// The engine reaches praisonai through a lazily-fetched chunk, and a fetch
// can fail: a flaky connection, a build that left the engine out, a hashed
// file the page no longer matches. This used to be exercised by the module's
// ABSENCE from disk; with praisonai installed the real loader succeeds here
// (and would go to the network), so the failing loader is INJECTED, through
// the seam appEngines exposes for exactly this. Constructing the engine must
// still succeed (create() opens no upstream), and the failure must arrive as
// a single recoverable `error` event through engine.ts's run loop, never as
// an unhandled rejection that takes down the turn opaquely. That is the
// named, on-screen failure engines.ts argues for.
const secrets = createFakeSecrets();
const store = createSettingsStore(SETTING_DEFS, createFakeStorage(), secrets);
await store.load();
@@ -119,6 +117,9 @@ test("the in-process engine, when its module cannot load, fails RECOVERABLY", as
http: createFakeHttp(),
persistence,
onIgnored: () => {},
loadAgent: async () => {
throw new Error("chunk-XXXXXXXX.js: failed to fetch");
},
}).find((c) => c.id === ENGINE_PRAISONAI_TS);
assert.ok(inProcess, "the in-process engine must be on offer to be exercised");
@@ -1279,3 +1280,58 @@ test("an ACCEPTED setting clears its OWN refusal, and only its own", async () =>
);
app?.dispose();
});
test("on a device with nothing configured, the first message reaches the in-process Agent", async () => {
// The whole wiring, end to end, from the real composition root: platform
// kind "tauri", empty storage, no engine picked -- so `defaultEngineIdFor`
// decides -- and a message typed into the real composer. It must arrive at
// praisonai's `Agent`, and the answer must come back through the real
// engine, controller and transcript onto the screen. No network and no
// key: the Agent class is a scripted one handed in through the seam
// `appEngines` exposes for it, the way praisonai-ts's own engine tests
// script theirs. Every other piece is the shipping one.
const prompts: string[] = [];
const configs: { instructions: string; llm?: string }[] = [];
class ScriptedAgent {
lastStopReason: "completed" | null = "completed";
constructor(config: { instructions: string; llm?: string }) {
configs.push(config);
}
async *streamEvents(prompt: string) {
prompts.push(prompt);
yield { type: "text", delta: "The capital of France is Paris." } as const;
yield { type: "finish", text: "The capital of France is Paris." } as const;
}
}
const { dom, http, platform: web } = harness();
http.on("/chat", () => {
throw new Error("the remote engine must not be contacted on a device");
});
const platform: Platform = { ...web, kind: "tauri" };
const app = await mount({
root: dom.root as never,
platform,
now: () => 1,
newChatId: () => "c1",
loadAgent: async () => ScriptedAgent,
});
assert.ok(app, "the app must mount");
assert.equal(app.engine.id, ENGINE_PRAISONAI_TS, "with nothing configured, a device runs in-process");
assert.equal(
app.settings.get("engineId"),
ENGINE_PRAISONAI_TS,
"and Settings shows the same engine -- main.ts must hand boot the platform's defs, not the web's",
);
submit(dom, "capital of france?");
await settle(120);
assert.deepEqual(prompts, ["capital of france?"], "the typed message must reach the Agent verbatim");
assert.equal(configs.length, 1, "one Agent, built on the first turn -- not at boot");
assert.equal(configs[0]?.llm, "gpt-4o-mini", "with the model the settings default to");
const answer = dom.find((n) => n.textContent.includes("Paris") && n.className.includes("row"));
assert.ok(answer, "the Agent's answer must reach the transcript");
assert.equal(dom.find((n) => n.className.includes("row-error")), null, "and nothing must fail on the way");
await app.dispose();
});
+41 -78
View File
@@ -15,7 +15,7 @@
*/
import { createApp, type App } from "./boot.ts";
import { detectPlatform, type Platform } from "./platform.ts";
import { enginesFor, ENGINE_REMOTE_HTTP, SETTING_DEFS } from "./registry.ts";
import { enginesFor, defaultEngineIdFor, settingDefsFor } from "./registry.ts";
import { intentFrom, type Actionable, type Intent } from "./intents.ts";
import { applyOps, emptyNodes, type RowNodes } from "./dom.ts";
import { installCrashHandler } from "./crash.ts";
@@ -63,31 +63,25 @@ import {
import type { EngineChoice } from "./engines.ts";
import type { AgentEnginePort } from "../../core/src/ports/agent-engine.ts";
import { createPraisonTsEngine, type RunPersistence } from "../../engines/src/praisonai-ts/engine.ts";
import { loadPraisonAgent, type PraisonAgentModule } from "../../engines/src/praisonai-ts/load-agent.ts";
import type { PraisonAgent } from "../../engines/src/praisonai-ts/agent-api.ts";
import type { SettingDef, SettingsFacade } from "../../core/src/settings/store.ts";
import type { IgnoredReason } from "../../protocol/src/decode.ts";
import type { HttpPort } from "../../core/src/ports/http.ts";
/** The one member of praisonai's `Agent` constructor this seam uses. Declared
* structurally, never imported -- `agent-api.ts` exists precisely so no
* `praisonai` type crosses into this package; `check:upstream` is the
* out-of-band check that the real `Agent` still matches. */
interface PraisonAgentModule {
new (config: { instructions: string; llm?: string }): PraisonAgent;
}
/**
* The in-process praisonai-ts engine, built lazily.
*
* DYNAMIC import, and through a runtime-computed specifier so NEITHER tsc nor
* esbuild pulls `praisonai` into this package. That preserves the invariant
* `agent-api.ts` documents: `praisonai` is not a dependency here and cannot be
* until its Agent graph is bundleable for a webview -- its bare `crypto` and
* `events` imports are import-time fatal (#4437), and its own sources do not
* even typecheck under this config (which is why `check:upstream` runs the
* coupling check out of band). Behind a lazy import the app still builds and
* ships with the remote engine, and the in-process one is OFFERED -- its agent
* module loading only where praisonai-ts is resolvable at run time.
* `praisonai` is a dependency now, and it reaches the webview as a CHUNK: the
* literal `import("praisonai/mobile")` in engines/praisonai-ts/load-agent.ts
* is what lets esbuild split it out, so the shell that loads at first paint
* stays at 66kB and the engine's ~1.3MB is fetched only when this factory
* first runs. It used to be a runtime-computed specifier that kept `praisonai`
* out of both tsc's and esbuild's graphs entirely -- necessary while its Agent
* graph imported `crypto` and `events` statically (#4437), and the reason the
* engine's module was simply ABSENT from every shipped build. `praisonai/mobile`
* is the upstream entry without those imports; `tools/bundle.mjs` is what
* proves that on every build.
*
* The engine takes a `createAgent` factory, not an agent: the model comes from
* settings, which can change between turns.
@@ -95,6 +89,7 @@ interface PraisonAgentModule {
async function createInProcessEngine(
persistence: RunPersistence,
settings: SettingsFacade,
loadAgent: () => Promise<PraisonAgentModule>,
): Promise<AgentEnginePort> {
const settingString = (key: string, fallback: string): string => {
const value = settings.get(key);
@@ -103,29 +98,11 @@ async function createInProcessEngine(
return createPraisonTsEngine({
persistence,
createAgent: async (): Promise<PraisonAgent> => {
// Computed, not a literal: a literal specifier would make tsc pull the
// untypecheckable upstream sources and esbuild bundle their import-time-
// fatal builtins. This keeps both graphs clean and the engine offerable.
const specifier = ["..", "..", "..", "praisonai-ts", "src", "agent", "simple.ts"].join("/");
// The import can REJECT: this specifier resolves at run time, and where
// praisonai-ts is not on disk (the shipping webview bundles only dist/,
// #4437) the module is simply absent. Left unwrapped, that rejection is
// an opaque "cannot find module" from deep inside the engine's run loop.
// Re-thrown as a plain Error, engine.ts turns it into a recoverable
// `error` event through its existing catch -- the named, on-screen
// failure engines.ts argues for, not a crash. This does NOT let the
// engine into the shipping picker (registry.ts keeps it out of
// engineId.choices); it makes the one path that offers it honest.
let mod: { Agent: PraisonAgentModule };
try {
mod = (await import(specifier)) as { Agent: PraisonAgentModule };
} catch (cause) {
throw new Error(
"the in-process engine is unavailable in this build: praisonai-ts could not be loaded",
{ cause },
);
}
return new mod.Agent({
// The chunk is fetched here, on the first turn, not at create(): a
// fetch that fails then surfaces through engine.ts's run loop as a
// recoverable `error` event rather than as a factory that throws.
const Agent = await loadAgent();
return new Agent({
instructions: "You are a helpful assistant.",
llm: settingString("model", "gpt-4o-mini"),
});
@@ -148,48 +125,21 @@ export function appEngines(deps: {
readonly http: HttpPort;
readonly persistence: RunPersistence;
readonly onIgnored: (reason: IgnoredReason, detail: string) => void;
/** How the in-process engine's `Agent` class is fetched. Defaults to the
* real chunk loader. A test injects a rejecting one to drive the failure
* path, because with `praisonai` installed the real one simply succeeds. */
readonly loadAgent?: () => Promise<PraisonAgentModule>;
}): readonly EngineChoice[] {
const loadAgent = deps.loadAgent ?? loadPraisonAgent;
return enginesFor({
settings: deps.settings,
http: deps.http,
persistence: deps.persistence,
onIgnored: deps.onIgnored,
createInProcess: (persistence) => createInProcessEngine(persistence, deps.settings),
createInProcess: (persistence) => createInProcessEngine(persistence, deps.settings, loadAgent),
});
}
/**
* The engine to start with when settings name none.
*
* `remote-http` on every platform, for now, and the reason is a hard fact about
* this build rather than a preference: the webview ships only `dist/app.js`
* (build-webview.mjs bundles `app/src/main.ts` and copies nothing else), and
* the in-process engine reaches praisonai-ts through a RUNTIME-computed import
* that is deliberately outside that bundle (#4437). So on a real device the
* in-process engine's module is simply ABSENT -- its first turn rejects with
* "the in-process engine is unavailable in this build". Making it the device
* DEFAULT would therefore swap the old "not answering" warning (which at least
* names Settings as the fix) for a first prompt that fails with no recovery,
* and would do it on the exact path registry.ts keeps the engine OUT of the
* shipping picker to avoid ("a picker must not offer a choice that bricks the
* app"). Until #4437 makes praisonai-ts resolvable inside the webview, the
* honest first-launch default stays the remote engine.
*
* A second reason the ternary this replaced was wrong: `Platform["kind"]` is
* only `"tauri" | "web"`, and DESKTOP Tauri (`cargo tauri dev`) reports
* `"tauri"` too -- so keying the in-process engine off `kind === "tauri"` also
* flipped the desktop/dev flow away from the remote engine it exists for, with
* no way here to tell a phone from a laptop.
*
* Kept as an exported function, taking the platform kind, so the seam is ready
* for the day a device can be told apart AND the engine ships -- and so a test
* drives it directly rather than asserting an expression appears in `mount`.
* The persisted `engineId` still wins over this (see `chosenStringOr` in
* boot.ts), so it only ever decides the very first launch.
*/
export function defaultEngineIdFor(_kind: Platform["kind"]): string {
return ENGINE_REMOTE_HTTP;
}
export interface MountDeps {
readonly root: HTMLElement;
@@ -200,6 +150,10 @@ export interface MountDeps {
/** The locales the user prefers, most-preferred first. Injected so a test is
* deterministic; defaults to the host's `navigator.languages`. */
readonly locales?: readonly string[];
/** How the in-process engine's `Agent` class is fetched. Defaults to the
* real chunk loader; a test hands in a scripted class so a first message
* can be driven into the in-process engine with no network and no key. */
readonly loadAgent?: () => Promise<PraisonAgentModule>;
}
/**
@@ -701,13 +655,22 @@ export async function mount(deps: MountDeps): Promise<App | null> {
// the engine at boot and never replaced -- so the engine address the user
// set was read, stored, and thrown away in favour of the hardcoded default.
engines: (persistence, settings, onIgnored) =>
appEngines({ settings, http: platform.http, persistence, onIgnored }),
settingDefs: SETTING_DEFS,
appEngines({
settings,
http: platform.http,
persistence,
onIgnored,
...(deps.loadAgent === undefined ? {} : { loadAgent: deps.loadAgent }),
}),
// The platform's defs, so the engine Settings shows as the default and the
// engine that answers a first launch are the same one.
settingDefs: settingDefsFor(platform.kind),
// The default when settings name none, chosen by platform: the in-process
// engine on a device (a phone has no `127.0.0.1:8765` to reach, and a
// cleartext localhost address is refused by iOS ATS and Android), the
// remote engine on desktop/dev where one actually runs. createApp prefers
// the persisted `engineId` over this, so it only decides the first launch.
// remote engine on the web where a server is what there is. createApp
// prefers the persisted `engineId` over this, so it only decides the
// first launch. registry.ts says why desktop Tauri counts as a device.
engineId: defaultEngineIdFor(platform.kind),
onPublish: publish,
now: deps.now ?? (() => Date.now()),
+29 -10
View File
@@ -17,8 +17,11 @@ import {
ENGINE_PRAISONAI_TS,
ENGINE_REMOTE_HTTP,
SETTING_DEFS,
defaultEngineIdFor,
settingDefsFor,
enginesFor,
} from "./registry.ts";
import { appEngines } from "./main.ts";
import { createSettingsStore, facadeFor } from "../../core/src/settings/store.ts";
import { createFakeStorage } from "../../testing/src/fake-storage.ts";
import { createFakeSecrets } from "../../testing/src/fake-secrets.ts";
@@ -45,8 +48,10 @@ test("the remote engine is always offered, because it needs nothing injected", a
});
test("the in-process engine is omitted when no factory is supplied", async () => {
// A picker listing a choice that cannot work is a support ticket. praisonai
// is not a dependency yet -- issue #4437 -- so offering it would be a lie.
// A picker listing a choice that cannot work is a support ticket. The
// factory is how main.ts supplies the engine (RegistryDeps says why it is
// injected); without one there is nothing behind the id, so offering it
// would be a lie.
const { settings, http, persistence } = await build();
assert.equal(enginesFor({ settings, http, persistence }).some((c) => c.id === ENGINE_PRAISONAI_TS), false);
});
@@ -105,11 +110,11 @@ test("the engine setting offers exactly the engines the SHIPPING build can make"
//
// This test used to assert the static array equalled itself -- it named the
// same two constants the def named, so the def and reality could not
// disagree in a way it could see. It now compares against what `enginesFor`
// actually offers in the composition main.ts uses (no `createInProcess`),
// which is the only comparison that can fail.
// disagree in a way it could see. It now compares against what `appEngines`
// -- the composition main.ts actually mounts -- offers, which is the only
// comparison that can fail.
const { settings, http, persistence } = await build();
const buildable = enginesFor({ settings, http, persistence }).map((c) => c.id);
const buildable = appEngines({ settings, http, persistence, onIgnored: () => {} }).map((c) => c.id);
const def = SETTING_DEFS.find((d) => d.key === "engineId");
assert.deepEqual(
@@ -119,10 +124,24 @@ test("the engine setting offers exactly the engines the SHIPPING build can make"
);
});
test("the default engine is one that works with nothing configured", async () => {
// First launch must reach a usable state without the user picking anything.
const def = SETTING_DEFS.find((d) => d.key === "engineId");
assert.equal(def?.default, ENGINE_REMOTE_HTTP);
test("the default engine is one that works with nothing configured, per platform", async () => {
// First launch must reach a usable state without the user picking anything,
// and what is usable depends on where it runs: a phone has no server at
// 127.0.0.1:8765 to reach, and the web has one.
assert.equal(defaultEngineIdFor("tauri"), ENGINE_PRAISONAI_TS, "a device starts in-process");
assert.equal(defaultEngineIdFor("web"), ENGINE_REMOTE_HTTP, "a browser tab talks to a server");
for (const kind of ["tauri", "web"] as const) {
const def = settingDefsFor(kind).find((d) => d.key === "engineId");
assert.equal(
def?.default,
defaultEngineIdFor(kind),
`${kind}: the def's default and the boot fallback must agree, or Settings shows one engine while another answers`,
);
assert.ok(def?.choices?.includes(def.default), `${kind}: the default must be a choice the picker offers`);
}
// The static list is the web's, and every other def is untouched.
assert.equal(SETTING_DEFS.find((d) => d.key === "engineId")?.default, ENGINE_REMOTE_HTTP);
assert.deepEqual(settingDefsFor("tauri").filter((d) => d.key !== "engineId"), SETTING_DEFS.filter((d) => d.key !== "engineId"));
});
// The files that actually read a setting in the shipping composition. A key is
+55 -13
View File
@@ -15,6 +15,7 @@ import type { AgentEnginePort } from "../../core/src/ports/agent-engine.ts";
import type { HttpPort } from "../../core/src/ports/http.ts";
import type { IgnoredReason } from "../../protocol/src/decode.ts";
import type { SettingDef, SettingsFacade } from "../../core/src/settings/store.ts";
import type { Platform } from "./platform.ts";
import { createRemoteHttpEngine, probeHealth } from "../../engines/src/remote-http/engine.ts";
import type { EngineChoice } from "./engines.ts";
import type { RunPersistence } from "../../engines/src/praisonai-ts/engine.ts";
@@ -46,13 +47,20 @@ export const SETTING_DEFS: readonly SettingDef[] = [
label: "Engine",
help: "Which agent runtime answers. Remote talks to a PraisonAI engine over HTTP; in-process runs the agent loop on this device.",
section: "Engine",
// Only what `enginesFor` can actually build in the shipping composition.
// ENGINE_PRAISONAI_TS was listed here and is only pushed when
// `createInProcess` is supplied, which main.ts does not do -- so selecting
// it persisted an id `selectEngine` then rejects, and the NEXT launch died
// at `renderFatal` with no way back except editing storage by hand. A
// picker must not offer a choice that bricks the app.
choices: [ENGINE_REMOTE_HTTP],
// Exactly what `appEngines` in main.ts can build -- registry.test.ts
// compares this list against that composition, because a picker offering
// an id `selectEngine` rejects is a dead end: the choice persists, the
// NEXT launch cannot build it, and boot dies at `renderFatal` with no way
// back except editing storage by hand. That is what happened when
// ENGINE_PRAISONAI_TS was listed here while nothing supplied
// `createInProcess`. It is back because main.ts supplies the factory and
// the engine ships as a chunk beside app.js (engines/praisonai-ts/
// load-agent.ts).
//
// `default` here is the WEB default; `settingDefsFor` swaps in the
// platform's, so the value Settings shows and the engine that answers a
// first launch are the same one.
choices: [ENGINE_REMOTE_HTTP, ENGINE_PRAISONAI_TS],
},
{
key: "baseUrl",
@@ -67,6 +75,38 @@ export const SETTING_DEFS: readonly SettingDef[] = [
* in `enginesFor` below. Exported so the test that forbids an inert setting
* compares `SETTING_DEFS` against a list an author has to consciously extend
* in the same commit that adds the code reading the new key. */
/**
* The engine to start with when settings name none, by platform.
*
* On a device, the in-process engine: a phone has no `127.0.0.1:8765` to
* reach, and a cleartext localhost address is refused by iOS ATS and Android
* besides, so the remote default was a first prompt that failed with a status
* code. Now that the engine ships as a chunk beside app.js, it is the one
* choice that works with nothing configured -- up to the model itself, which
* still needs a key the app has no setting for yet.
*
* On the web, the remote engine: there is a server to talk to, and no reason
* to fetch 1.3MB of engine into a browser tab first. `Platform["kind"]` is
* only `"tauri" | "web"`, so desktop Tauri (`cargo tauri dev`) counts as a
* device here and starts in-process too; a developer with a server running
* switches in Settings, and the persisted `engineId` wins over this
* (`chosenStringOr` in boot.ts), so it only ever decides the very first launch.
*
* Exported, and called by `settingDefsFor`, so the boot fallback and the
* def's `default` cannot disagree -- Settings would show one engine while
* another answered.
*/
export function defaultEngineIdFor(kind: Platform["kind"]): string {
return kind === "tauri" ? ENGINE_PRAISONAI_TS : ENGINE_REMOTE_HTTP;
}
/** SETTING_DEFS with the engine default the platform actually starts on. */
export function settingDefsFor(kind: Platform["kind"]): readonly SettingDef[] {
return SETTING_DEFS.map((def) =>
def.key === "engineId" ? { ...def, default: defaultEngineIdFor(kind) } : def,
);
}
export const CONSUMED_SETTING_KEYS: readonly string[] = ["engineId", "baseUrl"];
export interface RegistryDeps {
@@ -75,12 +115,14 @@ export interface RegistryDeps {
/**
* Builds the in-process engine.
*
* Injected rather than imported, because `praisonai` is not a dependency of
* this package and cannot be until it is bundleable for a webview -- issue
* #4437: `crypto` and `events` are static imports on its Agent graph, so the
* bundle dies at import time before any code runs. Keeping it an injection
* means the app builds and runs today with the remote engine, and gains the
* in-process one by passing a factory rather than by a refactor.
* Injected rather than imported, because tools/boundaries.json lets only
* engines/src/praisonai-ts name `praisonai` -- that seam is what makes the
* framework swappable, and this file sits above it. The injection is also
* what let the app build and run with the remote engine while the in-process
* one could not be bundled for a webview at all (#4437: `crypto` and
* `events` were static imports on its Agent graph). Now `praisonai/mobile`
* ships as a lazy chunk and main.ts supplies the factory; the injection
* stays because the boundary does.
*/
readonly createInProcess?: (persistence: RunPersistence) => AgentEnginePort | Promise<AgentEnginePort>;
/**
@@ -0,0 +1,23 @@
/**
* The seam that turns `praisonai` into a chunk, exercised for real.
*
* `load-agent.ts` is the one place the package name is a literal, and the
* whole split rests on that literal resolving to a module with an `Agent` in
* it. `tools/app-bundle.test.mjs` proves esbuild can see it; this proves Node
* can load it, which is the same claim from the other side -- a typo in the
* specifier, or an upstream release that stops exporting `Agent` from
* `praisonai/mobile`, fails here with the name in the message rather than on a
* device as "the in-process engine is unavailable in this build".
*/
import test from "node:test";
import assert from "node:assert/strict";
import { loadPraisonAgent } from "./load-agent.ts";
test("praisonai/mobile resolves and exports a constructible Agent", async () => {
const Agent = await loadPraisonAgent();
assert.equal(typeof Agent, "function", "Agent must be a class, not a namespace");
// Constructing opens no upstream: the model is only contacted on a turn.
const agent = new Agent({ instructions: "test", llm: "gpt-4o-mini" });
assert.equal(typeof agent.streamEvents, "function", "and the instance must speak agent-api.ts");
});
@@ -0,0 +1,43 @@
/**
* The one place `praisonai` is named, and the seam that makes it a CHUNK.
*
* The specifier is a LITERAL. That is the whole trick: esbuild can only put a
* module in its own lazily-fetched chunk if it can see which module it is, and
* a runtime-computed path (what this used to be) is opaque to the bundler --
* so the engine either came along eagerly or, as external, not at all.
*
* It lives HERE rather than in `app/src/main.ts` because `tools/boundaries.json`
* says only `engines/src/praisonai-ts` may import `praisonai`. That rule is the
* agent-framework seam; making the import literal must not cost it.
*
* `praisonai/mobile` -- not `praisonai` -- is the package's webview-safe
* allowlist entry. The default entry re-exports the CLI, the MCP server and the
* knowledge store, whose Node builtins are import-time fatal in a webview.
*/
import type { PraisonAgent } from "./agent-api.ts";
/** The one member of praisonai's `Agent` constructor this seam uses. */
export interface PraisonAgentModule {
new (config: { instructions: string; llm?: string }): PraisonAgent;
}
/**
* Load the upstream `Agent` class, on demand.
*
* The import can REJECT -- a chunk that fails to fetch on a flaky connection,
* or a build where the engine was left out. Left unwrapped that surfaces as an
* opaque module error from inside the run loop; re-thrown as a plain Error,
* `engine.ts` turns it into a recoverable `error` event through its existing
* catch, which is the named on-screen failure the picker's contract requires.
*/
export async function loadPraisonAgent(): Promise<PraisonAgentModule> {
try {
const mod = (await import("praisonai/mobile")) as unknown as { Agent: PraisonAgentModule };
return mod.Agent;
} catch (cause) {
throw new Error(
"the in-process engine is unavailable in this build: praisonai could not be loaded",
{ cause },
);
}
}
+250
View File
@@ -8,6 +8,13 @@
"name": "praisonai-mobile",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@ai-sdk/cohere": "^4.0.37",
"@ai-sdk/google": "^4.0.63",
"@ai-sdk/openai": "^4.0.57",
"ai": "^7.0.91",
"praisonai": "file:../praisonai-ts"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/node": "^22.20.1",
@@ -18,6 +25,173 @@
"node": ">=22.18"
}
},
"../praisonai-ts": {
"name": "praisonai",
"version": "1.7.4",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"axios": "1.8.4",
"fast-xml-parser": "^4.5.1",
"openai": "^4.81.0"
},
"bin": {
"praisonai-ts": "dist/cli/index.js"
},
"devDependencies": {
"@ai-sdk/anthropic": "^3.0.1",
"@ai-sdk/google": "^3.0.1",
"@ai-sdk/openai": "^3.0.1",
"@ai-sdk/provider-utils": "^2.2.8",
"@jest/globals": "^29.7.0",
"@jest/types": "^29.6.3",
"@opentelemetry/api": "^1.9.0",
"@types/figlet": "^1.7.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.12.0",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"ai": "^6.0.3",
"esbuild": "^0.25.0",
"eslint": "^9.19.0",
"jest": "^29.7.0",
"rimraf": "^5.0.5",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@airweave/sdk": ">=0.1.0",
"@mendable/firecrawl-js": ">=1.0.0",
"bedrock-agentcore": ">=0.1.0",
"better-sqlite3": "^12.6.2",
"boxen": "^7.1.1",
"chalk": "^4.1.2",
"cli-table3": "^0.6.3",
"figlet": "^1.7.0",
"ora": "^5.4.1"
},
"peerDependencies": {
"@ai-sdk/anthropic": ">=1.0.0",
"@ai-sdk/google": ">=1.0.0",
"@ai-sdk/openai": ">=1.0.0",
"ai": ">=4.0.0"
},
"peerDependenciesMeta": {
"@ai-sdk/anthropic": {
"optional": true
},
"@ai-sdk/google": {
"optional": true
},
"@ai-sdk/openai": {
"optional": true
},
"ai": {
"optional": true
}
}
},
"node_modules/@ai-sdk/cohere": {
"version": "4.0.37",
"resolved": "https://registry.npmjs.org/@ai-sdk/cohere/-/cohere-4.0.37.tgz",
"integrity": "sha512-wvTWyh1qcSTJXT/4zkBmoImGAMPMPmmJVhWfhiL0vapM4YRnKEGVEdz6D3dsG7Q7pRPDRzTwG8c142oYrwJlFA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "4.0.10",
"@ai-sdk/provider-utils": "5.0.36"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "4.0.73",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.73.tgz",
"integrity": "sha512-1Gp6QtVHfegKoMcf1pjk4fpgDg1cUdK5NnrEiUsYhLWa6owDMmF7kP+qL7ZgQNDYKTOijT+5cTeHrL281kUiiQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "4.0.10",
"@ai-sdk/provider-utils": "5.0.36",
"@vercel/oidc": "3.2.0"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google": {
"version": "4.0.63",
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-4.0.63.tgz",
"integrity": "sha512-SlKcqnN0oKC8JWahecs3IvFnIFgbrEE4i/bmom5xsPFUjkIVvXAJAhRzjfgfROSOOeyvouKUFPWJEjn11FWf3A==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "4.0.10",
"@ai-sdk/provider-utils": "5.0.36"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
"version": "4.0.57",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.57.tgz",
"integrity": "sha512-akpj7mE4z3qmofk+fVlnASxFF6Fh8kuihk1AVsY09uf/sv4CxmiGDjMJrmSWffIx+JEAuZmGUq8uKxEXjw/7Xw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "4.0.10",
"@ai-sdk/provider-utils": "5.0.36"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.10.tgz",
"integrity": "sha512-fX2ENAc7iDpZ+Wp4+Rk06Usn/Ys7dI9uAkGv0jlF6XVrW13NkRWx5Ou+U6lIM2E1fTLkCs16GGrUAaVvroag7A==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "5.0.36",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.36.tgz",
"integrity": "sha512-MFXBn6XDyf37PNQAge/HTatPJE8Vmg/g/w4WPtjSV53jq8FKAzoaN5+43hsdQa9bqgN+/13jxug9ChvsG+godQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "4.0.10",
"@standard-schema/spec": "^1.1.0",
"@workflow/serde": "4.1.0",
"eventsource-parser": "^3.0.8",
"undici": "^7.29.0"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -460,6 +634,12 @@
"node": ">=18"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
@@ -687,6 +867,38 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@vercel/oidc": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
"integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
}
},
"node_modules/@workflow/serde": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz",
"integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==",
"license": "Apache-2.0"
},
"node_modules/ai": {
"version": "7.0.91",
"resolved": "https://registry.npmjs.org/ai/-/ai-7.0.91.tgz",
"integrity": "sha512-Hn0iIQY1FXow2wd+DxzdA/4ijY+6TP9CREqpN3aIs01wnBW9119yHW0Dvn53sTQzkk7+rSVXaXlDAYM5hhMJZA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "4.0.73",
"@ai-sdk/provider": "4.0.10",
"@ai-sdk/provider-utils": "5.0.36"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -729,6 +941,25 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/eventsource-parser": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/praisonai": {
"resolved": "../praisonai-ts",
"link": true
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -743,12 +974,31 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
"integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+7
View File
@@ -25,5 +25,12 @@
"@types/node": "^22.20.1",
"esbuild": "^0.25.0",
"typescript": "^5.7.3"
},
"dependencies": {
"@ai-sdk/cohere": "^4.0.37",
"@ai-sdk/google": "^4.0.63",
"@ai-sdk/openai": "^4.0.57",
"ai": "^7.0.91",
"praisonai": "file:../praisonai-ts"
}
}
+73 -10
View File
@@ -9,6 +9,14 @@
*
* This file closes that. It is slower than the fixture suite because it
* bundles the whole application, which is exactly the point.
*
* Built SPLIT, because that is the shape index.html loads: `app.js` plus the
* chunks behind its one `import()`. Built TWICE, because two questions need
* two builds: the gate and the budgets are asked of the bytes that ship,
* which are minified; the symbol checks need names to look for, which
* minification removes. Asking the budget of an unminified build measured
* 2.9MB of lazy chunks against a 1.5MB allowance -- a failure about a build
* nobody ships.
*/
import test from "node:test";
import assert from "node:assert/strict";
@@ -16,11 +24,31 @@ import { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp } from "node:fs/promises";
import { bundle, SIZE_BUDGET_BYTES } from "./bundle.mjs";
import {
bundle,
classifyBareImports,
praisonaiRoot,
CLI_ONLY_PACKAGES,
SHELL_BUDGET_BYTES,
LAZY_BUDGET_BYTES,
} from "./bundle.mjs";
const pkg = resolve(dirname(new URL(import.meta.url).pathname), "..");
const out = join(await mkdtemp(join(tmpdir(), "praison-app-")), "app.js");
const report = await bundle({ entry: join(pkg, "app/src/main.ts"), outfile: out, minify: false });
const entry = join(pkg, "app/src/main.ts");
/** What ships: minified, split. Every gate and budget check reads this. */
const report = await bundle({ entry, outdir: await mkdtemp(join(tmpdir(), "praison-app-")) });
/** The same graph with its names intact, for the symbol checks only. */
const readable = await bundle({ entry, outdir: await mkdtemp(join(tmpdir(), "praison-app-")), minify: false });
/** The metafile's inputs for an emitted chunk, by the chunk's basename. */
const inputsOf = (name) => {
const out = Object.entries(report.metafile.outputs).find(([p]) => p.split("/").pop() === name)?.[1];
return Object.keys(out?.inputs ?? {});
};
// Through the `file:` link, so the real ../praisonai-ts directory: esbuild
// records inputs by real path, and a node_modules/ pattern would match nothing.
const upstream = praisonaiRoot();
const isUpstream = (input) => upstream !== null && resolve(process.cwd(), input).startsWith(upstream + "/");
test("the real app bundles with no problems at all", () => {
assert.deepEqual(report.problems, [], report.problems.join("\n"));
@@ -36,16 +64,51 @@ test("no top-level process.env read survives into the app", () => {
assert.deepEqual(report.processReads, []);
});
test("the app is within the size budget", () => {
test("the shell is within the SHELL budget", () => {
assert.ok(
report.bytes < SIZE_BUDGET_BYTES,
`${(report.bytes / 1024).toFixed(1)}kB of ${(SIZE_BUDGET_BYTES / 1024).toFixed(0)}kB`,
report.shellBytes < SHELL_BUDGET_BYTES,
`shell ${(report.shellBytes / 1024).toFixed(1)}kB of ${(SHELL_BUDGET_BYTES / 1024).toFixed(0)}kB`,
);
});
test("the bundle actually contains every layer, not just an entry stub", () => {
test("the engine's chunks are within the LAZY allowance", () => {
// Both halves: that there IS something lazy -- otherwise the allowance is
// asserting nothing -- and that it fits.
assert.ok(report.lazyBytes > 0, "the engine must be in the bundle, behind an import()");
assert.ok(
report.lazyBytes < LAZY_BUDGET_BYTES,
`lazy ${(report.lazyBytes / 1024).toFixed(1)}kB of ${(LAZY_BUDGET_BYTES / 1024).toFixed(0)}kB`,
);
});
test("praisonai reaches no eager chunk -- the split is real", () => {
// The claim both budgets rest on. If a static path to praisonai ever
// appears, every one of its bytes moves into the shell. The shell budget
// would catch that eventually, at 400kB; this catches it at one byte, and
// names the chunk.
for (const name of report.eager) {
const upstream = inputsOf(name).filter(isUpstream);
assert.deepEqual(upstream, [], `${name} loads at first paint and contains praisonai: ${upstream.slice(0, 3).join(", ")}`);
}
const lazyWithEngine = report.chunks.filter((c) => !report.eager.has(c.name) && inputsOf(c.name).some(isUpstream));
assert.ok(lazyWithEngine.length > 0, "and praisonai IS in the bundle, in a lazy chunk");
});
test("the CLI-only packages are reached lazily, and only lazily", () => {
// They are external (bundle.mjs says why), and external is safe only behind
// an import(). This pins the upstream fact that makes that true: no static
// import of chalk & co. anywhere on praisonai/mobile's graph. `readline` is
// the same story for a builtin, and `fatal` above already covers it.
const kinds = classifyBareImports(report.metafile);
for (const name of CLI_ONLY_PACKAGES) {
assert.equal(kinds.get(name), "dynamic", `${name} must be reached through import() only (saw ${kinds.get(name)})`);
}
assert.deepEqual(report.cliStatic, []);
});
test("the shell actually contains every layer, not just an entry stub", () => {
// A bundle that tree-shook the app away would pass every check above. These
// are one real symbol per layer, from the unminified output.
// are one real symbol per layer, from the unminified entry chunk.
for (const symbol of [
"buildTranscript", // ui/transcript
"reconcile", // ui/render
@@ -55,7 +118,7 @@ test("the bundle actually contains every layer, not just an entry stub", () => {
"applyOps", // app/dom
"installCrashHandler", // app/crash
]) {
assert.ok(report.code.includes(symbol), `${symbol} is missing from the bundle`);
assert.ok(readable.code.includes(symbol), `${symbol} is missing from the shell`);
}
});
@@ -63,6 +126,6 @@ test("the app does not pull in a test fake", () => {
// testing/ is testImports-only. A fake reaching a shipped bundle would mean
// the app runs against a scripted engine on a real device.
for (const fake of ["createScriptedEngine", "createFakeStorage", "createFakeShell", "createFakeWindow"]) {
assert.ok(!report.code.includes(fake), `${fake} reached the app bundle`);
assert.ok(!readable.code.includes(fake), `${fake} reached the app bundle`);
}
});
+15 -3
View File
@@ -7,7 +7,7 @@
*/
import { mkdir, copyFile, rm } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { bundle, isShippable } from "./bundle.mjs";
import { bundle, isShippable, SHELL_BUDGET_BYTES, LAZY_BUDGET_BYTES } from "./bundle.mjs";
const here = dirname(new URL(import.meta.url).pathname);
// Overridable so a test can drive the real script against a package whose
@@ -26,9 +26,12 @@ await mkdir(dist, { recursive: true });
await copyFile(join(pkg, "app/index.html"), join(dist, "index.html"));
await copyFile(join(pkg, "app/app.css"), join(dist, "app.css"));
// `outdir`, not `outfile`: esbuild's `splitting` needs a directory to put the
// shared and lazily-loaded chunks in. `entryNames` pins the entry to app.js so
// index.html's `<script type="module" src="./app.js">` keeps working unchanged.
const report = await bundle({
entry: join(pkg, "app/src/main.ts"),
outfile: join(dist, "app.js"),
outdir: dist,
});
for (const name of report.lazy) {
@@ -37,7 +40,16 @@ for (const name of report.lazy) {
for (const problem of report.problems) {
console.error(` ✖ ${problem}`);
}
console.log(`bundle: ${(report.bytes / 1024).toFixed(1)}kB of a 400kB budget, ${report.bare.length} external`);
for (const chunk of report.chunks) {
const when = report.eager.has(chunk.name) ? "eager" : "lazy ";
console.log(` ${when} ${chunk.name.padEnd(22)} ${(chunk.bytes / 1024).toFixed(1)}kB`);
}
console.log(
`bundle: shell ${(report.shellBytes / 1024).toFixed(1)}kB of a ` +
`${(SHELL_BUDGET_BYTES / 1024).toFixed(0)}kB budget, lazy ` +
`${(report.lazyBytes / 1024).toFixed(1)}kB of a ${(LAZY_BUDGET_BYTES / 1024).toFixed(0)}kB budget, ` +
`${report.chunks.length} chunks, ${report.bare.length} external`,
);
if (!isShippable(report)) {
console.error("\nThe webview bundle is not shippable. See above.");
@@ -64,3 +64,24 @@ test("an unshippable bundle FAILS the build, it does not merely print", () => {
assert.notEqual(r.status, 0, `an unshippable bundle was reported and then shipped:\n${out}`);
assert.match(out, /crypto/, "and it must say which module made it unshippable");
});
test("a chunk behind an import() is written beside app.js, where the page can reach it", () => {
// Splitting emits `import("./chunk-XXXX.js")` inside app.js, and a browser
// resolves that against app.js's own URL. index.html sits in the same
// directory and loads `./app.js`, so "beside app.js" is "beside the page".
// A build that put the chunks anywhere else -- an outdir of their own, say
// -- would pass every gate and 404 on the first lazy feature.
const dir = fixture("export const load = () => import('./lazy.ts');\ndocument.title = 'x';\n");
writeFileSync(join(dir, "app/src/lazy.ts"), "export const lazy: string = 'lazy'.repeat(10);\n");
const r = build(dir);
assert.equal(r.status, 0, `${r.stdout}${r.stderr}`);
const app = readFileSync(join(dir, "dist/app.js"), "utf8");
const refs = [...app.matchAll(/import\("\.\/(chunk-[A-Z0-9]+\.js)"\)/g)].map((m) => m[1]);
assert.ok(refs.length > 0, "the lazy module must have become a chunk, referenced relatively");
for (const ref of refs) {
assert.ok(existsSync(join(dir, "dist", ref)), `${ref} must be written beside app.js`);
}
assert.ok(existsSync(join(dir, "dist/index.html")), "and the page is in that same directory");
});
+109 -25
View File
@@ -11,31 +11,53 @@
* module, so it never installs -- a blank white screen, no error surface, no
* telemetry, on exactly the devices (AOSP, Play-less, long-offline) that a
* WebView floor exists to protect.
*
* The in-process engine is a chunk on this graph now, and it took the floor
* only once praisonai-ts stopped putting top-level await on it (praisonai-ts
* #4720) -- esbuild cannot lower that, so below chrome89 there was no bundle
* at all. `praisonai` is a `file:` link to ../praisonai-ts, so the build
* measured here is the one with that fix in it. One construct is kept above
* the floor on purpose, `import()` (Chrome 63), because lowering it undoes
* the split; the last two tests measure exactly that and pin the override
* that prevents it.
*/
import test from "node:test";
import * as esbuild from "esbuild";
import assert from "node:assert/strict";
import { readFileSync, mkdtempSync } from "node:fs";
import { readFileSync, readdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { bundle, TARGETS, ANDROID_WEBVIEW_FLOOR } from "./bundle.mjs";
import {
bundle,
chromeMajor,
praisonaiRoot,
ANDROID_WEBVIEW_FLOOR,
DECLARED_CHROME_FLOOR,
SHELL_BUDGET_BYTES,
SPLIT_MIN_CHROME,
TARGETS,
} from "./bundle.mjs";
const conf = JSON.parse(
readFileSync(join(import.meta.dirname, "../src-tauri/tauri.conf.json"), "utf8"),
);
const target = (prefix) => TARGETS.find((t) => t.startsWith(prefix));
const appEntry = join(import.meta.dirname, "../app/src/main.ts");
const scratch = () => mkdtempSync(join(tmpdir(), "floor-"));
test("the Chrome target matches the Android minSdkVersion the app declares", () => {
const minSdk = conf.bundle.android.minSdkVersion;
const required = ANDROID_WEBVIEW_FLOOR[minSdk];
const floor = ANDROID_WEBVIEW_FLOOR[minSdk];
assert.ok(
required !== undefined,
floor !== undefined,
`minSdkVersion ${minSdk} has no WebView floor recorded in bundle.mjs -- add one rather than guessing`,
);
assert.equal(DECLARED_CHROME_FLOOR, floor, "bundle.mjs derives the floor from the same config this test reads");
assert.equal(
target("chrome"),
required,
`minSdkVersion ${minSdk} ships a WebView at ${required}; the bundle targets ${target("chrome")}`,
floor,
`minSdkVersion ${minSdk} ships a WebView at ${floor}; the bundle targets ${target("chrome")}`,
);
});
@@ -46,27 +68,89 @@ test("the Safari target matches the iOS minimum the app declares", () => {
assert.equal(target("safari"), `safari${major}`, "iOS 16 ships Safari 16");
});
test("the shipped bundle contains no syntax the floor cannot parse", async () => {
test("the shipped bundle -- every chunk, engine included -- has no syntax the floor cannot parse", async () => {
// The assertion that would actually have caught it. Optional chaining and
// nullish coalescing are Chrome 80; logical assignment is Chrome 85. Any of
// them at a chrome58 floor is a blank screen.
// nullish coalescing are Chrome 80, logical assignment 85, async functions
// 55, class fields 72: any of them at a chrome58 floor is a blank screen.
//
// Checked with a PARSER, not a regex: a `?.` inside a string or a regex
// literal is not optional chaining, and zod has both. esbuild lowers what a
// target lacks, so transforming a chunk AT the floor is a no-op exactly
// when the chunk is already floor-clean -- and a transform at `esnext` is
// the control that says what "no-op" prints as. One printer quirk has to be
// held still for that: at a target with template literals esbuild prints
// a string containing both quote characters with backticks, which is not
// syntax in the chunk but a choice in the printer (measured: 12 lines, all
// of them strings). The one construct the build keeps above the floor on
// purpose, `import()`, is kept here too.
//
// Built here through the same bundle() at the same TARGETS the ship path
// uses, rather than read from a pre-built dist/app.js: the `check` job runs
// uses, rather than read from a pre-built dist/: the `check` job runs
// `npm test` without a prior `npm run build`, so a disk read would be a test
// that can only pass by accident of build order. Same entry, same targets,
// same output -- with no ordering dependency.
const out = join(mkdtempSync(join(tmpdir(), "floor-")), "app.js");
const { code } = await bundle({
entry: join(import.meta.dirname, "../app/src/main.ts"),
outfile: out,
write: false,
});
for (const [name, pattern] of [
["optional chaining", /\?\./],
["nullish coalescing", /\?\?[^=]/],
["logical assignment", /\?\?=|\|\|=|&&=/],
]) {
assert.doesNotMatch(code, pattern, `${name} is post-Chrome-58 and will not parse on the floor`);
// that can only pass by accident of build order. WRITTEN, so every chunk
// can be read back -- the engine's chunks are the ones that only recently
// became buildable at this floor, and the check must include them.
const outdir = scratch();
const report = await bundle({ entry: appEntry, outdir });
assert.deepEqual(report.problems, [], report.problems.join("\n"));
assert.ok(report.lazyBytes > 0, "the engine must be in this build for the check to mean anything");
const upstream = praisonaiRoot();
const engineChunks = Object.entries(report.metafile.outputs)
.filter(([, out]) => Object.keys(out.inputs ?? {}).some((i) => resolve(process.cwd(), i).startsWith(upstream + "/")))
.map(([p]) => p.split("/").pop());
assert.ok(engineChunks.length > 0, "and praisonai must be among the chunks checked");
const chunks = readdirSync(outdir).filter((f) => f.endsWith(".js"));
assert.ok(chunks.length > 1, "a split build writes more than the entry");
const print = (code, target) => esbuild.transformSync(code, {
target: [target], format: "esm", supported: { "dynamic-import": true, "template-literal": false },
}).code;
for (const name of chunks) {
const code = readFileSync(join(outdir, name), "utf8");
assert.equal(
print(code, target("chrome")),
print(code, "esnext"),
`${name}: lowering it to ${target("chrome")} changed it, so it carries syntax the floor cannot parse`,
);
}
});
test("import() survives at the floor, by an override that is load-bearing", async () => {
// What ships: the entry keeps its `import("./chunk-…")`, and the engine is
// behind it. Then the same build with esbuild left to its own table at the
// same target: the import is lowered, nothing is lazy, and the shell budget
// fails -- which is what the override in bundle.mjs exists to prevent, and
// the reason removing it cannot be silent.
const kept = await bundle({ entry: appEntry, outdir: scratch(), write: false });
assert.match(kept.code, /import\("\.\/chunk-[A-Z0-9]+\.js"\)/, "the entry must fetch the engine lazily");
assert.ok(kept.lazyBytes > 0);
assert.deepEqual(kept.problems, [], kept.problems.join("\n"));
const lowered = await bundle({ entry: appEntry, outdir: scratch(), write: false, keepDynamicImport: false });
assert.doesNotMatch(lowered.code, /import\("\.\/chunk-/, "without the override the floor lowers import()");
assert.equal(lowered.lazyBytes, 0, "and nothing is lazy any more");
assert.ok(lowered.shellBytes > SHELL_BUDGET_BYTES, "so the whole engine is in the shell");
assert.ok(lowered.problems.some((p) => /shell budget/.test(p)), "which the gate names");
});
test("SPLIT_MIN_CHROME is where esbuild leaves import() alone -- measured, one below and at", async () => {
// The number behind the override, pinned to esbuild's behaviour rather than
// to a browser table someone read once. Below it the same source produces
// no lazy chunk at all; at it, the chunk stays behind the import().
const dir = mkdtempSync(join(tmpdir(), "split-floor-"));
writeFileSync(join(dir, "main.js"), 'export const load = () => import("./lazy.js");\n');
writeFileSync(join(dir, "lazy.js"), 'export const lazy = "x".repeat(2000);\n');
const at = (t) => bundle({
entry: join(dir, "main.js"), outdir: join(dir, "out-" + t), write: false, targets: [t], keepDynamicImport: false,
});
const below = await at(`chrome${chromeMajor(SPLIT_MIN_CHROME) - 1}`);
assert.equal(below.lazyBytes, 0, "one below: import() is lowered and nothing is lazy");
assert.ok(below.eager.size > 1, "and what would have been a lazy chunk is eager");
const here = await at(SPLIT_MIN_CHROME);
assert.ok(here.lazyBytes > 0, "at it: the chunk stays behind the import()");
assert.deepEqual([...here.eager], ["app.js"]);
assert.ok(chromeMajor(SPLIT_MIN_CHROME) > chromeMajor(DECLARED_CHROME_FLOOR), "which is above the declared floor -- the override is not decorative");
});
+324 -80
View File
@@ -18,9 +18,11 @@
* thing people stop reading; this repo would rather not ship.
*/
import * as esbuild from "esbuild";
import { readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
/** Node builtins that must never survive into a webview bundle. */
/** esbuild's own metafile markers, which are not packages. */
@@ -48,41 +50,118 @@ export const FORBIDDEN_BUILTINS = [
];
/**
* The webview baseline, and why these two.
* Packages that exist only to make a TERMINAL pretty, and are reached only
* through `await import()` on a CLI path -- praisonai's `utils/pretty-logger`,
* which no mobile code calls, the same way its `createCLIApprovalPrompt` is the
* only thing that reaches `readline`.
*
* They stay external on purpose. Bundling them drags `assert`, `buffer`,
* `events`, `os`, `process`, `readline`, `string_decoder` and `util` in
* STATICALLY -- measured -- which is the import-time blank screen this file
* exists to prevent. External, they are a rejected dynamic import on a path a
* phone never takes. That holds only while the import STAYS dynamic: an
* external reached by a static import is import-time fatal, exactly like a
* builtin, so `bundle()` fails on one below.
*/
export const CLI_ONLY_PACKAGES = ["chalk", "boxen", "ora", "cli-table3", "figlet"];
/**
* The oldest engines the bundle must PARSE on -- DERIVED from what the app
* declares, not restated here.
*
* iOS ships WKWebView with the OS, so the floor is the oldest iOS worth
* supporting rather than the newest Safari. Android's WebView updates through
* Play, but a device kept offline keeps whatever it shipped with -- so the
* floor there is the OS too, not the current Chrome.
*/
/**
* The oldest engines the bundle must PARSE on, derived from the platform
* minimums the app declares -- not chosen independently of them.
* floor there is the OS too, not the current Chrome. ANDROID_WEBVIEW_FLOOR is
* that table, keyed by `minSdkVersion`, and the Chrome target is read THROUGH
* it from `tauri.conf.json`. It has to be: `chrome108` sat here while the
* config said `minSdkVersion: 26` (Android 8.0, WebView ~Chrome 58), and
* nothing noticed. `index.html` loads the bundle as `<script type="module">`,
* so post-58 syntax is a PARSE error: the module body never runs,
* `installCrashHandler` never installs, and the AOSP, Play-less and
* long-offline devices a floor exists to protect get a blank white screen with
* no error surface and no telemetry. `bundle-target.test.mjs` builds the real
* app at this target and scans every chunk.
*
* `chrome108` was set here while `tauri.conf.json` declared
* `minSdkVersion: 26` (Android 8.0, WebView ~Chrome 58). The output contains
* optional chaining, nullish coalescing and logical assignment -- all
* post-Chrome-58 -- and `index.html` loads it as `<script type="module">`, so
* on such a device the module body never evaluates. `installCrashHandler` is
* imported by that same module, so it never installs either: a blank white
* screen with no error surface and no telemetry.
* The in-process engine (`praisonai/mobile`, a lazily-fetched chunk) builds at
* this floor because praisonai-ts's esm shim no longer puts top-level await on
* its graph (praisonai-ts #4720) -- esbuild cannot lower that, so any target
* below chrome89 produced no bundle at all. `praisonai` is a `file:` link to
* ../praisonai-ts so the build that ships is the one with that fix in it, and
* `bundle-target.test.mjs` asserts the built engine takes the floor.
*
* Android's WebView updates through Play, so most devices are far newer. The
* ones that are not -- AOSP builds, Play-less devices, anything kept offline --
* are exactly the population this floor exists to protect, which is what the
* comment above already said before the two numbers drifted apart.
*
* Lowering the floor cost 5.2kB of a 400kB budget, measured.
*
* `bundle-target.test.mjs` asserts these stay derived from the declared
* minimums, because nothing linked the two numbers and they silently diverged.
* ONE construct is deliberately kept above the floor: `import()` itself, which
* is Chrome 63. Left to esbuild, a target below that LOWERS the dynamic import
* to a static one wrapped in a promise -- measured at chrome58: every chunk
* becomes eager and the whole engine lands in the shell, 1486.8kB against a
* 400kB budget, 0 lazy. So `bundle()` tells esbuild the target supports
* dynamic import (`supported`), and SPLIT_MIN_CHROME records what the shipped
* SHAPE needs regardless of the syntax target. A WebView that runs a `<script
* type="module">` page at all is Chrome 61+, so the gap this leaves is Chrome
* 61-62: the page loads and the shell's `import()` is a parse error.
* ANDROID_WEBVIEW_FLOOR records no level between 26 (chrome58) and 30
* (chrome87); whether `minSdkVersion` should move is recorded here, not
* decided here.
*/
export const ANDROID_WEBVIEW_FLOOR = { 26: "chrome58", 30: "chrome87", 33: "chrome108" };
export const TARGETS = ["safari16", "chrome58"];
const tauriConf = JSON.parse(
readFileSync(new URL("../src-tauri/tauri.conf.json", import.meta.url), "utf8"),
);
/** The Chrome the declared `minSdkVersion` ships. `undefined` when the table
* has no entry, and `bundle-target.test.mjs` says so rather than guessing. */
export const DECLARED_CHROME_FLOOR = ANDROID_WEBVIEW_FLOOR[tauriConf.bundle.android.minSdkVersion];
export const TARGETS = ["safari16", DECLARED_CHROME_FLOOR];
/** Bytes. Deliberately tight: this is a text UI, and the budget is what makes
* a dependency a decision rather than an accident. */
export const SIZE_BUDGET_BYTES = 400 * 1024;
/** `chrome89` -> 89, so two floors can be compared rather than eyeballed. */
export function chromeMajor(target) {
const m = /^chrome(\d+)$/.exec(target ?? "");
return m ? Number(m[1]) : NaN;
}
/** The first Chrome whose `import()` esbuild leaves as an `import()`. Measured
* in bundle-target.test.mjs, one below and at; the header says why the build
* keeps it regardless of the floor. */
export const SPLIT_MIN_CHROME = "chrome63";
/** The installed praisonai's root -- through the `file:` link, so the real
* ../praisonai-ts directory -- or null when it is not installed. */
export function praisonaiRoot() {
try {
return dirname(createRequire(import.meta.url).resolve("praisonai/package.json"));
} catch {
return null;
}
}
/**
* The budgets. TWO numbers, because the build emits two populations of chunk
* and they are paid for at different moments.
*
* SHELL_BUDGET_BYTES covers the entry chunk plus every chunk in its STATIC
* import graph -- what a browser fetches and parses before the first frame, on
* every cold start. This is the number that must stay tight, and it is the
* 400kB the single-file budget always was: a single output file's total and
* its shell cost were the same number. Measured after the split: 66.6kB, being
* app.js at 65.9kB plus esbuild's 0.6kB CommonJS-interop helper chunk.
*
* LAZY_BUDGET_BYTES covers everything reachable only through `import()` --
* today the in-process engine and the provider stack under it. It is NOT
* free: whoever picks that engine waits for it, over whatever connection they
* have. But it is paid once, after a deliberate choice, with the app already
* on screen -- so it gets its own ceiling rather than being folded into the
* shell's, which would either let the shell grow behind the engine's
* allowance or refuse the engine for being an engine. Measured at the
* chrome58 floor: 1459.6kB across 16 chunks -- 1361kB of engine at chrome89
* (zod 426kB, `ai` 276kB, @ai-sdk/openai 166kB, @ai-sdk/google 142kB, openai
* 102kB, praisonai itself 79kB) plus ~100kB of lowering, because async
* functions and classes become generators and prototypes below Chrome 55-72.
* The ceiling sits ~10% above that on purpose: one more provider is 100-170kB
* (measured), so adding one is a decision, not a drift.
*
* `tools/depgraph.test.mjs` pins both values; `bundle.test.mjs` proves each
* can fail, and that neither is the other in disguise.
*/
export const SHELL_BUDGET_BYTES = 400 * 1024;
export const LAZY_BUDGET_BYTES = 1600 * 1024;
/**
* Every bare (non-relative) import esbuild could not resolve.
@@ -154,81 +233,217 @@ export function topLevelProcessReads(code) {
return hits;
}
export async function bundle({ entry, outfile, minify = true, write = true }) {
/**
* Every emitted JS chunk with its byte count, keyed by basename.
*
* Read off the metafile rather than by stat-ing the directory, so it works
* with `write: false` and so a stale file left by an earlier build cannot be
* counted as part of this one.
*/
export function chunkSizes(metafile) {
return Object.entries(metafile.outputs)
.filter(([path]) => path.endsWith(".js"))
.map(([path, out]) => ({ name: path.split("/").pop(), bytes: out.bytes, entry: out.entryPoint !== undefined }))
.sort((a, b) => b.bytes - a.bytes || a.name.localeCompare(b.name));
}
/**
* The chunks a browser fetches BEFORE the entry's body runs: the entry itself
* plus the transitive closure of its STATIC imports.
*
* This is the distinction the whole split rests on. A chunk reached only
* through `import()` is fetched when someone picks the feature; a chunk in the
* static graph is paid for at first paint whether or not it is ever used. If
* an engine chunk ever lands in here, splitting has silently stopped working
* and the only visible symptom is a slower cold start.
*/
export function eagerChunks(metafile, entryPath) {
const byName = new Map();
for (const [path, out] of Object.entries(metafile.outputs)) {
if (path.endsWith(".js")) byName.set(path.split("/").pop(), out);
}
// The entry is named, never guessed. esbuild marks EVERY chunk that is the
// target of a dynamic import with an `entryPoint` too, so "the output with
// an entryPoint" picks an arbitrary lazy chunk -- measured: it reported a
// 672kB shell for a 66kB one, which is a gate that fails for the wrong
// reason and would be "fixed" by raising the budget.
const start = entryPath?.split("/").pop()
?? [...byName.keys()].find((n) => n === "app.js");
if (start === undefined || !byName.has(start)) return new Set();
const seen = new Set([start]);
const queue = [start];
while (queue.length > 0) {
const name = queue.pop();
for (const imported of byName.get(name)?.imports ?? []) {
// `dynamic-import` is the edge that makes a chunk lazy. Everything else
// -- import-statement, require-call -- runs before the importer's body.
if (imported.kind === "dynamic-import") continue;
const next = imported.path.split("/").pop();
if (byName.has(next) && !seen.has(next)) {
seen.add(next);
queue.push(next);
}
}
}
return seen;
}
/** Marks a resolution the plugin below started itself, so it lets it through. */
const PROBE = Symbol("resolve-probe");
/** This package: where bare imports are resolved from FIRST (see the plugin). */
const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
/**
* @param options
* `outdir` turns on code splitting: the entry is written as `app.js` and
* everything behind an `import()` becomes a `chunk-*.js` beside it, which
* is the shape `index.html` loads. `outfile` is the single-file form, kept
* for the fixture tests and for a caller that wants one artefact.
* `targets` and `keepDynamicImport` exist for `bundle-target.test.mjs`:
* the first to measure a floor other than the declared one, the second to
* show that the `supported` override below is load-bearing.
*/
export async function bundle({
entry, outfile, outdir, minify = true, write = true, targets = TARGETS, keepDynamicImport = true,
}) {
const splitting = outdir !== undefined;
const result = await esbuild.build({
entryPoints: [entry],
outfile,
...(splitting
? { outdir, splitting: true, entryNames: "app", chunkNames: "chunk-[hash]" }
: { outfile }),
bundle: true,
format: "esm",
platform: "browser",
target: TARGETS,
target: targets,
// `import()` is what makes the engine a chunk, and it is Chrome 63. Below
// that esbuild would LOWER it to a static import and quietly undo the
// split -- the file header has the numbers. Kept as an import() at any
// target; SPLIT_MIN_CHROME records what that costs. `false` leaves esbuild
// to its own table, which is how the test measures it.
...(keepDynamicImport ? { supported: { "dynamic-import": true } } : {}),
minify,
sourcemap: true,
metafile: true,
write,
logLevel: "silent",
// Bare specifiers stay external so they show up in the metafile as
// unresolved rather than being silently shimmed. That is the whole point:
// we want to SEE a `crypto` import, not have esbuild paper over it.
// Node builtins and the CLI-only pretty-printers stay external, so they
// show up in the metafile as unresolved rather than being silently
// shimmed. That is the point: we want to SEE a `crypto` import, not have
// esbuild paper over it.
//
// Everything else bare is left to esbuild to RESOLVE AND BUNDLE. It used
// to be externalised too, which quietly made splitting impossible: an
// external `import("praisonai/mobile")` survives verbatim into the output,
// a webview has no resolver for it, and the gate passed because the
// specifier resolves on the build machine. Bundling it is what turns it
// into a chunk.
plugins: [{
name: "surface-bare-imports",
setup(build) {
build.onResolve({ filter: /^[^.\/]/ }, (args) => ({ path: args.path, external: true }));
build.onResolve({ filter: /^[^.\/]/ }, async (args) => {
// The probe below re-enters this hook; hand that one to esbuild.
if (args.pluginData === PROBE) return null;
const head = args.path.replace(/^node:/, "").split("/")[0];
if (NODE_BUILTINS.includes(head)) return { path: args.path, external: true };
if (CLI_ONLY_PACKAGES.includes(head)) return { path: args.path, external: true };
// Resolved from THIS package first, and from the importer second.
//
// Peers are the consumer's to provide: praisonai lists `ai` and
// `@ai-sdk/*` as optional peers, this package declares them, and a
// registry install hoists them beside praisonai where its imports
// find them. Through the `file:` link Node's real-path resolution
// looks in ../praisonai-ts/node_modules instead and cannot see them
// -- `@ai-sdk/cohere`, which praisonai imports and never declares,
// was reported unresolved, measured. Consumer-first is the layout a
// registry install produces, without depending on hoisting to
// produce it, and it keeps `zod` and `@ai-sdk/provider-utils` to
// one copy. praisonai's own dependencies (openai, and the rest) are
// not here and fall through to its node_modules, as they should.
//
// A specifier neither can resolve must not abort the build with
// esbuild's own error: it is left external so it reaches the
// metafile, where the `unresolved` check below names it in the
// gate's words, alongside every other reason a bundle cannot ship.
const probe = { kind: args.kind, importer: args.importer, pluginData: PROBE };
const here = await build.resolve(args.path, { ...probe, resolveDir: PACKAGE_ROOT });
if (here.errors.length === 0) {
return { path: here.path, namespace: here.namespace, external: here.external, sideEffects: here.sideEffects };
}
const there = await build.resolve(args.path, { ...probe, resolveDir: args.resolveDir });
return there.errors.length > 0 ? { path: args.path, external: true } : null;
});
},
}],
});
// Chunk accounting. `outputs` in the metafile is keyed by path and carries
// both the byte count and, crucially, the IMPORTS of each chunk with their
// kind -- so eager and lazy are read off the real graph rather than guessed
// from a filename.
const chunks = chunkSizes(result.metafile);
// The entry chunk's own path, named rather than guessed (see eagerChunks).
// Under `outdir` that is always app.js -- `entryNames: "app"` above -- and
// under `outfile` it is the file itself.
const outPath = splitting
? Object.keys(result.metafile.outputs).find((o) => /(^|\/)app\.js$/.test(o))
: outfile;
const eagerNames = eagerChunks(result.metafile, outPath);
const shellBytes = chunks
.filter((c) => eagerNames.has(c.name))
.reduce((a, c) => a + c.bytes, 0);
const lazyBytes = chunks
.filter((c) => !eagerNames.has(c.name))
.reduce((a, c) => a + c.bytes, 0);
// The ENTRY chunk's text, for the source-level checks: a top-level
// process.env read is only import-time fatal in a chunk that loads at
// import time.
// By NAME, not "the first .js output": under `write: false` a split build's
// outputFiles are in no particular order, and the first one was a chunk.
const entryName = outPath.split("/").pop();
const code = write
? await readFile(outfile, "utf8")
: (result.outputFiles?.find((f) => f.path.endsWith(".js"))?.text ?? "");
? await readFile(outPath, "utf8")
: (result.outputFiles?.find((f) => f.path.split("/").pop() === entryName)?.text ?? "");
const classified = classifyBareImports(result.metafile);
const bare = [...classified.keys()].sort();
const forbidden = forbiddenAmong(bare);
const fatal = forbidden.filter((p) => classified.get(p) === "static");
const lazy = forbidden.filter((p) => classified.get(p) === "dynamic");
// A bare import that is NOT a Node builtin and still left the build as an
// external is one esbuild could not resolve -- the package is not installed.
// A CLI-only package is external by design, and an EXTERNAL that is imported
// statically dies at import time exactly like a builtin. Neither check above
// sees it: it is not a builtin, and it resolves fine on the build machine.
const cliStatic = bare.filter(
(p) => CLI_ONLY_PACKAGES.includes(p.split("/")[0]) && classified.get(p) === "static",
);
// A bare import that is NOT a builtin and NOT a CLI-only external, and still
// left the build as an external, is one the plugin's probe could not resolve
// FROM ITS IMPORTER -- the same question esbuild asks, in the same place.
// Read off the metafile rather than re-resolved from the entry, and that
// matters now that `praisonai` is a `file:` link: its own dependencies
// (openai, and the rest) live in ../praisonai-ts/node_modules and resolve
// from there, not from this package's entry, so a resolve-from-the-entry
// would call a bundled, working import "unresolved".
//
// A webview has no module resolver. `import "openai"` in the shipped file is
// a hard failure at import time, before any code runs, with the same blank
// screen as a Node builtin -- and until now the gate said `shippable: true`
// for it, because `problems` was built only from forbidden BUILTINS, top
// level process.env, and size.
//
// Measured: bundling praisonai-ts's webview entry through this gate reported
// 80.5kB, 0 problems and `shippable: true` while silently leaving out
// openai, ai, @ai-sdk/cohere, @ai-sdk/google, @ai-sdk/openai,
// @ai-sdk/provider-utils, chalk, ora, boxen, figlet and cli-table3. That
// bundle loads on a laptop with node_modules beside it and dies on a phone.
//
// CORRECTED. The first version of this asked "did it stay external?", which
// is true of EVERY bare import here -- the plugin below externalises them all
// on purpose, so builtins surface in the metafile instead of being shimmed.
// So it flagged installed, perfectly resolvable packages as unresolvable, and
// only passed because the shipped bundle happens to have no bare imports at
// all. Confirmed against a real install: `praisonai/mobile` resolves to
// node_modules/praisonai/dist/mobile.js and was reported unresolved anyway.
//
// The question that actually matters is whether the specifier can be
// RESOLVED from the entry, so it is asked directly.
const resolver = createRequire(resolve(entry));
const unresolved = bare.filter((name) => {
if (RUNTIME_MARKERS.has(name)) return false;
// Redundant in practice and kept for intent: `createRequire` resolves
// builtins too, so removing this line changes nothing (verified). A
// builtin is already classified as fatal-or-lazy above and must not be
// reported twice under a different heading -- that is what this says.
if (NODE_BUILTINS.includes(name.split("/")[0])) return false;
try {
resolver.resolve(name);
return false;
} catch {
return true;
}
});
// screen as a Node builtin -- and the gate once said `shippable: true` for
// exactly that: bundling praisonai-ts's webview entry reported 80.5kB, 0
// problems, while silently leaving out openai, ai, @ai-sdk/*, chalk, ora,
// boxen, figlet and cli-table3. Loads on a laptop with node_modules beside
// it, dies on a phone.
const unresolved = bare.filter(
(name) =>
!RUNTIME_MARKERS.has(name) &&
!NODE_BUILTINS.includes(name.split("/")[0]) &&
!CLI_ONLY_PACKAGES.includes(name.split("/")[0]),
);
const processReads = topLevelProcessReads(code);
const bytes = Buffer.byteLength(code, "utf8");
// `bytes` is the SHELL cost. For a single-file build that is the whole file,
// which is what the name always meant; for a split build it is what the
// page pays before first paint, which is what the name should have meant.
const bytes = shellBytes;
const problems = [];
if (fatal.length > 0) {
@@ -238,6 +453,14 @@ export async function bundle({ entry, outfile, minify = true, write = true }) {
` boundary and the screen stays blank. Alias them or fix upstream.`,
);
}
if (cliStatic.length > 0) {
problems.push(
`CLI-only packages imported STATICALLY: ${cliStatic.join(", ")}.\n` +
` They are left external on purpose (CLI_ONLY_PACKAGES), which is safe\n` +
` only behind an import() on a path a phone never takes. A static import\n` +
` of an external fails at IMPORT time like a builtin does.`,
);
}
if (processReads.length > 0) {
problems.push(
`top-level process.env reads (${processReads.length}):\n` +
@@ -253,27 +476,48 @@ export async function bundle({ entry, outfile, minify = true, write = true }) {
` dependencies so they are bundled, or stop importing them.`,
);
}
if (bytes > SIZE_BUDGET_BYTES) {
// TWO budgets, because the two populations are paid at different moments.
// Folding them into one number would either let the shell grow behind a
// lazy allowance or refuse a lazily-loaded engine for being big, and both
// of those are the wrong answer.
if (shellBytes > SHELL_BUDGET_BYTES) {
problems.push(
`bundle is ${(bytes / 1024).toFixed(1)}kB, over the ${(SIZE_BUDGET_BYTES / 1024).toFixed(0)}kB budget.`,
`the SHELL is ${(shellBytes / 1024).toFixed(1)}kB, over the ${(SHELL_BUDGET_BYTES / 1024).toFixed(0)}kB shell budget.\n` +
` That is the entry chunk plus everything statically imported from it --\n` +
` fetched and parsed before the first frame, on every cold start.`,
);
}
if (lazyBytes > LAZY_BUDGET_BYTES) {
problems.push(
`chunks behind an import() total ${(lazyBytes / 1024).toFixed(1)}kB, over the ` +
`${(LAZY_BUDGET_BYTES / 1024).toFixed(0)}kB lazy budget.`,
);
}
return { bytes, bare, forbidden, fatal, lazy, unresolved, processReads, problems, metafile: result.metafile, code };
return {
bytes, shellBytes, lazyBytes, chunks, eager: eagerNames,
bare, forbidden, fatal, lazy, cliStatic, unresolved, processReads,
problems, metafile: result.metafile, code,
};
}
/** CLI. Guarded so importing this module for tests does not run a build. */
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop())) {
const entry = process.argv[2] ?? "app/src/main.ts";
const outfile = process.argv[3] ?? "dist/app.js";
const report = await bundle({ entry, outfile });
const outdir = process.argv[3] ?? "dist";
const report = await bundle({ entry, outdir });
for (const problem of report.problems) console.error(` ✖ ${problem}`);
for (const name of report.lazy) {
// Reported, never silent: a lazy builtin is fine only while nothing on a
// phone calls into it, and that is a claim worth restating every build.
console.log(` ! ${name} is imported lazily -- fine while no mobile path calls it`);
}
console.log(`bundle: ${(report.bytes / 1024).toFixed(1)}kB, ${report.bare.length} external`);
console.log(
`bundle: shell ${(report.shellBytes / 1024).toFixed(1)}kB of ` +
`${(SHELL_BUDGET_BYTES / 1024).toFixed(0)}kB, lazy ${(report.lazyBytes / 1024).toFixed(1)}kB of ` +
`${(LAZY_BUDGET_BYTES / 1024).toFixed(0)}kB, ${report.chunks.length} chunks, ` +
`${report.bare.length} external`,
);
if (report.problems.length > 0) process.exit(1);
}
+123 -16
View File
@@ -21,7 +21,10 @@ import {
unresolvedBareImports,
classifyBareImports,
FORBIDDEN_BUILTINS,
SIZE_BUDGET_BYTES, isShippable } from "./bundle.mjs";
SHELL_BUDGET_BYTES,
LAZY_BUDGET_BYTES,
isShippable,
} from "./bundle.mjs";
/** Write a throwaway package and bundle it. */
async function fixture(files) {
@@ -35,6 +38,9 @@ async function fixture(files) {
dir,
run: (entry = "main.js") =>
bundle({ entry: join(dir, entry), outfile: join(dir, "out.js"), minify: false }),
// The split form, which is what ships: `outdir` turns on chunking.
runSplit: (entry = "main.js") =>
bundle({ entry: join(dir, entry), outdir: join(dir, "out"), minify: false }),
};
}
@@ -132,25 +138,75 @@ test("a property named env on another object is not a process read", () => {
assert.deepEqual(topLevelProcessReads("const x = config.process.env;\n"), []);
});
// ---- the size budget -------------------------------------------------------
// ---- the size budgets ------------------------------------------------------
//
// Two of them, and the tests are shaped to prove they are two: a chunk that
// is over one and under the other must pass or fail according to WHICH graph
// it sits in, never according to its size alone.
test("a bundle over budget fails the build", async () => {
// Generated, not committed: a real 400kB fixture in the repo would itself be
// the thing the budget exists to prevent.
const filler = `export const blob = "${"x".repeat(SIZE_BUDGET_BYTES + 1024)}";\n`;
const { run } = await fixture({ "main.js": filler });
/** Generated, not committed: a real 400kB fixture in the repo would itself be
* the thing the budget exists to prevent. */
const blob = (bytes) => `export const blob = "${"x".repeat(bytes)}";\n`;
test("a bundle over the shell budget fails the build", async () => {
const { run } = await fixture({ "main.js": blob(SHELL_BUDGET_BYTES + 1024) });
const report = await run();
assert.ok(report.bytes > SIZE_BUDGET_BYTES);
assert.ok(report.problems.some((p) => /budget/.test(p)));
assert.ok(report.shellBytes > SHELL_BUDGET_BYTES);
assert.ok(report.problems.some((p) => /shell budget/.test(p)), report.problems.join("\n"));
});
test("a small bundle is within budget", async () => {
const { run } = await fixture({ "main.js": "export const a = 1;\n" });
const report = await run();
assert.ok(report.bytes < SIZE_BUDGET_BYTES);
assert.ok(report.shellBytes < SHELL_BUDGET_BYTES);
assert.deepEqual(report.problems, []);
});
test("a chunk behind an import() is charged to the LAZY budget, not the shell", async () => {
// Over the shell budget, under the lazy one, reached only through import():
// this must PASS. It is the whole point of splitting -- the engine is far
// larger than the shell is allowed to be, and that is fine because nobody
// pays for it before first paint. One budget could not say this.
const { runSplit } = await fixture({
"main.js": "export const load = () => import('./big.js');\n",
"big.js": blob(SHELL_BUDGET_BYTES + 100 * 1024),
});
const report = await runSplit();
assert.ok(report.shellBytes < SHELL_BUDGET_BYTES, `shell is ${report.shellBytes}`);
assert.ok(report.lazyBytes > SHELL_BUDGET_BYTES, "the lazy side really is over the shell budget");
assert.ok(report.lazyBytes < LAZY_BUDGET_BYTES);
assert.deepEqual(report.problems, [], report.problems.join("\n"));
});
test("the same chunk imported STATICALLY lands in the shell and fails", async () => {
// The pair, and the regression splitting is most likely to suffer: someone
// turns an `import()` into an `import`, every byte moves to first paint, and
// the only visible symptom is a slower cold start. The gate is the symptom.
const { runSplit } = await fixture({
"main.js": "import { blob } from './big.js';\nexport const n = blob.length;\n",
"big.js": blob(SHELL_BUDGET_BYTES + 100 * 1024),
});
const report = await runSplit();
assert.ok(report.shellBytes > SHELL_BUDGET_BYTES);
assert.equal(report.lazyBytes, 0, "nothing is lazy when nothing is behind an import()");
assert.ok(report.problems.some((p) => /shell budget/.test(p)), report.problems.join("\n"));
});
test("lazy chunks over the LAZY allowance fail the build", async () => {
// Lazy is deferred, not free: whoever picks the engine pays for all of it.
// So it has a ceiling of its own, and this is the test that says the ceiling
// exists -- as opposed to "lazy bytes are simply not counted".
const { runSplit } = await fixture({
"main.js": "export const load = () => import('./huge.js');\n",
"huge.js": blob(LAZY_BUDGET_BYTES + 1024),
});
const report = await runSplit();
assert.ok(report.shellBytes < SHELL_BUDGET_BYTES, "the shell is still tiny");
assert.ok(report.lazyBytes > LAZY_BUDGET_BYTES);
assert.ok(report.problems.some((p) => /lazy budget/.test(p)), report.problems.join("\n"));
assert.ok(!report.problems.some((p) => /shell budget/.test(p)), "and it is the LAZY budget that named it");
});
// ---- the helpers ------------------------------------------------------------
test("forbiddenAmong matches a builtin submodule path", () => {
@@ -263,9 +319,9 @@ test("a relative import that is external is not reported as a bare module", () =
test("the shipping bundle resolves everything it imports", async () => {
// The positive control, and the one that would actually catch a regression:
// the real app entry, through the real gate.
const out = join(mkdtempSync(join(tmpdir(), "ship-")), "app.js");
const report = await bundle({ entry: "app/src/main.ts", outfile: out, write: false });
// the real app entry, through the real gate, in the real split shape.
const outdir = mkdtempSync(join(tmpdir(), "ship-"));
const report = await bundle({ entry: "app/src/main.ts", outdir, write: false });
assert.deepEqual(report.unresolved, [], `the shipped bundle must resolve everything: ${report.unresolved}`);
assert.equal(isShippable(report), true, report.problems.join("\n"));
@@ -297,14 +353,20 @@ test("a bare import that IS installed is not reported unresolvable", async () =>
// it flagged installed, resolvable packages, and only passed because the
// shipped bundle happens to have no bare imports at all.
//
// `esbuild` is a real dependency of this package, so it is the honest probe:
// resolvable from here, and nothing to do with Node builtins.
// The probe is `@ai-sdk/provider`: on praisonai/mobile's graph, 6kB, and
// browser-clean, so the gate's other checks stay out of the way. It used to
// be `esbuild`, which is now BUNDLED rather than left external -- and
// bundling esbuild's Node-only lib drags in `fs`, `child_process` and an
// optional `pnpapi`, so the probe failed the gate for reasons that had
// nothing to do with resolution. The declared deps all drag in zod's 426kB,
// which trips the shell budget instead: same wrong reason, other side.
//
// The probe lives INSIDE the package, because resolution is relative to the
// entry: a temp-directory entry has no node_modules above it, so everything
// would look missing and the test would pass for the wrong reason. The real
// app entry is inside the package too.
const entry = join(import.meta.dirname, ".resolvable-probe.ts");
writeFileSync(entry, 'import * as e from "esbuild";\nexport const x = e;\n');
writeFileSync(entry, 'import * as p from "@ai-sdk/provider";\nexport const x = p;\n');
try {
const report = await bundle({
entry,
@@ -331,3 +393,48 @@ test("a LAZY Node builtin is still allowed -- the pair", async () => {
assert.deepEqual(report.unresolved, [], "a builtin is classified as a builtin, not as unresolved");
assert.deepEqual(report.fatal, [], "and a dynamic one is not fatal");
});
// ---- the CLI-only externals ------------------------------------------------
test("a CLI-only package imported STATICALLY fails the gate", async () => {
// chalk, boxen, ora, cli-table3 and figlet are left external on purpose,
// and an external reached by a static import is import-time fatal -- the
// same blank screen as a builtin, with neither of the checks above able to
// see it: it is not a builtin, and on the build machine it resolves.
//
// Inside the package, for the same reason as the resolvable probe: chalk
// has to actually resolve for this to be about the static/dynamic line.
const entry = join(import.meta.dirname, ".cli-static-probe.ts");
writeFileSync(entry, 'import chalk from "chalk";\nexport const x = chalk;\n');
try {
const report = await bundle({
entry,
outfile: join(mkdtempSync(join(tmpdir(), "cli-")), "o.js"),
write: false,
});
assert.deepEqual(report.cliStatic, ["chalk"]);
assert.equal(isShippable(report), false, "a static external must not ship");
assert.match(report.problems.join("\n"), /CLI-only[^\n]*STATICALLY[^\n]*chalk/);
} finally {
rmSync(entry, { force: true });
}
});
test("a CLI-only package behind an import() is allowed -- the pair", async () => {
// This is how praisonai's pretty-logger reaches them, and why they can be
// external at all: a rejected dynamic import on a path a phone never takes.
const entry = join(import.meta.dirname, ".cli-dynamic-probe.ts");
writeFileSync(entry, 'export const pretty = async () => import("chalk");\n');
try {
const report = await bundle({
entry,
outfile: join(mkdtempSync(join(tmpdir(), "cli-")), "o.js"),
write: false,
});
assert.ok(report.bare.includes("chalk"), "it is still external, and still visible");
assert.deepEqual(report.cliStatic, []);
assert.equal(isShippable(report), true, report.problems.join("\n"));
} finally {
rmSync(entry, { force: true });
}
});
+10 -6
View File
@@ -24,7 +24,7 @@ import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { SIZE_BUDGET_BYTES, TARGETS } from "./bundle.mjs";
import { SHELL_BUDGET_BYTES, LAZY_BUDGET_BYTES, TARGETS } from "./bundle.mjs";
import { importsOf, layerOf, targetOf, matchesAllowlist, ungovernedRootsIn, violations, sourceFilesUnder } from "./depgraph.mjs";
const here = dirname(fileURLToPath(import.meta.url));
@@ -347,13 +347,17 @@ test("an ungoverned directory holding only .mjs is still reported", () => {
}
});
test("the size budget and the webview targets are the values the gate claims", () => {
// `SIZE_BUDGET_BYTES` and `TARGETS` are the entire contract of the bundle
// gate, and both could be changed silently -- 400kB to 4MB, or the baseline
// moved from safari16/chrome108 to a browser no target device runs. A gate
test("the size budgets and the webview targets are the values the gate claims", () => {
// `SHELL_BUDGET_BYTES`, `LAZY_BUDGET_BYTES` and `TARGETS` are the entire
// contract of the bundle gate, and each could be changed silently -- 400kB
// to 4MB, or the baseline moved to a browser no target device runs. A gate
// whose threshold can be edited without a failing test is a gate that can be
// turned off.
assert.equal(SIZE_BUDGET_BYTES, 400 * 1024, "the budget is what makes a dependency a decision");
assert.equal(SHELL_BUDGET_BYTES, 400 * 1024, "the shell budget is what makes a dependency a decision");
// The lazy allowance is a second number on purpose (bundle.mjs says why),
// pinned for the same reason as the first: the engine went from 0 to 1460kB
// in one PR, and the next provider is 100-170kB.
assert.equal(LAZY_BUDGET_BYTES, 1600 * 1024, "the lazy allowance is a decision too");
// The Chrome floor is DERIVED from `minSdkVersion`, not restated here. This
// line said `chrome108` while tauri.conf.json declared `minSdkVersion: 26`
// -- Android 8.0, WebView ~Chrome 58 -- so the two numbers disagreed and a