From 78fd17b5ee156ead0acbb3100489e04a6cfa3601 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 3 Jul 2026 05:13:13 +0000 Subject: [PATCH] refactor: consolidate URL/base-path handling on ufo and JSON parsing on destr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the overlapping hand-rolled base-path/URL normalizers with ufo's battle-tested primitives (joinURL/withBase/withTrailingSlash/withProtocol), so base-path handling — core to devframe's "SPAs own their basePath" principle — comes from one source instead of five local variants. ufo is runtime-agnostic, so it stays safe for the client bundle. Swap the two try/catch JSON.parse sites for destr in strict mode, keeping the throw-on-invalid semantics while gaining prototype-pollution protection (notably for the attacker-controllable remote connection descriptor). --- packages/devframe/package.json | 2 ++ packages/devframe/src/adapters/_shared.ts | 6 ++---- packages/devframe/src/adapters/dev.ts | 21 +++++++-------------- packages/devframe/src/client/rpc-ws.ts | 3 ++- packages/devframe/src/client/rpc.ts | 19 ++++--------------- packages/devframe/src/helpers/vite.ts | 13 ++++--------- packages/devframe/src/node/storage.ts | 3 ++- packages/hub/package.json | 1 + packages/hub/src/client/remote.ts | 3 ++- pnpm-lock.yaml | 15 +++++++++++++++ pnpm-workspace.yaml | 2 ++ 11 files changed, 43 insertions(+), 45 deletions(-) diff --git a/packages/devframe/package.json b/packages/devframe/package.json index 6481e80..0402b14 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -78,10 +78,12 @@ "@valibot/to-json-schema": "catalog:deps", "birpc": "catalog:deps", "cac": "catalog:deps", + "destr": "catalog:deps", "h3": "catalog:deps", "mrmime": "catalog:deps", "nostics": "catalog:deps", "pathe": "catalog:deps", + "ufo": "catalog:deps", "valibot": "catalog:deps", "ws": "catalog:deps" }, diff --git a/packages/devframe/src/adapters/_shared.ts b/packages/devframe/src/adapters/_shared.ts index 6f46014..e1317f2 100644 --- a/packages/devframe/src/adapters/_shared.ts +++ b/packages/devframe/src/adapters/_shared.ts @@ -1,4 +1,5 @@ import type { DevframeDefinition, DevframeDeploymentKind } from '../types/devframe' +import { cleanDoubleSlashes, withLeadingSlash, withTrailingSlash } from 'ufo' /** * Resolve the mount base path for a devframe's SPA. Hosted adapters @@ -15,8 +16,5 @@ export function resolveBasePath(def: DevframeDefinition, kind: DevframeDeploymen } export function normalizeBasePath(base: string): string { - let out = base.startsWith('/') ? base : `/${base}` - if (!out.endsWith('/')) - out = `${out}/` - return out.replace(/\/+/g, '/') + return cleanDoubleSlashes(withTrailingSlash(withLeadingSlash(base))) } diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 875bc39..bde0931 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -7,6 +7,7 @@ import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { H3 } from 'h3' import { resolve } from 'pathe' +import { joinURL, withBase, withLeadingSlash, withoutLeadingSlash } from 'ufo' import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants' import { createHostContext } from '../node/context' import { createH3DevframeHost } from '../node/host-h3' @@ -154,7 +155,7 @@ export async function createDevServer( // origin. Both files sit at the SPA root so the deployed SPA discovers them // via relative `./__connection.json` / `./` fetches. const { bindPath, wsPort, meta } = resolveWsConnection(def, options, basePath) - const connectionMetaPath = `${basePath}${DEVFRAME_CONNECTION_META_FILENAME}` + const connectionMetaPath = joinURL(basePath, DEVFRAME_CONNECTION_META_FILENAME) app.use(connectionMetaPath, () => ({ backend: 'websocket', websocket: meta, @@ -191,21 +192,21 @@ function resolveWsConnection( const ws = options.ws ?? def.cli?.ws ?? {} // Normalize the route to a bare segment; the meta carries it relative so the // client resolves it against its own origin (proxy-safe). - const route = (ws.route ?? DEVFRAME_WS_ROUTE).replace(/^\/+/, '') + const route = withoutLeadingSlash(ws.route ?? DEVFRAME_WS_ROUTE) // (3) Remote origin — host the socket locally on the shared route, but tell // the browser to dial the fully-qualified endpoint (a tunnel/relay) verbatim. if (ws.url) - return { bindPath: `${basePath}${route}`, wsPort: undefined, meta: ws.url } + return { bindPath: joinURL(basePath, route), wsPort: undefined, meta: ws.url } // (2) Different port — a standalone socket server on its own port, rooted at // `/`. The client targets `ws(s)://:/`. if (ws.port != null) - return { bindPath: `/${route}`, wsPort: ws.port, meta: { port: ws.port, path: route } } + return { bindPath: withLeadingSlash(route), wsPort: ws.port, meta: { port: ws.port, path: route } } // (1) Same server, different route (default) — share the HTTP port; advertise // a relative same-origin path. - return { bindPath: `${basePath}${route}`, wsPort: undefined, meta: { path: route } } + return { bindPath: joinURL(basePath, route), wsPort: undefined, meta: { path: route } } } async function maybeOpenBrowser( @@ -222,7 +223,7 @@ async function maybeOpenBrowser( if (resolved === undefined || resolved === false) return const target = typeof resolved === 'string' - ? resolveOpenTarget(origin, resolved) + ? withBase(resolved, origin) : origin try { await open(target) @@ -232,11 +233,3 @@ async function maybeOpenBrowser( // The user can navigate manually. } } - -function resolveOpenTarget(origin: string, target: string): string { - if (/^https?:/.test(target)) - return target - if (target.startsWith('/')) - return origin.replace(/\/$/, '') + target - return origin.replace(/\/$/, '') + (target ? `/${target}` : '') -} diff --git a/packages/devframe/src/client/rpc-ws.ts b/packages/devframe/src/client/rpc-ws.ts index e1b94d2..68a16d1 100644 --- a/packages/devframe/src/client/rpc-ws.ts +++ b/packages/devframe/src/client/rpc-ws.ts @@ -4,6 +4,7 @@ import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { promiseWithResolver } from 'devframe/utils/promise' import { parseUA } from 'ua-parser-modern' +import { withProtocol } from 'ufo' export interface CreateWsRpcClientModeOptions { authToken?: string @@ -82,7 +83,7 @@ export function resolveWsUrl( return str // HTTP(S) URL — swap to the matching WS protocol. if (/^https?:\/\//i.test(str)) - return str.replace(/^http/i, 'ws') + return withProtocol(str, /^https/i.test(str) ? 'wss://' : 'ws://') // Path string — resolve same-origin against the meta base. const target = new URL(str, base) target.protocol = wsProtocol diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 1dfce6f..db34c5b 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -10,6 +10,7 @@ import { } from 'devframe/constants' import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc' import { createEventEmitter } from 'devframe/utils/events' +import { withBase } from 'ufo' import { authenticateWithUrlOtp } from './otp' import { createRpcSharedStateClientHost } from './rpc-shared-state' import { createStaticRpcClientMode } from './rpc-static' @@ -231,7 +232,7 @@ export async function getDevframeRpcClient( // relative WS path against the SPA's own origin (proxy-safe). Falls back to // the page location when running outside a browser document. function resolveMetaBaseUrl(): string { - const metaPath = resolveBasePath(resolvedBaseURL, DEVFRAME_CONNECTION_META_FILENAME) + const metaPath = withBase(DEVFRAME_CONNECTION_META_FILENAME, resolvedBaseURL) try { return new URL(metaPath, globalThis.location?.href).href } @@ -240,23 +241,11 @@ export async function getDevframeRpcClient( } } - function normalizeBase(base: string): string { - return base.endsWith('/') ? base : `${base}/` - } - - function resolveBasePath(base: string, path: string): string { - if (/^https?:\/\//.test(path)) - return path - if (path.startsWith('/')) - return path - return `${normalizeBase(base)}${path}` - } - if (!connectionMeta) { const errors: Error[] = [] for (const base of bases) { try { - connectionMeta = await fetch(resolveBasePath(base, DEVFRAME_CONNECTION_META_FILENAME)) + connectionMeta = await fetch(withBase(DEVFRAME_CONNECTION_META_FILENAME, base)) .then(r => r.json()) as ConnectionMeta resolvedBaseURL = base ;(globalThis as any)[CONNECTION_META_KEY] = connectionMeta @@ -295,7 +284,7 @@ export async function getDevframeRpcClient( const errors: Error[] = [] for (const base of candidates) { try { - return await fetch(resolveBasePath(base, path)).then((r) => { + return await fetch(withBase(path, base)).then((r) => { if (!r.ok) { throw new Error(`Failed to fetch ${path} from ${base}: ${r.status}`) } diff --git a/packages/devframe/src/helpers/vite.ts b/packages/devframe/src/helpers/vite.ts index b1077c7..25218a6 100644 --- a/packages/devframe/src/helpers/vite.ts +++ b/packages/devframe/src/helpers/vite.ts @@ -1,7 +1,7 @@ import type { DevframeDefinition } from '../types/devframe' import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' import { resolve } from 'pathe' -import { resolveBasePath } from '../adapters/_shared' +import { normalizeBasePath, resolveBasePath } from '../adapters/_shared' import { createDevServer, resolveDevServerPort } from '../adapters/dev' import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants' import { diagnostics } from '../node/diagnostics' @@ -138,14 +138,9 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio /** * Make `base` safe for `server.middlewares.use(path, …)`. Vite's connect * router matches by absolute URL prefix, so relative spellings like - * `'./'` (commonly used for base-agnostic Nuxt builds) need to be - * converted to `/` first. + * `'./'` (commonly used for base-agnostic Nuxt builds) collapse to the + * origin root before the shared leading/trailing-slash normalization. */ function normalizeMountBase(base: string): string { - let out = base.replace(/^\.\/?/, '/') - if (!out.startsWith('/')) - out = `/${out}` - if (!out.endsWith('/')) - out = `${out}/` - return out.replace(/\/+/g, '/') + return normalizeBasePath(base.replace(/^\.\/?/, '/')) } diff --git a/packages/devframe/src/node/storage.ts b/packages/devframe/src/node/storage.ts index d321efb..507b035 100644 --- a/packages/devframe/src/node/storage.ts +++ b/packages/devframe/src/node/storage.ts @@ -1,4 +1,5 @@ import fs from 'node:fs' +import { destr } from 'destr' import { createSharedState } from 'devframe/utils/shared-state' import { dirname } from 'pathe' import { debounce } from 'perfect-debounce' @@ -20,7 +21,7 @@ export function createStorage(options: CreateStorageOptions let initialValue: T = options.initialValue if (fs.existsSync(options.filepath)) { try { - const savedValue = JSON.parse(fs.readFileSync(options.filepath, 'utf-8')) as T + const savedValue = destr(fs.readFileSync(options.filepath, 'utf-8'), { strict: true }) initialValue = mergeInitialValue ? mergeInitialValue(options.initialValue, savedValue) : savedValue } catch (error) { diff --git a/packages/hub/package.json b/packages/hub/package.json index ff4d197..f9bf8ec 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -43,6 +43,7 @@ }, "dependencies": { "birpc": "catalog:deps", + "destr": "catalog:deps", "nostics": "catalog:deps", "pathe": "catalog:deps", "perfect-debounce": "catalog:deps", diff --git a/packages/hub/src/client/remote.ts b/packages/hub/src/client/remote.ts index 6897104..553eed1 100644 --- a/packages/hub/src/client/remote.ts +++ b/packages/hub/src/client/remote.ts @@ -1,5 +1,6 @@ import type { DevframeRpcClient, DevframeRpcClientOptions } from 'devframe/client' import type { RemoteConnectionInfo } from '../types' +import { destr } from 'destr' import { getDevframeRpcClient } from 'devframe/client' import { REMOTE_CONNECTION_KEY } from 'devframe/constants' @@ -81,7 +82,7 @@ export function parseRemoteConnection(input?: string): RemoteConnectionInfo | nu let payload: unknown try { - payload = JSON.parse(base64UrlDecode(encoded)) + payload = destr(base64UrlDecode(encoded), { strict: true }) } catch (cause) { throw new Error('[@devframes/hub] Failed to decode remote connection descriptor.', { cause }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 522e1b3..4494d21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ catalogs: cac: specifier: ^7.0.0 version: 7.0.0 + destr: + specifier: ^2.0.5 + version: 2.0.5 get-port-please: specifier: ^3.2.0 version: 3.2.0 @@ -100,6 +103,9 @@ catalogs: tinyglobby: specifier: ^0.2.16 version: 0.2.16 + ufo: + specifier: ^1.6.4 + version: 1.6.4 valibot: specifier: ^1.4.1 version: 1.4.1 @@ -656,6 +662,9 @@ importers: cac: specifier: catalog:deps version: 7.0.0 + destr: + specifier: catalog:deps + version: 2.0.5 h3: specifier: catalog:deps version: 2.0.1-rc.22(crossws@0.4.5(srvx@0.11.15)) @@ -668,6 +677,9 @@ importers: pathe: specifier: catalog:deps version: 2.0.3 + ufo: + specifier: catalog:deps + version: 1.6.4 valibot: specifier: catalog:deps version: 1.4.1(typescript@6.0.3) @@ -726,6 +738,9 @@ importers: birpc: specifier: catalog:deps version: 4.0.0 + destr: + specifier: catalog:deps + version: 2.0.5 nostics: specifier: catalog:deps version: 1.1.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a2aa707..cf32020 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,6 +46,7 @@ catalogs: '@valibot/to-json-schema': ^1.7.0 birpc: ^4.0.0 cac: ^7.0.0 + destr: ^2.0.5 get-port-please: ^3.2.0 h3: 2.0.1-rc.22 immer: ^11.1.8 @@ -61,6 +62,7 @@ catalogs: structured-clone-es: ^2.0.0 tinyexec: ^1.2.2 tinyglobby: ^0.2.16 + ufo: ^1.6.4 valibot: ^1.4.1 whenexpr: ^0.1.2 ws: ^8.21.0