Skip to content

Commit c5bce49

Browse files
authored
perf(devframe): micro-dependency sweep (#233)
1 parent 52477d5 commit c5bce49

10 files changed

Lines changed: 113 additions & 62 deletions

File tree

packages/devframe/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@
9595
"@standard-schema/spec": "catalog:deps",
9696
"birpc": "catalog:deps",
9797
"crossws": "catalog:deps",
98-
"destr": "catalog:deps",
9998
"h3": "catalog:deps",
10099
"mrmime": "catalog:deps",
101100
"nostics": "catalog:deps",
@@ -112,7 +111,6 @@
112111
"mlly": "catalog:build",
113112
"obug": "catalog:deps",
114113
"ohash": "catalog:deps",
115-
"open": "catalog:deps",
116114
"p-limit": "catalog:deps",
117115
"perfect-debounce": "catalog:deps",
118116
"structured-clone-es": "catalog:deps",

packages/devframe/src/client/rpc-live.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunct
33
import type { DevframeConnectionStatus } from './connection'
44
import type { DevframeClientRpcHost, DevframeRpcClientMode, DevframeRpcClientOptions, RpcClientEvents } from './rpc'
55
import { createRpcClient } from 'devframe/rpc/client'
6-
import { parseUA } from 'ua-parser-modern'
76
import { promiseWithResolver } from '../utils/promise'
87
import { DevframeConnectionError } from './connection'
98

@@ -184,24 +183,15 @@ export function createLiveRpcClientMode(
184183

185184
let currentAuthToken: string | undefined = authToken
186185

187-
function describeUA(): string {
188-
const info = parseUA(navigator.userAgent)
189-
return [
190-
info.browser.name,
191-
info.browser.version,
192-
'|',
193-
info.os.name,
194-
info.os.version,
195-
info.device.type,
196-
].filter(i => i).join(' ')
197-
}
198-
199186
async function requestTrustWithToken(token: string) {
200187
currentAuthToken = token
201188

202189
const result = await serverRpc.$call('anonymous:devframe:auth', {
203190
authToken: token,
204-
ua: describeUA(),
191+
// Sent raw; the server parses it into a display label (see
192+
// `describeUA` in `node/auth/state.ts`) so `ua-parser-modern` stays
193+
// out of the browser bundle.
194+
ua: navigator.userAgent,
205195
origin: location.origin,
206196
})
207197

@@ -228,7 +218,7 @@ export function createLiveRpcClientMode(
228218
async function requestTrustWithCode(code: string): Promise<string | null> {
229219
const result = await serverRpc.$call('anonymous:devframe:auth:exchange', {
230220
code,
231-
ua: describeUA(),
221+
ua: navigator.userAgent,
232222
origin: location.origin,
233223
})
234224

packages/devframe/src/node/auth/state.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,29 @@ import type { SharedState } from 'devframe/utils/shared-state'
33
import type { InternalAnonymousAuthStorage } from '../hub-internals/context'
44
import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants'
55
import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token'
6+
import { parseUA } from 'ua-parser-modern'
7+
8+
/**
9+
* Format a raw `navigator.userAgent` string into the short display label
10+
* shown for a trusted device (e.g. "Chrome 120 | macOS 14 desktop").
11+
*
12+
* The client used to parse+format this itself, but that pulled
13+
* `ua-parser-modern` into the browser bundle for a label nothing else on
14+
* the client needs — the client now sends the raw string and parsing
15+
* happens here, at the server ingress, keeping the persisted label format
16+
* identical.
17+
*/
18+
function describeUA(userAgent: string): string {
19+
const info = parseUA(userAgent)
20+
return [
21+
info.browser.name,
22+
info.browser.version,
23+
'|',
24+
info.os.name,
25+
info.os.version,
26+
info.device.type,
27+
].filter(i => i).join(' ')
28+
}
629

730
/** Number of decimal digits in a human-typed one-time authentication code. */
831
const TEMP_AUTH_CODE_LENGTH = 6
@@ -121,7 +144,7 @@ export function exchangeTempAuthCode(
121144
storage.mutate((state) => {
122145
state.trusted[authToken] = {
123146
authToken,
124-
ua: info.ua,
147+
ua: describeUA(info.ua),
125148
origin: info.origin,
126149
timestamp: Date.now(),
127150
}

packages/devframe/src/node/storage.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import fs from 'node:fs'
22
import process from 'node:process'
3-
import { destr } from 'destr'
43
import { createSharedState } from 'devframe/utils/shared-state'
54
import { dirname } from 'pathe'
65
import { debounce } from 'perfect-debounce'
@@ -13,6 +12,20 @@ export interface CreateStorageOptions<T extends object> {
1312
debounce?: number
1413
}
1514

15+
// `JSON.parse` with a reviver that drops `__proto__`/`constructor.prototype`
16+
// keys, mirroring destr's core prototype-pollution guard. Storage only ever
17+
// reads back JSON it wrote itself via `JSON.stringify`, so destr's lenient
18+
// (non-strict) parsing of bare keywords/quoted strings never applies here -
19+
// this file is always a JSON object literal, and invalid JSON should throw
20+
// (caught below) rather than fall back to the raw string.
21+
function safeJsonParse<T>(text: string): T {
22+
return JSON.parse(text, (key, value) => {
23+
if (key === '__proto__' || (key === 'constructor' && value && typeof value === 'object' && 'prototype' in value))
24+
return undefined
25+
return value
26+
})
27+
}
28+
1629
export function createStorage<T extends object>(options: CreateStorageOptions<T>) {
1730
const {
1831
mergeInitialValue = (initialValue, savedValue) => ({ ...initialValue, ...savedValue }),
@@ -22,7 +35,7 @@ export function createStorage<T extends object>(options: CreateStorageOptions<T>
2235
let initialValue: T = options.initialValue
2336
if (fs.existsSync(options.filepath)) {
2437
try {
25-
const savedValue = destr<T>(fs.readFileSync(options.filepath, 'utf-8'), { strict: true })
38+
const savedValue = safeJsonParse<T>(fs.readFileSync(options.filepath, 'utf-8'))
2639
initialValue = mergeInitialValue ? mergeInitialValue(options.initialValue, savedValue) : savedValue
2740
}
2841
catch (error) {
Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,59 @@
1-
import openImpl from 'open'
1+
import { spawn } from 'node:child_process'
2+
import fs from 'node:fs'
3+
import process from 'node:process'
24

3-
export interface OpenOptions {
4-
/**
5-
* Resolve only after the launched app exits.
6-
*
7-
* @default false
8-
*/
9-
wait?: boolean
5+
/**
6+
* Launches `command` detached from the current process and resolves once
7+
* the OS has accepted the spawn (not once the launched app exits) — the
8+
* same "fire and forget" behavior `open`'s default (`wait: false`) gave us.
9+
*/
10+
function spawnDetached(command: string, args: string[]): Promise<void> {
11+
return new Promise((resolve, reject) => {
12+
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true })
13+
child.once('error', reject)
14+
child.once('spawn', () => {
15+
child.unref()
16+
resolve()
17+
})
18+
})
19+
}
20+
21+
function isWsl(): boolean {
22+
if (process.platform !== 'linux')
23+
return false
24+
try {
25+
return fs.readFileSync('/proc/version', 'utf-8').toLowerCase().includes('microsoft')
26+
}
27+
catch {
28+
return false
29+
}
1030
}
1131

1232
/**
1333
* Open a URL, file, or other target in its default OS handler
1434
* (browser for URLs, Finder/Explorer for paths, etc.).
1535
*/
16-
export async function open(target: string, options?: OpenOptions): Promise<void> {
17-
await openImpl(target, options)
36+
export async function open(target: string): Promise<void> {
37+
if (process.platform === 'darwin')
38+
return spawnDetached('open', [target])
39+
40+
if (process.platform === 'win32') {
41+
// `start` is a cmd.exe builtin; the empty title argument keeps `target`
42+
// from being mistaken for a window title when it's itself quoted.
43+
return spawnDetached('cmd', ['/c', 'start', '""', target])
44+
}
45+
46+
if (isWsl()) {
47+
// `wslview` (from wslu) hands the target to the Windows shell the same
48+
// way an interactive user would; fall back to invoking `cmd.exe`
49+
// directly on WSL distros that don't have wslu installed.
50+
try {
51+
return await spawnDetached('wslview', [target])
52+
}
53+
catch {
54+
return spawnDetached('cmd.exe', ['/c', 'start', '""', target])
55+
}
56+
}
57+
58+
return spawnDetached('xdg-open', [target])
1859
}

packages/devframe/tsdown.config.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,36 +36,38 @@ const deps = {
3636
},
3737
onlyBundle: [
3838
'acorn',
39-
'bundle-name',
40-
'default-browser',
41-
'default-browser-id',
42-
'define-lazy-prop',
4339
'get-port-please',
4440
'immer',
45-
'is-docker',
46-
'is-in-ssh',
47-
'is-inside-container',
48-
'is-wsl',
4941
'launch-editor',
5042
'mlly',
5143
'obug',
5244
'ohash',
53-
'open',
5445
'p-limit',
5546
'perfect-debounce',
5647
'picocolors',
57-
'powershell-utils',
58-
'run-applescript',
5948
'shell-quote',
6049
'structured-clone-es',
6150
'tinyexec',
6251
'ua-parser-modern',
6352
'whenexpr',
64-
'wsl-utils',
6553
'yocto-queue',
6654
],
6755
}
6856

57+
// The node build reaches `devframe/utils/shared-state` (`node/storage.ts`,
58+
// `node/rpc-shared-state.ts`) through the same `devframe/utils/shared-state`
59+
// tsconfig path alias the browser build's own `utils/shared-state` entry
60+
// uses — two independent rolldown graphs, so left alone each would inline
61+
// its own copy of `immer`. Externalizing the specifier here (node build
62+
// only) keeps the tsconfig-paths resolver from ever inlining that source;
63+
// the emitted `import 'devframe/utils/shared-state'` instead resolves at
64+
// runtime to the already-built `dist/utils/shared-state.mjs` chunk via
65+
// Node's package self-reference, so immer's bytes exist exactly once.
66+
const nodeDeps = {
67+
...deps,
68+
neverBundle: [...deps.neverBundle, 'devframe/utils/shared-state'],
69+
}
70+
6971
// Shared by the runtime client build and the combined dts build below.
7072
const clientEntries = {
7173
'client/index': 'src/client/index.ts',
@@ -166,7 +168,7 @@ export default defineConfig([
166168
clean: false,
167169
platform: 'node',
168170
tsconfig,
169-
deps,
171+
deps: nodeDeps,
170172
dts: false,
171173
entry: serverEntries,
172174
},

pnpm-lock.yaml

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@ catalogs:
9595
nostics: ^1.2.0
9696
obug: ^2.1.4
9797
ohash: ^2.0.11
98-
open: ^11.0.0
9998
p-limit: ^7.3.1
10099
parse5: ^8.0.1
101100
pathe: ^2.0.3
Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
/**
22
* Generated by tsnapi — public API snapshot of `devframe/utils/open`
33
*/
4-
// #region Interfaces
5-
export interface OpenOptions {
6-
wait?: boolean;
7-
}
8-
// #endregion
9-
104
// #region Functions
11-
export declare function open(_: string, _?: OpenOptions): Promise<void>;
5+
export declare function open(_: string): Promise<void>;
126
// #endregion
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Generated by tsnapi — public API snapshot of `devframe/utils/open`
33
*/
4-
// #region Other
5-
export { open }
4+
// #region Functions
5+
export async function open(_) {}
66
// #endregion

0 commit comments

Comments
 (0)