Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions packages/devframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -112,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",
Expand Down
20 changes: 5 additions & 15 deletions packages/devframe/src/client/rpc-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
})

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

Expand Down
25 changes: 24 additions & 1 deletion packages/devframe/src/node/auth/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
}
Expand Down
17 changes: 15 additions & 2 deletions packages/devframe/src/node/storage.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -13,6 +12,20 @@ export interface CreateStorageOptions<T extends object> {
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<T>(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<T extends object>(options: CreateStorageOptions<T>) {
const {
mergeInitialValue = (initialValue, savedValue) => ({ ...initialValue, ...savedValue }),
Expand All @@ -22,7 +35,7 @@ export function createStorage<T extends object>(options: CreateStorageOptions<T>
let initialValue: T = options.initialValue
if (fs.existsSync(options.filepath)) {
try {
const savedValue = destr<T>(fs.readFileSync(options.filepath, 'utf-8'), { strict: true })
const savedValue = safeJsonParse<T>(fs.readFileSync(options.filepath, 'utf-8'))
initialValue = mergeInitialValue ? mergeInitialValue(options.initialValue, savedValue) : savedValue
}
catch (error) {
Expand Down
61 changes: 51 additions & 10 deletions packages/devframe/src/utils/open.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
await openImpl(target, options)
export async function open(target: string): Promise<void> {
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])
}
28 changes: 15 additions & 13 deletions packages/devframe/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,36 +36,38 @@ 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',
],
}

// 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',
Expand Down Expand Up @@ -166,7 +168,7 @@ export default defineConfig([
clean: false,
platform: 'node',
tsconfig,
deps,
deps: nodeDeps,
dts: false,
entry: serverEntries,
},
Expand Down
9 changes: 0 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 1 addition & 7 deletions tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.d.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
export declare function open(_: string): Promise<void>;
// #endregion
4 changes: 2 additions & 2 deletions tests/__snapshots__/tsnapi/devframe/utils/open.snapshot.js
Original file line number Diff line number Diff line change
@@ -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
Loading