| import "./constants.mjs"; | ||
| import { n as getPort } from "./dist-eNI9qM_I.mjs"; | ||
| import { cleanDoubleSlashes, withLeadingSlash, withTrailingSlash, withoutLeadingSlash } from "ufo"; | ||
| //#region src/adapters/_shared.ts | ||
| const DEFAULT_PORT = 9999; | ||
| /** | ||
| * Resolve the mount base path for a devframe's SPA. Hosted adapters | ||
| * (`vite`, `embedded`) default to `/__<id>/` so they don't collide | ||
| * with the host app; standalone adapters (`cli`, `build`) | ||
| * default to `/` because they own the origin. | ||
| * | ||
| * The devframe author can override with `basePath` on the definition. | ||
| */ | ||
| function resolveBasePath(def, kind) { | ||
| if (def.basePath) return normalizeBasePath(def.basePath); | ||
| return kind === "standalone" ? "/" : `/__${def.id}/`; | ||
| } | ||
| function normalizeBasePath(base) { | ||
| return cleanDoubleSlashes(withTrailingSlash(withLeadingSlash(base))); | ||
| } | ||
| /** | ||
| * Resolve the listening port for `createDevServer` (and `createHandler`'s | ||
| * side-car tiers), honoring the definition's `cli.port` / `cli.portRange` / | ||
| * `cli.random` settings. Exposed separately so authors who run their own | ||
| * argv parsing can resolve a port up-front (to print it, log it, etc.) | ||
| * before starting the server. | ||
| */ | ||
| async function resolveDevServerPort(def, options = {}) { | ||
| const host = options.host ?? def.cli?.host ?? "localhost"; | ||
| const portOptions = { | ||
| port: options.defaultPort ?? def.cli?.port ?? DEFAULT_PORT, | ||
| host | ||
| }; | ||
| if (def.cli?.portRange) portOptions.portRange = def.cli.portRange; | ||
| if (def.cli?.random) portOptions.random = def.cli.random; | ||
| return getPort(portOptions); | ||
| } | ||
| /** | ||
| * Normalize the `cli.mcp` / `mcp` option (`boolean | McpRouteOptions`) into | ||
| * concrete options, or `undefined` when the MCP route is disabled. | ||
| */ | ||
| function resolveMcpConfig(mcp) { | ||
| if (!mcp) return void 0; | ||
| return mcp === true ? {} : mcp; | ||
| } | ||
| /** | ||
| * Resolve the `mcp` entry a `__connection.json` should advertise for a dev | ||
| * server started with the given `mcp` option (falling back to `def.cli?.mcp`, | ||
| * exactly like `createDevServer`), or `undefined` when the route is | ||
| * disabled. | ||
| * | ||
| * Hosted bridges that hand-roll their connection meta pass the side-car | ||
| * `port`: the advertised path becomes absolute (the side-car mounts at `/`) | ||
| * and the client dials `<page-host>:<port><path>`. Without `port` the path | ||
| * stays relative, resolved against `__connection.json`'s own location (the | ||
| * same-server default). | ||
| */ | ||
| function resolveMcpConnectionMeta(def, mcp, port) { | ||
| const config = resolveMcpConfig(mcp ?? def.cli?.mcp); | ||
| if (!config) return void 0; | ||
| const route = withoutLeadingSlash(config.path ?? "__mcp"); | ||
| return port != null ? { | ||
| path: withLeadingSlash(route), | ||
| port | ||
| } : { path: route }; | ||
| } | ||
| //#endregion | ||
| export { resolveMcpConnectionMeta as i, resolveBasePath as n, resolveDevServerPort as r, normalizeBasePath as t }; |
| import { DEVFRAME_CONNECTION_META_FILENAME } from "./constants.mjs"; | ||
| import { t as diagnostics } from "./diagnostics-DOQPnQmX.mjs"; | ||
| import { t as createHostContext } from "./context-CEe4PBz1.mjs"; | ||
| import { t as resolveStaticAssetsSource } from "./remote-assets-nTTxY2MU.mjs"; | ||
| import { i as resolveMcpConnectionMeta, n as resolveBasePath, r as resolveDevServerPort, t as normalizeBasePath } from "./_shared-CbZ_yhbz.mjs"; | ||
| import { t as createH3DevframeHost } from "./host-h3-Crkzm42q.mjs"; | ||
| import { i as normalizeHttpServerUrl, n as resolveInstanceRegister, t as createInstanceShell } from "./instance-shell-CMFyszWJ.mjs"; | ||
| import { open } from "./utils/open.mjs"; | ||
| import { mountStaticHandler } from "./utils/serve-static.mjs"; | ||
| import { createServer } from "node:http"; | ||
| import { resolve } from "pathe"; | ||
| import process from "node:process"; | ||
| import { joinURL, withBase } from "ufo"; | ||
| import { H3, toNodeHandler } from "h3"; | ||
| //#region src/adapters/initiate.ts | ||
| const INSTANCE_INTERNALS = /* @__PURE__ */ new WeakMap(); | ||
| /** @internal */ | ||
| function getInstanceInternals(handler) { | ||
| return INSTANCE_INTERNALS.get(handler) ?? {}; | ||
| } | ||
| /** | ||
| * Serve a devframe through one framework-agnostic, web-standard handler — | ||
| * the SPA, `__connection.json` discovery, the WebSocket RPC endpoint, the | ||
| * auth gate, and the optional MCP route, all under a single mount base. | ||
| * Mount `handler` on any framework's catch-all route (or `nodeMiddleware` on | ||
| * a connect stack) and the devframe is live inside that app. | ||
| * | ||
| * The factory is synchronous and kicks off initialization eagerly; | ||
| * `handler`/`nodeMiddleware` await readiness internally. Nothing binds a port | ||
| * on its own: the WebSocket resolves in precedence order — `ws.port` (pinned | ||
| * side-car) > `server` (shared upgrade at `<base>__ws`) > `ws.sidecar` | ||
| * (auto-port side-car) > the host driving upgrades itself through | ||
| * {@link DevframeInstance.attach} — while `ws.url`, when set, overrides the | ||
| * advertised* endpoint (the tunnel pattern) and on its own hands the whole | ||
| * transport to an external server. `__connection.json` reflects whichever | ||
| * combination is active. | ||
| */ | ||
| function initDevframe(def, options) { | ||
| const base = normalizeBasePath(options.base); | ||
| const distDir = options.distDir === false ? void 0 : options.distDir ?? def.cli?.distDir; | ||
| const app = options.app ?? new H3(); | ||
| const shell = createInstanceShell({ | ||
| base, | ||
| app, | ||
| host: options.host ?? def.cli?.host ?? "localhost", | ||
| origin: options.origin, | ||
| auth: options.auth !== void 0 ? options.auth : def.cli?.auth, | ||
| server: options.server, | ||
| ws: options.ws ?? def.cli?.ws, | ||
| sse: options.sse ?? def.cli?.sse, | ||
| allowedOrigins: options.allowedOrigins, | ||
| destroyUnmatchedUpgrades: options.destroyUnmatchedUpgrades, | ||
| onPeerConnect: options.onPeerConnect, | ||
| onPeerDisconnect: options.onPeerDisconnect, | ||
| register: resolveInstanceRegister(options.register, { | ||
| id: def.id, | ||
| name: def.name | ||
| }), | ||
| resolveSidecarPort: (sidecarHost) => resolveDevServerPort(def, { host: sidecarHost }), | ||
| onMetaUnavailable: () => { | ||
| throw diagnostics.DF0054({ id: def.id }); | ||
| }, | ||
| async init(api) { | ||
| const h3Host = createH3DevframeHost({ | ||
| origin: () => api.origin() ?? "http://localhost", | ||
| appName: def.id, | ||
| mount: (mountBase, dir) => { | ||
| mountStaticHandler(app, mountBase, dir); | ||
| } | ||
| }); | ||
| const hostImpl = options.getStorageDir ? { | ||
| ...h3Host, | ||
| getStorageDir: options.getStorageDir | ||
| } : h3Host; | ||
| const context = await createHostContext({ | ||
| cwd: process.cwd(), | ||
| mode: "dev", | ||
| host: hostImpl | ||
| }); | ||
| const setupInfo = { flags: options.flags ?? {} }; | ||
| await def.setup(context, setupInfo); | ||
| const mcpOption = options.mcp ?? def.cli?.mcp; | ||
| const mcpMeta = resolveMcpConnectionMeta(def, mcpOption); | ||
| let mcpDispose; | ||
| if (mcpMeta) { | ||
| const mcpConfig = mcpOption === true || mcpOption === void 0 ? {} : mcpOption; | ||
| const mcpPath = joinURL(base, mcpMeta.path); | ||
| let mountMcpHttp; | ||
| try { | ||
| ({mountMcpHttp} = await import("./http-DYpK0cUi.mjs").then((n) => n.t)); | ||
| } catch (error) { | ||
| const reason = error instanceof Error ? error.message : String(error); | ||
| throw diagnostics.DF0017({ | ||
| transport: "http", | ||
| reason, | ||
| cause: error | ||
| }); | ||
| } | ||
| mcpDispose = mountMcpHttp(app, context, mcpPath, { | ||
| serverName: `${def.id} (devframe)`, | ||
| serverVersion: def.version ?? "0.0.0", | ||
| exposeSharedState: true, | ||
| allowedOrigins: mcpConfig.allowedOrigins | ||
| }).dispose; | ||
| } | ||
| return { | ||
| context, | ||
| ...mcpMeta ? { mcp: mcpMeta } : {}, | ||
| ...mcpDispose ? { dispose: mcpDispose } : {} | ||
| }; | ||
| }, | ||
| mount(context, meta) { | ||
| app.use(joinURL(base, DEVFRAME_CONNECTION_META_FILENAME), () => meta); | ||
| if (distDir) { | ||
| const source = resolveStaticAssetsSource(distDir, context.host.getStorageDir("project")); | ||
| mountStaticHandler(app, base, typeof source === "string" ? resolve(source) : source); | ||
| } | ||
| } | ||
| }); | ||
| const instance = { | ||
| base: shell.base, | ||
| handler: shell.handler, | ||
| nodeMiddleware: shell.nodeMiddleware, | ||
| attach: shell.attach, | ||
| handleUpgrade: shell.handleUpgrade, | ||
| ready: shell.ready, | ||
| context: shell.context, | ||
| connectionMeta: shell.connectionMeta, | ||
| close: shell.close | ||
| }; | ||
| INSTANCE_INTERNALS.set(instance, shell.internals); | ||
| return instance; | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/dev.ts | ||
| /** | ||
| * Start a devframe dev server for a {@link DevframeDefinition} — | ||
| * h3 + WebSocket RPC + (optionally) the author's SPA mounted at the | ||
| * resolved base path. | ||
| * | ||
| * When `distDir` is omitted (and `def.cli?.distDir` is unset) the | ||
| * server runs in **bridge mode**: only `__connection.json` and the WS | ||
| * endpoint are mounted, with no SPA mount. The SPA is expected to be | ||
| * hosted elsewhere (e.g. by a parent Vite/Nuxt dev server) — see | ||
| * `devframeViteBridge` from `@devframes/vite`. | ||
| * | ||
| * Returns the underlying {@link StartedServer} handle so callers can | ||
| * close it gracefully (SIGINT, hot-reload, test teardown). | ||
| * | ||
| * Use this directly when integrating devframe into an existing CLI | ||
| * framework (commander, yargs, hand-rolled CAC). For the all-in-one | ||
| * `dev` / `build` / `mcp` shell, reach for {@link createCac} instead. | ||
| */ | ||
| async function createDevServer(def, options = {}) { | ||
| if (def.capabilities?.dev === false && !options.force) throw diagnostics.DF0058({ id: def.id }); | ||
| const host = options.host ?? def.cli?.host ?? "localhost"; | ||
| const requestedPort = options.port ?? await resolveDevServerPort(def, { host }); | ||
| const flags = options.flags ?? {}; | ||
| const basePath = options.basePath ? normalizeBasePath(options.basePath) : resolveBasePath(def, "standalone"); | ||
| const app = options.app ?? new H3(); | ||
| const server = createServer(toNodeHandler(app)); | ||
| try { | ||
| await new Promise((resolveListen, rejectListen) => { | ||
| const onError = (error) => rejectListen(error); | ||
| server.once("error", onError); | ||
| server.listen(requestedPort, host, () => { | ||
| server.removeListener("error", onError); | ||
| resolveListen(); | ||
| }); | ||
| }); | ||
| } catch (error) { | ||
| throw diagnostics.DF0052({ | ||
| host, | ||
| port: requestedPort, | ||
| reason: error instanceof Error ? error.message : String(error), | ||
| cause: error | ||
| }); | ||
| } | ||
| const address = server.address(); | ||
| const port = typeof address === "object" && address ? address.port : requestedPort; | ||
| const origin = normalizeHttpServerUrl(host, port); | ||
| const devframe = initDevframe(def, { | ||
| base: basePath, | ||
| distDir: options.distDir, | ||
| app, | ||
| server, | ||
| host, | ||
| origin, | ||
| ws: options.ws, | ||
| allowedOrigins: options.allowedOrigins, | ||
| sse: options.sse, | ||
| auth: flags.auth === false ? false : options.auth, | ||
| mcp: options.mcp, | ||
| flags, | ||
| onPeerConnect: options.onPeerConnect, | ||
| onPeerDisconnect: options.onPeerDisconnect, | ||
| register: true, | ||
| destroyUnmatchedUpgrades: true | ||
| }); | ||
| try { | ||
| await devframe.ready; | ||
| } catch (error) { | ||
| await new Promise((resolveClose) => server.close(() => resolveClose())); | ||
| throw error; | ||
| } | ||
| const internals = getInstanceInternals(devframe); | ||
| const transport = internals.started; | ||
| await options.onReady?.({ | ||
| origin, | ||
| port, | ||
| app | ||
| }); | ||
| await maybeOpenBrowser(def, flags, `${origin}${basePath}`, options.openBrowser, internals.authHandler); | ||
| return { | ||
| origin, | ||
| port, | ||
| app, | ||
| ws: transport.ws, | ||
| rpcGroup: transport.rpcGroup, | ||
| connectionMeta: transport.connectionMeta, | ||
| async close() { | ||
| await devframe.close(); | ||
| await new Promise((resolveClose) => server.close(() => resolveClose())); | ||
| } | ||
| }; | ||
| } | ||
| async function maybeOpenBrowser(def, flags, origin, override, authHandler) { | ||
| const flagsOpen = flags.open; | ||
| const cliOpen = def.cli?.open; | ||
| const resolved = override ?? flagsOpen ?? cliOpen; | ||
| if (resolved === void 0 || resolved === false) return; | ||
| const target = typeof resolved === "string" ? withBase(resolved, origin) : origin; | ||
| const authorizedTarget = authHandler?.buildOpenUrl?.(target) ?? target; | ||
| try { | ||
| await open(authorizedTarget); | ||
| } catch {} | ||
| } | ||
| //#endregion | ||
| export { getInstanceInternals as n, initDevframe as r, createDevServer as t }; |
| import { n as __exportAll } from "./rolldown-runtime-JspESFgx.mjs"; | ||
| import "node:fs/promises"; | ||
| import { createServer } from "node:net"; | ||
| import { networkInterfaces } from "node:os"; | ||
| //#region ../../node_modules/.pnpm/get-port-please@3.2.0/node_modules/get-port-please/dist/index.mjs | ||
| var dist_exports = /* @__PURE__ */ __exportAll({ | ||
| checkPort: () => checkPort, | ||
| getPort: () => getPort, | ||
| getRandomPort: () => getRandomPort, | ||
| isSafePort: () => isSafePort, | ||
| isUnsafePort: () => isUnsafePort | ||
| }); | ||
| const unsafePorts = /* @__PURE__ */ new Set([ | ||
| 1, | ||
| 7, | ||
| 9, | ||
| 11, | ||
| 13, | ||
| 15, | ||
| 17, | ||
| 19, | ||
| 20, | ||
| 21, | ||
| 22, | ||
| 23, | ||
| 25, | ||
| 37, | ||
| 42, | ||
| 43, | ||
| 53, | ||
| 69, | ||
| 77, | ||
| 79, | ||
| 87, | ||
| 95, | ||
| 101, | ||
| 102, | ||
| 103, | ||
| 104, | ||
| 109, | ||
| 110, | ||
| 111, | ||
| 113, | ||
| 115, | ||
| 117, | ||
| 119, | ||
| 123, | ||
| 135, | ||
| 137, | ||
| 139, | ||
| 143, | ||
| 161, | ||
| 179, | ||
| 389, | ||
| 427, | ||
| 465, | ||
| 512, | ||
| 513, | ||
| 514, | ||
| 515, | ||
| 526, | ||
| 530, | ||
| 531, | ||
| 532, | ||
| 540, | ||
| 548, | ||
| 554, | ||
| 556, | ||
| 563, | ||
| 587, | ||
| 601, | ||
| 636, | ||
| 989, | ||
| 990, | ||
| 993, | ||
| 995, | ||
| 1719, | ||
| 1720, | ||
| 1723, | ||
| 2049, | ||
| 3659, | ||
| 4045, | ||
| 5060, | ||
| 5061, | ||
| 6e3, | ||
| 6566, | ||
| 6665, | ||
| 6666, | ||
| 6667, | ||
| 6668, | ||
| 6669, | ||
| 6697, | ||
| 10080 | ||
| ]); | ||
| function isUnsafePort(port) { | ||
| return unsafePorts.has(port); | ||
| } | ||
| function isSafePort(port) { | ||
| return !isUnsafePort(port); | ||
| } | ||
| var GetPortError = class extends Error { | ||
| constructor(message, opts) { | ||
| super(message, opts); | ||
| this.message = message; | ||
| } | ||
| name = "GetPortError"; | ||
| }; | ||
| function _log(verbose, message) { | ||
| if (verbose) console.log(`[get-port] ${message}`); | ||
| } | ||
| function _generateRange(from, to) { | ||
| if (to < from) return []; | ||
| const r = []; | ||
| for (let index = from; index <= to; index++) r.push(index); | ||
| return r; | ||
| } | ||
| function _tryPort(port, host) { | ||
| return new Promise((resolve) => { | ||
| const server = createServer(); | ||
| server.unref(); | ||
| server.on("error", () => { | ||
| resolve(false); | ||
| }); | ||
| server.listen({ | ||
| port, | ||
| host | ||
| }, () => { | ||
| const { port: port2 } = server.address(); | ||
| server.close(() => { | ||
| resolve(isSafePort(port2) && port2); | ||
| }); | ||
| }); | ||
| }); | ||
| } | ||
| function _getLocalHosts(additional) { | ||
| const hosts = new Set(additional); | ||
| for (const _interface of Object.values(networkInterfaces())) for (const config of _interface || []) if (config.address && !config.internal && !config.address.startsWith("fe80::") && !config.address.startsWith("169.254")) hosts.add(config.address); | ||
| return [...hosts]; | ||
| } | ||
| async function _findPort(ports, host) { | ||
| for (const port of ports) { | ||
| const r = await _tryPort(port, host); | ||
| if (r) return r; | ||
| } | ||
| } | ||
| function _fmtOnHost(hostname) { | ||
| return hostname ? `on host ${JSON.stringify(hostname)}` : "on any host"; | ||
| } | ||
| const HOSTNAME_RE = /^(?!-)[\d.:A-Za-z-]{1,63}(?<!-)$/; | ||
| function _validateHostname(hostname, _public, verbose) { | ||
| if (hostname && !HOSTNAME_RE.test(hostname)) { | ||
| const fallbackHost = _public ? "0.0.0.0" : "127.0.0.1"; | ||
| _log(verbose, `Invalid hostname: ${JSON.stringify(hostname)}. Using ${JSON.stringify(fallbackHost)} as fallback.`); | ||
| return fallbackHost; | ||
| } | ||
| return hostname; | ||
| } | ||
| async function getPort(_userOptions = {}) { | ||
| if (typeof _userOptions === "number" || typeof _userOptions === "string") _userOptions = { port: Number.parseInt(_userOptions + "") || 0 }; | ||
| const _port = Number(_userOptions.port ?? process.env.PORT); | ||
| const _userSpecifiedAnyPort = Boolean(_userOptions.port || _userOptions.ports?.length || _userOptions.portRange?.length); | ||
| const options = { | ||
| random: _port === 0, | ||
| ports: [], | ||
| portRange: [], | ||
| alternativePortRange: _userSpecifiedAnyPort ? [] : [3e3, 3100], | ||
| verbose: false, | ||
| ..._userOptions, | ||
| port: _port, | ||
| host: _validateHostname(_userOptions.host ?? process.env.HOST, _userOptions.public, _userOptions.verbose) | ||
| }; | ||
| if (options.random && !_userSpecifiedAnyPort) return getRandomPort(options.host); | ||
| const portsToCheck = [ | ||
| options.port, | ||
| ...options.ports, | ||
| ..._generateRange(...options.portRange) | ||
| ].filter((port) => { | ||
| if (!port) return false; | ||
| if (!isSafePort(port)) { | ||
| _log(options.verbose, `Ignoring unsafe port: ${port}`); | ||
| return false; | ||
| } | ||
| return true; | ||
| }); | ||
| if (portsToCheck.length === 0) portsToCheck.push(3e3); | ||
| let availablePort = await _findPort(portsToCheck, options.host); | ||
| if (!availablePort && options.alternativePortRange.length > 0) { | ||
| availablePort = await _findPort(_generateRange(...options.alternativePortRange), options.host); | ||
| if (portsToCheck.length > 0) { | ||
| let message = `Unable to find an available port (tried ${portsToCheck.join("-")} ${_fmtOnHost(options.host)}).`; | ||
| if (availablePort) message += ` Using alternative port ${availablePort}.`; | ||
| _log(options.verbose, message); | ||
| } | ||
| } | ||
| if (!availablePort && _userOptions.random !== false) { | ||
| availablePort = await getRandomPort(options.host); | ||
| if (availablePort) _log(options.verbose, `Using random port ${availablePort}`); | ||
| } | ||
| if (!availablePort) { | ||
| const triedRanges = [ | ||
| options.port, | ||
| options.portRange.join("-"), | ||
| options.alternativePortRange.join("-") | ||
| ].filter(Boolean).join(", "); | ||
| throw new GetPortError(`Unable to find an available port ${_fmtOnHost(options.host)} (tried ${triedRanges})`); | ||
| } | ||
| return availablePort; | ||
| } | ||
| async function getRandomPort(host) { | ||
| const port = await checkPort(0, host); | ||
| if (port === false) throw new GetPortError(`Unable to find a random port ${_fmtOnHost(host)}`); | ||
| return port; | ||
| } | ||
| async function checkPort(port, host = process.env.HOST, verbose) { | ||
| if (!host) host = _getLocalHosts([void 0, "0.0.0.0"]); | ||
| if (!Array.isArray(host)) return _tryPort(port, host); | ||
| for (const _host of host) { | ||
| const _port = await _tryPort(port, _host); | ||
| if (_port === false) { | ||
| if (port < 1024 && verbose) _log(verbose, `Unable to listen to the privileged port ${port} ${_fmtOnHost(_host)}`); | ||
| return false; | ||
| } | ||
| if (port === 0 && _port !== 0) port = _port; | ||
| } | ||
| return port; | ||
| } | ||
| //#endregion | ||
| export { getPort as n, dist_exports as t }; |
| import { n as __exportAll } from "./rolldown-runtime-JspESFgx.mjs"; | ||
| import { t as Diagnostic } from "./nostics-CzECRXpE.mjs"; | ||
| import { i as isAllowedOrigin } from "./ws-server-BdSLrhxE.mjs"; | ||
| import { t as diagnostics } from "./diagnostics-DOQPnQmX.mjs"; | ||
| import { t as createHostContext } from "./context-CEe4PBz1.mjs"; | ||
| import { t as toAgentToolName } from "./agent-tool-name-EgfoFO8C.mjs"; | ||
| import { randomUUID } from "node:crypto"; | ||
| import { join } from "pathe"; | ||
| import process from "node:process"; | ||
| import { homedir } from "node:os"; | ||
| import { defineHandler } from "h3"; | ||
| import { Server, WebStandardStreamableHTTPServerTransport, isInitializeRequest } from "@modelcontextprotocol/server"; | ||
| //#region src/adapters/mcp/stringify.ts | ||
| /** | ||
| * JSON-coercing serializer for MCP text payloads. | ||
| * | ||
| * MCP carries tool results and resource reads as plain text over a | ||
| * JSON-RPC transport, so we cannot use the `s:`-prefixed structured-clone | ||
| * format the WS RPC transport falls back to for non-JSON values. Instead, | ||
| * we coerce common non-JSON types into JSON-friendly forms so the LLM | ||
| * client sees something useful instead of `[object Object]`. | ||
| * | ||
| * Coercions: | ||
| * - `BigInt` → `"123n"` | ||
| * - `Date` → ISO string (via the native `toJSON`) | ||
| * - `Map` → `{ __type: 'Map', entries: [[k, v], …] }` | ||
| * - `Set` → `{ __type: 'Set', entries: [v, …] }` | ||
| * - `Error` → `{ name, message, stack, cause? }` (cause recurses) | ||
| * - `Function` → `"[Function: name]"` | ||
| * - `Symbol` → `value.toString()` | ||
| * - cycles → `"[Circular]"` | ||
| */ | ||
| function stringifyForMcp(value) { | ||
| if (value === void 0) return "undefined"; | ||
| if (typeof value === "string") return value; | ||
| const seen = /* @__PURE__ */ new WeakSet(); | ||
| return JSON.stringify(value, (_key, val) => { | ||
| if (typeof val === "bigint") return `${val}n`; | ||
| if (val instanceof Error) { | ||
| const out = { | ||
| name: val.name, | ||
| message: val.message, | ||
| stack: val.stack | ||
| }; | ||
| if (val.cause !== void 0) out.cause = val.cause; | ||
| return out; | ||
| } | ||
| if (val instanceof Map) return { | ||
| __type: "Map", | ||
| entries: [...val.entries()] | ||
| }; | ||
| if (val instanceof Set) return { | ||
| __type: "Set", | ||
| entries: [...val] | ||
| }; | ||
| if (typeof val === "function") return `[Function: ${val.name || "anonymous"}]`; | ||
| if (typeof val === "symbol") return val.toString(); | ||
| if (val !== null && typeof val === "object") { | ||
| if (seen.has(val)) return "[Circular]"; | ||
| seen.add(val); | ||
| } | ||
| return val; | ||
| }, 2); | ||
| } | ||
| /** | ||
| * Format a thrown value for an MCP `isError` text payload. | ||
| * | ||
| * A nostics `Diagnostic` (every coded devframe error) becomes structured | ||
| * JSON — `{ error: { code, message, fix?, docs? } }` — so an agent receives | ||
| * the actionable next step (`fix`) and the docs URL instead of a bare | ||
| * message string. Other errors surface `Error.name`/`message`, plus one | ||
| * level of `cause.message` so context isn't dropped silently. | ||
| */ | ||
| function formatMcpError(error) { | ||
| if (error instanceof Diagnostic) return JSON.stringify({ error: { | ||
| code: error.code, | ||
| message: error.message, | ||
| ...error.fix ? { fix: error.fix } : {}, | ||
| ...error.docs ? { docs: error.docs } : {} | ||
| } }, null, 2); | ||
| if (!(error instanceof Error)) return String(error); | ||
| const cause = error.cause; | ||
| const causeText = cause instanceof Error ? ` (cause: ${cause.message})` : cause !== void 0 ? ` (cause: ${String(cause)})` : ""; | ||
| return `${error.name}: ${error.message}${causeText}`; | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/mcp/to-json-schema.ts | ||
| const FALLBACK_OBJECT_SCHEMA = Object.freeze({ | ||
| type: "object", | ||
| additionalProperties: true | ||
| }); | ||
| /** | ||
| * Convert a Standard Schema to JSON Schema for the agent/MCP surface. | ||
| * | ||
| * Devframe stays validator-neutral, so conversion uses the schema's own | ||
| * [Standard JSON Schema](https://standardschema.dev/) converter | ||
| * (`~standard.jsonSchema`) when the validator provides one — zod 4 does, | ||
| * for example. Validators without a native converter (e.g. valibot) degrade | ||
| * to a permissive object schema rather than pulling in a converter library. | ||
| */ | ||
| function safeToJsonSchema(schema) { | ||
| const standard = schema["~standard"]; | ||
| if (standard.jsonSchema) try { | ||
| return standard.jsonSchema.input({ target: "draft-2020-12" }); | ||
| } catch { | ||
| return FALLBACK_OBJECT_SCHEMA; | ||
| } | ||
| return FALLBACK_OBJECT_SCHEMA; | ||
| } | ||
| /** | ||
| * JSON Schema for an RPC return value on the agent/MCP surface. | ||
| * @internal | ||
| */ | ||
| function returnToJsonSchema(schema) { | ||
| if (!schema) return void 0; | ||
| return safeToJsonSchema(schema); | ||
| } | ||
| /** | ||
| * JSON Schema for an RPC function's positional args on the agent/MCP | ||
| * surface. Each positional arg is advertised under `arg0` / `arg1` / … — | ||
| * matching how the agent bridge coerces the incoming object payload back | ||
| * into positional arguments. | ||
| * | ||
| * Returns `{ type: 'object', properties: {} }` when there are no args. | ||
| * @internal | ||
| */ | ||
| function argsToJsonSchema(args) { | ||
| if (!args || args.length === 0) return { | ||
| schema: { | ||
| type: "object", | ||
| properties: {} | ||
| }, | ||
| unwrapped: false | ||
| }; | ||
| const properties = {}; | ||
| const required = []; | ||
| for (let i = 0; i < args.length; i++) { | ||
| const key = `arg${i}`; | ||
| properties[key] = safeToJsonSchema(args[i]); | ||
| required.push(key); | ||
| } | ||
| return { | ||
| schema: { | ||
| type: "object", | ||
| properties, | ||
| required, | ||
| additionalProperties: false | ||
| }, | ||
| unwrapped: false | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/mcp/build-server.ts | ||
| /** | ||
| * Wire an MCP {@link Server} to a devframe context. Returns the server | ||
| * plus a disposal function for the subscriptions it sets up. The | ||
| * transport is the caller's responsibility — `createMcpServer` connects | ||
| * stdio; tests can connect an {@link InMemoryTransport} instead. | ||
| * | ||
| * @internal | ||
| */ | ||
| function buildMcpServerFromContext(ctx, options) { | ||
| const server = new Server({ | ||
| name: options.serverName, | ||
| version: options.serverVersion | ||
| }, { capabilities: { | ||
| tools: { listChanged: true }, | ||
| resources: { listChanged: true } | ||
| } }); | ||
| registerToolHandlers(server, ctx, options.exposeSharedState); | ||
| registerResourceHandlers(server, ctx, options.exposeSharedState); | ||
| const notify = (method) => { | ||
| server.notification({ method }).catch(() => {}); | ||
| }; | ||
| const offManifest = ctx.agent.events.on("agent:manifest:changed", () => { | ||
| notify("notifications/tools/list_changed"); | ||
| notify("notifications/resources/list_changed"); | ||
| }); | ||
| const offKeyAdded = ctx.rpc.sharedState.onKeyAdded(() => { | ||
| notify("notifications/resources/list_changed"); | ||
| }); | ||
| return { | ||
| server, | ||
| dispose: () => { | ||
| offManifest(); | ||
| offKeyAdded(); | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * Build an MCP server over the agent surface of a devframe definition. | ||
| * Currently supports `stdio` transport only. | ||
| */ | ||
| async function createMcpServer(definition, options = {}) { | ||
| const transport = options.transport ?? "stdio"; | ||
| if (transport !== "stdio") throw diagnostics.DF0017({ | ||
| transport, | ||
| reason: "Only stdio transport is supported in this release." | ||
| }); | ||
| const ctx = await createHostContext({ | ||
| cwd: process.cwd(), | ||
| mode: "dev", | ||
| host: { | ||
| mountStatic: () => {}, | ||
| resolveOrigin: () => "mcp://devframe", | ||
| getStorageDir: (scope) => { | ||
| if (scope === "workspace") return join(process.cwd(), ".devframe"); | ||
| if (scope === "project") return join(process.cwd(), `node_modules/.${definition.id}/devframe`); | ||
| return join(homedir(), `.${definition.id}/devframe`); | ||
| } | ||
| } | ||
| }); | ||
| await definition.setup(ctx); | ||
| const { server, dispose } = buildMcpServerFromContext(ctx, { | ||
| serverName: options.serverName ?? `${definition.id} (devframe)`, | ||
| serverVersion: options.serverVersion ?? definition.version ?? "0.0.0", | ||
| exposeSharedState: options.exposeSharedState ?? true | ||
| }); | ||
| const { startStdioTransport } = await import("./transports-vhizgqXM.mjs"); | ||
| let stop; | ||
| try { | ||
| stop = await startStdioTransport(server); | ||
| } catch (error) { | ||
| const reason = error instanceof Error ? error.message : String(error); | ||
| throw diagnostics.DF0017({ | ||
| transport, | ||
| reason, | ||
| cause: error | ||
| }); | ||
| } | ||
| options.onReady?.({ transport: "stdio" }); | ||
| return { async stop() { | ||
| dispose(); | ||
| await stop(); | ||
| } }; | ||
| } | ||
| /** | ||
| * Id of the built-in shared-state read tool — namespaced like every other | ||
| * built-in (`devframe:<area>:<fn>`). Tool-shaped access matters because many | ||
| * MCP clients only consume tools — the parallel `devframe://state/<key>` | ||
| * resource projection stays for the clients that do read resources. | ||
| */ | ||
| const READ_STATE_TOOL = "devframe:state:read"; | ||
| /** Wire name of the built-in shared-state read tool: `devframe_state_read`. */ | ||
| const READ_STATE_NAME = toAgentToolName(READ_STATE_TOOL); | ||
| function sharedStateFilter(exposeSharedState) { | ||
| if (exposeSharedState === false) return void 0; | ||
| return typeof exposeSharedState === "function" ? exposeSharedState : () => true; | ||
| } | ||
| function readStateToolProjection() { | ||
| return { | ||
| name: READ_STATE_NAME, | ||
| title: "Read shared state", | ||
| description: "Read this devtool's live shared state. Call without arguments to list the available keys, then with a key to get that value as JSON. Safe to call freely.", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { key: { | ||
| type: "string", | ||
| description: "A shared-state key from the key list. Omit to list all keys." | ||
| } } | ||
| }, | ||
| annotations: { | ||
| title: "Read shared state", | ||
| readOnlyHint: true, | ||
| destructiveHint: false | ||
| } | ||
| }; | ||
| } | ||
| async function readStateResult(ctx, filter, key) { | ||
| const keys = ctx.rpc.sharedState.keys().filter(filter); | ||
| if (key === void 0) return { keys }; | ||
| if (!keys.includes(key)) throw diagnostics.DF0048({ key }); | ||
| return { | ||
| key, | ||
| value: (await ctx.rpc.sharedState.get(key)).value() | ||
| }; | ||
| } | ||
| function registerToolHandlers(server, ctx, exposeSharedState) { | ||
| const stateFilter = sharedStateFilter(exposeSharedState); | ||
| const warnedCollisions = /* @__PURE__ */ new Set(); | ||
| /** | ||
| * Resolve a wire tool name back to the registered {@link AgentTool}. | ||
| * Wire-name matching runs first, in manifest order — the same tool the | ||
| * list projection advertises under that name — with a raw-id fallback so | ||
| * a colon-namespaced id keeps working as a call name. | ||
| */ | ||
| const resolveTool = (name) => { | ||
| return ctx.agent.list().tools.find((tool) => toAgentToolName(tool.id) === name) ?? ctx.agent.getTool(name); | ||
| }; | ||
| server.setRequestHandler("tools/list", async () => { | ||
| const byName = /* @__PURE__ */ new Map(); | ||
| for (const tool of ctx.agent.list().tools) { | ||
| const name = toAgentToolName(tool.id); | ||
| const existing = byName.get(name); | ||
| if (existing) { | ||
| if (!warnedCollisions.has(`${name}|${tool.id}`)) { | ||
| warnedCollisions.add(`${name}|${tool.id}`); | ||
| diagnostics.DF0047({ | ||
| name, | ||
| id: tool.id, | ||
| existing: existing.id | ||
| }); | ||
| } | ||
| continue; | ||
| } | ||
| byName.set(name, tool); | ||
| } | ||
| const tools = [...byName.entries()].map(([name, tool]) => projectTool(name, tool, ctx)); | ||
| if (stateFilter && !byName.has(READ_STATE_NAME)) tools.push(readStateToolProjection()); | ||
| return { tools }; | ||
| }); | ||
| server.setRequestHandler("tools/call", async (request) => { | ||
| const { name, arguments: args } = request.params; | ||
| try { | ||
| const tool = resolveTool(name); | ||
| if (stateFilter && !tool && (name === READ_STATE_NAME || name === READ_STATE_TOOL)) { | ||
| const key = args?.key; | ||
| const result = await readStateResult(ctx, stateFilter, key); | ||
| return { | ||
| content: [{ | ||
| type: "text", | ||
| text: stringifyForMcp(result) | ||
| }], | ||
| structuredContent: result | ||
| }; | ||
| } | ||
| const outputSchema = tool ? usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)) : void 0; | ||
| const result = await ctx.agent.invoke(tool?.id ?? name, args ?? {}); | ||
| return { | ||
| content: [{ | ||
| type: "text", | ||
| text: stringifyForMcp(result) | ||
| }], | ||
| ...outputSchema ? { structuredContent: result } : {} | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| isError: true, | ||
| content: [{ | ||
| type: "text", | ||
| text: `Error invoking "${name}": ${formatMcpError(error)}` | ||
| }] | ||
| }; | ||
| } | ||
| }); | ||
| } | ||
| function registerResourceHandlers(server, ctx, exposeSharedState) { | ||
| server.setRequestHandler("resources/list", async () => { | ||
| const resources = ctx.agent.list().resources.map((resource) => ({ | ||
| uri: resource.uri, | ||
| name: resource.name, | ||
| description: resource.description, | ||
| mimeType: resource.mimeType | ||
| })); | ||
| if (exposeSharedState !== false) { | ||
| const filter = typeof exposeSharedState === "function" ? exposeSharedState : () => true; | ||
| for (const key of ctx.rpc.sharedState.keys()) { | ||
| if (!filter(key)) continue; | ||
| resources.push({ | ||
| uri: `devframe://state/${encodeURIComponent(key)}`, | ||
| name: key, | ||
| description: `Shared state: ${key}`, | ||
| mimeType: "application/json" | ||
| }); | ||
| } | ||
| } | ||
| return { resources }; | ||
| }); | ||
| server.setRequestHandler("resources/read", async (request) => { | ||
| const { uri } = request.params; | ||
| const parsed = parseResourceUri(uri); | ||
| if (parsed.kind === "resource") { | ||
| const content = await ctx.agent.read(parsed.id); | ||
| return { contents: [{ | ||
| uri, | ||
| mimeType: content.mimeType ?? "application/json", | ||
| text: content.text ?? stringifyForMcp(content.json) | ||
| }] }; | ||
| } | ||
| if (parsed.kind === "state") return { contents: [{ | ||
| uri, | ||
| mimeType: "application/json", | ||
| text: stringifyForMcp((await ctx.rpc.sharedState.get(parsed.key)).value()) | ||
| }] }; | ||
| throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`); | ||
| }); | ||
| } | ||
| /** | ||
| * MCP constrains a tool's `outputSchema` to a JSON Schema of `type: | ||
| * "object"` — clients (the SDK included) reject anything else. Non-object | ||
| * return schemas (e.g. a schema for `void` / a bare string) simply project | ||
| * no output schema; the text content still carries the result. | ||
| */ | ||
| function usableOutputSchema(schema) { | ||
| return schema && typeof schema === "object" && schema.type === "object" ? schema : void 0; | ||
| } | ||
| function projectTool(name, tool, ctx) { | ||
| const inputSchema = tool.inputSchema ?? computeInputSchema(tool, ctx); | ||
| const outputSchema = usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)); | ||
| return { | ||
| name, | ||
| title: tool.title, | ||
| description: tool.description, | ||
| inputSchema, | ||
| ...outputSchema ? { outputSchema } : {}, | ||
| annotations: { | ||
| title: tool.title, | ||
| readOnlyHint: tool.safety === "read", | ||
| destructiveHint: tool.safety === "destructive" | ||
| } | ||
| }; | ||
| } | ||
| function computeInputSchema(tool, ctx) { | ||
| if (tool.kind === "tool") return argsToJsonSchema(tool.args).schema; | ||
| if (tool.kind !== "rpc" || !tool.rpcName) return { | ||
| type: "object", | ||
| properties: {} | ||
| }; | ||
| const def = ctx.rpc.definitions.get(tool.rpcName); | ||
| if (!def) return { | ||
| type: "object", | ||
| properties: {} | ||
| }; | ||
| const args = def.args; | ||
| return argsToJsonSchema(args).schema; | ||
| } | ||
| function computeOutputSchema(tool, ctx) { | ||
| if (tool.kind !== "rpc" || !tool.rpcName) return void 0; | ||
| const def = ctx.rpc.definitions.get(tool.rpcName); | ||
| if (!def) return void 0; | ||
| return returnToJsonSchema(def.returns); | ||
| } | ||
| function parseResourceUri(uri) { | ||
| const match = uri.match(/^devframe:\/\/(resource|state)\/(.+)$/); | ||
| if (!match) return { kind: "unknown" }; | ||
| const [, kind, rest] = match; | ||
| const decoded = decodeURIComponent(rest); | ||
| if (kind === "resource") return { | ||
| kind: "resource", | ||
| id: decoded | ||
| }; | ||
| return { | ||
| kind: "state", | ||
| key: decoded | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/mcp/fetch.ts | ||
| /** | ||
| * Build a framework-agnostic MCP Streamable-HTTP endpoint over a devframe | ||
| * context: a web-standard `Request → Response` handler any host can mount — | ||
| * h3 (see `mountMcpHttp`), a Next.js App Router route, or any other | ||
| * fetch-shaped server. | ||
| * | ||
| * Each MCP session gets its own {@link WebStandardStreamableHTTPServerTransport} | ||
| * and MCP server (built from the shared, live `ctx` via | ||
| * `buildMcpServerFromContext`), correlated by the `Mcp-Session-Id` header: an | ||
| * `initialize` POST spins up a session; later requests route to it; a `DELETE` | ||
| * (or client disconnect) tears it down. The origin gate guards every request: | ||
| * loopback-default DNS-rebinding protection that — unlike the WS upgrade's | ||
| * `isAllowedOrigin` — also rejects `Origin`-less requests, so a route-based | ||
| * endpoint isn't reachable by an arbitrary local process. | ||
| */ | ||
| function createMcpFetchHandler(ctx, options) { | ||
| const sessions = /* @__PURE__ */ new Map(); | ||
| const allowedOrigins = options.allowedOrigins; | ||
| function drop(sessionId) { | ||
| const session = sessions.get(sessionId); | ||
| if (!session) return; | ||
| sessions.delete(sessionId); | ||
| session.dispose(); | ||
| } | ||
| async function createSession() { | ||
| let session; | ||
| const transport = new WebStandardStreamableHTTPServerTransport({ | ||
| sessionIdGenerator: () => randomUUID(), | ||
| onsessioninitialized: (id) => { | ||
| sessions.set(id, session); | ||
| }, | ||
| onsessionclosed: (id) => { | ||
| drop(id); | ||
| } | ||
| }); | ||
| const { server, dispose } = buildMcpServerFromContext(ctx, { | ||
| serverName: options.serverName, | ||
| serverVersion: options.serverVersion, | ||
| exposeSharedState: options.exposeSharedState | ||
| }); | ||
| session = { | ||
| transport, | ||
| dispose: async () => { | ||
| dispose(); | ||
| await server.close(); | ||
| } | ||
| }; | ||
| transport.onclose = () => { | ||
| if (transport.sessionId) drop(transport.sessionId); | ||
| }; | ||
| await server.connect(transport); | ||
| return session; | ||
| } | ||
| async function handle(req) { | ||
| const origin = req.headers.get("origin") ?? void 0; | ||
| if (allowedOrigins !== false && (origin === void 0 || !isAllowedOrigin(origin, allowedOrigins ?? []))) return new Response("Forbidden: origin required", { status: 403 }); | ||
| const sessionId = req.headers.get("mcp-session-id") ?? void 0; | ||
| let session = sessionId ? sessions.get(sessionId) : void 0; | ||
| if (!session && req.method === "POST") { | ||
| let body; | ||
| try { | ||
| body = await req.json(); | ||
| } catch { | ||
| body = void 0; | ||
| } | ||
| if (!sessionId && isInitializeRequest(body)) session = await createSession(); | ||
| else return new Response(sessionId ? "Not Found: unknown MCP session" : "Bad Request: no valid session ID and not an initialize request", { status: sessionId ? 404 : 400 }); | ||
| return session.transport.handleRequest(req, { parsedBody: body }); | ||
| } | ||
| if (!session) return new Response(sessionId ? "Not Found: unknown MCP session" : "Bad Request: missing MCP session ID", { status: sessionId ? 404 : 400 }); | ||
| return session.transport.handleRequest(req); | ||
| } | ||
| return { | ||
| fetch: handle, | ||
| dispose: async () => { | ||
| const live = [...sessions.values()]; | ||
| sessions.clear(); | ||
| await Promise.all(live.map((session) => session.dispose())); | ||
| } | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/mcp/http.ts | ||
| var http_exports = /* @__PURE__ */ __exportAll({ mountMcpHttp: () => mountMcpHttp }); | ||
| /** | ||
| * Mount an MCP Streamable-HTTP endpoint on an h3 app at `path` — the h3 | ||
| * binding over {@link createMcpFetchHandler}, which owns the sessions, the | ||
| * origin gate, and the transport plumbing. | ||
| * | ||
| * The handler is web-standard — it takes the h3 event's web `Request` and | ||
| * returns a web `Response` (an SSE `ReadableStream` body for the | ||
| * server→client stream). We copy that response onto `event.res` and return | ||
| * its body rather than returning the `Response` object directly, so a | ||
| * legitimate MCP 404 (unknown session) isn't swallowed by h3's | ||
| * "Response-with-404 falls through to the next handler" rule (which would | ||
| * otherwise hand the request to the SPA static catch-all). | ||
| */ | ||
| function mountMcpHttp(app, ctx, path, options) { | ||
| const handler = createMcpFetchHandler(ctx, options); | ||
| app.use(path, defineHandler(async (event) => respond(event, await handler.fetch(event.req)))); | ||
| return { dispose: handler.dispose }; | ||
| } | ||
| /** | ||
| * Copy a web `Response` from the MCP transport onto the h3 event's response | ||
| * and return its body. Returning the body (a `ReadableStream` or `null`) | ||
| * rather than the `Response` object avoids h3's 404-fall-through behavior. | ||
| */ | ||
| function respond(event, response) { | ||
| event.res.status = response.status; | ||
| event.res.statusText = response.statusText; | ||
| response.headers.forEach((value, key) => { | ||
| event.res.headers.set(key, value); | ||
| }); | ||
| return response.body ?? ""; | ||
| } | ||
| //#endregion | ||
| export { createMcpServer as i, mountMcpHttp as n, createMcpFetchHandler as r, http_exports as t }; |
| import "./constants.mjs"; | ||
| import { t as diagnostics } from "./diagnostics-DOQPnQmX.mjs"; | ||
| import { t as getInternalContext } from "./context-xdynGSLP.mjs"; | ||
| import { createInteractiveAuth } from "./recipes/interactive-auth.mjs"; | ||
| import { createServer } from "node:http"; | ||
| import process from "node:process"; | ||
| import { isIP } from "node:net"; | ||
| import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from "ufo"; | ||
| import { H3, defineHandler, toNodeHandler } from "h3"; | ||
| //#region src/node/utils.ts | ||
| const NON_DIALABLE_HOSTS = /* @__PURE__ */ new Set([ | ||
| "0.0.0.0", | ||
| "127.0.0.1", | ||
| "::", | ||
| "0000:0000:0000:0000:0000:0000:0000:0000", | ||
| "" | ||
| ]); | ||
| /** Map a bind host to a host a client can actually connect to. */ | ||
| function toDialableHost(host) { | ||
| return NON_DIALABLE_HOSTS.has(host) ? "localhost" : host; | ||
| } | ||
| /** Format a bind host for use in a URL authority (dialable, IPv6-bracketed). */ | ||
| function formatHostForUrl(host) { | ||
| const dialable = toDialableHost(host); | ||
| return isIP(dialable) === 6 ? `[${dialable}]` : dialable; | ||
| } | ||
| function normalizeHttpServerUrl(host, port) { | ||
| return `http://${formatHostForUrl(host)}:${port}`; | ||
| } | ||
| //#endregion | ||
| //#region src/node/instance-shell.ts | ||
| /** | ||
| * Compose an h3 + WebSocket RPC server for a devframe context — the low-level | ||
| * "listen on a port (or share one) + attach the WS transport" binding the | ||
| * side-car and shared-server tiers below are built on. Owns and listens on a | ||
| * fresh `node:http` server unless `server` is supplied, in which case it only | ||
| * attaches the upgrade listener and leaves that server's lifecycle to its | ||
| * owner. | ||
| */ | ||
| async function bindHttpAndWs(options) { | ||
| const { context, port, core } = options; | ||
| const bindHost = options.host; | ||
| const app = new H3(); | ||
| const ownsHttpServer = !options.server; | ||
| const httpServer = options.server ?? createServer(toNodeHandler(app)); | ||
| const rpcHost = context.rpc; | ||
| const websocket = options.websocket !== false; | ||
| let ws; | ||
| let closeWs = async () => {}; | ||
| if (websocket) { | ||
| const { attachWsRpcTransport } = await import("./rpc/transports/ws-server.mjs"); | ||
| const transport = attachWsRpcTransport(core.rpcGroup, { | ||
| server: httpServer, | ||
| path: options.path, | ||
| destroyUnmatched: options.destroyUnmatched ?? ownsHttpServer, | ||
| allowedOrigins: options.allowedOrigins, | ||
| onConnected: core.onConnected, | ||
| onDisconnected: core.onDisconnected | ||
| }); | ||
| ws = transport.ws; | ||
| closeWs = transport.close; | ||
| } | ||
| if (ownsHttpServer) try { | ||
| await new Promise((resolve, reject) => { | ||
| const onError = (error) => reject(error); | ||
| httpServer.once("error", onError); | ||
| httpServer.listen(port, bindHost, () => { | ||
| httpServer.removeListener("error", onError); | ||
| resolve(); | ||
| }); | ||
| }); | ||
| } catch (error) { | ||
| await closeWs().catch(() => {}); | ||
| throw diagnostics.DF0052({ | ||
| host: bindHost, | ||
| port, | ||
| reason: error instanceof Error ? error.message : String(error), | ||
| cause: error | ||
| }); | ||
| } | ||
| const address = httpServer.address(); | ||
| const resolvedPort = typeof address === "object" && address ? address.port : port; | ||
| const origin = normalizeHttpServerUrl(bindHost, resolvedPort); | ||
| const internal = getInternalContext(context); | ||
| const wsUrl = `ws://${formatHostForUrl(bindHost)}:${resolvedPort}${options.path ?? ""}`; | ||
| if (websocket) internal.setWsEndpoint({ url: wsUrl }); | ||
| function connectionMeta() { | ||
| const jsonSerializableMethods = []; | ||
| for (const def of rpcHost.definitions.values()) if (def.jsonSerializable === true) jsonSerializableMethods.push(def.name); | ||
| return { | ||
| backend: "websocket", | ||
| websocket: { path: options.path }, | ||
| jsonSerializableMethods | ||
| }; | ||
| } | ||
| return { | ||
| origin, | ||
| port: resolvedPort, | ||
| app, | ||
| ws, | ||
| rpcGroup: core.rpcGroup, | ||
| connectionMeta, | ||
| async close() { | ||
| await closeWs(); | ||
| if (ownsHttpServer) await new Promise((r) => httpServer.close(() => r())); | ||
| if (websocket && getInternalContext(context).wsEndpoint?.url === wsUrl) getInternalContext(context).setWsEndpoint(void 0); | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * Translate the public `register?: boolean | Partial<DevframeInstanceRecord>` | ||
| * option into a shell {@link InstanceRegisterConfig}, or `undefined` when | ||
| * registration is opted out. The object form supplies record overrides on top | ||
| * of the caller-provided identity defaults. | ||
| */ | ||
| function resolveInstanceRegister(option, defaults) { | ||
| if (!option) return void 0; | ||
| return { | ||
| id: defaults.id, | ||
| ...defaults.name !== void 0 ? { name: defaults.name } : {}, | ||
| ...defaults.rootDir !== void 0 ? { rootDir: defaults.rootDir } : {}, | ||
| ...typeof option === "object" ? { overrides: option } : {} | ||
| }; | ||
| } | ||
| /** Compare two URL paths ignoring a trailing slash. */ | ||
| function samePath(a, b) { | ||
| return withoutTrailingSlash(a) === withoutTrailingSlash(b); | ||
| } | ||
| /** | ||
| * Copy a web `Response` from a fetch-style transport handler onto the h3 | ||
| * event's response and return its body — mirroring the MCP route's bridge. | ||
| * Returning the body (a `ReadableStream`, or `''` for an empty one — h3 | ||
| * middleware only falls through on `undefined`) terminates the chain with | ||
| * the status/headers set here instead of continuing to the SPA catch-all. | ||
| */ | ||
| function respondWith(event, response) { | ||
| event.res.status = response.status; | ||
| event.res.statusText = response.statusText; | ||
| response.headers.forEach((value, key) => { | ||
| event.res.headers.set(key, value); | ||
| }); | ||
| return response.body ?? ""; | ||
| } | ||
| /** | ||
| * The shared machinery behind `initDevframe` and `initHub`: one mount base, | ||
| * one h3 app, one lazily-derived public origin (and the auth banner that waits | ||
| * for it), one WebSocket binding, and the fetch / connect-middleware pair that | ||
| * serves them. Each factory supplies only what makes it itself — its context, | ||
| * its routes, its diagnostics — through `init` / `mount`. | ||
| * | ||
| * Nothing here listens on a port unless a side-car was explicitly requested: | ||
| * the default tier leaves the socket `unbound`, so a host chains it onto its | ||
| * own server through {@link InstanceShell.attach} / | ||
| * {@link InstanceShell.handleUpgrade}. | ||
| * | ||
| * @internal | ||
| */ | ||
| function createInstanceShell(options) { | ||
| const base = options.base; | ||
| const baseNoSlash = withoutTrailingSlash(base); | ||
| const app = options.app ?? new H3(); | ||
| const wsDisabled = options.ws === false; | ||
| const ws = options.ws === false ? {} : options.ws ?? {}; | ||
| const route = withoutLeadingSlash(ws.route ?? "__ws"); | ||
| /** Where an upgrade lands on the host's own origin. */ | ||
| const routePath = joinURL(base, route); | ||
| /** What `__connection.json` advertises for a same-origin socket. */ | ||
| const advertisedPath = options.absoluteWsPath ? routePath : route; | ||
| const sidecarRequested = ws.port != null || ws.sidecar === true; | ||
| const tier = wsDisabled ? "disabled" : sidecarRequested ? "sidecar" : options.server ? "server" : ws.url ? "external" : "unbound"; | ||
| const sseEnabled = options.sse !== false && tier !== "external"; | ||
| const sseRoute = withoutLeadingSlash((typeof options.sse === "object" ? options.sse.route : void 0) ?? "__sse"); | ||
| const sseRoutePath = joinURL(base, sseRoute); | ||
| const advertisedSsePath = options.absoluteWsPath ? sseRoutePath : sseRoute; | ||
| let derivedOrigin; | ||
| function currentOrigin() { | ||
| return (typeof options.origin === "function" ? options.origin() : options.origin) || derivedOrigin; | ||
| } | ||
| let authHandler; | ||
| let bannerPrinted = false; | ||
| function maybePrintBanner() { | ||
| if (bannerPrinted || !authHandler || !currentOrigin()) return; | ||
| bannerPrinted = true; | ||
| authHandler.printBanner(); | ||
| } | ||
| let meta; | ||
| let registration; | ||
| let registerPromise; | ||
| /** | ||
| * Publish the instance in the global registry the moment both its origin | ||
| * and connection meta are known — at init end for a pinned origin, or on | ||
| * the first request for a derived one. Registration never throws (the | ||
| * registry writer degrades to a coded warning), so failures never surface. | ||
| */ | ||
| function maybeRegister() { | ||
| const cfg = options.register; | ||
| const origin = currentOrigin(); | ||
| if (!cfg || registerPromise || !origin || !meta) return; | ||
| const resolvedMeta = meta; | ||
| registerPromise = import("./instance-registry-WvQkt42E.mjs").then((n) => n.t).then(({ registerDevframeInstance }) => { | ||
| let port = 0; | ||
| try { | ||
| const url = new URL(origin); | ||
| port = Number(url.port) || (url.protocol === "https:" ? 443 : 80); | ||
| } catch {} | ||
| registration = registerDevframeInstance({ | ||
| pid: process.pid, | ||
| port, | ||
| origin, | ||
| basePath: base, | ||
| id: cfg.id, | ||
| ...cfg.name !== void 0 ? { name: cfg.name } : {}, | ||
| rootDir: cfg.rootDir ?? process.cwd(), | ||
| mcp: resolvedMeta.mcp ? { path: joinURL(base, resolvedMeta.mcp.path) } : null, | ||
| startedAt: Date.now(), | ||
| ...cfg.overrides | ||
| }); | ||
| }).catch(() => {}); | ||
| } | ||
| function noteOrigin(origin) { | ||
| derivedOrigin ??= origin; | ||
| maybePrintBanner(); | ||
| maybeRegister(); | ||
| } | ||
| let started; | ||
| let transport; | ||
| let dispose; | ||
| let ctx; | ||
| const api = { | ||
| base, | ||
| app, | ||
| origin: currentOrigin, | ||
| connectionMeta: () => meta | ||
| }; | ||
| /** | ||
| * Auth resolution: gate by default, `false` opts out, a handler object | ||
| * installs a custom scheme. The `external` tier has no local transport to | ||
| * gate — the server behind `ws.url` owns auth — so it resolves to nothing. | ||
| */ | ||
| function resolveAuth() { | ||
| if (options.auth === false) return false; | ||
| if (typeof options.auth === "object") { | ||
| authHandler = options.auth; | ||
| return options.auth; | ||
| } | ||
| authHandler = createInteractiveAuth(ctx); | ||
| return authHandler; | ||
| } | ||
| /** | ||
| * The context's RPC core (birpc group, session lifecycle, auth gate) — | ||
| * one per instance, shared by every transport binding (WS and SSE), so a | ||
| * WS peer and an SSE session live in the same session/broadcast space. | ||
| * Built lazily: an `unbound` host that never wires a transport pays | ||
| * nothing for it, not even the imports. `resolvedAuth` and `ctx` are | ||
| * assigned during `init()` before any caller can reach this. | ||
| */ | ||
| let resolvedAuth = false; | ||
| let corePromise; | ||
| function ensureCore() { | ||
| corePromise ??= import("./rpc-core-DMTe-hLo.mjs").then((n) => n.n).then(({ createContextRpcServer }) => createContextRpcServer({ | ||
| context: ctx, | ||
| auth: resolvedAuth, | ||
| onPeerConnect: options.onPeerConnect, | ||
| onPeerDisconnect: options.onPeerDisconnect | ||
| })); | ||
| return corePromise; | ||
| } | ||
| /** | ||
| * The SSE transport, built on the first request to its route so an | ||
| * instance nobody dials over SSE never loads it. | ||
| */ | ||
| let ssePromise; | ||
| function ensureSse() { | ||
| ssePromise ??= (async () => { | ||
| const [core, { attachSseRpcTransport }] = await Promise.all([ensureCore(), import("./rpc/transports/sse-server.mjs")]); | ||
| return attachSseRpcTransport(core.rpcGroup, { | ||
| allowedOrigins: options.allowedOrigins, | ||
| onConnected: core.onConnected, | ||
| onDisconnected: core.onDisconnected | ||
| }); | ||
| })(); | ||
| return ssePromise; | ||
| } | ||
| /** | ||
| * A side-car server on its own port. `getPort` probes and the bind can | ||
| * still race (or disagree across the v4/v6 duals of `localhost`), so an | ||
| * auto-port side-car retries on a fresh random port instead of failing | ||
| * init; a pinned `ws.port` is honored as given and fails loudly. | ||
| */ | ||
| async function startSidecar(core) { | ||
| const sidecarHost = options.host ?? "localhost"; | ||
| const start = (port) => bindHttpAndWs({ | ||
| context: ctx, | ||
| core, | ||
| host: sidecarHost, | ||
| port, | ||
| path: withLeadingSlash(route), | ||
| allowedOrigins: options.allowedOrigins | ||
| }); | ||
| if (ws.port != null) return await start(ws.port); | ||
| const { getPort } = await import("./dist-eNI9qM_I.mjs").then((n) => n.t); | ||
| let lastError; | ||
| for (let attempt = 0; attempt < 3; attempt++) { | ||
| const port = attempt === 0 && options.resolveSidecarPort ? await options.resolveSidecarPort(sidecarHost) : await getPort({ | ||
| random: true, | ||
| host: sidecarHost | ||
| }); | ||
| try { | ||
| return await start(port); | ||
| } catch (error) { | ||
| lastError = error; | ||
| } | ||
| } | ||
| throw lastError; | ||
| } | ||
| async function init() { | ||
| const result = await options.init(api); | ||
| ctx = result.context; | ||
| dispose = result.dispose; | ||
| resolvedAuth = tier === "external" ? false : resolveAuth(); | ||
| let websocketMeta; | ||
| if (tier === "sidecar") { | ||
| started = await startSidecar(await ensureCore()); | ||
| websocketMeta = { | ||
| port: started.port, | ||
| path: route | ||
| }; | ||
| } else if (tier === "server") { | ||
| started = await bindHttpAndWs({ | ||
| context: ctx, | ||
| core: await ensureCore(), | ||
| host: options.host ?? "localhost", | ||
| port: 0, | ||
| server: options.server, | ||
| path: routePath, | ||
| allowedOrigins: options.allowedOrigins, | ||
| destroyUnmatched: options.destroyUnmatchedUpgrades | ||
| }); | ||
| websocketMeta = { path: advertisedPath }; | ||
| } else if (tier === "external") websocketMeta = ws.url; | ||
| else if (tier === "unbound") websocketMeta = { path: advertisedPath }; | ||
| if (!wsDisabled && ws.url) websocketMeta = ws.url; | ||
| if (sseEnabled) app.use(sseRoutePath, defineHandler(async (event) => respondWith(event, await (await ensureSse()).handler(event.req)))); | ||
| meta = { | ||
| backend: wsDisabled ? sseEnabled ? "sse" : "none" : "websocket", | ||
| ...websocketMeta !== void 0 ? { websocket: websocketMeta } : {}, | ||
| ...sseEnabled ? { sse: { path: advertisedSsePath } } : {}, | ||
| ...result.mcp ? { mcp: result.mcp } : {} | ||
| }; | ||
| if (Object.keys(ctx.staticConfig).length > 0) meta.configs = ctx.staticConfig; | ||
| await options.mount?.(ctx, meta, api); | ||
| maybePrintBanner(); | ||
| maybeRegister(); | ||
| } | ||
| const initPromise = init(); | ||
| initPromise.catch(() => {}); | ||
| const contextPromise = initPromise.then(() => ctx); | ||
| contextPromise.catch(() => {}); | ||
| /** | ||
| * The `unbound` tier: the RPC core and its crossws adapter, bound to | ||
| * nothing. Built on the first `attach` / `handleUpgrade` — a host that | ||
| * never wires the socket (or whose runtime brings its own WS transport) | ||
| * pays nothing for it, not even the adapter's imports. | ||
| */ | ||
| let transportPromise; | ||
| function ensureTransport() { | ||
| transportPromise ??= initPromise.then(async () => { | ||
| const [core, { attachWsRpcTransport }] = await Promise.all([ensureCore(), import("./rpc/transports/ws-server.mjs")]); | ||
| transport = attachWsRpcTransport(core.rpcGroup, { | ||
| unbound: true, | ||
| path: routePath, | ||
| allowedOrigins: options.allowedOrigins, | ||
| onConnected: core.onConnected, | ||
| onDisconnected: core.onDisconnected | ||
| }); | ||
| return transport; | ||
| }); | ||
| return transportPromise; | ||
| } | ||
| async function handleRequest(request) { | ||
| await initPromise; | ||
| noteOrigin(new URL(request.url).origin); | ||
| const response = await app.fetch(request); | ||
| if (response.status === 404) return new Response(null, { status: 404 }); | ||
| return response; | ||
| } | ||
| let nodeHandler; | ||
| function nodeMiddleware(req, res, next) { | ||
| let pathname = req.url ?? "/"; | ||
| try { | ||
| pathname = new URL(pathname, "http://localhost").pathname; | ||
| } catch {} | ||
| if (!(samePath(pathname, baseNoSlash) || pathname.startsWith(base))) { | ||
| if (next) { | ||
| next(); | ||
| return; | ||
| } | ||
| res.statusCode = 404; | ||
| res.end(); | ||
| return; | ||
| } | ||
| initPromise.then(async () => { | ||
| const host = req.headers.host; | ||
| if (host) { | ||
| const encrypted = req.socket.encrypted; | ||
| noteOrigin(`${encrypted ? "https" : "http"}://${host}`); | ||
| } | ||
| if (!nodeHandler) { | ||
| const { toNodeHandler } = await import("h3/node"); | ||
| nodeHandler = toNodeHandler(app); | ||
| } | ||
| return nodeHandler(req, res); | ||
| }).catch((err) => { | ||
| if (next) { | ||
| next(err); | ||
| return; | ||
| } | ||
| res.statusCode = 500; | ||
| res.end(); | ||
| }); | ||
| } | ||
| /** The `unbound` tier is the only one whose socket the host may drive. */ | ||
| function assertUnbound() { | ||
| if (tier === "disabled") throw diagnostics.DF0057(); | ||
| if (tier === "external") throw diagnostics.DF0056({ url: ws.url }); | ||
| if (tier !== "unbound") throw diagnostics.DF0055({ tier }); | ||
| } | ||
| /** | ||
| * Publish the socket's absolute URL on the context, so surfaces that hand | ||
| * out a complete endpoint (the hub's remote docks) work on this tier too. | ||
| * {@link bindHttpAndWs} does the same for the tiers it owns. | ||
| */ | ||
| function publishWsEndpoint(server) { | ||
| const record = () => { | ||
| const address = server.address(); | ||
| if (typeof address !== "object" || !address) return; | ||
| const host = options.host ?? (address.address === "::" || address.address === "0.0.0.0" ? "localhost" : address.address); | ||
| getInternalContext(ctx).setWsEndpoint({ url: `ws://${formatHostForUrl(host)}:${address.port}${routePath}` }); | ||
| }; | ||
| if (server.listening) record(); | ||
| else server.once("listening", record); | ||
| } | ||
| function handleUpgrade(req, socket, head) { | ||
| assertUnbound(); | ||
| if (transport) { | ||
| transport.handleUpgrade(req, socket, head); | ||
| return; | ||
| } | ||
| ensureTransport().then((live) => live.handleUpgrade(req, socket, head)).catch(() => socket.destroy()); | ||
| } | ||
| function attach(server) { | ||
| assertUnbound(); | ||
| server.on("upgrade", handleUpgrade); | ||
| ensureTransport().then(() => publishWsEndpoint(server)).catch(() => {}); | ||
| return () => server.off("upgrade", handleUpgrade); | ||
| } | ||
| return { | ||
| base, | ||
| handler: handleRequest, | ||
| nodeMiddleware, | ||
| ready: initPromise, | ||
| context: contextPromise, | ||
| connectionMeta: () => meta ?? options.onMetaUnavailable(), | ||
| handleUpgrade, | ||
| attach, | ||
| async close() { | ||
| await initPromise.catch(() => {}); | ||
| await registerPromise?.catch(() => {}); | ||
| registration?.unregister(); | ||
| await dispose?.(); | ||
| await ssePromise?.then((live) => live.close()).catch(() => {}); | ||
| await started?.close(); | ||
| await transportPromise?.then((live) => live.close()).catch(() => {}); | ||
| }, | ||
| internals: { | ||
| get started() { | ||
| return started; | ||
| }, | ||
| get authHandler() { | ||
| return authHandler; | ||
| } | ||
| } | ||
| }; | ||
| } | ||
| //#endregion | ||
| export { normalizeHttpServerUrl as i, resolveInstanceRegister as n, samePath as r, createInstanceShell as t }; |
| //#region ../../node_modules/.pnpm/get-port-please@3.2.0/node_modules/get-port-please/dist/index.d.mts | ||
| interface GetPortOptions { | ||
| name: string; | ||
| random: boolean; | ||
| port: number; | ||
| ports: number[]; | ||
| portRange: [fromInclusive: number, toInclusive: number]; | ||
| alternativePortRange: [fromInclusive: number, toInclusive: number]; | ||
| host: string; | ||
| verbose?: boolean; | ||
| public?: boolean; | ||
| } | ||
| type GetPortInput = Partial<GetPortOptions> | number | string; | ||
| type PortNumber = number; | ||
| declare function getPort(_userOptions?: GetPortInput): Promise<PortNumber>; | ||
| //#endregion | ||
| export { type GetPortInput, type GetPortOptions, getPort }; |
| import { n as getPort } from "../dist-eNI9qM_I.mjs"; | ||
| export { getPort }; |
| import { n as strictJsonStringify } from "./serialization-BGzEwAdr.mjs"; | ||
| import { n as structuredCloneStringify, t as structuredCloneParse } from "./structured-clone-CbAV5rFI.mjs"; | ||
| //#region src/rpc/wire-codec.ts | ||
| const EMPTY_WIRE_DEFS = /* @__PURE__ */ new Map(); | ||
| /** | ||
| * Build the per-connection wire codec every live transport (WS server, WS | ||
| * client, SSE server, SSE client) shares: per-method dispatch between strict | ||
| * JSON (methods declared `jsonSerializable: true`) and `s:`-prefixed | ||
| * structured-clone (everything else, including all error envelopes), with a | ||
| * request-id → method map so a response independently picks the same | ||
| * encoder as its request. One codec per connection — request-id spaces | ||
| * don't collide across connections. | ||
| * | ||
| * @internal | ||
| */ | ||
| function createRpcWireCodec(definitions = EMPTY_WIRE_DEFS) { | ||
| const pendingRequestMethods = /* @__PURE__ */ new Map(); | ||
| return { | ||
| serialize: (msg) => { | ||
| let method; | ||
| if (msg.t === "q") method = msg.m; | ||
| else { | ||
| method = pendingRequestMethods.get(msg.i); | ||
| pendingRequestMethods.delete(msg.i); | ||
| } | ||
| if (!(msg.t === "s" && "e" in msg) && !!method && definitions.get(method)?.jsonSerializable === true) return strictJsonStringify(msg, method ?? ""); | ||
| return `s:${structuredCloneStringify(msg)}`; | ||
| }, | ||
| deserialize: (raw) => { | ||
| const msg = raw.startsWith("s:") ? structuredCloneParse(raw.slice(2)) : JSON.parse(raw); | ||
| if (msg.t === "q" && msg.i && msg.m) pendingRequestMethods.set(msg.i, msg.m); | ||
| return msg; | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * Peek at a wire frame's birpc envelope without engaging a codec's | ||
| * request-id bookkeeping — used by the SSE transport to route a frame | ||
| * (park a POST for its response / answer with a bare 202) before it is | ||
| * handed to birpc proper. | ||
| * | ||
| * @internal | ||
| */ | ||
| function peekRpcWireFrame(raw) { | ||
| try { | ||
| const msg = raw.startsWith("s:") ? structuredCloneParse(raw.slice(2)) : JSON.parse(raw); | ||
| return { | ||
| t: msg?.t, | ||
| i: msg?.i | ||
| }; | ||
| } catch { | ||
| return {}; | ||
| } | ||
| } | ||
| //#endregion | ||
| export { peekRpcWireFrame as n, createRpcWireCodec as t }; |
| import "./constants.mjs"; | ||
| import { t as createRpcWireCodec } from "./wire-codec-0K-o5MYW.mjs"; | ||
| import { n as randomToken, r as timingSafeEqual } from "./crypto-token-XCqTSMg9.mjs"; | ||
| import { createServer } from "node:http"; | ||
| import { createServer as createServer$1 } from "node:https"; | ||
| import crossws from "crossws/adapters/node"; | ||
| //#region src/rpc/transports/session.ts | ||
| let sessionId = 0; | ||
| /** | ||
| * Mint the per-connection session meta every transport binding shares — | ||
| * one id space across transports, so session bookkeeping (streaming | ||
| * subscriptions, shared-state sync, auth trust) never collides between a | ||
| * WS peer and an SSE session on the same server. | ||
| */ | ||
| function createRpcSessionMeta() { | ||
| return { | ||
| id: sessionId++, | ||
| subscribedStates: /* @__PURE__ */ new Set() | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/rpc/transports/ws-server.ts | ||
| /** | ||
| * 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. | ||
| */ | ||
| function createWsOriginRegistry(options = {}) { | ||
| const token = randomToken(); | ||
| const origins = new Set(options.allowedOrigins ?? []); | ||
| function normalizeOrigin(origin) { | ||
| if (!origin) return; | ||
| try { | ||
| const url = new URL(origin); | ||
| const normalized = url.origin === "null" ? `${url.protocol}//${url.host}` : url.origin; | ||
| return origin === normalized ? normalized : void 0; | ||
| } catch {} | ||
| } | ||
| function registerOrigin(origin, candidateToken) { | ||
| 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; | ||
| } | ||
| return { | ||
| token, | ||
| registerFromUrl(url) { | ||
| let parsed; | ||
| try { | ||
| parsed = new URL(url, "http://localhost"); | ||
| } catch { | ||
| return; | ||
| } | ||
| const origin = parsed.searchParams.get("devframe_viewer_origin") ?? void 0; | ||
| return registerOrigin(origin, parsed.searchParams.get("devframe_viewer_origin_token") ?? void 0) ? origin : void 0; | ||
| }, | ||
| isAllowed(origin) { | ||
| return isAllowedOrigin(origin, [...origins]); | ||
| } | ||
| }; | ||
| } | ||
| const EMPTY_DEFS = /* @__PURE__ */ new Map(); | ||
| function NOOP() {} | ||
| function listen(server, port, host) { | ||
| return new Promise((resolve, reject) => { | ||
| const onError = (error) => reject(error); | ||
| server.once("error", onError); | ||
| try { | ||
| server.listen(port, host, () => { | ||
| server.off("error", onError); | ||
| resolve(); | ||
| }); | ||
| } catch (error) { | ||
| server.off("error", onError); | ||
| reject(error); | ||
| } | ||
| }); | ||
| } | ||
| /** Compare two URL paths ignoring a trailing slash. */ | ||
| function pathMatches(a, b) { | ||
| const strip = (p) => p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p; | ||
| return strip(a) === strip(b); | ||
| } | ||
| function isLoopbackHostname(hostname) { | ||
| const h = hostname.replace(/^\[|\]$/g, ""); | ||
| return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost") || h.startsWith("127."); | ||
| } | ||
| /** | ||
| * Default origin policy for a localhost dev tool: allow requests with no | ||
| * `Origin` header (native, non-browser clients), allow any loopback origin | ||
| * (so cross-port localhost dev setups keep working), and allow explicitly | ||
| * configured origins. Everything else — a real remote page in the dev's | ||
| * browser — is rejected. | ||
| */ | ||
| function isAllowedOrigin(origin, allowedOrigins) { | ||
| if (!origin) return true; | ||
| if (allowedOrigins.includes(origin)) return true; | ||
| try { | ||
| return isLoopbackHostname(new URL(origin).hostname); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function isWsOriginRegistry(value) { | ||
| return !!value && !Array.isArray(value); | ||
| } | ||
| /** | ||
| * Build the `upgrade` listener that hands a request to the crossws adapter, | ||
| * optionally filtered to a single `path`. Non-matching requests are left | ||
| * untouched so other upgrade listeners (e.g. a Vite dev server's HMR socket) | ||
| * can claim them, unless `destroyUnmatched` is set. | ||
| */ | ||
| function createUpgradeListener(ws, path, destroyUnmatched, allowedOrigins) { | ||
| return (req, socket, head) => { | ||
| socket.on("error", () => {}); | ||
| if (path) { | ||
| let pathname = req.url ?? "/"; | ||
| try { | ||
| pathname = new URL(req.url ?? "/", "http://localhost").pathname; | ||
| } catch {} | ||
| if (!pathMatches(pathname, path)) { | ||
| if (destroyUnmatched) { | ||
| socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); | ||
| socket.destroy(); | ||
| } | ||
| return; | ||
| } | ||
| } | ||
| 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; | ||
| } | ||
| ws.handleUpgrade(req, socket, head); | ||
| }; | ||
| } | ||
| /** | ||
| * The per-peer lifecycle hooks driving a devframe RPC WebSocket, shaped for | ||
| * any [crossws](https://crossws.h3.dev) adapter. {@link attachWsRpcTransport} | ||
| * feeds them to the Node adapter; runtime-specific attachments (e.g. Bun's | ||
| * fetch-upgrade adapter) reuse the same hooks so every transport speaks the | ||
| * identical wire protocol — one birpc channel per peer, per-method | ||
| * `jsonSerializable` dispatch between strict JSON and structured-clone. | ||
| */ | ||
| function createWsRpcPeerHooks(rpcGroup, options = {}) { | ||
| const { onConnected = NOOP, onDisconnected = NOOP, definitions = EMPTY_DEFS, serialize: serializeOverride, deserialize: deserializeOverride } = options; | ||
| const states = /* @__PURE__ */ new WeakMap(); | ||
| return { | ||
| open: (peer) => { | ||
| const meta = createRpcSessionMeta(); | ||
| meta.peer = peer; | ||
| const connection = { | ||
| id: meta.id, | ||
| transport: "websocket", | ||
| request: peer.request, | ||
| send: (data) => peer.send(data), | ||
| close: (code, reason) => peer.close(code, reason), | ||
| peer | ||
| }; | ||
| const codec = createRpcWireCodec(definitions); | ||
| const state = { | ||
| meta, | ||
| connection, | ||
| channel: void 0 | ||
| }; | ||
| const channel = { | ||
| post: (data) => { | ||
| peer.send(data); | ||
| }, | ||
| on: (fn) => { | ||
| state.onMessage = fn; | ||
| }, | ||
| serialize: serializeOverride ?? codec.serialize, | ||
| deserialize: deserializeOverride ?? codec.deserialize, | ||
| meta | ||
| }; | ||
| state.channel = channel; | ||
| states.set(peer, state); | ||
| rpcGroup.updateChannels((channels) => { | ||
| channels.push(channel); | ||
| }); | ||
| onConnected(connection, meta); | ||
| }, | ||
| message: (peer, message) => { | ||
| states.get(peer)?.onMessage?.(message.text()); | ||
| }, | ||
| close: (peer) => { | ||
| const state = states.get(peer); | ||
| if (!state) return; | ||
| states.delete(peer); | ||
| rpcGroup.updateChannels((channels) => { | ||
| const index = channels.indexOf(state.channel); | ||
| if (index >= 0) channels.splice(index, 1); | ||
| }); | ||
| onDisconnected(state.connection, state.meta); | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * Attach a WebSocket transport to an existing RPC group, powered by | ||
| * [crossws](https://crossws.h3.dev). Either attach to an existing HTTP(S) | ||
| * `server` (sharing its port, optionally scoped to a `path`), or let this | ||
| * helper create a standalone server from `port` / `host` / `https`. | ||
| * | ||
| * Returns the crossws node adapter, standalone-server readiness/address | ||
| * accessors, `detach` (remove the upgrade listener from a shared `server`), | ||
| * and `close` (full deterministic teardown). | ||
| */ | ||
| function attachWsRpcTransport(rpcGroup, options = {}) { | ||
| const { server, port, host = "localhost", path, destroyUnmatched = false, unbound, https, allowedOrigins } = options; | ||
| const ws = crossws({ hooks: createWsRpcPeerHooks(rpcGroup, options) }); | ||
| const sharedUpgradeListener = createUpgradeListener(ws, path, destroyUnmatched, allowedOrigins); | ||
| const ownedUpgradeListener = createUpgradeListener(ws, path, true, allowedOrigins); | ||
| /** Bind a server's `upgrade` events, tracked so `close()` detaches them. */ | ||
| const attachments = /* @__PURE__ */ new Set(); | ||
| function attachTo(target, listener) { | ||
| target.on("upgrade", listener); | ||
| const detachOne = () => { | ||
| target.off("upgrade", listener); | ||
| attachments.delete(detachOne); | ||
| }; | ||
| attachments.add(detachOne); | ||
| return detachOne; | ||
| } | ||
| let ready = Promise.resolve(); | ||
| let ownedServer; | ||
| if (unbound) {} else if (server) attachTo(server, sharedUpgradeListener); | ||
| else if (https) { | ||
| ownedServer = createServer$1(https); | ||
| attachTo(ownedServer, ownedUpgradeListener); | ||
| ready = listen(ownedServer, port ?? 0, host); | ||
| } else { | ||
| ownedServer = createServer((_req, res) => { | ||
| res.writeHead(426, { "content-type": "text/plain" }); | ||
| res.end("Upgrade Required"); | ||
| }); | ||
| attachTo(ownedServer, ownedUpgradeListener); | ||
| ready = listen(ownedServer, port ?? 0, host); | ||
| } | ||
| const activeServer = server ?? ownedServer; | ||
| function detachAll() { | ||
| for (const detachOne of [...attachments]) detachOne(); | ||
| } | ||
| return { | ||
| ws, | ||
| ready, | ||
| address: () => activeServer?.address() ?? null, | ||
| handleUpgrade: sharedUpgradeListener, | ||
| attach: (target) => attachTo(target, sharedUpgradeListener), | ||
| detach: detachAll, | ||
| async close() { | ||
| detachAll(); | ||
| ws.closeAll(void 0, void 0, true); | ||
| if (ownedServer) { | ||
| const srv = ownedServer; | ||
| await ready.catch(() => {}); | ||
| if (!srv.listening) return; | ||
| await new Promise((r) => srv.close(() => r())); | ||
| } | ||
| } | ||
| }; | ||
| } | ||
| //#endregion | ||
| export { isLoopbackHostname as a, isAllowedOrigin as i, createWsOriginRegistry as n, createRpcSessionMeta as o, createWsRpcPeerHooks as r, attachWsRpcTransport as t }; |
| import { s as colors } from "../nostics-CzECRXpE.mjs"; | ||
| import { r as resolveDevServerPort } from "../_shared-CKZPvSrN.mjs"; | ||
| import { r as resolveDevServerPort } from "../_shared-CbZ_yhbz.mjs"; | ||
| import { createBuild } from "./build.mjs"; | ||
| import { t as createDevServer } from "../dev-Chvlqs7I.mjs"; | ||
| import { t as createDevServer } from "../dev--rAPzbvO.mjs"; | ||
| import process from "node:process"; | ||
@@ -6,0 +6,0 @@ import cac$1 from "cac"; |
@@ -1,3 +0,3 @@ | ||
| import { i as resolveMcpConnectionMeta, r as resolveDevServerPort } from "../_shared-CKZPvSrN.mjs"; | ||
| import { t as createDevServer } from "../dev-Chvlqs7I.mjs"; | ||
| import { i as resolveMcpConnectionMeta, r as resolveDevServerPort } from "../_shared-CbZ_yhbz.mjs"; | ||
| import { t as createDevServer } from "../dev--rAPzbvO.mjs"; | ||
| export { createDevServer, resolveDevServerPort, resolveMcpConnectionMeta }; |
@@ -1,2 +0,2 @@ | ||
| import { n as getInstanceInternals, r as initDevframe } from "../dev-Chvlqs7I.mjs"; | ||
| import { n as getInstanceInternals, r as initDevframe } from "../dev--rAPzbvO.mjs"; | ||
| export { getInstanceInternals, initDevframe }; |
@@ -1,2 +0,2 @@ | ||
| import { i as createMcpServer, n as mountMcpHttp, r as createMcpFetchHandler } from "../http-8Kpkap4_.mjs"; | ||
| import { i as createMcpServer, n as mountMcpHttp, r as createMcpFetchHandler } from "../http-DYpK0cUi.mjs"; | ||
| export { createMcpFetchHandler, createMcpServer, mountMcpHttp }; |
@@ -725,3 +725,2 @@ import { t as hash } from "../hash-KtDZYXDN.mjs"; | ||
| * @internal | ||
| * implementations; not part of the stable public API. | ||
| */ | ||
@@ -728,0 +727,0 @@ function createRpcWireCodec(definitions = EMPTY_WIRE_DEFS) { |
@@ -367,3 +367,2 @@ import { Ct as DevframeAgentHost$1, P as DevframeHost, St as AgentToolProviderHandle, Tt as EventEmitter, _t as AgentResourceContent, bt as AgentToolInput, g as DevframeNodeContext, gt as AgentResource, ht as AgentManifest, mt as AgentHandle, vt as AgentResourceInput, wt as DevframeAgentHostEvents, xt as AgentToolProvider, yt as AgentTool } from "../devframe-DJMg0WLe.mjs"; | ||
| * @internal | ||
| * implementations; not part of the stable public API. | ||
| */ | ||
@@ -384,3 +383,2 @@ interface RpcWireCodec { | ||
| * @internal | ||
| * implementations; not part of the stable public API. | ||
| */ | ||
@@ -395,3 +393,2 @@ declare function createRpcWireCodec(definitions?: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>>): RpcWireCodec; | ||
| * @internal | ||
| * implementations; not part of the stable public API. | ||
| */ | ||
@@ -398,0 +395,0 @@ declare function peekRpcWireFrame(raw: string): { |
@@ -1,9 +0,9 @@ | ||
| import { n as peekRpcWireFrame, t as createRpcWireCodec } from "../wire-codec-GsoRxIsD.mjs"; | ||
| import { n as peekRpcWireFrame, t as createRpcWireCodec } from "../wire-codec-0K-o5MYW.mjs"; | ||
| import { t as diagnostics } from "../diagnostics-DOQPnQmX.mjs"; | ||
| import { n as coerceAgentPositionalArgs, t as DevframeAgentHost } from "../host-agent-xLCSe65D.mjs"; | ||
| import { n as resolveBasePath, t as normalizeBasePath } from "../_shared-CKZPvSrN.mjs"; | ||
| import { n as resolveBasePath, t as normalizeBasePath } from "../_shared-CbZ_yhbz.mjs"; | ||
| import { t as createH3DevframeHost } from "../host-h3-Crkzm42q.mjs"; | ||
| import { i as registerDevframeInstance, n as listLiveDevframeInstances } from "../instance-registry-WvQkt42E.mjs"; | ||
| import { i as normalizeHttpServerUrl, n as resolveInstanceRegister, r as samePath, t as createInstanceShell } from "../instance-shell-XvN5USg4.mjs"; | ||
| import { i as normalizeHttpServerUrl, n as resolveInstanceRegister, r as samePath, t as createInstanceShell } from "../instance-shell-CMFyszWJ.mjs"; | ||
| import { t as createContextRpcServer } from "../rpc-core-DMTe-hLo.mjs"; | ||
| export { DevframeAgentHost, coerceAgentPositionalArgs, createContextRpcServer, createH3DevframeHost, createInstanceShell, createRpcWireCodec, diagnostics, listLiveDevframeInstances, normalizeBasePath, normalizeHttpServerUrl, peekRpcWireFrame, registerDevframeInstance, resolveBasePath, resolveInstanceRegister, samePath }; |
@@ -1,3 +0,3 @@ | ||
| import { n as resolveBasePath, t as normalizeBasePath } from "../_shared-CKZPvSrN.mjs"; | ||
| import { n as resolveBasePath, t as normalizeBasePath } from "../_shared-CbZ_yhbz.mjs"; | ||
| import { n as internalContextMap, t as getInternalContext } from "../context-xdynGSLP.mjs"; | ||
| export { getInternalContext, internalContextMap, normalizeBasePath, resolveBasePath }; |
| import { DEVFRAME_AUTH_TOKEN_QUERY_PARAM, DEVFRAME_SSE_SESSION_HEADER } from "../../constants.mjs"; | ||
| import { t as createRpcWireCodec } from "../../wire-codec-GsoRxIsD.mjs"; | ||
| import { t as createRpcWireCodec } from "../../wire-codec-0K-o5MYW.mjs"; | ||
| //#region src/rpc/transports/sse-client.ts | ||
@@ -4,0 +4,0 @@ function NOOP() {} |
| import { DEVFRAME_SSE_SESSION_HEADER } from "../../constants.mjs"; | ||
| import { n as peekRpcWireFrame, t as createRpcWireCodec } from "../../wire-codec-GsoRxIsD.mjs"; | ||
| import { i as isAllowedOrigin, o as createRpcSessionMeta } from "../../ws-server-BiUne4K7.mjs"; | ||
| import { n as peekRpcWireFrame, t as createRpcWireCodec } from "../../wire-codec-0K-o5MYW.mjs"; | ||
| import { i as isAllowedOrigin, o as createRpcSessionMeta } from "../../ws-server-BdSLrhxE.mjs"; | ||
| //#region src/rpc/transports/sse-server.ts | ||
@@ -5,0 +5,0 @@ const SSE_STREAM_HEADERS = { |
@@ -1,2 +0,2 @@ | ||
| import { i as isAllowedOrigin, r as createWsRpcPeerHooks } from "../../ws-server-BiUne4K7.mjs"; | ||
| import { i as isAllowedOrigin, r as createWsRpcPeerHooks } from "../../ws-server-BdSLrhxE.mjs"; | ||
| //#region src/rpc/transports/ws-bun.ts | ||
@@ -3,0 +3,0 @@ /** |
| import { DEVFRAME_AUTH_TOKEN_QUERY_PARAM } from "../../constants.mjs"; | ||
| import { t as createRpcWireCodec } from "../../wire-codec-GsoRxIsD.mjs"; | ||
| import { t as createRpcWireCodec } from "../../wire-codec-0K-o5MYW.mjs"; | ||
| //#region src/rpc/transports/ws-client.ts | ||
@@ -4,0 +4,0 @@ function NOOP() {} |
@@ -1,2 +0,2 @@ | ||
| import { a as isLoopbackHostname, i as isAllowedOrigin, n as createWsOriginRegistry, r as createWsRpcPeerHooks, t as attachWsRpcTransport } from "../../ws-server-BiUne4K7.mjs"; | ||
| import { a as isLoopbackHostname, i as isAllowedOrigin, n as createWsOriginRegistry, r as createWsRpcPeerHooks, t as attachWsRpcTransport } from "../../ws-server-BdSLrhxE.mjs"; | ||
| export { attachWsRpcTransport, createWsOriginRegistry, createWsRpcPeerHooks, isAllowedOrigin, isLoopbackHostname }; |
+2
-1
| { | ||
| "name": "devframe", | ||
| "type": "module", | ||
| "version": "0.9.0-beta.9", | ||
| "version": "0.9.0-beta.10", | ||
| "description": "Framework for building one portable devtool integration that runs in any viewer.", | ||
@@ -51,2 +51,3 @@ "author": "Anthony Fu <anthonyfu117@hotmail.com>", | ||
| "./utils/events": "./dist/utils/events.mjs", | ||
| "./utils/get-port": "./dist/utils/get-port.mjs", | ||
| "./utils/hash": "./dist/utils/hash.mjs", | ||
@@ -53,0 +54,0 @@ "./utils/launch-editor": "./dist/utils/launch-editor.mjs", |
| import { n as __exportAll } from "./rolldown-runtime-JspESFgx.mjs"; | ||
| import "./constants.mjs"; | ||
| import "node:fs/promises"; | ||
| import { createServer } from "node:net"; | ||
| import { networkInterfaces } from "node:os"; | ||
| import { cleanDoubleSlashes, withLeadingSlash, withTrailingSlash, withoutLeadingSlash } from "ufo"; | ||
| //#region ../../node_modules/.pnpm/get-port-please@3.2.0/node_modules/get-port-please/dist/index.mjs | ||
| var dist_exports = /* @__PURE__ */ __exportAll({ | ||
| checkPort: () => checkPort, | ||
| getPort: () => getPort, | ||
| getRandomPort: () => getRandomPort, | ||
| isSafePort: () => isSafePort, | ||
| isUnsafePort: () => isUnsafePort | ||
| }); | ||
| const unsafePorts = /* @__PURE__ */ new Set([ | ||
| 1, | ||
| 7, | ||
| 9, | ||
| 11, | ||
| 13, | ||
| 15, | ||
| 17, | ||
| 19, | ||
| 20, | ||
| 21, | ||
| 22, | ||
| 23, | ||
| 25, | ||
| 37, | ||
| 42, | ||
| 43, | ||
| 53, | ||
| 69, | ||
| 77, | ||
| 79, | ||
| 87, | ||
| 95, | ||
| 101, | ||
| 102, | ||
| 103, | ||
| 104, | ||
| 109, | ||
| 110, | ||
| 111, | ||
| 113, | ||
| 115, | ||
| 117, | ||
| 119, | ||
| 123, | ||
| 135, | ||
| 137, | ||
| 139, | ||
| 143, | ||
| 161, | ||
| 179, | ||
| 389, | ||
| 427, | ||
| 465, | ||
| 512, | ||
| 513, | ||
| 514, | ||
| 515, | ||
| 526, | ||
| 530, | ||
| 531, | ||
| 532, | ||
| 540, | ||
| 548, | ||
| 554, | ||
| 556, | ||
| 563, | ||
| 587, | ||
| 601, | ||
| 636, | ||
| 989, | ||
| 990, | ||
| 993, | ||
| 995, | ||
| 1719, | ||
| 1720, | ||
| 1723, | ||
| 2049, | ||
| 3659, | ||
| 4045, | ||
| 5060, | ||
| 5061, | ||
| 6e3, | ||
| 6566, | ||
| 6665, | ||
| 6666, | ||
| 6667, | ||
| 6668, | ||
| 6669, | ||
| 6697, | ||
| 10080 | ||
| ]); | ||
| function isUnsafePort(port) { | ||
| return unsafePorts.has(port); | ||
| } | ||
| function isSafePort(port) { | ||
| return !isUnsafePort(port); | ||
| } | ||
| var GetPortError = class extends Error { | ||
| constructor(message, opts) { | ||
| super(message, opts); | ||
| this.message = message; | ||
| } | ||
| name = "GetPortError"; | ||
| }; | ||
| function _log(verbose, message) { | ||
| if (verbose) console.log(`[get-port] ${message}`); | ||
| } | ||
| function _generateRange(from, to) { | ||
| if (to < from) return []; | ||
| const r = []; | ||
| for (let index = from; index <= to; index++) r.push(index); | ||
| return r; | ||
| } | ||
| function _tryPort(port, host) { | ||
| return new Promise((resolve) => { | ||
| const server = createServer(); | ||
| server.unref(); | ||
| server.on("error", () => { | ||
| resolve(false); | ||
| }); | ||
| server.listen({ | ||
| port, | ||
| host | ||
| }, () => { | ||
| const { port: port2 } = server.address(); | ||
| server.close(() => { | ||
| resolve(isSafePort(port2) && port2); | ||
| }); | ||
| }); | ||
| }); | ||
| } | ||
| function _getLocalHosts(additional) { | ||
| const hosts = new Set(additional); | ||
| for (const _interface of Object.values(networkInterfaces())) for (const config of _interface || []) if (config.address && !config.internal && !config.address.startsWith("fe80::") && !config.address.startsWith("169.254")) hosts.add(config.address); | ||
| return [...hosts]; | ||
| } | ||
| async function _findPort(ports, host) { | ||
| for (const port of ports) { | ||
| const r = await _tryPort(port, host); | ||
| if (r) return r; | ||
| } | ||
| } | ||
| function _fmtOnHost(hostname) { | ||
| return hostname ? `on host ${JSON.stringify(hostname)}` : "on any host"; | ||
| } | ||
| const HOSTNAME_RE = /^(?!-)[\d.:A-Za-z-]{1,63}(?<!-)$/; | ||
| function _validateHostname(hostname, _public, verbose) { | ||
| if (hostname && !HOSTNAME_RE.test(hostname)) { | ||
| const fallbackHost = _public ? "0.0.0.0" : "127.0.0.1"; | ||
| _log(verbose, `Invalid hostname: ${JSON.stringify(hostname)}. Using ${JSON.stringify(fallbackHost)} as fallback.`); | ||
| return fallbackHost; | ||
| } | ||
| return hostname; | ||
| } | ||
| async function getPort(_userOptions = {}) { | ||
| if (typeof _userOptions === "number" || typeof _userOptions === "string") _userOptions = { port: Number.parseInt(_userOptions + "") || 0 }; | ||
| const _port = Number(_userOptions.port ?? process.env.PORT); | ||
| const _userSpecifiedAnyPort = Boolean(_userOptions.port || _userOptions.ports?.length || _userOptions.portRange?.length); | ||
| const options = { | ||
| random: _port === 0, | ||
| ports: [], | ||
| portRange: [], | ||
| alternativePortRange: _userSpecifiedAnyPort ? [] : [3e3, 3100], | ||
| verbose: false, | ||
| ..._userOptions, | ||
| port: _port, | ||
| host: _validateHostname(_userOptions.host ?? process.env.HOST, _userOptions.public, _userOptions.verbose) | ||
| }; | ||
| if (options.random && !_userSpecifiedAnyPort) return getRandomPort(options.host); | ||
| const portsToCheck = [ | ||
| options.port, | ||
| ...options.ports, | ||
| ..._generateRange(...options.portRange) | ||
| ].filter((port) => { | ||
| if (!port) return false; | ||
| if (!isSafePort(port)) { | ||
| _log(options.verbose, `Ignoring unsafe port: ${port}`); | ||
| return false; | ||
| } | ||
| return true; | ||
| }); | ||
| if (portsToCheck.length === 0) portsToCheck.push(3e3); | ||
| let availablePort = await _findPort(portsToCheck, options.host); | ||
| if (!availablePort && options.alternativePortRange.length > 0) { | ||
| availablePort = await _findPort(_generateRange(...options.alternativePortRange), options.host); | ||
| if (portsToCheck.length > 0) { | ||
| let message = `Unable to find an available port (tried ${portsToCheck.join("-")} ${_fmtOnHost(options.host)}).`; | ||
| if (availablePort) message += ` Using alternative port ${availablePort}.`; | ||
| _log(options.verbose, message); | ||
| } | ||
| } | ||
| if (!availablePort && _userOptions.random !== false) { | ||
| availablePort = await getRandomPort(options.host); | ||
| if (availablePort) _log(options.verbose, `Using random port ${availablePort}`); | ||
| } | ||
| if (!availablePort) { | ||
| const triedRanges = [ | ||
| options.port, | ||
| options.portRange.join("-"), | ||
| options.alternativePortRange.join("-") | ||
| ].filter(Boolean).join(", "); | ||
| throw new GetPortError(`Unable to find an available port ${_fmtOnHost(options.host)} (tried ${triedRanges})`); | ||
| } | ||
| return availablePort; | ||
| } | ||
| async function getRandomPort(host) { | ||
| const port = await checkPort(0, host); | ||
| if (port === false) throw new GetPortError(`Unable to find a random port ${_fmtOnHost(host)}`); | ||
| return port; | ||
| } | ||
| async function checkPort(port, host = process.env.HOST, verbose) { | ||
| if (!host) host = _getLocalHosts([void 0, "0.0.0.0"]); | ||
| if (!Array.isArray(host)) return _tryPort(port, host); | ||
| for (const _host of host) { | ||
| const _port = await _tryPort(port, _host); | ||
| if (_port === false) { | ||
| if (port < 1024 && verbose) _log(verbose, `Unable to listen to the privileged port ${port} ${_fmtOnHost(_host)}`); | ||
| return false; | ||
| } | ||
| if (port === 0 && _port !== 0) port = _port; | ||
| } | ||
| return port; | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/_shared.ts | ||
| const DEFAULT_PORT = 9999; | ||
| /** | ||
| * Resolve the mount base path for a devframe's SPA. Hosted adapters | ||
| * (`vite`, `embedded`) default to `/__<id>/` so they don't collide | ||
| * with the host app; standalone adapters (`cli`, `build`) | ||
| * default to `/` because they own the origin. | ||
| * | ||
| * The devframe author can override with `basePath` on the definition. | ||
| */ | ||
| function resolveBasePath(def, kind) { | ||
| if (def.basePath) return normalizeBasePath(def.basePath); | ||
| return kind === "standalone" ? "/" : `/__${def.id}/`; | ||
| } | ||
| function normalizeBasePath(base) { | ||
| return cleanDoubleSlashes(withTrailingSlash(withLeadingSlash(base))); | ||
| } | ||
| /** | ||
| * Resolve the listening port for `createDevServer` (and `createHandler`'s | ||
| * side-car tiers), honoring the definition's `cli.port` / `cli.portRange` / | ||
| * `cli.random` settings. Exposed separately so authors who run their own | ||
| * argv parsing can resolve a port up-front (to print it, log it, etc.) | ||
| * before starting the server. | ||
| */ | ||
| async function resolveDevServerPort(def, options = {}) { | ||
| const host = options.host ?? def.cli?.host ?? "localhost"; | ||
| const portOptions = { | ||
| port: options.defaultPort ?? def.cli?.port ?? DEFAULT_PORT, | ||
| host | ||
| }; | ||
| if (def.cli?.portRange) portOptions.portRange = def.cli.portRange; | ||
| if (def.cli?.random) portOptions.random = def.cli.random; | ||
| return getPort(portOptions); | ||
| } | ||
| /** | ||
| * Normalize the `cli.mcp` / `mcp` option (`boolean | McpRouteOptions`) into | ||
| * concrete options, or `undefined` when the MCP route is disabled. | ||
| */ | ||
| function resolveMcpConfig(mcp) { | ||
| if (!mcp) return void 0; | ||
| return mcp === true ? {} : mcp; | ||
| } | ||
| /** | ||
| * Resolve the `mcp` entry a `__connection.json` should advertise for a dev | ||
| * server started with the given `mcp` option (falling back to `def.cli?.mcp`, | ||
| * exactly like `createDevServer`), or `undefined` when the route is | ||
| * disabled. | ||
| * | ||
| * Hosted bridges that hand-roll their connection meta pass the side-car | ||
| * `port`: the advertised path becomes absolute (the side-car mounts at `/`) | ||
| * and the client dials `<page-host>:<port><path>`. Without `port` the path | ||
| * stays relative, resolved against `__connection.json`'s own location (the | ||
| * same-server default). | ||
| */ | ||
| function resolveMcpConnectionMeta(def, mcp, port) { | ||
| const config = resolveMcpConfig(mcp ?? def.cli?.mcp); | ||
| if (!config) return void 0; | ||
| const route = withoutLeadingSlash(config.path ?? "__mcp"); | ||
| return port != null ? { | ||
| path: withLeadingSlash(route), | ||
| port | ||
| } : { path: route }; | ||
| } | ||
| //#endregion | ||
| export { dist_exports as a, resolveMcpConnectionMeta as i, resolveBasePath as n, resolveDevServerPort as r, normalizeBasePath as t }; |
| import { DEVFRAME_CONNECTION_META_FILENAME } from "./constants.mjs"; | ||
| import { t as diagnostics } from "./diagnostics-DOQPnQmX.mjs"; | ||
| import { t as createHostContext } from "./context-CEe4PBz1.mjs"; | ||
| import { t as resolveStaticAssetsSource } from "./remote-assets-nTTxY2MU.mjs"; | ||
| import { i as resolveMcpConnectionMeta, n as resolveBasePath, r as resolveDevServerPort, t as normalizeBasePath } from "./_shared-CKZPvSrN.mjs"; | ||
| import { t as createH3DevframeHost } from "./host-h3-Crkzm42q.mjs"; | ||
| import { i as normalizeHttpServerUrl, n as resolveInstanceRegister, t as createInstanceShell } from "./instance-shell-XvN5USg4.mjs"; | ||
| import { open } from "./utils/open.mjs"; | ||
| import { mountStaticHandler } from "./utils/serve-static.mjs"; | ||
| import { createServer } from "node:http"; | ||
| import { resolve } from "pathe"; | ||
| import process from "node:process"; | ||
| import { joinURL, withBase } from "ufo"; | ||
| import { H3, toNodeHandler } from "h3"; | ||
| //#region src/adapters/initiate.ts | ||
| const INSTANCE_INTERNALS = /* @__PURE__ */ new WeakMap(); | ||
| /** @internal */ | ||
| function getInstanceInternals(handler) { | ||
| return INSTANCE_INTERNALS.get(handler) ?? {}; | ||
| } | ||
| /** | ||
| * Serve a devframe through one framework-agnostic, web-standard handler — | ||
| * the SPA, `__connection.json` discovery, the WebSocket RPC endpoint, the | ||
| * auth gate, and the optional MCP route, all under a single mount base. | ||
| * Mount `handler` on any framework's catch-all route (or `nodeMiddleware` on | ||
| * a connect stack) and the devframe is live inside that app. | ||
| * | ||
| * The factory is synchronous and kicks off initialization eagerly; | ||
| * `handler`/`nodeMiddleware` await readiness internally. Nothing binds a port | ||
| * on its own: the WebSocket resolves in precedence order — `ws.port` (pinned | ||
| * side-car) > `server` (shared upgrade at `<base>__ws`) > `ws.sidecar` | ||
| * (auto-port side-car) > the host driving upgrades itself through | ||
| * {@link DevframeInstance.attach} — while `ws.url`, when set, overrides the | ||
| * advertised* endpoint (the tunnel pattern) and on its own hands the whole | ||
| * transport to an external server. `__connection.json` reflects whichever | ||
| * combination is active. | ||
| */ | ||
| function initDevframe(def, options) { | ||
| const base = normalizeBasePath(options.base); | ||
| const distDir = options.distDir === false ? void 0 : options.distDir ?? def.cli?.distDir; | ||
| const app = options.app ?? new H3(); | ||
| const shell = createInstanceShell({ | ||
| base, | ||
| app, | ||
| host: options.host ?? def.cli?.host ?? "localhost", | ||
| origin: options.origin, | ||
| auth: options.auth !== void 0 ? options.auth : def.cli?.auth, | ||
| server: options.server, | ||
| ws: options.ws ?? def.cli?.ws, | ||
| sse: options.sse ?? def.cli?.sse, | ||
| allowedOrigins: options.allowedOrigins, | ||
| destroyUnmatchedUpgrades: options.destroyUnmatchedUpgrades, | ||
| onPeerConnect: options.onPeerConnect, | ||
| onPeerDisconnect: options.onPeerDisconnect, | ||
| register: resolveInstanceRegister(options.register, { | ||
| id: def.id, | ||
| name: def.name | ||
| }), | ||
| resolveSidecarPort: (sidecarHost) => resolveDevServerPort(def, { host: sidecarHost }), | ||
| onMetaUnavailable: () => { | ||
| throw diagnostics.DF0054({ id: def.id }); | ||
| }, | ||
| async init(api) { | ||
| const h3Host = createH3DevframeHost({ | ||
| origin: () => api.origin() ?? "http://localhost", | ||
| appName: def.id, | ||
| mount: (mountBase, dir) => { | ||
| mountStaticHandler(app, mountBase, dir); | ||
| } | ||
| }); | ||
| const hostImpl = options.getStorageDir ? { | ||
| ...h3Host, | ||
| getStorageDir: options.getStorageDir | ||
| } : h3Host; | ||
| const context = await createHostContext({ | ||
| cwd: process.cwd(), | ||
| mode: "dev", | ||
| host: hostImpl | ||
| }); | ||
| const setupInfo = { flags: options.flags ?? {} }; | ||
| await def.setup(context, setupInfo); | ||
| const mcpOption = options.mcp ?? def.cli?.mcp; | ||
| const mcpMeta = resolveMcpConnectionMeta(def, mcpOption); | ||
| let mcpDispose; | ||
| if (mcpMeta) { | ||
| const mcpConfig = mcpOption === true || mcpOption === void 0 ? {} : mcpOption; | ||
| const mcpPath = joinURL(base, mcpMeta.path); | ||
| let mountMcpHttp; | ||
| try { | ||
| ({mountMcpHttp} = await import("./http-8Kpkap4_.mjs").then((n) => n.t)); | ||
| } catch (error) { | ||
| const reason = error instanceof Error ? error.message : String(error); | ||
| throw diagnostics.DF0017({ | ||
| transport: "http", | ||
| reason, | ||
| cause: error | ||
| }); | ||
| } | ||
| mcpDispose = mountMcpHttp(app, context, mcpPath, { | ||
| serverName: `${def.id} (devframe)`, | ||
| serverVersion: def.version ?? "0.0.0", | ||
| exposeSharedState: true, | ||
| allowedOrigins: mcpConfig.allowedOrigins | ||
| }).dispose; | ||
| } | ||
| return { | ||
| context, | ||
| ...mcpMeta ? { mcp: mcpMeta } : {}, | ||
| ...mcpDispose ? { dispose: mcpDispose } : {} | ||
| }; | ||
| }, | ||
| mount(context, meta) { | ||
| app.use(joinURL(base, DEVFRAME_CONNECTION_META_FILENAME), () => meta); | ||
| if (distDir) { | ||
| const source = resolveStaticAssetsSource(distDir, context.host.getStorageDir("project")); | ||
| mountStaticHandler(app, base, typeof source === "string" ? resolve(source) : source); | ||
| } | ||
| } | ||
| }); | ||
| const instance = { | ||
| base: shell.base, | ||
| handler: shell.handler, | ||
| nodeMiddleware: shell.nodeMiddleware, | ||
| attach: shell.attach, | ||
| handleUpgrade: shell.handleUpgrade, | ||
| ready: shell.ready, | ||
| context: shell.context, | ||
| connectionMeta: shell.connectionMeta, | ||
| close: shell.close | ||
| }; | ||
| INSTANCE_INTERNALS.set(instance, shell.internals); | ||
| return instance; | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/dev.ts | ||
| /** | ||
| * Start a devframe dev server for a {@link DevframeDefinition} — | ||
| * h3 + WebSocket RPC + (optionally) the author's SPA mounted at the | ||
| * resolved base path. | ||
| * | ||
| * When `distDir` is omitted (and `def.cli?.distDir` is unset) the | ||
| * server runs in **bridge mode**: only `__connection.json` and the WS | ||
| * endpoint are mounted, with no SPA mount. The SPA is expected to be | ||
| * hosted elsewhere (e.g. by a parent Vite/Nuxt dev server) — see | ||
| * `devframeViteBridge` from `@devframes/vite`. | ||
| * | ||
| * Returns the underlying {@link StartedServer} handle so callers can | ||
| * close it gracefully (SIGINT, hot-reload, test teardown). | ||
| * | ||
| * Use this directly when integrating devframe into an existing CLI | ||
| * framework (commander, yargs, hand-rolled CAC). For the all-in-one | ||
| * `dev` / `build` / `mcp` shell, reach for {@link createCac} instead. | ||
| */ | ||
| async function createDevServer(def, options = {}) { | ||
| if (def.capabilities?.dev === false && !options.force) throw diagnostics.DF0058({ id: def.id }); | ||
| const host = options.host ?? def.cli?.host ?? "localhost"; | ||
| const requestedPort = options.port ?? await resolveDevServerPort(def, { host }); | ||
| const flags = options.flags ?? {}; | ||
| const basePath = options.basePath ? normalizeBasePath(options.basePath) : resolveBasePath(def, "standalone"); | ||
| const app = options.app ?? new H3(); | ||
| const server = createServer(toNodeHandler(app)); | ||
| try { | ||
| await new Promise((resolveListen, rejectListen) => { | ||
| const onError = (error) => rejectListen(error); | ||
| server.once("error", onError); | ||
| server.listen(requestedPort, host, () => { | ||
| server.removeListener("error", onError); | ||
| resolveListen(); | ||
| }); | ||
| }); | ||
| } catch (error) { | ||
| throw diagnostics.DF0052({ | ||
| host, | ||
| port: requestedPort, | ||
| reason: error instanceof Error ? error.message : String(error), | ||
| cause: error | ||
| }); | ||
| } | ||
| const address = server.address(); | ||
| const port = typeof address === "object" && address ? address.port : requestedPort; | ||
| const origin = normalizeHttpServerUrl(host, port); | ||
| const devframe = initDevframe(def, { | ||
| base: basePath, | ||
| distDir: options.distDir, | ||
| app, | ||
| server, | ||
| host, | ||
| origin, | ||
| ws: options.ws, | ||
| allowedOrigins: options.allowedOrigins, | ||
| sse: options.sse, | ||
| auth: flags.auth === false ? false : options.auth, | ||
| mcp: options.mcp, | ||
| flags, | ||
| onPeerConnect: options.onPeerConnect, | ||
| onPeerDisconnect: options.onPeerDisconnect, | ||
| register: true, | ||
| destroyUnmatchedUpgrades: true | ||
| }); | ||
| try { | ||
| await devframe.ready; | ||
| } catch (error) { | ||
| await new Promise((resolveClose) => server.close(() => resolveClose())); | ||
| throw error; | ||
| } | ||
| const internals = getInstanceInternals(devframe); | ||
| const transport = internals.started; | ||
| await options.onReady?.({ | ||
| origin, | ||
| port, | ||
| app | ||
| }); | ||
| await maybeOpenBrowser(def, flags, `${origin}${basePath}`, options.openBrowser, internals.authHandler); | ||
| return { | ||
| origin, | ||
| port, | ||
| app, | ||
| ws: transport.ws, | ||
| rpcGroup: transport.rpcGroup, | ||
| connectionMeta: transport.connectionMeta, | ||
| async close() { | ||
| await devframe.close(); | ||
| await new Promise((resolveClose) => server.close(() => resolveClose())); | ||
| } | ||
| }; | ||
| } | ||
| async function maybeOpenBrowser(def, flags, origin, override, authHandler) { | ||
| const flagsOpen = flags.open; | ||
| const cliOpen = def.cli?.open; | ||
| const resolved = override ?? flagsOpen ?? cliOpen; | ||
| if (resolved === void 0 || resolved === false) return; | ||
| const target = typeof resolved === "string" ? withBase(resolved, origin) : origin; | ||
| const authorizedTarget = authHandler?.buildOpenUrl?.(target) ?? target; | ||
| try { | ||
| await open(authorizedTarget); | ||
| } catch {} | ||
| } | ||
| //#endregion | ||
| export { getInstanceInternals as n, initDevframe as r, createDevServer as t }; |
| import { n as __exportAll } from "./rolldown-runtime-JspESFgx.mjs"; | ||
| import { t as Diagnostic } from "./nostics-CzECRXpE.mjs"; | ||
| import { i as isAllowedOrigin } from "./ws-server-BiUne4K7.mjs"; | ||
| import { t as diagnostics } from "./diagnostics-DOQPnQmX.mjs"; | ||
| import { t as createHostContext } from "./context-CEe4PBz1.mjs"; | ||
| import { t as toAgentToolName } from "./agent-tool-name-EgfoFO8C.mjs"; | ||
| import { randomUUID } from "node:crypto"; | ||
| import { join } from "pathe"; | ||
| import process from "node:process"; | ||
| import { homedir } from "node:os"; | ||
| import { defineHandler } from "h3"; | ||
| import { Server, WebStandardStreamableHTTPServerTransport, isInitializeRequest } from "@modelcontextprotocol/server"; | ||
| //#region src/adapters/mcp/stringify.ts | ||
| /** | ||
| * JSON-coercing serializer for MCP text payloads. | ||
| * | ||
| * MCP carries tool results and resource reads as plain text over a | ||
| * JSON-RPC transport, so we cannot use the `s:`-prefixed structured-clone | ||
| * format the WS RPC transport falls back to for non-JSON values. Instead, | ||
| * we coerce common non-JSON types into JSON-friendly forms so the LLM | ||
| * client sees something useful instead of `[object Object]`. | ||
| * | ||
| * Coercions: | ||
| * - `BigInt` → `"123n"` | ||
| * - `Date` → ISO string (via the native `toJSON`) | ||
| * - `Map` → `{ __type: 'Map', entries: [[k, v], …] }` | ||
| * - `Set` → `{ __type: 'Set', entries: [v, …] }` | ||
| * - `Error` → `{ name, message, stack, cause? }` (cause recurses) | ||
| * - `Function` → `"[Function: name]"` | ||
| * - `Symbol` → `value.toString()` | ||
| * - cycles → `"[Circular]"` | ||
| */ | ||
| function stringifyForMcp(value) { | ||
| if (value === void 0) return "undefined"; | ||
| if (typeof value === "string") return value; | ||
| const seen = /* @__PURE__ */ new WeakSet(); | ||
| return JSON.stringify(value, (_key, val) => { | ||
| if (typeof val === "bigint") return `${val}n`; | ||
| if (val instanceof Error) { | ||
| const out = { | ||
| name: val.name, | ||
| message: val.message, | ||
| stack: val.stack | ||
| }; | ||
| if (val.cause !== void 0) out.cause = val.cause; | ||
| return out; | ||
| } | ||
| if (val instanceof Map) return { | ||
| __type: "Map", | ||
| entries: [...val.entries()] | ||
| }; | ||
| if (val instanceof Set) return { | ||
| __type: "Set", | ||
| entries: [...val] | ||
| }; | ||
| if (typeof val === "function") return `[Function: ${val.name || "anonymous"}]`; | ||
| if (typeof val === "symbol") return val.toString(); | ||
| if (val !== null && typeof val === "object") { | ||
| if (seen.has(val)) return "[Circular]"; | ||
| seen.add(val); | ||
| } | ||
| return val; | ||
| }, 2); | ||
| } | ||
| /** | ||
| * Format a thrown value for an MCP `isError` text payload. | ||
| * | ||
| * A nostics `Diagnostic` (every coded devframe error) becomes structured | ||
| * JSON — `{ error: { code, message, fix?, docs? } }` — so an agent receives | ||
| * the actionable next step (`fix`) and the docs URL instead of a bare | ||
| * message string. Other errors surface `Error.name`/`message`, plus one | ||
| * level of `cause.message` so context isn't dropped silently. | ||
| */ | ||
| function formatMcpError(error) { | ||
| if (error instanceof Diagnostic) return JSON.stringify({ error: { | ||
| code: error.code, | ||
| message: error.message, | ||
| ...error.fix ? { fix: error.fix } : {}, | ||
| ...error.docs ? { docs: error.docs } : {} | ||
| } }, null, 2); | ||
| if (!(error instanceof Error)) return String(error); | ||
| const cause = error.cause; | ||
| const causeText = cause instanceof Error ? ` (cause: ${cause.message})` : cause !== void 0 ? ` (cause: ${String(cause)})` : ""; | ||
| return `${error.name}: ${error.message}${causeText}`; | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/mcp/to-json-schema.ts | ||
| const FALLBACK_OBJECT_SCHEMA = Object.freeze({ | ||
| type: "object", | ||
| additionalProperties: true | ||
| }); | ||
| /** | ||
| * Convert a Standard Schema to JSON Schema for the agent/MCP surface. | ||
| * | ||
| * Devframe stays validator-neutral, so conversion uses the schema's own | ||
| * [Standard JSON Schema](https://standardschema.dev/) converter | ||
| * (`~standard.jsonSchema`) when the validator provides one — zod 4 does, | ||
| * for example. Validators without a native converter (e.g. valibot) degrade | ||
| * to a permissive object schema rather than pulling in a converter library. | ||
| */ | ||
| function safeToJsonSchema(schema) { | ||
| const standard = schema["~standard"]; | ||
| if (standard.jsonSchema) try { | ||
| return standard.jsonSchema.input({ target: "draft-2020-12" }); | ||
| } catch { | ||
| return FALLBACK_OBJECT_SCHEMA; | ||
| } | ||
| return FALLBACK_OBJECT_SCHEMA; | ||
| } | ||
| /** | ||
| * JSON Schema for an RPC return value on the agent/MCP surface. | ||
| * @internal | ||
| */ | ||
| function returnToJsonSchema(schema) { | ||
| if (!schema) return void 0; | ||
| return safeToJsonSchema(schema); | ||
| } | ||
| /** | ||
| * JSON Schema for an RPC function's positional args on the agent/MCP | ||
| * surface. Each positional arg is advertised under `arg0` / `arg1` / … — | ||
| * matching how the agent bridge coerces the incoming object payload back | ||
| * into positional arguments. | ||
| * | ||
| * Returns `{ type: 'object', properties: {} }` when there are no args. | ||
| * @internal | ||
| */ | ||
| function argsToJsonSchema(args) { | ||
| if (!args || args.length === 0) return { | ||
| schema: { | ||
| type: "object", | ||
| properties: {} | ||
| }, | ||
| unwrapped: false | ||
| }; | ||
| const properties = {}; | ||
| const required = []; | ||
| for (let i = 0; i < args.length; i++) { | ||
| const key = `arg${i}`; | ||
| properties[key] = safeToJsonSchema(args[i]); | ||
| required.push(key); | ||
| } | ||
| return { | ||
| schema: { | ||
| type: "object", | ||
| properties, | ||
| required, | ||
| additionalProperties: false | ||
| }, | ||
| unwrapped: false | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/mcp/build-server.ts | ||
| /** | ||
| * Wire an MCP {@link Server} to a devframe context. Returns the server | ||
| * plus a disposal function for the subscriptions it sets up. The | ||
| * transport is the caller's responsibility — `createMcpServer` connects | ||
| * stdio; tests can connect an {@link InMemoryTransport} instead. | ||
| * | ||
| * @internal | ||
| */ | ||
| function buildMcpServerFromContext(ctx, options) { | ||
| const server = new Server({ | ||
| name: options.serverName, | ||
| version: options.serverVersion | ||
| }, { capabilities: { | ||
| tools: { listChanged: true }, | ||
| resources: { listChanged: true } | ||
| } }); | ||
| registerToolHandlers(server, ctx, options.exposeSharedState); | ||
| registerResourceHandlers(server, ctx, options.exposeSharedState); | ||
| const notify = (method) => { | ||
| server.notification({ method }).catch(() => {}); | ||
| }; | ||
| const offManifest = ctx.agent.events.on("agent:manifest:changed", () => { | ||
| notify("notifications/tools/list_changed"); | ||
| notify("notifications/resources/list_changed"); | ||
| }); | ||
| const offKeyAdded = ctx.rpc.sharedState.onKeyAdded(() => { | ||
| notify("notifications/resources/list_changed"); | ||
| }); | ||
| return { | ||
| server, | ||
| dispose: () => { | ||
| offManifest(); | ||
| offKeyAdded(); | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * Build an MCP server over the agent surface of a devframe definition. | ||
| * Currently supports `stdio` transport only. | ||
| */ | ||
| async function createMcpServer(definition, options = {}) { | ||
| const transport = options.transport ?? "stdio"; | ||
| if (transport !== "stdio") throw diagnostics.DF0017({ | ||
| transport, | ||
| reason: "Only stdio transport is supported in this release." | ||
| }); | ||
| const ctx = await createHostContext({ | ||
| cwd: process.cwd(), | ||
| mode: "dev", | ||
| host: { | ||
| mountStatic: () => {}, | ||
| resolveOrigin: () => "mcp://devframe", | ||
| getStorageDir: (scope) => { | ||
| if (scope === "workspace") return join(process.cwd(), ".devframe"); | ||
| if (scope === "project") return join(process.cwd(), `node_modules/.${definition.id}/devframe`); | ||
| return join(homedir(), `.${definition.id}/devframe`); | ||
| } | ||
| } | ||
| }); | ||
| await definition.setup(ctx); | ||
| const { server, dispose } = buildMcpServerFromContext(ctx, { | ||
| serverName: options.serverName ?? `${definition.id} (devframe)`, | ||
| serverVersion: options.serverVersion ?? definition.version ?? "0.0.0", | ||
| exposeSharedState: options.exposeSharedState ?? true | ||
| }); | ||
| const { startStdioTransport } = await import("./transports-vhizgqXM.mjs"); | ||
| let stop; | ||
| try { | ||
| stop = await startStdioTransport(server); | ||
| } catch (error) { | ||
| const reason = error instanceof Error ? error.message : String(error); | ||
| throw diagnostics.DF0017({ | ||
| transport, | ||
| reason, | ||
| cause: error | ||
| }); | ||
| } | ||
| options.onReady?.({ transport: "stdio" }); | ||
| return { async stop() { | ||
| dispose(); | ||
| await stop(); | ||
| } }; | ||
| } | ||
| /** | ||
| * Id of the built-in shared-state read tool — namespaced like every other | ||
| * built-in (`devframe:<area>:<fn>`). Tool-shaped access matters because many | ||
| * MCP clients only consume tools — the parallel `devframe://state/<key>` | ||
| * resource projection stays for the clients that do read resources. | ||
| */ | ||
| const READ_STATE_TOOL = "devframe:state:read"; | ||
| /** Wire name of the built-in shared-state read tool: `devframe_state_read`. */ | ||
| const READ_STATE_NAME = toAgentToolName(READ_STATE_TOOL); | ||
| function sharedStateFilter(exposeSharedState) { | ||
| if (exposeSharedState === false) return void 0; | ||
| return typeof exposeSharedState === "function" ? exposeSharedState : () => true; | ||
| } | ||
| function readStateToolProjection() { | ||
| return { | ||
| name: READ_STATE_NAME, | ||
| title: "Read shared state", | ||
| description: "Read this devtool's live shared state. Call without arguments to list the available keys, then with a key to get that value as JSON. Safe to call freely.", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { key: { | ||
| type: "string", | ||
| description: "A shared-state key from the key list. Omit to list all keys." | ||
| } } | ||
| }, | ||
| annotations: { | ||
| title: "Read shared state", | ||
| readOnlyHint: true, | ||
| destructiveHint: false | ||
| } | ||
| }; | ||
| } | ||
| async function readStateResult(ctx, filter, key) { | ||
| const keys = ctx.rpc.sharedState.keys().filter(filter); | ||
| if (key === void 0) return { keys }; | ||
| if (!keys.includes(key)) throw diagnostics.DF0048({ key }); | ||
| return { | ||
| key, | ||
| value: (await ctx.rpc.sharedState.get(key)).value() | ||
| }; | ||
| } | ||
| function registerToolHandlers(server, ctx, exposeSharedState) { | ||
| const stateFilter = sharedStateFilter(exposeSharedState); | ||
| const warnedCollisions = /* @__PURE__ */ new Set(); | ||
| /** | ||
| * Resolve a wire tool name back to the registered {@link AgentTool}. | ||
| * Wire-name matching runs first, in manifest order — the same tool the | ||
| * list projection advertises under that name — with a raw-id fallback so | ||
| * a colon-namespaced id keeps working as a call name. | ||
| */ | ||
| const resolveTool = (name) => { | ||
| return ctx.agent.list().tools.find((tool) => toAgentToolName(tool.id) === name) ?? ctx.agent.getTool(name); | ||
| }; | ||
| server.setRequestHandler("tools/list", async () => { | ||
| const byName = /* @__PURE__ */ new Map(); | ||
| for (const tool of ctx.agent.list().tools) { | ||
| const name = toAgentToolName(tool.id); | ||
| const existing = byName.get(name); | ||
| if (existing) { | ||
| if (!warnedCollisions.has(`${name}|${tool.id}`)) { | ||
| warnedCollisions.add(`${name}|${tool.id}`); | ||
| diagnostics.DF0047({ | ||
| name, | ||
| id: tool.id, | ||
| existing: existing.id | ||
| }); | ||
| } | ||
| continue; | ||
| } | ||
| byName.set(name, tool); | ||
| } | ||
| const tools = [...byName.entries()].map(([name, tool]) => projectTool(name, tool, ctx)); | ||
| if (stateFilter && !byName.has(READ_STATE_NAME)) tools.push(readStateToolProjection()); | ||
| return { tools }; | ||
| }); | ||
| server.setRequestHandler("tools/call", async (request) => { | ||
| const { name, arguments: args } = request.params; | ||
| try { | ||
| const tool = resolveTool(name); | ||
| if (stateFilter && !tool && (name === READ_STATE_NAME || name === READ_STATE_TOOL)) { | ||
| const key = args?.key; | ||
| const result = await readStateResult(ctx, stateFilter, key); | ||
| return { | ||
| content: [{ | ||
| type: "text", | ||
| text: stringifyForMcp(result) | ||
| }], | ||
| structuredContent: result | ||
| }; | ||
| } | ||
| const outputSchema = tool ? usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)) : void 0; | ||
| const result = await ctx.agent.invoke(tool?.id ?? name, args ?? {}); | ||
| return { | ||
| content: [{ | ||
| type: "text", | ||
| text: stringifyForMcp(result) | ||
| }], | ||
| ...outputSchema ? { structuredContent: result } : {} | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| isError: true, | ||
| content: [{ | ||
| type: "text", | ||
| text: `Error invoking "${name}": ${formatMcpError(error)}` | ||
| }] | ||
| }; | ||
| } | ||
| }); | ||
| } | ||
| function registerResourceHandlers(server, ctx, exposeSharedState) { | ||
| server.setRequestHandler("resources/list", async () => { | ||
| const resources = ctx.agent.list().resources.map((resource) => ({ | ||
| uri: resource.uri, | ||
| name: resource.name, | ||
| description: resource.description, | ||
| mimeType: resource.mimeType | ||
| })); | ||
| if (exposeSharedState !== false) { | ||
| const filter = typeof exposeSharedState === "function" ? exposeSharedState : () => true; | ||
| for (const key of ctx.rpc.sharedState.keys()) { | ||
| if (!filter(key)) continue; | ||
| resources.push({ | ||
| uri: `devframe://state/${encodeURIComponent(key)}`, | ||
| name: key, | ||
| description: `Shared state: ${key}`, | ||
| mimeType: "application/json" | ||
| }); | ||
| } | ||
| } | ||
| return { resources }; | ||
| }); | ||
| server.setRequestHandler("resources/read", async (request) => { | ||
| const { uri } = request.params; | ||
| const parsed = parseResourceUri(uri); | ||
| if (parsed.kind === "resource") { | ||
| const content = await ctx.agent.read(parsed.id); | ||
| return { contents: [{ | ||
| uri, | ||
| mimeType: content.mimeType ?? "application/json", | ||
| text: content.text ?? stringifyForMcp(content.json) | ||
| }] }; | ||
| } | ||
| if (parsed.kind === "state") return { contents: [{ | ||
| uri, | ||
| mimeType: "application/json", | ||
| text: stringifyForMcp((await ctx.rpc.sharedState.get(parsed.key)).value()) | ||
| }] }; | ||
| throw new Error(`[devframe/mcp] unknown resource URI "${uri}"`); | ||
| }); | ||
| } | ||
| /** | ||
| * MCP constrains a tool's `outputSchema` to a JSON Schema of `type: | ||
| * "object"` — clients (the SDK included) reject anything else. Non-object | ||
| * return schemas (e.g. a schema for `void` / a bare string) simply project | ||
| * no output schema; the text content still carries the result. | ||
| */ | ||
| function usableOutputSchema(schema) { | ||
| return schema && typeof schema === "object" && schema.type === "object" ? schema : void 0; | ||
| } | ||
| function projectTool(name, tool, ctx) { | ||
| const inputSchema = tool.inputSchema ?? computeInputSchema(tool, ctx); | ||
| const outputSchema = usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)); | ||
| return { | ||
| name, | ||
| title: tool.title, | ||
| description: tool.description, | ||
| inputSchema, | ||
| ...outputSchema ? { outputSchema } : {}, | ||
| annotations: { | ||
| title: tool.title, | ||
| readOnlyHint: tool.safety === "read", | ||
| destructiveHint: tool.safety === "destructive" | ||
| } | ||
| }; | ||
| } | ||
| function computeInputSchema(tool, ctx) { | ||
| if (tool.kind === "tool") return argsToJsonSchema(tool.args).schema; | ||
| if (tool.kind !== "rpc" || !tool.rpcName) return { | ||
| type: "object", | ||
| properties: {} | ||
| }; | ||
| const def = ctx.rpc.definitions.get(tool.rpcName); | ||
| if (!def) return { | ||
| type: "object", | ||
| properties: {} | ||
| }; | ||
| const args = def.args; | ||
| return argsToJsonSchema(args).schema; | ||
| } | ||
| function computeOutputSchema(tool, ctx) { | ||
| if (tool.kind !== "rpc" || !tool.rpcName) return void 0; | ||
| const def = ctx.rpc.definitions.get(tool.rpcName); | ||
| if (!def) return void 0; | ||
| return returnToJsonSchema(def.returns); | ||
| } | ||
| function parseResourceUri(uri) { | ||
| const match = uri.match(/^devframe:\/\/(resource|state)\/(.+)$/); | ||
| if (!match) return { kind: "unknown" }; | ||
| const [, kind, rest] = match; | ||
| const decoded = decodeURIComponent(rest); | ||
| if (kind === "resource") return { | ||
| kind: "resource", | ||
| id: decoded | ||
| }; | ||
| return { | ||
| kind: "state", | ||
| key: decoded | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/mcp/fetch.ts | ||
| /** | ||
| * Build a framework-agnostic MCP Streamable-HTTP endpoint over a devframe | ||
| * context: a web-standard `Request → Response` handler any host can mount — | ||
| * h3 (see `mountMcpHttp`), a Next.js App Router route, or any other | ||
| * fetch-shaped server. | ||
| * | ||
| * Each MCP session gets its own {@link WebStandardStreamableHTTPServerTransport} | ||
| * and MCP server (built from the shared, live `ctx` via | ||
| * `buildMcpServerFromContext`), correlated by the `Mcp-Session-Id` header: an | ||
| * `initialize` POST spins up a session; later requests route to it; a `DELETE` | ||
| * (or client disconnect) tears it down. The origin gate guards every request: | ||
| * loopback-default DNS-rebinding protection that — unlike the WS upgrade's | ||
| * `isAllowedOrigin` — also rejects `Origin`-less requests, so a route-based | ||
| * endpoint isn't reachable by an arbitrary local process. | ||
| */ | ||
| function createMcpFetchHandler(ctx, options) { | ||
| const sessions = /* @__PURE__ */ new Map(); | ||
| const allowedOrigins = options.allowedOrigins; | ||
| function drop(sessionId) { | ||
| const session = sessions.get(sessionId); | ||
| if (!session) return; | ||
| sessions.delete(sessionId); | ||
| session.dispose(); | ||
| } | ||
| async function createSession() { | ||
| let session; | ||
| const transport = new WebStandardStreamableHTTPServerTransport({ | ||
| sessionIdGenerator: () => randomUUID(), | ||
| onsessioninitialized: (id) => { | ||
| sessions.set(id, session); | ||
| }, | ||
| onsessionclosed: (id) => { | ||
| drop(id); | ||
| } | ||
| }); | ||
| const { server, dispose } = buildMcpServerFromContext(ctx, { | ||
| serverName: options.serverName, | ||
| serverVersion: options.serverVersion, | ||
| exposeSharedState: options.exposeSharedState | ||
| }); | ||
| session = { | ||
| transport, | ||
| dispose: async () => { | ||
| dispose(); | ||
| await server.close(); | ||
| } | ||
| }; | ||
| transport.onclose = () => { | ||
| if (transport.sessionId) drop(transport.sessionId); | ||
| }; | ||
| await server.connect(transport); | ||
| return session; | ||
| } | ||
| async function handle(req) { | ||
| const origin = req.headers.get("origin") ?? void 0; | ||
| if (allowedOrigins !== false && (origin === void 0 || !isAllowedOrigin(origin, allowedOrigins ?? []))) return new Response("Forbidden: origin required", { status: 403 }); | ||
| const sessionId = req.headers.get("mcp-session-id") ?? void 0; | ||
| let session = sessionId ? sessions.get(sessionId) : void 0; | ||
| if (!session && req.method === "POST") { | ||
| let body; | ||
| try { | ||
| body = await req.json(); | ||
| } catch { | ||
| body = void 0; | ||
| } | ||
| if (!sessionId && isInitializeRequest(body)) session = await createSession(); | ||
| else return new Response(sessionId ? "Not Found: unknown MCP session" : "Bad Request: no valid session ID and not an initialize request", { status: sessionId ? 404 : 400 }); | ||
| return session.transport.handleRequest(req, { parsedBody: body }); | ||
| } | ||
| if (!session) return new Response(sessionId ? "Not Found: unknown MCP session" : "Bad Request: missing MCP session ID", { status: sessionId ? 404 : 400 }); | ||
| return session.transport.handleRequest(req); | ||
| } | ||
| return { | ||
| fetch: handle, | ||
| dispose: async () => { | ||
| const live = [...sessions.values()]; | ||
| sessions.clear(); | ||
| await Promise.all(live.map((session) => session.dispose())); | ||
| } | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/mcp/http.ts | ||
| var http_exports = /* @__PURE__ */ __exportAll({ mountMcpHttp: () => mountMcpHttp }); | ||
| /** | ||
| * Mount an MCP Streamable-HTTP endpoint on an h3 app at `path` — the h3 | ||
| * binding over {@link createMcpFetchHandler}, which owns the sessions, the | ||
| * origin gate, and the transport plumbing. | ||
| * | ||
| * The handler is web-standard — it takes the h3 event's web `Request` and | ||
| * returns a web `Response` (an SSE `ReadableStream` body for the | ||
| * server→client stream). We copy that response onto `event.res` and return | ||
| * its body rather than returning the `Response` object directly, so a | ||
| * legitimate MCP 404 (unknown session) isn't swallowed by h3's | ||
| * "Response-with-404 falls through to the next handler" rule (which would | ||
| * otherwise hand the request to the SPA static catch-all). | ||
| */ | ||
| function mountMcpHttp(app, ctx, path, options) { | ||
| const handler = createMcpFetchHandler(ctx, options); | ||
| app.use(path, defineHandler(async (event) => respond(event, await handler.fetch(event.req)))); | ||
| return { dispose: handler.dispose }; | ||
| } | ||
| /** | ||
| * Copy a web `Response` from the MCP transport onto the h3 event's response | ||
| * and return its body. Returning the body (a `ReadableStream` or `null`) | ||
| * rather than the `Response` object avoids h3's 404-fall-through behavior. | ||
| */ | ||
| function respond(event, response) { | ||
| event.res.status = response.status; | ||
| event.res.statusText = response.statusText; | ||
| response.headers.forEach((value, key) => { | ||
| event.res.headers.set(key, value); | ||
| }); | ||
| return response.body ?? ""; | ||
| } | ||
| //#endregion | ||
| export { createMcpServer as i, mountMcpHttp as n, createMcpFetchHandler as r, http_exports as t }; |
| import "./constants.mjs"; | ||
| import { t as diagnostics } from "./diagnostics-DOQPnQmX.mjs"; | ||
| import { t as getInternalContext } from "./context-xdynGSLP.mjs"; | ||
| import { createInteractiveAuth } from "./recipes/interactive-auth.mjs"; | ||
| import { createServer } from "node:http"; | ||
| import process from "node:process"; | ||
| import { isIP } from "node:net"; | ||
| import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from "ufo"; | ||
| import { H3, defineHandler, toNodeHandler } from "h3"; | ||
| //#region src/node/utils.ts | ||
| const NON_DIALABLE_HOSTS = /* @__PURE__ */ new Set([ | ||
| "0.0.0.0", | ||
| "127.0.0.1", | ||
| "::", | ||
| "0000:0000:0000:0000:0000:0000:0000:0000", | ||
| "" | ||
| ]); | ||
| /** Map a bind host to a host a client can actually connect to. */ | ||
| function toDialableHost(host) { | ||
| return NON_DIALABLE_HOSTS.has(host) ? "localhost" : host; | ||
| } | ||
| /** Format a bind host for use in a URL authority (dialable, IPv6-bracketed). */ | ||
| function formatHostForUrl(host) { | ||
| const dialable = toDialableHost(host); | ||
| return isIP(dialable) === 6 ? `[${dialable}]` : dialable; | ||
| } | ||
| function normalizeHttpServerUrl(host, port) { | ||
| return `http://${formatHostForUrl(host)}:${port}`; | ||
| } | ||
| //#endregion | ||
| //#region src/node/instance-shell.ts | ||
| /** | ||
| * Compose an h3 + WebSocket RPC server for a devframe context — the low-level | ||
| * "listen on a port (or share one) + attach the WS transport" binding the | ||
| * side-car and shared-server tiers below are built on. Owns and listens on a | ||
| * fresh `node:http` server unless `server` is supplied, in which case it only | ||
| * attaches the upgrade listener and leaves that server's lifecycle to its | ||
| * owner. | ||
| */ | ||
| async function bindHttpAndWs(options) { | ||
| const { context, port, core } = options; | ||
| const bindHost = options.host; | ||
| const app = new H3(); | ||
| const ownsHttpServer = !options.server; | ||
| const httpServer = options.server ?? createServer(toNodeHandler(app)); | ||
| const rpcHost = context.rpc; | ||
| const websocket = options.websocket !== false; | ||
| let ws; | ||
| let closeWs = async () => {}; | ||
| if (websocket) { | ||
| const { attachWsRpcTransport } = await import("./rpc/transports/ws-server.mjs"); | ||
| const transport = attachWsRpcTransport(core.rpcGroup, { | ||
| server: httpServer, | ||
| path: options.path, | ||
| destroyUnmatched: options.destroyUnmatched ?? ownsHttpServer, | ||
| allowedOrigins: options.allowedOrigins, | ||
| onConnected: core.onConnected, | ||
| onDisconnected: core.onDisconnected | ||
| }); | ||
| ws = transport.ws; | ||
| closeWs = transport.close; | ||
| } | ||
| if (ownsHttpServer) try { | ||
| await new Promise((resolve, reject) => { | ||
| const onError = (error) => reject(error); | ||
| httpServer.once("error", onError); | ||
| httpServer.listen(port, bindHost, () => { | ||
| httpServer.removeListener("error", onError); | ||
| resolve(); | ||
| }); | ||
| }); | ||
| } catch (error) { | ||
| await closeWs().catch(() => {}); | ||
| throw diagnostics.DF0052({ | ||
| host: bindHost, | ||
| port, | ||
| reason: error instanceof Error ? error.message : String(error), | ||
| cause: error | ||
| }); | ||
| } | ||
| const address = httpServer.address(); | ||
| const resolvedPort = typeof address === "object" && address ? address.port : port; | ||
| const origin = normalizeHttpServerUrl(bindHost, resolvedPort); | ||
| const internal = getInternalContext(context); | ||
| const wsUrl = `ws://${formatHostForUrl(bindHost)}:${resolvedPort}${options.path ?? ""}`; | ||
| if (websocket) internal.setWsEndpoint({ url: wsUrl }); | ||
| function connectionMeta() { | ||
| const jsonSerializableMethods = []; | ||
| for (const def of rpcHost.definitions.values()) if (def.jsonSerializable === true) jsonSerializableMethods.push(def.name); | ||
| return { | ||
| backend: "websocket", | ||
| websocket: { path: options.path }, | ||
| jsonSerializableMethods | ||
| }; | ||
| } | ||
| return { | ||
| origin, | ||
| port: resolvedPort, | ||
| app, | ||
| ws, | ||
| rpcGroup: core.rpcGroup, | ||
| connectionMeta, | ||
| async close() { | ||
| await closeWs(); | ||
| if (ownsHttpServer) await new Promise((r) => httpServer.close(() => r())); | ||
| if (websocket && getInternalContext(context).wsEndpoint?.url === wsUrl) getInternalContext(context).setWsEndpoint(void 0); | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * Translate the public `register?: boolean | Partial<DevframeInstanceRecord>` | ||
| * option into a shell {@link InstanceRegisterConfig}, or `undefined` when | ||
| * registration is opted out. The object form supplies record overrides on top | ||
| * of the caller-provided identity defaults. | ||
| */ | ||
| function resolveInstanceRegister(option, defaults) { | ||
| if (!option) return void 0; | ||
| return { | ||
| id: defaults.id, | ||
| ...defaults.name !== void 0 ? { name: defaults.name } : {}, | ||
| ...defaults.rootDir !== void 0 ? { rootDir: defaults.rootDir } : {}, | ||
| ...typeof option === "object" ? { overrides: option } : {} | ||
| }; | ||
| } | ||
| /** Compare two URL paths ignoring a trailing slash. */ | ||
| function samePath(a, b) { | ||
| return withoutTrailingSlash(a) === withoutTrailingSlash(b); | ||
| } | ||
| /** | ||
| * Copy a web `Response` from a fetch-style transport handler onto the h3 | ||
| * event's response and return its body — mirroring the MCP route's bridge. | ||
| * Returning the body (a `ReadableStream`, or `''` for an empty one — h3 | ||
| * middleware only falls through on `undefined`) terminates the chain with | ||
| * the status/headers set here instead of continuing to the SPA catch-all. | ||
| */ | ||
| function respondWith(event, response) { | ||
| event.res.status = response.status; | ||
| event.res.statusText = response.statusText; | ||
| response.headers.forEach((value, key) => { | ||
| event.res.headers.set(key, value); | ||
| }); | ||
| return response.body ?? ""; | ||
| } | ||
| /** | ||
| * The shared machinery behind `initDevframe` and `initHub`: one mount base, | ||
| * one h3 app, one lazily-derived public origin (and the auth banner that waits | ||
| * for it), one WebSocket binding, and the fetch / connect-middleware pair that | ||
| * serves them. Each factory supplies only what makes it itself — its context, | ||
| * its routes, its diagnostics — through `init` / `mount`. | ||
| * | ||
| * Nothing here listens on a port unless a side-car was explicitly requested: | ||
| * the default tier leaves the socket `unbound`, so a host chains it onto its | ||
| * own server through {@link InstanceShell.attach} / | ||
| * {@link InstanceShell.handleUpgrade}. | ||
| * | ||
| * @internal | ||
| */ | ||
| function createInstanceShell(options) { | ||
| const base = options.base; | ||
| const baseNoSlash = withoutTrailingSlash(base); | ||
| const app = options.app ?? new H3(); | ||
| const wsDisabled = options.ws === false; | ||
| const ws = options.ws === false ? {} : options.ws ?? {}; | ||
| const route = withoutLeadingSlash(ws.route ?? "__ws"); | ||
| /** Where an upgrade lands on the host's own origin. */ | ||
| const routePath = joinURL(base, route); | ||
| /** What `__connection.json` advertises for a same-origin socket. */ | ||
| const advertisedPath = options.absoluteWsPath ? routePath : route; | ||
| const sidecarRequested = ws.port != null || ws.sidecar === true; | ||
| const tier = wsDisabled ? "disabled" : sidecarRequested ? "sidecar" : options.server ? "server" : ws.url ? "external" : "unbound"; | ||
| const sseEnabled = options.sse !== false && tier !== "external"; | ||
| const sseRoute = withoutLeadingSlash((typeof options.sse === "object" ? options.sse.route : void 0) ?? "__sse"); | ||
| const sseRoutePath = joinURL(base, sseRoute); | ||
| const advertisedSsePath = options.absoluteWsPath ? sseRoutePath : sseRoute; | ||
| let derivedOrigin; | ||
| function currentOrigin() { | ||
| return (typeof options.origin === "function" ? options.origin() : options.origin) || derivedOrigin; | ||
| } | ||
| let authHandler; | ||
| let bannerPrinted = false; | ||
| function maybePrintBanner() { | ||
| if (bannerPrinted || !authHandler || !currentOrigin()) return; | ||
| bannerPrinted = true; | ||
| authHandler.printBanner(); | ||
| } | ||
| let meta; | ||
| let registration; | ||
| let registerPromise; | ||
| /** | ||
| * Publish the instance in the global registry the moment both its origin | ||
| * and connection meta are known — at init end for a pinned origin, or on | ||
| * the first request for a derived one. Registration never throws (the | ||
| * registry writer degrades to a coded warning), so failures never surface. | ||
| */ | ||
| function maybeRegister() { | ||
| const cfg = options.register; | ||
| const origin = currentOrigin(); | ||
| if (!cfg || registerPromise || !origin || !meta) return; | ||
| const resolvedMeta = meta; | ||
| registerPromise = import("./instance-registry-WvQkt42E.mjs").then((n) => n.t).then(({ registerDevframeInstance }) => { | ||
| let port = 0; | ||
| try { | ||
| const url = new URL(origin); | ||
| port = Number(url.port) || (url.protocol === "https:" ? 443 : 80); | ||
| } catch {} | ||
| registration = registerDevframeInstance({ | ||
| pid: process.pid, | ||
| port, | ||
| origin, | ||
| basePath: base, | ||
| id: cfg.id, | ||
| ...cfg.name !== void 0 ? { name: cfg.name } : {}, | ||
| rootDir: cfg.rootDir ?? process.cwd(), | ||
| mcp: resolvedMeta.mcp ? { path: joinURL(base, resolvedMeta.mcp.path) } : null, | ||
| startedAt: Date.now(), | ||
| ...cfg.overrides | ||
| }); | ||
| }).catch(() => {}); | ||
| } | ||
| function noteOrigin(origin) { | ||
| derivedOrigin ??= origin; | ||
| maybePrintBanner(); | ||
| maybeRegister(); | ||
| } | ||
| let started; | ||
| let transport; | ||
| let dispose; | ||
| let ctx; | ||
| const api = { | ||
| base, | ||
| app, | ||
| origin: currentOrigin, | ||
| connectionMeta: () => meta | ||
| }; | ||
| /** | ||
| * Auth resolution: gate by default, `false` opts out, a handler object | ||
| * installs a custom scheme. The `external` tier has no local transport to | ||
| * gate — the server behind `ws.url` owns auth — so it resolves to nothing. | ||
| */ | ||
| function resolveAuth() { | ||
| if (options.auth === false) return false; | ||
| if (typeof options.auth === "object") { | ||
| authHandler = options.auth; | ||
| return options.auth; | ||
| } | ||
| authHandler = createInteractiveAuth(ctx); | ||
| return authHandler; | ||
| } | ||
| /** | ||
| * The context's RPC core (birpc group, session lifecycle, auth gate) — | ||
| * one per instance, shared by every transport binding (WS and SSE), so a | ||
| * WS peer and an SSE session live in the same session/broadcast space. | ||
| * Built lazily: an `unbound` host that never wires a transport pays | ||
| * nothing for it, not even the imports. `resolvedAuth` and `ctx` are | ||
| * assigned during `init()` before any caller can reach this. | ||
| */ | ||
| let resolvedAuth = false; | ||
| let corePromise; | ||
| function ensureCore() { | ||
| corePromise ??= import("./rpc-core-DMTe-hLo.mjs").then((n) => n.n).then(({ createContextRpcServer }) => createContextRpcServer({ | ||
| context: ctx, | ||
| auth: resolvedAuth, | ||
| onPeerConnect: options.onPeerConnect, | ||
| onPeerDisconnect: options.onPeerDisconnect | ||
| })); | ||
| return corePromise; | ||
| } | ||
| /** | ||
| * The SSE transport, built on the first request to its route so an | ||
| * instance nobody dials over SSE never loads it. | ||
| */ | ||
| let ssePromise; | ||
| function ensureSse() { | ||
| ssePromise ??= (async () => { | ||
| const [core, { attachSseRpcTransport }] = await Promise.all([ensureCore(), import("./rpc/transports/sse-server.mjs")]); | ||
| return attachSseRpcTransport(core.rpcGroup, { | ||
| allowedOrigins: options.allowedOrigins, | ||
| onConnected: core.onConnected, | ||
| onDisconnected: core.onDisconnected | ||
| }); | ||
| })(); | ||
| return ssePromise; | ||
| } | ||
| /** | ||
| * A side-car server on its own port. `getPort` probes and the bind can | ||
| * still race (or disagree across the v4/v6 duals of `localhost`), so an | ||
| * auto-port side-car retries on a fresh random port instead of failing | ||
| * init; a pinned `ws.port` is honored as given and fails loudly. | ||
| */ | ||
| async function startSidecar(core) { | ||
| const sidecarHost = options.host ?? "localhost"; | ||
| const start = (port) => bindHttpAndWs({ | ||
| context: ctx, | ||
| core, | ||
| host: sidecarHost, | ||
| port, | ||
| path: withLeadingSlash(route), | ||
| allowedOrigins: options.allowedOrigins | ||
| }); | ||
| if (ws.port != null) return await start(ws.port); | ||
| const { getPort } = await import("./_shared-CKZPvSrN.mjs").then((n) => n.a); | ||
| let lastError; | ||
| for (let attempt = 0; attempt < 3; attempt++) { | ||
| const port = attempt === 0 && options.resolveSidecarPort ? await options.resolveSidecarPort(sidecarHost) : await getPort({ | ||
| random: true, | ||
| host: sidecarHost | ||
| }); | ||
| try { | ||
| return await start(port); | ||
| } catch (error) { | ||
| lastError = error; | ||
| } | ||
| } | ||
| throw lastError; | ||
| } | ||
| async function init() { | ||
| const result = await options.init(api); | ||
| ctx = result.context; | ||
| dispose = result.dispose; | ||
| resolvedAuth = tier === "external" ? false : resolveAuth(); | ||
| let websocketMeta; | ||
| if (tier === "sidecar") { | ||
| started = await startSidecar(await ensureCore()); | ||
| websocketMeta = { | ||
| port: started.port, | ||
| path: route | ||
| }; | ||
| } else if (tier === "server") { | ||
| started = await bindHttpAndWs({ | ||
| context: ctx, | ||
| core: await ensureCore(), | ||
| host: options.host ?? "localhost", | ||
| port: 0, | ||
| server: options.server, | ||
| path: routePath, | ||
| allowedOrigins: options.allowedOrigins, | ||
| destroyUnmatched: options.destroyUnmatchedUpgrades | ||
| }); | ||
| websocketMeta = { path: advertisedPath }; | ||
| } else if (tier === "external") websocketMeta = ws.url; | ||
| else if (tier === "unbound") websocketMeta = { path: advertisedPath }; | ||
| if (!wsDisabled && ws.url) websocketMeta = ws.url; | ||
| if (sseEnabled) app.use(sseRoutePath, defineHandler(async (event) => respondWith(event, await (await ensureSse()).handler(event.req)))); | ||
| meta = { | ||
| backend: wsDisabled ? sseEnabled ? "sse" : "none" : "websocket", | ||
| ...websocketMeta !== void 0 ? { websocket: websocketMeta } : {}, | ||
| ...sseEnabled ? { sse: { path: advertisedSsePath } } : {}, | ||
| ...result.mcp ? { mcp: result.mcp } : {} | ||
| }; | ||
| if (Object.keys(ctx.staticConfig).length > 0) meta.configs = ctx.staticConfig; | ||
| await options.mount?.(ctx, meta, api); | ||
| maybePrintBanner(); | ||
| maybeRegister(); | ||
| } | ||
| const initPromise = init(); | ||
| initPromise.catch(() => {}); | ||
| const contextPromise = initPromise.then(() => ctx); | ||
| contextPromise.catch(() => {}); | ||
| /** | ||
| * The `unbound` tier: the RPC core and its crossws adapter, bound to | ||
| * nothing. Built on the first `attach` / `handleUpgrade` — a host that | ||
| * never wires the socket (or whose runtime brings its own WS transport) | ||
| * pays nothing for it, not even the adapter's imports. | ||
| */ | ||
| let transportPromise; | ||
| function ensureTransport() { | ||
| transportPromise ??= initPromise.then(async () => { | ||
| const [core, { attachWsRpcTransport }] = await Promise.all([ensureCore(), import("./rpc/transports/ws-server.mjs")]); | ||
| transport = attachWsRpcTransport(core.rpcGroup, { | ||
| unbound: true, | ||
| path: routePath, | ||
| allowedOrigins: options.allowedOrigins, | ||
| onConnected: core.onConnected, | ||
| onDisconnected: core.onDisconnected | ||
| }); | ||
| return transport; | ||
| }); | ||
| return transportPromise; | ||
| } | ||
| async function handleRequest(request) { | ||
| await initPromise; | ||
| noteOrigin(new URL(request.url).origin); | ||
| const response = await app.fetch(request); | ||
| if (response.status === 404) return new Response(null, { status: 404 }); | ||
| return response; | ||
| } | ||
| let nodeHandler; | ||
| function nodeMiddleware(req, res, next) { | ||
| let pathname = req.url ?? "/"; | ||
| try { | ||
| pathname = new URL(pathname, "http://localhost").pathname; | ||
| } catch {} | ||
| if (!(samePath(pathname, baseNoSlash) || pathname.startsWith(base))) { | ||
| if (next) { | ||
| next(); | ||
| return; | ||
| } | ||
| res.statusCode = 404; | ||
| res.end(); | ||
| return; | ||
| } | ||
| initPromise.then(async () => { | ||
| const host = req.headers.host; | ||
| if (host) { | ||
| const encrypted = req.socket.encrypted; | ||
| noteOrigin(`${encrypted ? "https" : "http"}://${host}`); | ||
| } | ||
| if (!nodeHandler) { | ||
| const { toNodeHandler } = await import("h3/node"); | ||
| nodeHandler = toNodeHandler(app); | ||
| } | ||
| return nodeHandler(req, res); | ||
| }).catch((err) => { | ||
| if (next) { | ||
| next(err); | ||
| return; | ||
| } | ||
| res.statusCode = 500; | ||
| res.end(); | ||
| }); | ||
| } | ||
| /** The `unbound` tier is the only one whose socket the host may drive. */ | ||
| function assertUnbound() { | ||
| if (tier === "disabled") throw diagnostics.DF0057(); | ||
| if (tier === "external") throw diagnostics.DF0056({ url: ws.url }); | ||
| if (tier !== "unbound") throw diagnostics.DF0055({ tier }); | ||
| } | ||
| /** | ||
| * Publish the socket's absolute URL on the context, so surfaces that hand | ||
| * out a complete endpoint (the hub's remote docks) work on this tier too. | ||
| * {@link bindHttpAndWs} does the same for the tiers it owns. | ||
| */ | ||
| function publishWsEndpoint(server) { | ||
| const record = () => { | ||
| const address = server.address(); | ||
| if (typeof address !== "object" || !address) return; | ||
| const host = options.host ?? (address.address === "::" || address.address === "0.0.0.0" ? "localhost" : address.address); | ||
| getInternalContext(ctx).setWsEndpoint({ url: `ws://${formatHostForUrl(host)}:${address.port}${routePath}` }); | ||
| }; | ||
| if (server.listening) record(); | ||
| else server.once("listening", record); | ||
| } | ||
| function handleUpgrade(req, socket, head) { | ||
| assertUnbound(); | ||
| if (transport) { | ||
| transport.handleUpgrade(req, socket, head); | ||
| return; | ||
| } | ||
| ensureTransport().then((live) => live.handleUpgrade(req, socket, head)).catch(() => socket.destroy()); | ||
| } | ||
| function attach(server) { | ||
| assertUnbound(); | ||
| server.on("upgrade", handleUpgrade); | ||
| ensureTransport().then(() => publishWsEndpoint(server)).catch(() => {}); | ||
| return () => server.off("upgrade", handleUpgrade); | ||
| } | ||
| return { | ||
| base, | ||
| handler: handleRequest, | ||
| nodeMiddleware, | ||
| ready: initPromise, | ||
| context: contextPromise, | ||
| connectionMeta: () => meta ?? options.onMetaUnavailable(), | ||
| handleUpgrade, | ||
| attach, | ||
| async close() { | ||
| await initPromise.catch(() => {}); | ||
| await registerPromise?.catch(() => {}); | ||
| registration?.unregister(); | ||
| await dispose?.(); | ||
| await ssePromise?.then((live) => live.close()).catch(() => {}); | ||
| await started?.close(); | ||
| await transportPromise?.then((live) => live.close()).catch(() => {}); | ||
| }, | ||
| internals: { | ||
| get started() { | ||
| return started; | ||
| }, | ||
| get authHandler() { | ||
| return authHandler; | ||
| } | ||
| } | ||
| }; | ||
| } | ||
| //#endregion | ||
| export { normalizeHttpServerUrl as i, resolveInstanceRegister as n, samePath as r, createInstanceShell as t }; |
| import { n as strictJsonStringify } from "./serialization-BGzEwAdr.mjs"; | ||
| import { n as structuredCloneStringify, t as structuredCloneParse } from "./structured-clone-CbAV5rFI.mjs"; | ||
| //#region src/rpc/wire-codec.ts | ||
| const EMPTY_WIRE_DEFS = /* @__PURE__ */ new Map(); | ||
| /** | ||
| * Build the per-connection wire codec every live transport (WS server, WS | ||
| * client, SSE server, SSE client) shares: per-method dispatch between strict | ||
| * JSON (methods declared `jsonSerializable: true`) and `s:`-prefixed | ||
| * structured-clone (everything else, including all error envelopes), with a | ||
| * request-id → method map so a response independently picks the same | ||
| * encoder as its request. One codec per connection — request-id spaces | ||
| * don't collide across connections. | ||
| * | ||
| * @internal | ||
| * implementations; not part of the stable public API. | ||
| */ | ||
| function createRpcWireCodec(definitions = EMPTY_WIRE_DEFS) { | ||
| const pendingRequestMethods = /* @__PURE__ */ new Map(); | ||
| return { | ||
| serialize: (msg) => { | ||
| let method; | ||
| if (msg.t === "q") method = msg.m; | ||
| else { | ||
| method = pendingRequestMethods.get(msg.i); | ||
| pendingRequestMethods.delete(msg.i); | ||
| } | ||
| if (!(msg.t === "s" && "e" in msg) && !!method && definitions.get(method)?.jsonSerializable === true) return strictJsonStringify(msg, method ?? ""); | ||
| return `s:${structuredCloneStringify(msg)}`; | ||
| }, | ||
| deserialize: (raw) => { | ||
| const msg = raw.startsWith("s:") ? structuredCloneParse(raw.slice(2)) : JSON.parse(raw); | ||
| if (msg.t === "q" && msg.i && msg.m) pendingRequestMethods.set(msg.i, msg.m); | ||
| return msg; | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * Peek at a wire frame's birpc envelope without engaging a codec's | ||
| * request-id bookkeeping — used by the SSE transport to route a frame | ||
| * (park a POST for its response / answer with a bare 202) before it is | ||
| * handed to birpc proper. | ||
| * | ||
| * @internal | ||
| * implementations; not part of the stable public API. | ||
| */ | ||
| function peekRpcWireFrame(raw) { | ||
| try { | ||
| const msg = raw.startsWith("s:") ? structuredCloneParse(raw.slice(2)) : JSON.parse(raw); | ||
| return { | ||
| t: msg?.t, | ||
| i: msg?.i | ||
| }; | ||
| } catch { | ||
| return {}; | ||
| } | ||
| } | ||
| //#endregion | ||
| export { peekRpcWireFrame as n, createRpcWireCodec as t }; |
| import "./constants.mjs"; | ||
| import { t as createRpcWireCodec } from "./wire-codec-GsoRxIsD.mjs"; | ||
| import { n as randomToken, r as timingSafeEqual } from "./crypto-token-XCqTSMg9.mjs"; | ||
| import { createServer } from "node:http"; | ||
| import { createServer as createServer$1 } from "node:https"; | ||
| import crossws from "crossws/adapters/node"; | ||
| //#region src/rpc/transports/session.ts | ||
| let sessionId = 0; | ||
| /** | ||
| * Mint the per-connection session meta every transport binding shares — | ||
| * one id space across transports, so session bookkeeping (streaming | ||
| * subscriptions, shared-state sync, auth trust) never collides between a | ||
| * WS peer and an SSE session on the same server. | ||
| */ | ||
| function createRpcSessionMeta() { | ||
| return { | ||
| id: sessionId++, | ||
| subscribedStates: /* @__PURE__ */ new Set() | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/rpc/transports/ws-server.ts | ||
| /** | ||
| * 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. | ||
| */ | ||
| function createWsOriginRegistry(options = {}) { | ||
| const token = randomToken(); | ||
| const origins = new Set(options.allowedOrigins ?? []); | ||
| function normalizeOrigin(origin) { | ||
| if (!origin) return; | ||
| try { | ||
| const url = new URL(origin); | ||
| const normalized = url.origin === "null" ? `${url.protocol}//${url.host}` : url.origin; | ||
| return origin === normalized ? normalized : void 0; | ||
| } catch {} | ||
| } | ||
| function registerOrigin(origin, candidateToken) { | ||
| 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; | ||
| } | ||
| return { | ||
| token, | ||
| registerFromUrl(url) { | ||
| let parsed; | ||
| try { | ||
| parsed = new URL(url, "http://localhost"); | ||
| } catch { | ||
| return; | ||
| } | ||
| const origin = parsed.searchParams.get("devframe_viewer_origin") ?? void 0; | ||
| return registerOrigin(origin, parsed.searchParams.get("devframe_viewer_origin_token") ?? void 0) ? origin : void 0; | ||
| }, | ||
| isAllowed(origin) { | ||
| return isAllowedOrigin(origin, [...origins]); | ||
| } | ||
| }; | ||
| } | ||
| const EMPTY_DEFS = /* @__PURE__ */ new Map(); | ||
| function NOOP() {} | ||
| function listen(server, port, host) { | ||
| return new Promise((resolve, reject) => { | ||
| const onError = (error) => reject(error); | ||
| server.once("error", onError); | ||
| try { | ||
| server.listen(port, host, () => { | ||
| server.off("error", onError); | ||
| resolve(); | ||
| }); | ||
| } catch (error) { | ||
| server.off("error", onError); | ||
| reject(error); | ||
| } | ||
| }); | ||
| } | ||
| /** Compare two URL paths ignoring a trailing slash. */ | ||
| function pathMatches(a, b) { | ||
| const strip = (p) => p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p; | ||
| return strip(a) === strip(b); | ||
| } | ||
| function isLoopbackHostname(hostname) { | ||
| const h = hostname.replace(/^\[|\]$/g, ""); | ||
| return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost") || h.startsWith("127."); | ||
| } | ||
| /** | ||
| * Default origin policy for a localhost dev tool: allow requests with no | ||
| * `Origin` header (native, non-browser clients), allow any loopback origin | ||
| * (so cross-port localhost dev setups keep working), and allow explicitly | ||
| * configured origins. Everything else — a real remote page in the dev's | ||
| * browser — is rejected. | ||
| */ | ||
| function isAllowedOrigin(origin, allowedOrigins) { | ||
| if (!origin) return true; | ||
| if (allowedOrigins.includes(origin)) return true; | ||
| try { | ||
| return isLoopbackHostname(new URL(origin).hostname); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function isWsOriginRegistry(value) { | ||
| return !!value && !Array.isArray(value); | ||
| } | ||
| /** | ||
| * Build the `upgrade` listener that hands a request to the crossws adapter, | ||
| * optionally filtered to a single `path`. Non-matching requests are left | ||
| * untouched so other upgrade listeners (e.g. a Vite dev server's HMR socket) | ||
| * can claim them, unless `destroyUnmatched` is set. | ||
| */ | ||
| function createUpgradeListener(ws, path, destroyUnmatched, allowedOrigins) { | ||
| return (req, socket, head) => { | ||
| socket.on("error", () => {}); | ||
| if (path) { | ||
| let pathname = req.url ?? "/"; | ||
| try { | ||
| pathname = new URL(req.url ?? "/", "http://localhost").pathname; | ||
| } catch {} | ||
| if (!pathMatches(pathname, path)) { | ||
| if (destroyUnmatched) { | ||
| socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); | ||
| socket.destroy(); | ||
| } | ||
| return; | ||
| } | ||
| } | ||
| 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; | ||
| } | ||
| ws.handleUpgrade(req, socket, head); | ||
| }; | ||
| } | ||
| /** | ||
| * The per-peer lifecycle hooks driving a devframe RPC WebSocket, shaped for | ||
| * any [crossws](https://crossws.h3.dev) adapter. {@link attachWsRpcTransport} | ||
| * feeds them to the Node adapter; runtime-specific attachments (e.g. Bun's | ||
| * fetch-upgrade adapter) reuse the same hooks so every transport speaks the | ||
| * identical wire protocol — one birpc channel per peer, per-method | ||
| * `jsonSerializable` dispatch between strict JSON and structured-clone. | ||
| */ | ||
| function createWsRpcPeerHooks(rpcGroup, options = {}) { | ||
| const { onConnected = NOOP, onDisconnected = NOOP, definitions = EMPTY_DEFS, serialize: serializeOverride, deserialize: deserializeOverride } = options; | ||
| const states = /* @__PURE__ */ new WeakMap(); | ||
| return { | ||
| open: (peer) => { | ||
| const meta = createRpcSessionMeta(); | ||
| meta.peer = peer; | ||
| const connection = { | ||
| id: meta.id, | ||
| transport: "websocket", | ||
| request: peer.request, | ||
| send: (data) => peer.send(data), | ||
| close: (code, reason) => peer.close(code, reason), | ||
| peer | ||
| }; | ||
| const codec = createRpcWireCodec(definitions); | ||
| const state = { | ||
| meta, | ||
| connection, | ||
| channel: void 0 | ||
| }; | ||
| const channel = { | ||
| post: (data) => { | ||
| peer.send(data); | ||
| }, | ||
| on: (fn) => { | ||
| state.onMessage = fn; | ||
| }, | ||
| serialize: serializeOverride ?? codec.serialize, | ||
| deserialize: deserializeOverride ?? codec.deserialize, | ||
| meta | ||
| }; | ||
| state.channel = channel; | ||
| states.set(peer, state); | ||
| rpcGroup.updateChannels((channels) => { | ||
| channels.push(channel); | ||
| }); | ||
| onConnected(connection, meta); | ||
| }, | ||
| message: (peer, message) => { | ||
| states.get(peer)?.onMessage?.(message.text()); | ||
| }, | ||
| close: (peer) => { | ||
| const state = states.get(peer); | ||
| if (!state) return; | ||
| states.delete(peer); | ||
| rpcGroup.updateChannels((channels) => { | ||
| const index = channels.indexOf(state.channel); | ||
| if (index >= 0) channels.splice(index, 1); | ||
| }); | ||
| onDisconnected(state.connection, state.meta); | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * Attach a WebSocket transport to an existing RPC group, powered by | ||
| * [crossws](https://crossws.h3.dev). Either attach to an existing HTTP(S) | ||
| * `server` (sharing its port, optionally scoped to a `path`), or let this | ||
| * helper create a standalone server from `port` / `host` / `https`. | ||
| * | ||
| * Returns the crossws node adapter, standalone-server readiness/address | ||
| * accessors, `detach` (remove the upgrade listener from a shared `server`), | ||
| * and `close` (full deterministic teardown). | ||
| */ | ||
| function attachWsRpcTransport(rpcGroup, options = {}) { | ||
| const { server, port, host = "localhost", path, destroyUnmatched = false, unbound, https, allowedOrigins } = options; | ||
| const ws = crossws({ hooks: createWsRpcPeerHooks(rpcGroup, options) }); | ||
| const sharedUpgradeListener = createUpgradeListener(ws, path, destroyUnmatched, allowedOrigins); | ||
| const ownedUpgradeListener = createUpgradeListener(ws, path, true, allowedOrigins); | ||
| /** Bind a server's `upgrade` events, tracked so `close()` detaches them. */ | ||
| const attachments = /* @__PURE__ */ new Set(); | ||
| function attachTo(target, listener) { | ||
| target.on("upgrade", listener); | ||
| const detachOne = () => { | ||
| target.off("upgrade", listener); | ||
| attachments.delete(detachOne); | ||
| }; | ||
| attachments.add(detachOne); | ||
| return detachOne; | ||
| } | ||
| let ready = Promise.resolve(); | ||
| let ownedServer; | ||
| if (unbound) {} else if (server) attachTo(server, sharedUpgradeListener); | ||
| else if (https) { | ||
| ownedServer = createServer$1(https); | ||
| attachTo(ownedServer, ownedUpgradeListener); | ||
| ready = listen(ownedServer, port ?? 0, host); | ||
| } else { | ||
| ownedServer = createServer((_req, res) => { | ||
| res.writeHead(426, { "content-type": "text/plain" }); | ||
| res.end("Upgrade Required"); | ||
| }); | ||
| attachTo(ownedServer, ownedUpgradeListener); | ||
| ready = listen(ownedServer, port ?? 0, host); | ||
| } | ||
| const activeServer = server ?? ownedServer; | ||
| function detachAll() { | ||
| for (const detachOne of [...attachments]) detachOne(); | ||
| } | ||
| return { | ||
| ws, | ||
| ready, | ||
| address: () => activeServer?.address() ?? null, | ||
| handleUpgrade: sharedUpgradeListener, | ||
| attach: (target) => attachTo(target, sharedUpgradeListener), | ||
| detach: detachAll, | ||
| async close() { | ||
| detachAll(); | ||
| ws.closeAll(void 0, void 0, true); | ||
| if (ownedServer) { | ||
| const srv = ownedServer; | ||
| await ready.catch(() => {}); | ||
| if (!srv.listening) return; | ||
| await new Promise((r) => srv.close(() => r())); | ||
| } | ||
| } | ||
| }; | ||
| } | ||
| //#endregion | ||
| export { isLoopbackHostname as a, isAllowedOrigin as i, createWsOriginRegistry as n, createRpcSessionMeta as o, createWsRpcPeerHooks as r, attachWsRpcTransport as t }; |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
767778
0.07%140
2.19%14101
0.01%