Skip to content

perf(devframe): micro-dependency sweep - #233

Merged
antfu merged 4 commits into
mainfrom
plan-039-micro-dep-sweep
Aug 15, 2026
Merged

perf(devframe): micro-dependency sweep#233
antfu merged 4 commits into
mainfrom
plan-039-micro-dep-sweep

Conversation

@antfubot

Copy link
Copy Markdown
Collaborator

Executes plan-039 (devframe core micro-dependency sweep). Four of the five planned items landed; item 5 (dead devDeps) is skipped with the reasoning below.

1. destr → local safe-parse

packages/devframe/src/node/storage.ts was the only runtime call site (destr(text, { strict: true })). Storage only ever reads back JSON it wrote itself via JSON.stringify, so destr's lenient non-strict parsing of bare keywords/quoted strings never applied. Replaced with a ~15-line JSON.parse + reviver that mirrors destr's __proto__/constructor.prototype prototype-pollution guard, and dropped the ~36 KB destr dependency. @devframes/hub also depends on destr (client/remote.ts) — left alone per the plan's decided scope.

2. open → native spawn helper

All three call sites (adapters/dev.ts, recipes/common-rpc-functions.ts, plugins/assets's reveal-in-folder) pass a bare string and never use open()'s options. Rewrote utils/open.ts as a ~50-line spawn helper: darwinopen, win32cmd /c start, linuxxdg-open, with WSL detection (/proc/version contains microsoft) preferring wslview, falling back to cmd.exe directly. Dropped the unused wait option (deliberate, narrow API break — the devframe/utils/open subpath itself is unchanged) and removed open plus its now-unreferenced transitive shims (bundle-name, default-browser, default-browser-id, define-lazy-prop, is-docker, is-in-ssh, is-inside-container, is-wsl, powershell-utils, run-applescript, wsl-utils) from tsdown.config.ts's onlyBundle list.

Verified the replacement fails gracefully (ENOENT, caught by the existing call-site try/catch) on this Linux container, which has none of open, xdg-open, or wslview installed.

Measured: dist/client/index.mjs unaffected (open lived only in the node build); the dedicated open-*.mjs chunk (~19 KB) is gone entirely.

3. ua-parser-modern → server-side

The client's only use was formatting navigator.userAgent into a short device label before sending it in the anonymous:devframe:auth(:exchange) handshake — the parsed shape never crossed the wire, only the resulting string did. The client now sends the raw navigator.userAgent; parsing + formatting moved to the server ingress (node/auth/state.ts, where the label is stored), keeping the persisted label format and the ua: string wire shape identical.

Measured: dist/client/index.mjs dropped from 90,433 → 62,461 bytes (−27.3 KB, matching the plan's −25–30 KB estimate).

4. immer deduped between node and browser builds

node/storage.ts and node/rpc-shared-state.ts reached devframe/utils/shared-state through the same tsconfig path alias the browser build's own utils/shared-state entry uses — two independent rolldown graphs, each inlining its own copy of immer (dist/storage-*.mjs ~38 KB, dist/shared-state-*.mjs ~33 KB).

Added devframe/utils/shared-state to the node build's deps.neverBundle only (a new nodeDeps, not touching the shared deps the browser/dts configs still use) so it's externalized instead of resolved through the path alias. The emitted import 'devframe/utils/shared-state' resolves at runtime to the already-built dist/utils/shared-state.mjs chunk via Node's package self-reference (the client build runs first in the same tsdown invocation, so the chunk is on disk by the time the node build needs it).

Verified end-to-end: built dist/node/index.mjs's createStorage() round-trips through the self-referencing import correctly (mutate → debounced write → file persisted), and grep-ing the built chunks shows immer's createDraft/produceWithPatches in exactly one file post-change (previously two). dist/storage-*.mjs dropped from ~38 KB → ~4 KB.

The rpc-augments single-declaration constraint (config 3, the combined dts build) is untouched — this change only affects config 2's runtime JS output.

5. Dead devDeps — SKIPPED, plan evidence was wrong

The plan claimed valibot and tinyglobby have "zero imports in packages/devframe/src/". That's true only if packages/devframe/test/ (the package's own top-level test directory, separate from src/**/__tests__/) is excluded from the check — and it shouldn't be:

  • tinyglobby is imported by packages/devframe/test/dts-dedupe.test.ts, which is the regression test guarding the exact "rpc-augments single declaration" invariant tsdown.config.ts's config-3 comment (and this plan's own STOP conditions) call out as load-bearing. Removing it breaks that test.
  • valibot is imported by seven src/**/__tests__/*.test.ts files (flags.test.ts, mcp-server.test.ts, to-json-schema.test.ts, host-agent.test.ts, dump.test.ts, rpc/types.test.ts, rpc/validate-io.test.ts) exercising devframe's Standard Schema interop against a real third-party validator — the doc-comment mentions the plan's evidence noticed are real, but they're not the only usages.

knip (run clean, zero findings) already correctly treats both as used via its default test-file entry detection, confirming no knip.jsonc override is masking anything. Skipped this item rather than force a removal that breaks tests, per the plan's own "material mismatch → skip and note" guidance.

Verification

  • pnpm lint && pnpm knip && pnpm build && pnpm typecheck — all clean (0 errors; 3 pre-existing unrelated jsdoc warnings in wire-codec.ts).
  • vitest run (root, all projects) — 1132/1132 passing, including the tsnapi API snapshot suite (only devframe/utils/open's deliberate signature narrowing required --allow-breaking to accept).
  • Manually exercised open() and createStorage() against the built dist/ output (see items 2 and 4 above).
  • Total packages/devframe/dist size: 746,375 → 696,630 bytes (−48.6 KB) even before counting the removed node_modules install weight (destr ~36 KB + the open tree's ~300+ KB across its transitive packages).

Created with the help of an AI agent (OpenCode).

Storage's only JSON parsing need is reading back its own previously
written state (node/storage.ts), where destr's lenient (non-strict)
handling of bare keywords/quoted strings never applies - the file is
always a JSON object literal. Inline a ~15-line JSON.parse + reviver
that mirrors destr's core __proto__/constructor.prototype
prototype-pollution guard, and drop the ~36 KB destr runtime
dependency.

@devframes/hub also depends on destr (client/remote.ts) - left alone
in this PR per the plan's decided scope.
All three call sites (adapters/dev.ts, recipes/common-rpc-functions.ts,
plugins/assets's reveal-in-folder) pass a bare string target and never
use open()'s options - the npm open package is inlined via tsdown
onlyBundle with its full is-wsl/wsl-utils/run-applescript/
powershell-utils/default-browser* dependency tree, producing a ~19 KB
dist chunk for functionality a ~30-line spawn helper covers: darwin
'open', win32 'cmd /c start', linux 'xdg-open', with WSL detection
(/proc/version) preferring wslview and falling back to invoking
cmd.exe directly.

Drops the unused wait option along with the open dependency (and its
now-unreferenced transitive shims from tsdown's onlyBundle list) -
deliberate, narrow surface break; the utils/open subpath export is
unchanged. Verified the new implementation fails gracefully (ENOENT,
caught by the existing call-site try/catches) on a plain Linux
container with none of open, xdg-open, or wslview installed.
The only client-side use of ua-parser-modern was formatting
navigator.userAgent into a short device label before sending it in the
anonymous:devframe:auth(:exchange) handshake - the parsed shape never
crossed the wire, only the resulting string did. Send the raw
navigator.userAgent instead and parse+format it at the server ingress
(node/auth/state.ts, where it's stored), keeping the persisted label
format and the ua: string wire shape identical while moving
ua-parser-modern out of the ~90 KB client bundle every embedded page
loads.
node/storage.ts and node/rpc-shared-state.ts reach
devframe/utils/shared-state through the same tsconfig path alias the
browser build's own utils/shared-state entry uses - two independent
rolldown graphs, so each was inlining its own copy of immer
(dist/storage-*.mjs and dist/shared-state-*.mjs, ~38 KB and ~33 KB).

Add devframe/utils/shared-state to the node build's deps.neverBundle
so it's externalized instead of resolved through the tsconfig path
alias; the emitted import resolves at runtime to the already-built
dist/utils/shared-state.mjs chunk via Node's package self-reference
(the client build runs first in the same tsdown invocation, so the
chunk exists on disk by the time the node build needs it). Verified
end-to-end: built dist/node/index.mjs's createStorage() round-trips
through the self-referencing import correctly, and immer's bytes now
appear in exactly one dist chunk.
@netlify

netlify Bot commented Aug 15, 2026

Copy link
Copy Markdown

Deploy Preview for devfra ready!

Name Link
🔨 Latest commit b059759
🔍 Latest deploy log https://app.netlify.com/projects/devfra/deploys/6a7ffc6a06de6700086de374
😎 Deploy Preview https://deploy-preview-233--devfra.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@antfu
antfu merged commit c5bce49 into main Aug 15, 2026
12 checks passed
@antfu
antfu deleted the plan-039-micro-dep-sweep branch August 15, 2026 05:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants