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: 2 additions & 0 deletions packages/devframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
6 changes: 2 additions & 4 deletions packages/devframe/src/adapters/_shared.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)))
}
21 changes: 7 additions & 14 deletions packages/devframe/src/adapters/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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` / `./<route>` 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,
Expand Down Expand Up @@ -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
// `/<route>`. The client targets `ws(s)://<page-host>:<port>/<route>`.
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(
Expand All @@ -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)
Expand All @@ -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}` : '')
}
3 changes: 2 additions & 1 deletion packages/devframe/src/client/rpc-ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 4 additions & 15 deletions packages/devframe/src/client/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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}`)
}
Expand Down
13 changes: 4 additions & 9 deletions packages/devframe/src/helpers/vite.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(/^\.\/?/, '/'))
}
3 changes: 2 additions & 1 deletion packages/devframe/src/node/storage.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -20,7 +21,7 @@ export function createStorage<T extends object>(options: CreateStorageOptions<T>
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<T>(fs.readFileSync(options.filepath, 'utf-8'), { strict: true })
initialValue = mergeInitialValue ? mergeInitialValue(options.initialValue, savedValue) : savedValue
}
catch (error) {
Expand Down
1 change: 1 addition & 0 deletions packages/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
},
"dependencies": {
"birpc": "catalog:deps",
"destr": "catalog:deps",
"nostics": "catalog:deps",
"pathe": "catalog:deps",
"perfect-debounce": "catalog:deps",
Expand Down
3 changes: 2 additions & 1 deletion packages/hub/src/client/remote.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 })
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

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

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading