diff --git a/docs/guide/client.md b/docs/guide/client.md index d1bdaf29..e24b0559 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -63,6 +63,16 @@ window or an accessible parent window. Cross-realm viewers can read the serializable value through `DEVFRAME_CONNECTION_KEY` from `devframe/constants`. +An external browser viewer can register its origin before opening the WebSocket: + +```ts +import { registerDevframeViewerOrigin } from 'devframe/client' + +await registerDevframeViewerOrigin(connection) +``` + +The host provides `viewerOriginToken` in its connection metadata to enable registration. The token-protected server registry is described in [External viewer origins](/guide/security#external-viewer-origins). + ### Options ```ts @@ -252,7 +262,7 @@ await connectDevframe({ ## Remote docks -Remote docks are a host-side feature — hosts that support them (Vite DevTools is one; see [its remote-client docs](https://devtools.vite.dev/kit/remote-client) for that implementation) inject a connection descriptor into the iframe URL. On the hosted page, `connectDevframe` auto-detects the descriptor from the URL fragment / query string — call it as usual: +Remote docks are a host-side feature — hosts that support them (Vite DevTools is one; see [its remote-client docs](https://devtools.vite.dev/kit/remote-client) for that implementation) inject a connection descriptor into the iframe URL. On the hosted page, `connectDevframe` auto-detects the descriptor from the URL fragment or query string — call it as usual: ```ts import { connectDevframe } from 'devframe/client' @@ -263,6 +273,20 @@ const rpc = await connectDevframe() The descriptor carries a session-only, pre-approved auth token, so `ensureTrusted()` resolves immediately. +An external hub can build a viewer URL from an existing trusted connection: + +```ts +import { + buildRemoteDevframeUrl, + stripRemoteConnectionFromUrl, +} from '@devframes/hub/client' + +const viewerUrl = buildRemoteDevframeUrl('/viewer/', connection) +const displayUrl = stripRemoteConnectionFromUrl(viewerUrl) +``` + +`buildRemoteDevframeUrl()` stores the descriptor in the URL fragment, keeping its token out of HTTP requests and referrer headers. Hub-managed remote docks continue to support their configured descriptor transport. + ## Events The client emits over `rpc.events`: diff --git a/docs/guide/hub.md b/docs/guide/hub.md index 48c52388..a424d84d 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -279,6 +279,8 @@ Plus broadcast notifications (`devframe:docks:activate`, `devframe:terminals:upd The hub also ships a headless browser runtime, `createDevframeClientHost()` from `@devframes/hub/client`. Booted in the host page, it assembles the shared client context from the protocol above and imports each dock entry's client script into that page — how a plugin like the a11y inspector runs code inside the page being inspected. See [Client Scripts & Client Context](./client-context) for the boot flow, the context surface, and the dock-script contract. +External viewers resolve dock resources against the connection that delivered the dock entries. `resolveDockUrl(url, connection)` keeps iframe paths on the Devframe server, while `resolveDockIcon(icon, connection)` handles both string icons and `{ light, dark }` pairs. Absolute URLs, data URLs, and Iconify names remain unchanged. + ## Example Two minimal, copyable hubs mount every built-in plugin (git, terminals, code-server, inspect, a11y) behind an icon dock — the same shape [vite-devtools](https://github.com/vitejs/devtools) wears as the full Vite viewer, shrunk to the smallest thing you can build your own viewer from: diff --git a/docs/guide/security.md b/docs/guide/security.md index 6fa44c37..804fc705 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -99,3 +99,25 @@ Higher-level integrations can drive their own authentication UI instead: disable - **Authorize every handler.** A registered function is callable by any trusted client. Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. - **Origin-lock remote docks.** When a hub embeds a remote-UI dock, enable `originLock` so a dock token is only honored from its expected origin. - **Serve encrypted off-machine.** Use `https://`/`wss://` for any surface reachable beyond `localhost`. + +## External viewer origins + +WebSocket handshakes from browser extensions and other external viewers carry the viewer's own `Origin` header. A host can authorize that origin through a live registry: + +```ts +import { attachWsRpcTransport, createWsOriginRegistry } from 'devframe/rpc/transports/ws-server' + +const viewerOrigins = createWsOriginRegistry({ + validateOrigin: origin => origin.startsWith('chrome-extension://') + || origin.startsWith('moz-extension://'), +}) + +attachWsRpcTransport(rpc, { + server, + allowedOrigins: viewerOrigins, +}) +``` + +Include `viewerOrigins.token` as `viewerOriginToken` in the connection metadata. In the connection metadata handler, call `viewerOrigins.registerFromUrl(request.url)`. When it returns an origin, set `Access-Control-Allow-Origin` to that value. The external viewer then calls `registerDevframeViewerOrigin(connection)` before connecting. + +The registration token grants access through the transport's origin check. RPC authentication still authorizes the session and every non-anonymous method. Keep metadata containing this token same-origin until the registration request has been verified. diff --git a/packages/devframe/src/client/connection.test.ts b/packages/devframe/src/client/connection.test.ts index dc9c6249..19f672d9 100644 --- a/packages/devframe/src/client/connection.test.ts +++ b/packages/devframe/src/client/connection.test.ts @@ -1,7 +1,7 @@ import type { ConnectionMeta } from 'devframe/types' import { DEVFRAME_CONNECTION_KEY } from 'devframe/constants' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getDevframeConnection, setupDevframeConnection } from './connection' +import { getDevframeConnection, registerDevframeViewerOrigin, setupDevframeConnection } from './connection' import { getDevframeRpcClient } from './rpc' const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__' @@ -209,3 +209,33 @@ describe('setupDevframeConnection', () => { }) }) }) + +describe('registerDevframeViewerOrigin', () => { + it('registers the exact origin with the advertised bootstrap token', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + const registered = await registerDevframeViewerOrigin({ + connectionMeta: { + backend: 'websocket', + viewerOriginToken: 'bootstrap-secret', + }, + metaBaseUrl: 'http://localhost:5173/__connection.json', + }, 'chrome-extension://abcdefghijklmnop') + + expect(registered).toBe(true) + const [url, init] = fetchMock.mock.calls[0] + expect(String(url)).toContain('devframe_viewer_origin=chrome-extension%3A%2F%2Fabcdefghijklmnop') + expect(String(url)).toContain('devframe_viewer_origin_token=bootstrap-secret') + expect(init).toEqual({ cache: 'no-store' }) + }) + + it('does nothing when the host did not advertise registration', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + await expect(registerDevframeViewerOrigin({ + connectionMeta: { backend: 'websocket' }, + metaBaseUrl: 'http://localhost:5173/__connection.json', + }, 'chrome-extension://abcdefghijklmnop')).resolves.toBe(false) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/packages/devframe/src/client/connection.ts b/packages/devframe/src/client/connection.ts index d4cd1bfb..bae3d1a2 100644 --- a/packages/devframe/src/client/connection.ts +++ b/packages/devframe/src/client/connection.ts @@ -1,5 +1,9 @@ import type { ConnectionMeta } from 'devframe/types' -import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' +import { + DEVFRAME_CONNECTION_META_FILENAME, + DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, + DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM, +} from 'devframe/constants' import { withBase } from 'ufo' import { readStoredAuthToken, @@ -32,6 +36,28 @@ export interface SetupDevframeConnectionOptions { authToken?: string } +/** + * Allow an external viewer to connect by registering its browser origin with + * the Devframe host. Returns `false` if the host did not provide an origin + * registration token. + */ +export async function registerDevframeViewerOrigin( + connection: DevframeConnection, + origin = globalThis.location?.origin, +): Promise { + const token = connection.connectionMeta.viewerOriginToken + if (!token || !origin) + return false + + const url = new URL(connection.metaBaseUrl) + url.searchParams.set(DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, origin) + url.searchParams.set(DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM, token) + const response = await fetch(url, { cache: 'no-store' }) + if (!response.ok) + throw new Error(`Failed to register external viewer origin (${response.status}).`) + return true +} + function resolveMetaBaseUrl(baseURL: string): string { const metaPath = withBase(DEVFRAME_CONNECTION_META_FILENAME, baseURL) try { diff --git a/packages/devframe/src/client/index.ts b/packages/devframe/src/client/index.ts index 6cc990dd..8f5d203d 100644 --- a/packages/devframe/src/client/index.ts +++ b/packages/devframe/src/client/index.ts @@ -4,6 +4,7 @@ export * from './connection' export * from './otp' export * from './rpc' export * from './rpc-streaming' +export { resolveWsUrl, type WsUrlLocation } from './rpc-ws' export * from './scope' export * from './settings' diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index 90ef82e0..bdd18a53 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -58,6 +58,12 @@ export const DEVFRAME_OTP_URL_PARAM = 'devframe_otp' */ export const DEVFRAME_AUTH_TOKEN_QUERY_PARAM = 'devframe_auth_token' +/** External viewer origin requested during connection bootstrap. */ +export const DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM = 'devframe_viewer_origin' + +/** Token that authorizes an external viewer origin registration. */ +export const DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM = 'devframe_viewer_origin_token' + /** * Prefix that marks an RPC method as callable before a connection is * trusted. This is the *only* rule the pre-trust gate applies — there is no diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts index bd60745a..17c53854 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -1,6 +1,7 @@ import type { BirpcGroup, EventOptions } from 'birpc' import type { Peer } from 'crossws' import type { NodeAdapter } from 'crossws/adapters/node' +import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server' import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' import type { Server as NodeHttpServer } from 'node:http' import type { DevframeAuthHandler } from './auth' @@ -102,7 +103,7 @@ export interface StartHttpAndWsOptions { * from another host. Pass `false` to disable origin checking entirely * (not recommended). Default: loopback-only. */ - allowedOrigins?: readonly string[] | false + allowedOrigins?: readonly string[] | WsOriginRegistry | false /** * Called once the WS server is bound so callers can mount static * handlers whose origin depends on the resolved port, or print their diff --git a/packages/devframe/src/rpc/transports/ws-server.ts b/packages/devframe/src/rpc/transports/ws-server.ts index 4c2d169b..3319d91c 100644 --- a/packages/devframe/src/rpc/transports/ws-server.ts +++ b/packages/devframe/src/rpc/transports/ws-server.ts @@ -9,6 +9,8 @@ import type { RpcFunctionDefinitionAny } from '../types' import { createServer as createHttpServer } from 'node:http' import { createServer as createHttpsServer } from 'node:https' import crossws from 'crossws/adapters/node' +import { DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM } from 'devframe/constants' +import { randomToken, timingSafeEqual } from 'devframe/utils/crypto-token' import { structuredCloneParse, structuredCloneStringify } from 'devframe/utils/structured-clone' import { strictJsonStringify, STRUCTURED_CLONE_PREFIX } from '../serialization' @@ -70,7 +72,7 @@ export interface WsRpcTransportOptions { * Pass `false` to disable origin checking entirely (not recommended). * Default: loopback-only. */ - allowedOrigins?: readonly string[] | false + allowedOrigins?: readonly string[] | WsOriginRegistry | false /** * RPC function definitions, used by the per-call wire serializer to * dispatch between strict-JSON and structured-clone encoding based @@ -88,6 +90,78 @@ export interface WsRpcTransportOptions { deserialize?: ChannelOptions['deserialize'] } +export interface CreateWsOriginRegistryOptions { + /** Origins allowed before any external viewers are registered. */ + allowedOrigins?: readonly string[] + /** Additional validation to run after the registration token is verified. */ + validateOrigin?: (origin: string) => boolean +} + +export interface WsOriginRegistry { + /** Registration token to include in connection metadata. */ + readonly token: string + /** Read and register an origin from a connection bootstrap URL. */ + registerFromUrl: (url: string) => string | undefined + /** Check whether an origin is currently allowed. */ + isAllowed: (origin: string | undefined) => boolean +} + +/** + * Create a live, token-protected origin allowlist for external browser + * viewers. Pass it to {@link WsRpcTransportOptions.allowedOrigins}, then use + * `registerFromUrl()` in the connection metadata handler to authorize a + * viewer without sharing a mutable array or disabling DNS-rebinding protection. + */ +export function createWsOriginRegistry( + options: CreateWsOriginRegistryOptions = {}, +): WsOriginRegistry { + const token = randomToken() + const origins = new Set(options.allowedOrigins ?? []) + + function normalizeOrigin(origin: string | undefined): string | undefined { + if (!origin) + return + try { + const url = new URL(origin) + const normalized = url.origin === 'null' + ? `${url.protocol}//${url.host}` + : url.origin + return origin === normalized ? normalized : undefined + } + catch {} + } + + function registerOrigin(origin: string | undefined, candidateToken: string | undefined): boolean { + const normalized = normalizeOrigin(origin) + if (!normalized || !candidateToken || !timingSafeEqual(token, candidateToken)) + return false + if (options.validateOrigin && !options.validateOrigin(normalized)) + return false + origins.add(normalized) + return true + } + + const registry: WsOriginRegistry = { + token, + registerFromUrl(url) { + let parsed: URL + try { + parsed = new URL(url, 'http://localhost') + } + catch { + return + } + const origin = parsed.searchParams.get(DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM) ?? undefined + const candidateToken = parsed.searchParams.get(DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM) ?? undefined + return registerOrigin(origin, candidateToken) ? origin : undefined + }, + isAllowed(origin) { + return isAllowedOrigin(origin, [...origins]) + }, + } + return registry +} + export interface WsRpcTransport { /** * The crossws node adapter driving the socket — exposes the connected @@ -142,6 +216,12 @@ export function isAllowedOrigin(origin: string | undefined, allowedOrigins: read } } +function isWsOriginRegistry( + value: readonly string[] | WsOriginRegistry | false | undefined, +): value is WsOriginRegistry { + return !!value && !Array.isArray(value) +} + /** * Route `upgrade` events on a server to the crossws adapter, optionally * filtered to a single `path`. Non-matching requests are left untouched so @@ -154,7 +234,7 @@ function routeUpgrades( ws: NodeAdapter, path: string | undefined, destroyUnmatched: boolean, - allowedOrigins: readonly string[] | false | undefined, + allowedOrigins: readonly string[] | WsOriginRegistry | false | undefined, ): () => void { const listener = (req: IncomingMessage, socket: Duplex, head: Buffer) => { socket.on('error', () => { @@ -176,7 +256,10 @@ function routeUpgrades( return } } - if (allowedOrigins !== false && !isAllowedOrigin(req.headers.origin, allowedOrigins ?? [])) { + const originAllowed = isWsOriginRegistry(allowedOrigins) + ? allowedOrigins.isAllowed(req.headers.origin) + : isAllowedOrigin(req.headers.origin, allowedOrigins || []) + if (allowedOrigins !== false && !originAllowed) { socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n') socket.destroy() return diff --git a/packages/devframe/src/rpc/transports/ws.test.ts b/packages/devframe/src/rpc/transports/ws.test.ts index f59e9e90..b4606146 100644 --- a/packages/devframe/src/rpc/transports/ws.test.ts +++ b/packages/devframe/src/rpc/transports/ws.test.ts @@ -2,10 +2,11 @@ import { createServer } from 'node:http' import { getPort } from 'get-port-please' import { describe, expect, it, vi } from 'vitest' import { WebSocket, WebSocketServer } from 'ws' +import { registerDevframeViewerOrigin } from '../../client/connection' import { createRpcClient } from '../client' import { createRpcServer } from '../server' import { createWsRpcChannel } from './ws-client' -import { attachWsRpcTransport, isAllowedOrigin, isLoopbackHostname } from './ws-server' +import { attachWsRpcTransport, createWsOriginRegistry, isAllowedOrigin, isLoopbackHostname } from './ws-server' vi.stubGlobal('WebSocket', WebSocket) @@ -393,4 +394,72 @@ describe('ws origin check', () => { await close() } }) + + it('registers an exact external viewer origin with a bearer token', async () => { + const registry = createWsOriginRegistry() + const registrationUrl = (origin: string, token = registry.token) => { + const params = new URLSearchParams({ + devframe_viewer_origin: origin, + devframe_viewer_origin_token: token, + }) + return `/__connection.json?${params}` + } + expect(registry.isAllowed('chrome-extension://abcdefghijklmnop')).toBe(false) + expect(registry.registerFromUrl(registrationUrl('chrome-extension://abcdefghijklmnop/path'))).toBeUndefined() + expect(registry.registerFromUrl(registrationUrl('chrome-extension://abcdefghijklmnop', 'wrong'))).toBeUndefined() + expect(registry.registerFromUrl(registrationUrl('chrome-extension://abcdefghijklmnop'))) + .toBe('chrome-extension://abcdefghijklmnop') + expect(registry.isAllowed('chrome-extension://abcdefghijklmnop')).toBe(true) + }) + + it('registers from the standard bootstrap query parameters', () => { + const registry = createWsOriginRegistry({ + validateOrigin: origin => origin.startsWith('moz-extension://'), + }) + const params = new URLSearchParams({ + devframe_viewer_origin: 'moz-extension://abcdefghijklmnop', + devframe_viewer_origin_token: registry.token, + }) + expect(registry.registerFromUrl(`/__connection.json?${params}`)) + .toBe('moz-extension://abcdefghijklmnop') + expect(registry.isAllowed('moz-extension://abcdefghijklmnop')).toBe(true) + + params.set('devframe_viewer_origin', 'https://viewer.example') + expect(registry.registerFromUrl(`/__connection.json?${params}`)).toBeUndefined() + }) + + it('allows a WebSocket upgrade after the client registers its viewer origin', async () => { + const HOST = '127.0.0.1' + const PORT = await getPort({ host: HOST, random: true }) + const origin = 'chrome-extension://abcdefghijklmnop' + const registry = createWsOriginRegistry({ + validateOrigin: value => value.startsWith('chrome-extension://'), + }) + const server = createRpcServer, Record>({}) + const { close } = attachWsRpcTransport(server, { + port: PORT, + host: HOST, + allowedOrigins: registry, + }) + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const registered = registry.registerFromUrl(String(input)) + return new Response(null, { status: registered ? 204 : 403 }) + }) + + try { + await expect(connectRaw(`ws://${HOST}:${PORT}`, origin)).resolves.toBe('closed') + await expect(registerDevframeViewerOrigin({ + connectionMeta: { + backend: 'websocket', + viewerOriginToken: registry.token, + }, + metaBaseUrl: `http://${HOST}:${PORT}/__connection.json`, + }, origin)).resolves.toBe(true) + await expect(connectRaw(`ws://${HOST}:${PORT}`, origin)).resolves.toBe('open') + } + finally { + fetchMock.mockRestore() + await close() + } + }) }) diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index 57fca826..4a812bec 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -160,4 +160,13 @@ export interface ConnectionMeta { * > from a publicly reachable static `__connection.json`. */ authToken?: string + /** + * Session-scoped token that lets a trusted external viewer register its + * browser origin before opening the WebSocket. The host includes it in the + * connection metadata, which the viewer obtains through the host page. + * + * Treat this as a bearer credential. Keep connection metadata containing the + * token same-origin until the requesting origin has been verified. + */ + viewerOriginToken?: string } diff --git a/packages/hub/src/client/dock-resources.test.ts b/packages/hub/src/client/dock-resources.test.ts new file mode 100644 index 00000000..30164947 --- /dev/null +++ b/packages/hub/src/client/dock-resources.test.ts @@ -0,0 +1,41 @@ +import type { DevframeConnection } from 'devframe/client' +import { describe, expect, it } from 'vitest' +import { resolveDockIcon, resolveDockUrl } from './dock-resources' + +const connection: DevframeConnection = { + connectionMeta: { backend: 'websocket' }, + metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', +} + +describe('dock resource resolution', () => { + it('resolves root and dot-relative iframe URLs from the connection source', () => { + expect(resolveDockUrl('/__vite/', connection)).toBe('http://localhost:5173/__vite/') + expect(resolveDockUrl('./viewer/', connection)).toBe('http://localhost:5173/__devtools/viewer/') + expect(resolveDockUrl('../viewer/', connection)).toBe('http://localhost:5173/viewer/') + }) + + it('preserves absolute URLs and accepts host-like iframe URLs', () => { + expect(resolveDockUrl('https://viewer.example/app', connection)).toBe('https://viewer.example/app') + expect(resolveDockUrl('localhost:3000/app', connection)).toBe('http://localhost:3000/app') + expect(resolveDockUrl('viewer.example/app', connection)).toBe('http://viewer.example/app') + }) + + it('resolves URL icons and preserves Iconify names and data URLs', () => { + expect(resolveDockIcon('/icons/vite.svg', connection)).toBe('http://localhost:5173/icons/vite.svg') + expect(resolveDockIcon('icons/vite.svg', connection)).toBe('http://localhost:5173/__devtools/icons/vite.svg') + expect(resolveDockIcon('vite.svg', connection)).toBe('http://localhost:5173/__devtools/vite.svg') + expect(resolveDockIcon('ph:gear-duotone', connection)).toBe('ph:gear-duotone') + expect(resolveDockIcon('gear', connection)).toBe('gear') + expect(resolveDockIcon('data:image/svg+xml;base64,abc', connection)).toBe('data:image/svg+xml;base64,abc') + }) + + it('resolves light and dark icon variants independently', () => { + expect(resolveDockIcon({ + light: './icons/light.svg', + dark: '/icons/dark.svg', + }, connection)).toEqual({ + light: 'http://localhost:5173/__devtools/icons/light.svg', + dark: 'http://localhost:5173/icons/dark.svg', + }) + }) +}) diff --git a/packages/hub/src/client/dock-resources.ts b/packages/hub/src/client/dock-resources.ts new file mode 100644 index 00000000..fa8634aa --- /dev/null +++ b/packages/hub/src/client/dock-resources.ts @@ -0,0 +1,73 @@ +import type { DevframeConnection } from 'devframe/client' +import type { DevframeDockEntryIcon } from '../types' + +const URL_SCHEME_RE = /^[a-z][a-z\d+.-]*:/i + +function resolveResourceUrl(value: string, connection: DevframeConnection): string { + const url = value.trim() + if (!url || URL_SCHEME_RE.test(url) || url.startsWith('//')) + return url + + if (!url.startsWith('/') && !url.startsWith('./') && !url.startsWith('../')) + return url + + try { + return new URL(url, connection.metaBaseUrl).href + } + catch { + return url + } +} + +function resolveIconUrl(value: string, connection: DevframeConnection): string { + const url = value.trim() + if (!url || URL_SCHEME_RE.test(url) || url.startsWith('//')) + return url + + // Preserve symbolic icon names while resolving URL-like filenames and paths. + if (!url.includes('/') && !url.includes('.')) + return url + + try { + return new URL(url, connection.metaBaseUrl).href + } + catch { + return url + } +} + +/** + * Resolve a dock iframe URL relative to the Devframe server that provided the + * dock entry. This keeps root-relative and dot-relative paths on the host + * server when an external viewer renders the hub. + */ +export function resolveDockUrl(url: string, connection: DevframeConnection): string { + const resolved = resolveResourceUrl(url, connection) + if (resolved !== url.trim()) + return resolved + + const value = url.trim() + if (!value || URL_SCHEME_RE.test(value) || value.startsWith('//')) + return /^localhost:\d/i.test(value) ? `http://${value}` : value + + try { + return new URL(`http://${value}`).href + } + catch { + return value + } +} + +/** Resolve URL-backed dock icons while preserving Iconify names and data URLs. */ +export function resolveDockIcon( + icon: DevframeDockEntryIcon, + connection: DevframeConnection, +): DevframeDockEntryIcon { + if (typeof icon === 'string') + return resolveIconUrl(icon, connection) + + return { + light: resolveIconUrl(icon.light, connection), + dark: resolveIconUrl(icon.dark, connection), + } +} diff --git a/packages/hub/src/client/index.ts b/packages/hub/src/client/index.ts index d547d05f..55a7b030 100644 --- a/packages/hub/src/client/index.ts +++ b/packages/hub/src/client/index.ts @@ -3,6 +3,7 @@ export { DEFAULT_CATEGORIES_ORDER } from '../constants' export * from './client-script' export * from './context' +export * from './dock-resources' export * from './docks' export * from './frame-nav' export * from './host' diff --git a/packages/hub/src/client/remote.test.ts b/packages/hub/src/client/remote.test.ts new file mode 100644 index 00000000..effe14c8 --- /dev/null +++ b/packages/hub/src/client/remote.test.ts @@ -0,0 +1,82 @@ +import type { DevframeConnection } from 'devframe/client' +import { describe, expect, it } from 'vitest' +import { buildRemoteConnectionUrl } from '../remote-url' +import { + buildRemoteDevframeUrl, + parseRemoteConnection, + stripRemoteConnectionFromUrl, +} from './remote' + +const connection: DevframeConnection = { + connectionMeta: { backend: 'websocket', websocket: { path: '__devframe_ws' } }, + metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', + authToken: 'secret', +} + +describe('remote connection URLs', () => { + it('builds a descriptor from an existing connection', () => { + const url = buildRemoteDevframeUrl('https://viewer.example/app', connection) + expect(parseRemoteConnection(url)).toEqual({ + v: 1, + backend: 'websocket', + websocket: 'ws://localhost:5173/__devtools/__devframe_ws', + authToken: 'secret', + origin: 'http://localhost:5173', + }) + }) + + it('preserves hash routes and replaces an existing descriptor', () => { + const first = buildRemoteDevframeUrl('https://viewer.example/#/inspect?tab=state', connection) + const second = buildRemoteDevframeUrl(first, { ...connection, authToken: 'new-secret' }) + expect(second.match(/devframe-remote-connection/g)).toHaveLength(1) + expect(second).toContain('#/inspect?tab=state&') + expect(parseRemoteConnection(second)?.authToken).toBe('new-secret') + }) + + it('parses descriptors produced by query-based remote docks', () => { + const url = buildRemoteConnectionUrl( + 'https://viewer.example/app?tab=one#section', + { + v: 1, + backend: 'websocket', + websocket: 'ws://localhost:5173/__devtools/__devframe_ws', + authToken: 'secret', + origin: 'http://localhost:5173', + }, + 'query', + ) + expect(url).toContain('?tab=one&devframe-remote-connection=') + expect(url.endsWith('#section')).toBe(true) + expect(parseRemoteConnection(url)?.authToken).toBe('secret') + }) + + it('returns the original URL for untrusted or static connections', () => { + expect(buildRemoteDevframeUrl('/viewer', { ...connection, authToken: undefined })).toBe('/viewer') + expect(buildRemoteDevframeUrl('/viewer', { + ...connection, + connectionMeta: { backend: 'static' }, + })).toBe('/viewer') + }) + + it('strips descriptors from query, fragment, and hash-route query forms', () => { + const url = buildRemoteDevframeUrl('https://viewer.example/#/inspect?tab=state', connection) + expect(stripRemoteConnectionFromUrl(url)).toBe('https://viewer.example/#/inspect?tab=state') + const section = buildRemoteDevframeUrl('https://viewer.example/#section', connection) + expect(stripRemoteConnectionFromUrl(section)).toBe('https://viewer.example/#section') + const query = buildRemoteConnectionUrl('https://viewer.example/?tab=state#section', { + v: 1, + backend: 'websocket', + websocket: 'ws://localhost:5173/__devtools/__devframe_ws', + authToken: 'secret', + origin: 'http://localhost:5173', + }, 'query') + expect(stripRemoteConnectionFromUrl(query)).toBe('https://viewer.example/?tab=state#section') + }) + + it('preserves the original encoding of hash-route parameters', () => { + const baseUrl = 'https://viewer.example/#/inspect?tab=a%20b&filter=a+b' + const url = buildRemoteDevframeUrl(baseUrl, connection) + expect(url).toContain('#/inspect?tab=a%20b&filter=a+b&devframe-remote-connection=') + expect(stripRemoteConnectionFromUrl(url)).toBe(baseUrl) + }) +}) diff --git a/packages/hub/src/client/remote.ts b/packages/hub/src/client/remote.ts index 553eed1a..20a1ed5c 100644 --- a/packages/hub/src/client/remote.ts +++ b/packages/hub/src/client/remote.ts @@ -1,8 +1,11 @@ -import type { DevframeRpcClient, DevframeRpcClientOptions } from 'devframe/client' +import type { DevframeConnection, DevframeRpcClient, DevframeRpcClientOptions } from 'devframe/client' import type { RemoteConnectionInfo } from '../types' import { destr } from 'destr' -import { getDevframeRpcClient } from 'devframe/client' +import { getDevframeRpcClient, resolveWsUrl } from 'devframe/client' import { REMOTE_CONNECTION_KEY } from 'devframe/constants' +import { buildRemoteConnectionUrl } from '../remote-url' + +export { stripRemoteConnectionFromUrl } from '../remote-url' export type ConnectRemoteDevframeOptions = Omit @@ -16,14 +19,43 @@ function base64UrlDecode(value: string): string { return new TextDecoder().decode(bytes) } +/** + * Build an external viewer URL from an existing trusted Devframe connection. + * Returns the original URL if the connection has no auth token, does not use + * WebSockets, or has an invalid metadata URL. + */ +export function buildRemoteDevframeUrl( + url: string, + connection: DevframeConnection, +): string { + if (!connection.authToken || connection.connectionMeta.backend !== 'websocket') + return url + + let base: URL + try { + base = new URL(connection.metaBaseUrl) + } + catch { + return url + } + + const websocket = resolveWsUrl(connection.connectionMeta.websocket, connection.metaBaseUrl, base) + return buildRemoteConnectionUrl(url, { + v: 1, + backend: 'websocket', + websocket, + authToken: connection.authToken, + origin: base.origin, + }) +} + function extractKeyFromFragment(hash: string): string | null { if (!hash) return null const raw = hash.startsWith('#') ? hash.slice(1) : hash const queryIdx = raw.indexOf('?') if (queryIdx !== -1) { - const params = new URLSearchParams(raw.slice(queryIdx + 1)) - const value = params.get(REMOTE_CONNECTION_KEY) + const value = new URLSearchParams(raw.slice(queryIdx + 1)).get(REMOTE_CONNECTION_KEY) if (value) return value } @@ -39,8 +71,8 @@ function extractKeyFromFragment(hash: string): string | null { function extractKeyFromQuery(search: string): string | null { if (!search) return null - const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search) - return params.get(REMOTE_CONNECTION_KEY) + return new URLSearchParams(search.startsWith('?') ? search.slice(1) : search) + .get(REMOTE_CONNECTION_KEY) } /** @@ -66,7 +98,7 @@ export function parseRemoteConnection(input?: string): RemoteConnectionInfo | nu search = parsed.search } catch { - // Treat as raw fragment or query string. + // Treat as a raw fragment or query string. if (input.startsWith('#')) hash = input else if (input.startsWith('?')) diff --git a/packages/hub/src/node/host-docks.ts b/packages/hub/src/node/host-docks.ts index f89215c9..4f1769a6 100644 --- a/packages/hub/src/node/host-docks.ts +++ b/packages/hub/src/node/host-docks.ts @@ -10,12 +10,12 @@ import type { } from '../types/docks' import type { DevframeDocksUserSettings } from '../types/settings' import type { DevframeHubContext } from './context' -import { REMOTE_CONNECTION_KEY } from 'devframe/constants' import { createStorage } from 'devframe/node' import { getInternalContext } from 'devframe/node/hub-internals' import { createEventEmitter } from 'devframe/utils/events' import { join } from 'pathe' import { DEFAULT_STATE_USER_SETTINGS } from '../constants' +import { buildRemoteConnectionUrl } from '../remote-url' import { diagnostics } from './diagnostics' interface RemoteDockRecord { @@ -31,56 +31,6 @@ function normaliseRemoteOptions(remote: true | RemoteDockOptions): Required { - const [key] = part.split('=') - return key === REMOTE_CONNECTION_KEY - }) - if (existingIdx >= 0) { - parts[existingIdx] = param - return `${before}#${parts.join('&')}` - } - return `${before}#${rawHash}&${param}` - } - // query - const qIdx = baseUrl.indexOf('?') - const hashIdx = baseUrl.indexOf('#') - const hash = hashIdx === -1 ? '' : baseUrl.slice(hashIdx) - const beforeHash = hashIdx === -1 ? baseUrl : baseUrl.slice(0, hashIdx) - const sep = qIdx === -1 || qIdx >= (hashIdx === -1 ? beforeHash.length : hashIdx) ? '?' : '&' - return `${beforeHash}${sep}${param}${hash}` -} - export class DevframeDocksHost implements DevframeDocksHostType { public readonly views: DevframeDocksHostType['views'] = new Map() public readonly events: DevframeDocksHostType['events'] = createEventEmitter() @@ -124,7 +74,7 @@ export class DevframeDocksHost implements DevframeDocksHostType { } return { ...view, - url: buildRemoteUrl(view.url, payload, record.options.transport), + url: buildRemoteConnectionUrl(view.url, payload, record.options.transport), } satisfies DevframeViewIframe } diff --git a/packages/hub/src/remote-url.ts b/packages/hub/src/remote-url.ts new file mode 100644 index 00000000..88934269 --- /dev/null +++ b/packages/hub/src/remote-url.ts @@ -0,0 +1,101 @@ +import type { RemoteConnectionInfo } from './types' +import { REMOTE_CONNECTION_KEY } from 'devframe/constants' + +function base64UrlEncode(value: string): string { + const bytes = new TextEncoder().encode(value) + let binary = '' + for (const byte of bytes) + binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function setRemoteConnectionParam(value: string, param: string): string { + const parts = value ? value.split('&') : [] + const existingIdx = parts.findIndex(part => part.split('=')[0] === REMOTE_CONNECTION_KEY) + if (existingIdx >= 0) + parts[existingIdx] = param + else + parts.push(param) + return parts.join('&') +} + +/** Encode a remote connection descriptor into an external viewer URL. */ +export function buildRemoteConnectionUrl( + baseUrl: string, + payload: RemoteConnectionInfo, + transport: 'fragment' | 'query' = 'fragment', +): string { + const encoded = base64UrlEncode(JSON.stringify(payload)) + const param = `${REMOTE_CONNECTION_KEY}=${encoded}` + + if (transport === 'fragment') { + const hashIdx = baseUrl.indexOf('#') + if (hashIdx === -1) + return `${baseUrl}#${param}` + + const beforeHash = baseUrl.slice(0, hashIdx) + const rawHash = baseUrl.slice(hashIdx + 1) + if (!rawHash) + return `${beforeHash}#${param}` + + const routeQueryIdx = rawHash.indexOf('?') + if (routeQueryIdx !== -1) { + const route = rawHash.slice(0, routeQueryIdx + 1) + const query = setRemoteConnectionParam(rawHash.slice(routeQueryIdx + 1), param) + return `${beforeHash}#${route}${query}` + } + + return `${beforeHash}#${setRemoteConnectionParam(rawHash, param)}` + } + + const hashIdx = baseUrl.indexOf('#') + const hash = hashIdx === -1 ? '' : baseUrl.slice(hashIdx) + const beforeHash = hashIdx === -1 ? baseUrl : baseUrl.slice(0, hashIdx) + const separator = beforeHash.includes('?') ? '&' : '?' + return `${beforeHash}${separator}${param}${hash}` +} + +/** Remove the remote connection descriptor from a URL before displaying or copying it. */ +export function stripRemoteConnectionFromUrl(input: string): string { + function stripParamList(value: string): [value: string, changed: boolean] { + const parts = value.split('&') + const filtered = parts.filter(part => part.split('=')[0] !== REMOTE_CONNECTION_KEY) + return [filtered.join('&'), filtered.length !== parts.length] + } + + const hashIdx = input.indexOf('#') + let beforeHash = hashIdx === -1 ? input : input.slice(0, hashIdx) + let hash = hashIdx === -1 ? undefined : input.slice(hashIdx + 1) + let changed = false + + if (hash !== undefined) { + const routeQueryIdx = hash.indexOf('?') + if (routeQueryIdx === -1) { + const [nextHash, didChange] = stripParamList(hash) + hash = nextHash + changed ||= didChange + } + else { + const route = hash.slice(0, routeQueryIdx) + const [query, didChange] = stripParamList(hash.slice(routeQueryIdx + 1)) + if (didChange) { + hash = query ? `${route}?${query}` : route + changed = true + } + } + } + + const queryIdx = beforeHash.indexOf('?') + if (queryIdx !== -1) { + const path = beforeHash.slice(0, queryIdx) + const [query, didChange] = stripParamList(beforeHash.slice(queryIdx + 1)) + if (didChange) { + beforeHash = query ? `${path}?${query}` : path + changed = true + } + } + + if (!changed) + return input + return hash ? `${beforeHash}#${hash}` : beforeHash +} diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index 416b488b..c64917e9 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -182,12 +182,16 @@ export type FrameNavHostPayload = { // #region Functions export declare function attachFrameNavClient(_: FrameNavClientOptions): FrameNavClient; +export declare function buildRemoteDevframeUrl(_: string, _: DevframeConnection): string; export declare function connectRemoteDevframe(_?: ConnectRemoteDevframeOptions): Promise; export declare function createDevframeClientHost(_?: DevframeClientHostOptions): Promise; export declare function createMessagesClient(_: DevframeRpcClient, _?: MessagesClientOptions): DevframeMessagesClient; export declare function getDevframeClientContext(): DevframeClientContext | undefined; export declare function parseRemoteConnection(_?: string): RemoteConnectionInfo | null; +export declare function resolveDockIcon(_: DevframeDockEntryIcon, _: DevframeConnection): DevframeDockEntryIcon; +export declare function resolveDockUrl(_: string, _: DevframeConnection): string; export declare function setDevframeClientContext(_: DevframeClientContext | undefined): void; +export declare function stripRemoteConnectionFromUrl(_: string): string; // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js index 210e191f..59a5fd04 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js @@ -3,11 +3,14 @@ */ // #region Functions export function attachFrameNavClient(_) {} +export function buildRemoteDevframeUrl(_, _) {} export async function connectRemoteDevframe(_) {} export async function createDevframeClientHost(_) {} export function createMessagesClient(_, _) {} export function getDevframeClientContext() {} export function parseRemoteConnection(_) {} +export function resolveDockIcon(_, _) {} +export function resolveDockUrl(_, _) {} export function setDevframeClientContext(_) {} // #endregion @@ -23,4 +26,5 @@ export * from "devframe/client"; // #region Other export { DEFAULT_CATEGORIES_ORDER } +export { stripRemoteConnectionFromUrl } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 79a44dba..e5892068 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -108,6 +108,12 @@ export interface SetupDevframeConnectionOptions { export interface StreamingSubscribeOptions { highWaterMark?: number; } +export interface WsUrlLocation { + protocol: string; + host: string; + hostname: string; + href: string; +} // #endregion // #region Types @@ -141,6 +147,8 @@ export declare function getDevframeConnection(): DevframeConnection | undefined; export declare function getDevframeRpcClient(_?: DevframeRpcClientOptions): Promise; export declare function isCallableStatus(_: DevframeConnectionStatus): boolean; export declare function readOtpFromUrl(_?: string): string | undefined; +export declare function registerDevframeViewerOrigin(_: DevframeConnection, _?: any): Promise; +export declare function resolveWsUrl(_: ConnectionMeta['websocket'], _: string, _: WsUrlLocation): string; export declare function setupDevframeConnection(_?: SetupDevframeConnectionOptions): Promise; // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js index b37b884a..6eb06ac9 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -19,6 +19,8 @@ export function getDevframeConnection() {} export async function getDevframeRpcClient(_) {} export function isCallableStatus(_) {} export function readOtpFromUrl(_) {} +export async function registerDevframeViewerOrigin(_, _) {} +export function resolveWsUrl(_, _, _) {} export async function setupDevframeConnection(_) {} // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index 6c00b0cd..02968315 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -19,6 +19,8 @@ export declare const DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH: string; export declare const DEVFRAME_OTP_URL_PARAM: string; export declare const DEVFRAME_RPC_DUMP_DIRNAME: string; export declare const DEVFRAME_RPC_DUMP_MANIFEST_FILENAME: string; +export declare const DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM: string; +export declare const DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM: string; export declare const DEVFRAME_WS_ROUTE: string; export declare const REMOTE_CONNECTION_KEY: string; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js index 462baa00..59c17ed9 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js @@ -19,6 +19,8 @@ export var DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH /* const */ export var DEVFRAME_OTP_URL_PARAM /* const */ export var DEVFRAME_RPC_DUMP_DIRNAME /* const */ export var DEVFRAME_RPC_DUMP_MANIFEST_FILENAME /* const */ +export var DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM /* const */ +export var DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM /* const */ export var DEVFRAME_WS_ROUTE /* const */ export var REMOTE_CONNECTION_KEY /* const */ // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 45808a4b..d2966636 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -73,6 +73,7 @@ export interface ConnectionMeta { jsonSerializableMethods?: string[]; baseUrl?: string; authToken?: string; + viewerOriginToken?: string; } export interface ConnectionMetaWebsocket { path?: string; diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.d.ts index dc966c63..3cd2c972 100644 --- a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.d.ts @@ -3,9 +3,12 @@ */ // #region Other export { attachWsRpcTransport } +export { createWsOriginRegistry } +export { CreateWsOriginRegistryOptions } export { DevframeNodeRpcSessionMeta } export { isAllowedOrigin } export { isLoopbackHostname } +export { WsOriginRegistry } export { WsRpcTransport } export { WsRpcTransportOptions } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js index 22750f70..ffa6f3c7 100644 --- a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js @@ -3,6 +3,7 @@ */ // #region Functions export function attachWsRpcTransport(_, _) {} +export function createWsOriginRegistry(_) {} export function isAllowedOrigin(_, _) {} export function isLoopbackHostname(_) {} // #endregion \ No newline at end of file