From 2d65182840b09b9d8bf3450dd5d2eb711a66007e Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 17 Aug 2026 05:09:12 +0000 Subject: [PATCH 1/5] perf(data-inspector): lazy-load and inline jora on the node side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jora only loads on the first actual query now — engine/query-engine.ts caches a single `await import('jora')` + `setup()` instead of paying for it eagerly at module load, which used to happen just from registering the plugin's RPC functions, whether or not anyone opens the panel. The node tsdown build inlines jora (deps.alwaysBundle) so that lazy import resolves a chunk shipped inside dist instead of a node_modules lookup consumers would otherwise need to satisfy. The browser build keeps jora external/dependency-resolved, since it already loads eagerly there for query-editor syntax gating. --- .../data-inspector/src/engine/query-engine.ts | 109 +++++++++++------- plugins/data-inspector/test/engine.test.ts | 32 ++--- plugins/data-inspector/test/registry.test.ts | 2 +- plugins/data-inspector/tsdown.config.ts | 8 ++ 4 files changed, 90 insertions(+), 61 deletions(-) diff --git a/plugins/data-inspector/src/engine/query-engine.ts b/plugins/data-inspector/src/engine/query-engine.ts index 09d55204..fd01258d 100644 --- a/plugins/data-inspector/src/engine/query-engine.ts +++ b/plugins/data-inspector/src/engine/query-engine.ts @@ -9,10 +9,18 @@ * forms (`{ $type: 'Map', value }`), keeping queries portable; * - suggestions come from jora's stat mode, flattened into plain * RPC-safe completion items. + * + * jora itself loads lazily, on the first query: `import('jora')` only runs + * once `runQuery`/`runQueryAtPath`/`suggest` are actually called, so simply + * registering the data-inspector's RPC functions (which happens on every + * host that sets it up, whether or not anyone opens the panel) never pays + * for parsing jora. The node build inlines jora into its own output (see + * `tsdown.config.ts`'s `deps.alwaysBundle`) so that on-demand `import()` + * resolves a local chunk instead of a `node_modules` lookup. */ +import type { Jora } from 'jora' import type { NodePath, QueryOutcome, SuggestItem, SuggestOutcome } from './contract' import type { NormalizeOptions } from './normalize' -import jora from 'jora' import { navigate, normalize } from './normalize' export type { SuggestItem, SuggestOutcome } from './contract' @@ -45,51 +53,62 @@ function isSetLike(v: unknown): v is Set { && typeof (v as Map).get !== 'function' } -const createQuery = jora.setup({ - methods: { - /** Map(-like or normalized tag) -> plain object (string-coerced keys). */ - fromMap: (v) => { - if (isMapLike(v)) - return Object.fromEntries(v.entries()) - if (isMapTag(v)) - return v.value ?? Object.fromEntries((v.entries ?? []).map(e => [String(e.key), e.value])) - return v - }, - /** Map(-like or normalized tag) -> [{ key, value }] preserving key identity. */ - mapEntries: (v) => { - if (isMapLike(v)) - return [...v.entries()].map(([key, value]) => ({ key, value })) - if (isMapTag(v)) { - if (v.entries) - return v.entries - return Object.entries(v.value ?? {}).map(([key, value]) => ({ key, value })) - } - return [] - }, - /** Set(-like or normalized tag) -> array. */ - fromSet: (v) => { - if (isSetLike(v)) - return [...v] - if (isSetTag(v)) - return v.values ?? [] - return v - }, - /** Constructor name of any value. */ - typeOf: (v) => { - if (v === null) - return 'null' - if (typeof v !== 'object') - return typeof v - return (v as object).constructor?.name ?? 'Object' +type CreateQuery = ReturnType + +/** + * jora loads on first use and is cached for the process lifetime — a single + * `import('jora')` + `setup()`, however many queries follow. + */ +let createQueryPromise: Promise | undefined + +function getCreateQuery(): Promise { + return createQueryPromise ??= import('jora').then(({ default: jora }) => jora.setup({ + methods: { + /** Map(-like or normalized tag) -> plain object (string-coerced keys). */ + fromMap: (v) => { + if (isMapLike(v)) + return Object.fromEntries(v.entries()) + if (isMapTag(v)) + return v.value ?? Object.fromEntries((v.entries ?? []).map(e => [String(e.key), e.value])) + return v + }, + /** Map(-like or normalized tag) -> [{ key, value }] preserving key identity. */ + mapEntries: (v) => { + if (isMapLike(v)) + return [...v.entries()].map(([key, value]) => ({ key, value })) + if (isMapTag(v)) { + if (v.entries) + return v.entries + return Object.entries(v.value ?? {}).map(([key, value]) => ({ key, value })) + } + return [] + }, + /** Set(-like or normalized tag) -> array. */ + fromSet: (v) => { + if (isSetLike(v)) + return [...v] + if (isSetTag(v)) + return v.values ?? [] + return v + }, + /** Constructor name of any value. */ + typeOf: (v) => { + if (v === null) + return 'null' + if (typeof v !== 'object') + return typeof v + return (v as object).constructor?.name ?? 'Object' + }, + /** All own keys (incl. non-enumerable), as strings. */ + ownKeys: v => (v && typeof v === 'object') ? Reflect.ownKeys(v).map(String) : [], }, - /** All own keys (incl. non-enumerable), as strings. */ - ownKeys: v => (v && typeof v === 'object') ? Reflect.ownKeys(v).map(String) : [], - }, -}) + })) +} -export function runQuery(target: unknown, query: string, options?: NormalizeOptions): QueryOutcome { +export async function runQuery(target: unknown, query: string, options?: NormalizeOptions): Promise { try { const started = performance.now() + const createQuery = await getCreateQuery() const raw = createQuery(query)(target) const queryMs = Math.round((performance.now() - started) * 100) / 100 const { data, stats } = normalize(raw, options) @@ -110,9 +129,10 @@ export function runQuery(target: unknown, query: string, options?: NormalizeOpti * 'depth'` marker the client is expanding, so the same filter options must be * threaded through (they shift array indices and drop keys). */ -export function runQueryAtPath(target: unknown, query: string, path: NodePath, options?: NormalizeOptions): QueryOutcome { +export async function runQueryAtPath(target: unknown, query: string, path: NodePath, options?: NormalizeOptions): Promise { try { const started = performance.now() + const createQuery = await getCreateQuery() const raw = createQuery(query)(target) const node = navigate(raw, path, options) const queryMs = Math.round((performance.now() - started) * 100) / 100 @@ -140,9 +160,10 @@ interface JoraStatEntry { * its candidates in a nested `suggestions` array — flattened here into plain, * RPC-safe completion items. */ -export function suggest(target: unknown, query: string, pos: number, limit = 30): SuggestOutcome { +export async function suggest(target: unknown, query: string, pos: number, limit = 30): Promise { try { const started = performance.now() + const createQuery = await getCreateQuery() const statApi = createQuery(query, { tolerant: true, stat: true })(target) as { suggestion: (pos: number, opts?: { limit?: number }) => JoraStatEntry[] | null } diff --git a/plugins/data-inspector/test/engine.test.ts b/plugins/data-inspector/test/engine.test.ts index 0602c576..deb23069 100644 --- a/plugins/data-inspector/test/engine.test.ts +++ b/plugins/data-inspector/test/engine.test.ts @@ -101,8 +101,8 @@ describe('depth truncation + lazy expand', () => { expect(navigate(g, [['k', 'map'], ['mv', 0]])).toBe('v') }) - it('runQueryAtPath re-runs and returns a fresh slice of the subtree', () => { - const out = runQueryAtPath(deep(), '$', [['k', 'level0'], ['k', 'level1']], { maxDepth: 3 }) + it('runQueryAtPath re-runs and returns a fresh slice of the subtree', async () => { + const out = await runQueryAtPath(deep(), '$', [['k', 'level0'], ['k', 'level1']], { maxDepth: 3 }) expect(out.ok).toBe(true) if (out.ok) { // The subtree normalizes from level2 with a fresh budget, reaching the leaf. @@ -110,45 +110,45 @@ describe('depth truncation + lazy expand', () => { } }) - it('runQueryAtPath fails soft on a broken base query', () => { - expect(runQueryAtPath(deep(), 'nope.method()', []).ok).toBe(false) + it('runQueryAtPath fails soft on a broken base query', async () => { + expect((await runQueryAtPath(deep(), 'nope.method()', [])).ok).toBe(false) }) }) describe('runQuery (live)', () => { - it('queries live Maps and Sets through the bridge methods', () => { - const out = runQuery(liveGraph(), 'store.entries.mapEntries().key') + it('queries live Maps and Sets through the bridge methods', async () => { + const out = await runQuery(liveGraph(), 'store.entries.mapEntries().key') expect(out).toMatchObject({ ok: true, result: ['a', 'b'] }) - const set = runQuery(liveGraph(), 'tags.fromSet()') + const set = await runQuery(liveGraph(), 'tags.fromSet()') expect(set).toMatchObject({ ok: true, result: ['alpha', 'beta'] }) }) - it('reports payload size and timings', () => { - const out = runQuery(liveGraph(), 'store.name') + it('reports payload size and timings', async () => { + const out = await runQuery(liveGraph(), 'store.name') expect(out.ok && out.stats.payloadBytes).toBeGreaterThan(0) }) - it('fails soft with an error envelope', () => { - const out = runQuery(liveGraph(), 'nope.method()') + it('fails soft with an error envelope', async () => { + const out = await runQuery(liveGraph(), 'nope.method()') expect(out.ok).toBe(false) }) }) describe('runQuery (static portability)', () => { - it('the same query works against the NORMALIZED form of the data', () => { + it('the same query works against the NORMALIZED form of the data', async () => { const { data } = normalize(liveGraph()) // `store.entries` is now a `{ $type: 'Map', value }` tag; the bridge // methods duck-type it so live-authored queries stay portable. - const out = runQuery(data, 'store.entries.mapEntries().key') + const out = await runQuery(data, 'store.entries.mapEntries().key') expect(out).toMatchObject({ ok: true, result: ['a', 'b'] }) - const set = runQuery(data, 'tags.fromSet()') + const set = await runQuery(data, 'tags.fromSet()') expect(set).toMatchObject({ ok: true, result: ['alpha', 'beta'] }) }) }) describe('suggest', () => { - it('returns flattened, prefix-ranged completion items', () => { - const out = suggest({ foo: { bar: 1, baz: 2 } }, 'foo.', 4) + it('returns flattened, prefix-ranged completion items', async () => { + const out = await suggest({ foo: { bar: 1, baz: 2 } }, 'foo.', 4) expect(out.ok).toBe(true) expect(out.suggestions.map(s => s.value)).toEqual(['bar', 'baz']) expect(out.suggestions[0]).toMatchObject({ from: 4, to: 4, current: '' }) diff --git a/plugins/data-inspector/test/registry.test.ts b/plugins/data-inspector/test/registry.test.ts index f8403cc9..b6609547 100644 --- a/plugins/data-inspector/test/registry.test.ts +++ b/plugins/data-inspector/test/registry.test.ts @@ -158,7 +158,7 @@ describe('example source', () => { const data = await resolveSourceData(getDataSource(entry.id)!) const { runQuery } = await import('../src/engine/query-engine') for (const recipe of entry.queries ?? []) { - const out = runQuery(data, recipe.query.trim() || '$', recipe) + const out = await runQuery(data, recipe.query.trim() || '$', recipe) expect(out.ok, `suggested query "${recipe.title}" must run`).toBe(true) } }) diff --git a/plugins/data-inspector/tsdown.config.ts b/plugins/data-inspector/tsdown.config.ts index d7bfce02..cde57256 100644 --- a/plugins/data-inspector/tsdown.config.ts +++ b/plugins/data-inspector/tsdown.config.ts @@ -35,6 +35,14 @@ export default defineConfig([ tsconfig, dts: false, entry: serverEntries, + // jora is loaded via a lazy `import('jora')` in `engine/query-engine.ts` + // (only paid for on the first query), and inlined here so that lazy + // import resolves a chunk shipped inside this package's own `dist` + // instead of a `node_modules` lookup consumers would otherwise need to + // satisfy just to load the RPC functions. The browser build (SPA, + // `engine/index` client entry) keeps jora external/dependency-resolved, + // since jora already loads eagerly there for query-editor syntax gating. + deps: { alwaysBundle: ['jora'] }, }, // One dts graph PER entry: a single-entry graph can never split shared // chunks, so declarations always inline and the emitted .d.mts files are From c969ac20e7da4ea0405cbddad2c64b49fd9916a0 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 17 Aug 2026 05:21:03 +0000 Subject: [PATCH 2/5] chore(data-inspector): move jora to devDependencies (catalog:inlined) jora no longer needs to be an installable dependency for either build: it now moves under devDependencies with catalog:inlined (matching devframe's ua-parser-modern precedent), so tsdown vendors it into this package's own dist on both the node and browser platforms by default. Drops the now-redundant deps.alwaysBundle override from tsdown.config.ts. --- plugins/data-inspector/package.json | 4 ++-- plugins/data-inspector/src/engine/query-engine.ts | 8 +++++--- plugins/data-inspector/tsdown.config.ts | 8 -------- pnpm-lock.yaml | 12 ++++++------ pnpm-workspace.yaml | 2 +- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/plugins/data-inspector/package.json b/plugins/data-inspector/package.json index ad512e89..be5ac2b4 100644 --- a/plugins/data-inspector/package.json +++ b/plugins/data-inspector/package.json @@ -63,8 +63,7 @@ }, "dependencies": { "cac": "catalog:deps", - "get-port-please": "catalog:deps", - "jora": "catalog:deps" + "get-port-please": "catalog:deps" }, "devDependencies": { "@antfu/design": "catalog:frontend", @@ -80,6 +79,7 @@ "devframe": "workspace:*", "dompurify": "catalog:frontend", "floating-vue": "catalog:frontend", + "jora": "catalog:inlined", "reka-ui": "catalog:frontend", "splitpanes": "catalog:frontend", "storybook": "catalog:storybook", diff --git a/plugins/data-inspector/src/engine/query-engine.ts b/plugins/data-inspector/src/engine/query-engine.ts index fd01258d..c67a9964 100644 --- a/plugins/data-inspector/src/engine/query-engine.ts +++ b/plugins/data-inspector/src/engine/query-engine.ts @@ -14,9 +14,11 @@ * once `runQuery`/`runQueryAtPath`/`suggest` are actually called, so simply * registering the data-inspector's RPC functions (which happens on every * host that sets it up, whether or not anyone opens the panel) never pays - * for parsing jora. The node build inlines jora into its own output (see - * `tsdown.config.ts`'s `deps.alwaysBundle`) so that on-demand `import()` - * resolves a local chunk instead of a `node_modules` lookup. + * for parsing jora. jora is a `devDependency` (`catalog:inlined` in the + * workspace catalog) rather than a regular `dependency`, so tsdown vendors + * it straight into this package's own `dist` on both the node and browser + * builds — the on-demand `import()` resolves a local chunk, and neither + * side needs consumers to install jora themselves. */ import type { Jora } from 'jora' import type { NodePath, QueryOutcome, SuggestItem, SuggestOutcome } from './contract' diff --git a/plugins/data-inspector/tsdown.config.ts b/plugins/data-inspector/tsdown.config.ts index cde57256..d7bfce02 100644 --- a/plugins/data-inspector/tsdown.config.ts +++ b/plugins/data-inspector/tsdown.config.ts @@ -35,14 +35,6 @@ export default defineConfig([ tsconfig, dts: false, entry: serverEntries, - // jora is loaded via a lazy `import('jora')` in `engine/query-engine.ts` - // (only paid for on the first query), and inlined here so that lazy - // import resolves a chunk shipped inside this package's own `dist` - // instead of a `node_modules` lookup consumers would otherwise need to - // satisfy just to load the RPC functions. The browser build (SPA, - // `engine/index` client entry) keeps jora external/dependency-resolved, - // since jora already loads eagerly there for query-editor syntax gating. - deps: { alwaysBundle: ['jora'] }, }, // One dts graph PER entry: a single-entry graph can never split shared // chunks, so declarations always inline and the emitted .d.mts files are diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad32ad35..2121fe70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,9 +85,6 @@ catalogs: immer: specifier: ^11.1.16 version: 11.1.16 - jora: - specifier: ^1.0.0-beta.16 - version: 1.0.0-beta.16 launch-editor: specifier: ^2.14.1 version: 2.14.1 @@ -268,6 +265,9 @@ catalogs: '@antfu/utils': specifier: ^9.3.0 version: 9.3.0 + jora: + specifier: ^1.0.0-beta.16 + version: 1.0.0-beta.16 ua-parser-modern: specifier: ^0.1.1 version: 0.1.1 @@ -1703,9 +1703,6 @@ importers: get-port-please: specifier: catalog:deps version: 3.2.0 - jora: - specifier: catalog:deps - version: 1.0.0-beta.16 devDependencies: '@antfu/design': specifier: catalog:frontend @@ -1746,6 +1743,9 @@ importers: floating-vue: specifier: catalog:frontend version: 5.2.2(vue@3.5.41(typescript@6.0.3)) + jora: + specifier: catalog:inlined + version: 1.0.0-beta.16 reka-ui: specifier: catalog:frontend version: 2.10.3(vue@3.5.41(typescript@6.0.3)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 92b92c6f..f9d14f0a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -89,7 +89,6 @@ catalogs: hono: ^4.13.1 image-meta: ^0.2.2 immer: ^11.1.16 - jora: ^1.0.0-beta.16 launch-editor: ^2.14.1 mrmime: ^2.0.1 nitro: ^3.0.260610-beta @@ -152,6 +151,7 @@ catalogs: vue: ^3.5.41 inlined: '@antfu/utils': ^9.3.0 + jora: ^1.0.0-beta.16 ua-parser-modern: ^0.1.1 storybook: '@storybook/addon-a11y': *storybook From e4b50c820b5030285a0bcf3bac68097b76fdb588 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Mon, 17 Aug 2026 14:37:46 +0900 Subject: [PATCH 3/5] chore: update --- packages/devframe/src/rpc/wire-codec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/devframe/src/rpc/wire-codec.ts b/packages/devframe/src/rpc/wire-codec.ts index 5d3eca59..2f6f1b04 100644 --- a/packages/devframe/src/rpc/wire-codec.ts +++ b/packages/devframe/src/rpc/wire-codec.ts @@ -6,7 +6,6 @@ import { strictJsonStringify, STRUCTURED_CLONE_PREFIX } from './serialization' * The per-connection `serialize`/`deserialize` pair for a live RPC wire. * * @internal - * implementations; not part of the stable public API. */ export interface RpcWireCodec { serialize: (msg: any) => string @@ -25,7 +24,6 @@ const EMPTY_WIRE_DEFS: ReadonlyMap> = EMPTY_WIRE_DEFS, @@ -72,7 +70,6 @@ export function createRpcWireCodec( * handed to birpc proper. * * @internal - * implementations; not part of the stable public API. */ export function peekRpcWireFrame(raw: string): { t?: string, i?: string } { try { From 76a9655aeadb5eee8767e5d8dcd96ec845158593 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Mon, 17 Aug 2026 14:41:35 +0900 Subject: [PATCH 4/5] chore: update ci --- .github/workflows/ci.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8788dd9e..49c891e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,13 +14,9 @@ jobs: uses: sxzz/workflows/.github/workflows/unit-test.yml@main with: build: pnpm run ci:build - # The Build step above already produced a fresh dist/ for this exact - # checkout, so skip `test`'s own `build && vitest` - running plain - # vitest halves the number of full-monorepo `turbo run build` passes - # per job, which is where the flaky Windows native-toolchain crash - # (see scripts/ci-retry.ts) shows up. test: pnpm exec vitest lint: pnpm run lint && pnpm run knip + build-for-lint: true e2e: runs-on: ubuntu-latest From 2cc08a2f1a28f5a2321134b649b486aec6b4dba3 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Mon, 17 Aug 2026 14:46:56 +0900 Subject: [PATCH 5/5] chore: update deps --- .../@devframes/plugin-data-inspector/engine.snapshot.d.ts | 6 +++--- .../@devframes/plugin-data-inspector/engine.snapshot.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.d.ts index 99f26a25..5042815b 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.d.ts @@ -148,12 +148,12 @@ export declare function normalize(_: unknown, _?: NormalizeOptions): { data: unknown; stats: NormalizeStats; }; -export declare function runQuery(_: unknown, _: string, _?: NormalizeOptions): QueryOutcome; -export declare function runQueryAtPath(_: unknown, _: string, _: NodePath, _?: NormalizeOptions): QueryOutcome; +export declare function runQuery(_: unknown, _: string, _?: NormalizeOptions): Promise; +export declare function runQueryAtPath(_: unknown, _: string, _: NodePath, _?: NormalizeOptions): Promise; export declare function skeletonOf(_: unknown, _?: SkeletonOptions): { skeleton: unknown; nodes: number; ms: number; }; -export declare function suggest(_: unknown, _: string, _: number, _?: number): SuggestOutcome; +export declare function suggest(_: unknown, _: string, _: number, _?: number): Promise; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.js index 2fceb801..cba124dc 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.js @@ -6,8 +6,8 @@ export function applyWrite(_, _, _) {} export function isExcludedKey(_, _) {} export function navigate(_, _, _) {} export function normalize(_, _) {} -export function runQuery(_, _, _) {} -export function runQueryAtPath(_, _, _, _) {} +export async function runQuery(_, _, _) {} +export async function runQueryAtPath(_, _, _, _) {} export function skeletonOf(_, _) {} -export function suggest(_, _, _, _) {} +export async function suggest(_, _, _, _) {} // #endregion \ No newline at end of file