From 549ab4a00883bff6cc29b47d4e1f5cd7ae339e88 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Sat, 15 Aug 2026 05:38:06 +0000 Subject: [PATCH 1/4] perf(devframe): replace destr with local safe-parse 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. --- packages/devframe/package.json | 1 - packages/devframe/src/node/storage.ts | 17 +++++++++++++++-- pnpm-lock.yaml | 3 --- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/devframe/package.json b/packages/devframe/package.json index 77c03e43..cc1c368f 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -95,7 +95,6 @@ "@standard-schema/spec": "catalog:deps", "birpc": "catalog:deps", "crossws": "catalog:deps", - "destr": "catalog:deps", "h3": "catalog:deps", "mrmime": "catalog:deps", "nostics": "catalog:deps", diff --git a/packages/devframe/src/node/storage.ts b/packages/devframe/src/node/storage.ts index 40a4ab6e..4a65f103 100644 --- a/packages/devframe/src/node/storage.ts +++ b/packages/devframe/src/node/storage.ts @@ -1,6 +1,5 @@ import fs from 'node:fs' import process from 'node:process' -import { destr } from 'destr' import { createSharedState } from 'devframe/utils/shared-state' import { dirname } from 'pathe' import { debounce } from 'perfect-debounce' @@ -13,6 +12,20 @@ export interface CreateStorageOptions { debounce?: number } +// `JSON.parse` with a reviver that drops `__proto__`/`constructor.prototype` +// keys, mirroring destr's core prototype-pollution guard. 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 applies here - +// this file is always a JSON object literal, and invalid JSON should throw +// (caught below) rather than fall back to the raw string. +function safeJsonParse(text: string): T { + return JSON.parse(text, (key, value) => { + if (key === '__proto__' || (key === 'constructor' && value && typeof value === 'object' && 'prototype' in value)) + return undefined + return value + }) +} + export function createStorage(options: CreateStorageOptions) { const { mergeInitialValue = (initialValue, savedValue) => ({ ...initialValue, ...savedValue }), @@ -22,7 +35,7 @@ export function createStorage(options: CreateStorageOptions let initialValue: T = options.initialValue if (fs.existsSync(options.filepath)) { try { - const savedValue = destr(fs.readFileSync(options.filepath, 'utf-8'), { strict: true }) + const savedValue = safeJsonParse(fs.readFileSync(options.filepath, 'utf-8')) initialValue = mergeInitialValue ? mergeInitialValue(options.initialValue, savedValue) : savedValue } catch (error) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b6a80e5..daa72b54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1129,9 +1129,6 @@ importers: crossws: specifier: ^0.4.10 version: 0.4.10(srvx@0.12.4) - destr: - specifier: catalog:deps - version: 2.0.5 h3: specifier: catalog:deps version: 2.0.1-rc.26(crossws@0.4.10(srvx@0.12.4)) From 8a8ec858b67ac8fef09f9dfeb4d847df80d29a6e Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Sat, 15 Aug 2026 05:38:56 +0000 Subject: [PATCH 2/4] perf(devframe): replace open with a 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 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. --- packages/devframe/package.json | 1 - packages/devframe/src/utils/open.ts | 61 ++++++++++++++++--- packages/devframe/tsdown.config.ts | 12 ---- pnpm-lock.yaml | 6 -- pnpm-workspace.yaml | 1 - .../tsnapi/devframe/utils/open.snapshot.d.ts | 8 +-- .../tsnapi/devframe/utils/open.snapshot.js | 4 +- 7 files changed, 54 insertions(+), 39 deletions(-) diff --git a/packages/devframe/package.json b/packages/devframe/package.json index cc1c368f..ee6f5268 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -111,7 +111,6 @@ "mlly": "catalog:build", "obug": "catalog:deps", "ohash": "catalog:deps", - "open": "catalog:deps", "p-limit": "catalog:deps", "perfect-debounce": "catalog:deps", "structured-clone-es": "catalog:deps", diff --git a/packages/devframe/src/utils/open.ts b/packages/devframe/src/utils/open.ts index d673cfa4..a5c723a6 100644 --- a/packages/devframe/src/utils/open.ts +++ b/packages/devframe/src/utils/open.ts @@ -1,18 +1,59 @@ -import openImpl from 'open' +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import process from 'node:process' -export interface OpenOptions { - /** - * Resolve only after the launched app exits. - * - * @default false - */ - wait?: boolean +/** + * Launches `command` detached from the current process and resolves once + * the OS has accepted the spawn (not once the launched app exits) — the + * same "fire and forget" behavior `open`'s default (`wait: false`) gave us. + */ +function spawnDetached(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true }) + child.once('error', reject) + child.once('spawn', () => { + child.unref() + resolve() + }) + }) +} + +function isWsl(): boolean { + if (process.platform !== 'linux') + return false + try { + return fs.readFileSync('/proc/version', 'utf-8').toLowerCase().includes('microsoft') + } + catch { + return false + } } /** * Open a URL, file, or other target in its default OS handler * (browser for URLs, Finder/Explorer for paths, etc.). */ -export async function open(target: string, options?: OpenOptions): Promise { - await openImpl(target, options) +export async function open(target: string): Promise { + if (process.platform === 'darwin') + return spawnDetached('open', [target]) + + if (process.platform === 'win32') { + // `start` is a cmd.exe builtin; the empty title argument keeps `target` + // from being mistaken for a window title when it's itself quoted. + return spawnDetached('cmd', ['/c', 'start', '""', target]) + } + + if (isWsl()) { + // `wslview` (from wslu) hands the target to the Windows shell the same + // way an interactive user would; fall back to invoking `cmd.exe` + // directly on WSL distros that don't have wslu installed. + try { + return await spawnDetached('wslview', [target]) + } + catch { + return spawnDetached('cmd.exe', ['/c', 'start', '""', target]) + } + } + + return spawnDetached('xdg-open', [target]) } diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 45329bae..2c5b8602 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -36,32 +36,20 @@ const deps = { }, onlyBundle: [ 'acorn', - 'bundle-name', - 'default-browser', - 'default-browser-id', - 'define-lazy-prop', 'get-port-please', 'immer', - 'is-docker', - 'is-in-ssh', - 'is-inside-container', - 'is-wsl', 'launch-editor', 'mlly', 'obug', 'ohash', - 'open', 'p-limit', 'perfect-debounce', 'picocolors', - 'powershell-utils', - 'run-applescript', 'shell-quote', 'structured-clone-es', 'tinyexec', 'ua-parser-modern', 'whenexpr', - 'wsl-utils', 'yocto-queue', ], } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daa72b54..7c37ce11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,9 +106,6 @@ catalogs: ohash: specifier: ^2.0.11 version: 2.0.11 - open: - specifier: ^11.0.0 - version: 11.0.0 p-limit: specifier: ^7.3.1 version: 7.3.1 @@ -1172,9 +1169,6 @@ importers: ohash: specifier: catalog:deps version: 2.0.11 - open: - specifier: catalog:deps - version: 11.0.0 p-limit: specifier: catalog:deps version: 7.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0b515d32..0c3e3496 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -95,7 +95,6 @@ catalogs: nostics: ^1.2.0 obug: ^2.1.4 ohash: ^2.0.11 - open: ^11.0.0 p-limit: ^7.3.1 parse5: ^8.0.1 pathe: ^2.0.3 diff --git a/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.d.ts index b1a6822d..466287d4 100644 --- a/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.d.ts @@ -1,12 +1,6 @@ /** * Generated by tsnapi — public API snapshot of `devframe/utils/open` */ -// #region Interfaces -export interface OpenOptions { - wait?: boolean; -} -// #endregion - // #region Functions -export declare function open(_: string, _?: OpenOptions): Promise; +export declare function open(_: string): Promise; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.js index f0c60dcb..16d81970 100644 --- a/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.js @@ -1,6 +1,6 @@ /** * Generated by tsnapi — public API snapshot of `devframe/utils/open` */ -// #region Other -export { open } +// #region Functions +export async function open(_) {} // #endregion \ No newline at end of file From 512342d2c983892aede2f723d1c80cec19c32767 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Sat, 15 Aug 2026 05:39:14 +0000 Subject: [PATCH 3/4] perf(devframe): parse user-agent server-side 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. --- packages/devframe/src/client/rpc-live.ts | 20 +++++-------------- packages/devframe/src/node/auth/state.ts | 25 +++++++++++++++++++++++- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/devframe/src/client/rpc-live.ts b/packages/devframe/src/client/rpc-live.ts index 7e7602f1..c3546ad3 100644 --- a/packages/devframe/src/client/rpc-live.ts +++ b/packages/devframe/src/client/rpc-live.ts @@ -3,7 +3,6 @@ import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunct import type { DevframeConnectionStatus } from './connection' import type { DevframeClientRpcHost, DevframeRpcClientMode, DevframeRpcClientOptions, RpcClientEvents } from './rpc' import { createRpcClient } from 'devframe/rpc/client' -import { parseUA } from 'ua-parser-modern' import { promiseWithResolver } from '../utils/promise' import { DevframeConnectionError } from './connection' @@ -184,24 +183,15 @@ export function createLiveRpcClientMode( let currentAuthToken: string | undefined = authToken - function describeUA(): string { - const info = parseUA(navigator.userAgent) - return [ - info.browser.name, - info.browser.version, - '|', - info.os.name, - info.os.version, - info.device.type, - ].filter(i => i).join(' ') - } - async function requestTrustWithToken(token: string) { currentAuthToken = token const result = await serverRpc.$call('anonymous:devframe:auth', { authToken: token, - ua: describeUA(), + // Sent raw; the server parses it into a display label (see + // `describeUA` in `node/auth/state.ts`) so `ua-parser-modern` stays + // out of the browser bundle. + ua: navigator.userAgent, origin: location.origin, }) @@ -228,7 +218,7 @@ export function createLiveRpcClientMode( async function requestTrustWithCode(code: string): Promise { const result = await serverRpc.$call('anonymous:devframe:auth:exchange', { code, - ua: describeUA(), + ua: navigator.userAgent, origin: location.origin, }) diff --git a/packages/devframe/src/node/auth/state.ts b/packages/devframe/src/node/auth/state.ts index 823a7ed8..de58951c 100644 --- a/packages/devframe/src/node/auth/state.ts +++ b/packages/devframe/src/node/auth/state.ts @@ -3,6 +3,29 @@ import type { SharedState } from 'devframe/utils/shared-state' import type { InternalAnonymousAuthStorage } from '../hub-internals/context' import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants' import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token' +import { parseUA } from 'ua-parser-modern' + +/** + * Format a raw `navigator.userAgent` string into the short display label + * shown for a trusted device (e.g. "Chrome 120 | macOS 14 desktop"). + * + * The client used to parse+format this itself, but that pulled + * `ua-parser-modern` into the browser bundle for a label nothing else on + * the client needs — the client now sends the raw string and parsing + * happens here, at the server ingress, keeping the persisted label format + * identical. + */ +function describeUA(userAgent: string): string { + const info = parseUA(userAgent) + return [ + info.browser.name, + info.browser.version, + '|', + info.os.name, + info.os.version, + info.device.type, + ].filter(i => i).join(' ') +} /** Number of decimal digits in a human-typed one-time authentication code. */ const TEMP_AUTH_CODE_LENGTH = 6 @@ -121,7 +144,7 @@ export function exchangeTempAuthCode( storage.mutate((state) => { state.trusted[authToken] = { authToken, - ua: info.ua, + ua: describeUA(info.ua), origin: info.origin, timestamp: Date.now(), } From b059759eb01ef105ae782a1f7d3fcda485c2590f Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Sat, 15 Aug 2026 05:39:39 +0000 Subject: [PATCH 4/4] perf(devframe): dedupe immer between the node and browser builds 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. --- packages/devframe/tsdown.config.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 2c5b8602..d367adb0 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -54,6 +54,20 @@ const deps = { ], } +// The node build reaches `devframe/utils/shared-state` (`node/storage.ts`, +// `node/rpc-shared-state.ts`) through the same `devframe/utils/shared-state` +// tsconfig path alias the browser build's own `utils/shared-state` entry +// uses — two independent rolldown graphs, so left alone each would inline +// its own copy of `immer`. Externalizing the specifier here (node build +// only) keeps the tsconfig-paths resolver from ever inlining that source; +// the emitted `import 'devframe/utils/shared-state'` instead resolves at +// runtime to the already-built `dist/utils/shared-state.mjs` chunk via +// Node's package self-reference, so immer's bytes exist exactly once. +const nodeDeps = { + ...deps, + neverBundle: [...deps.neverBundle, 'devframe/utils/shared-state'], +} + // Shared by the runtime client build and the combined dts build below. const clientEntries = { 'client/index': 'src/client/index.ts', @@ -154,7 +168,7 @@ export default defineConfig([ clean: false, platform: 'node', tsconfig, - deps, + deps: nodeDeps, dts: false, entry: serverEntries, },