| import { n as colors } from "./diagnostics-reporter-CsIG85Q5.mjs"; | ||
| import { createBuild } from "./adapters/build.mjs"; | ||
| import { n as resolveDevServerPort, t as createDevServer } from "./dev-BSHFZGZr.mjs"; | ||
| import process from "node:process"; | ||
| import cac$1 from "cac"; | ||
| //#region src/adapters/flags.ts | ||
| /** | ||
| * Identity helper that preserves the literal schema-map type — use this | ||
| * so `InferCliFlags<typeof myFlags>` resolves to the right object shape. | ||
| * | ||
| * ```ts | ||
| * const appFlags = defineCliFlags({ | ||
| * depth: v.pipe(v.number(), v.integer()), | ||
| * config: v.optional(v.string()), | ||
| * }) | ||
| * | ||
| * defineDevframe({ | ||
| * cli: { flags: appFlags }, | ||
| * setup(ctx, info) { | ||
| * const flags = info.flags as InferCliFlags<typeof appFlags> | ||
| * flags.depth // number | ||
| * flags.config // string | undefined | ||
| * }, | ||
| * }) | ||
| * ``` | ||
| */ | ||
| function defineCliFlags(flags) { | ||
| return flags; | ||
| } | ||
| /** | ||
| * Best-effort, dependency-free probe of a schema to decide whether the | ||
| * corresponding CAC option takes a value. Duck-types the `type` / | ||
| * `wrapped` / `inner` / `pipe` fields exposed by valibot and by devframe's | ||
| * built-in `s` builder, unwrapping `optional` / `nullable` / `nullish` / | ||
| * `pipe` wrappers then matching on the inner kind. Validators that don't | ||
| * expose these fields (e.g. zod) fall through to a value-taking option. | ||
| */ | ||
| function getSchemaKind(schema) { | ||
| let current = schema; | ||
| while (current) { | ||
| const kind = current.type; | ||
| if (kind === "optional" || kind === "nullable" || kind === "nullish" || kind === "undefined") { | ||
| current = current.wrapped ?? current.inner; | ||
| continue; | ||
| } | ||
| if (kind === "pipe" && Array.isArray(current.pipe) && current.pipe.length > 0) { | ||
| current = current.pipe[0]; | ||
| continue; | ||
| } | ||
| return kind ?? "unknown"; | ||
| } | ||
| return "unknown"; | ||
| } | ||
| /** Whether the CAC option for this schema should be a boolean flag. */ | ||
| function isBooleanFlag(schema) { | ||
| return getSchemaKind(schema) === "boolean"; | ||
| } | ||
| /** Validate the raw cac-parsed bag against a {@link CliFlagsSchema}. */ | ||
| function parseCliFlags(schema, raw) { | ||
| const flags = {}; | ||
| const issues = []; | ||
| for (const [key, fieldSchema] of Object.entries(schema)) { | ||
| const result = fieldSchema["~standard"].validate(raw[key]); | ||
| if (result instanceof Promise) { | ||
| issues.push(`--${toKebab(key)}: async flag validation is not supported`); | ||
| continue; | ||
| } | ||
| if (result.issues) issues.push(`--${toKebab(key)}: ${result.issues.map((i) => i.message).join(", ")}`); | ||
| else flags[key] = result.value; | ||
| } | ||
| for (const [key, value] of Object.entries(raw)) if (!(key in schema) && !(key in flags)) flags[key] = value; | ||
| return issues.length ? { | ||
| flags, | ||
| issues | ||
| } : { flags }; | ||
| } | ||
| function toKebab(camel) { | ||
| return camel.replaceAll(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); | ||
| } | ||
| /** Kebab-case a schema key for CAC option registration. */ | ||
| function flagKeyToOption(camel) { | ||
| return toKebab(camel); | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/cac.ts | ||
| /** | ||
| * Wrap a {@link DevframeDefinition} in a `cac`-powered command-line | ||
| * interface exposing `dev` / `build` / `mcp` subcommands. | ||
| * | ||
| * Requires the optional `cac` peer dependency. | ||
| */ | ||
| function createCac(d, options = {}) { | ||
| const defaultPort = options.defaultPort ?? d.cli?.port ?? 9999; | ||
| const defaultHost = d.cli?.host ?? "localhost"; | ||
| const cli = cac$1(d.cli?.command ?? d.id); | ||
| const devCommand = cli.command("[...args]", "Start a local dev server").option("--port <port>", "Port to listen on").option("--host <host>", "Host to bind to", { default: defaultHost }).option("--open", "Open the browser on start").option("--no-open", "Do not open the browser").option("--no-auth", "Disable the interactive authentication gate").option("--mcp", "Expose an MCP server over HTTP at /__mcp (use --no-mcp to disable) [experimental]"); | ||
| if (d.cli?.flags) for (const [key, schema] of Object.entries(d.cli.flags)) { | ||
| const optionName = flagKeyToOption(key); | ||
| const description = schema.description ?? ""; | ||
| if (isBooleanFlag(schema)) devCommand.option(`--${optionName}`, description); | ||
| else devCommand.option(`--${optionName} <value>`, description); | ||
| } | ||
| devCommand.action(async (_args, rawFlags) => { | ||
| const flags = resolveTypedFlags(d, rawFlags); | ||
| const host = flags.host ?? defaultHost; | ||
| const port = flags.port ?? await resolveDevServerPort(d, { | ||
| host, | ||
| defaultPort | ||
| }); | ||
| const mcp = flags.mcp; | ||
| await createDevServer(d, { | ||
| host, | ||
| port, | ||
| flags, | ||
| mcp, | ||
| onReady: options.onReady | ||
| }); | ||
| }); | ||
| if (d.capabilities?.build !== false) cli.command("build", "Build a self-contained static deploy of the devframe").option("--out-dir <outDir>", "Output directory", { default: "dist-static" }).option("--base <base>", "URL base", { default: "/" }).option("--pretty", "Pretty-print dump JSON (larger on disk)").action(async (flags) => { | ||
| await createBuild(d, { | ||
| outDir: flags.outDir, | ||
| base: flags.base, | ||
| pretty: flags.pretty | ||
| }); | ||
| }); | ||
| cli.command("mcp", "Start an MCP server exposing agent-facing tools (stdio) [experimental]").action(async () => { | ||
| const { createMcpServer } = await import("./adapters/mcp.mjs"); | ||
| await createMcpServer(d, { | ||
| transport: "stdio", | ||
| onReady: ({ transport }) => { | ||
| console.error(`[devframe] "${d.id}" MCP server ready (${transport})`); | ||
| } | ||
| }); | ||
| }); | ||
| d.cli?.configure?.(cli); | ||
| options.configureCli?.(cli); | ||
| cli.help(); | ||
| cli.version("0.0.0"); | ||
| return { | ||
| cli, | ||
| async parse(argv = process.argv) { | ||
| cli.parse(argv, { run: false }); | ||
| await cli.runMatchedCommand(); | ||
| } | ||
| }; | ||
| } | ||
| function resolveTypedFlags(d, raw) { | ||
| if (!d.cli?.flags) return raw; | ||
| const { flags, issues } = parseCliFlags(d.cli.flags, raw); | ||
| if (issues?.length) { | ||
| for (const issue of issues) console.error(colors.red`[devframe] invalid flag — ${issue}`); | ||
| process.exit(1); | ||
| } | ||
| return flags; | ||
| } | ||
| //#endregion | ||
| export { defineCliFlags as n, parseCliFlags as r, createCac as t }; |
| import { n as randomToken } from "./crypto-token-XCqTSMg9.mjs"; | ||
| import { t as createStorage } from "./storage-Dzoc3NVC.mjs"; | ||
| import { n as revokeAuthToken, t as revokeActiveConnectionsForToken } from "./revoke-DZbJ7Cl0.mjs"; | ||
| import { join } from "pathe"; | ||
| //#region src/node/hub-internals/context.ts | ||
| const internalContextMap = /* @__PURE__ */ new WeakMap(); | ||
| function getInternalContext(context) { | ||
| if (!internalContextMap.has(context)) { | ||
| const storage = createStorage({ | ||
| filepath: join(context.host.getStorageDir("global"), "auth.json"), | ||
| initialValue: { trusted: {} } | ||
| }); | ||
| const remoteTokens = /* @__PURE__ */ new Map(); | ||
| function revokeRemoteToken(token) { | ||
| if (!remoteTokens.delete(token)) return; | ||
| revokeActiveConnectionsForToken(context, token); | ||
| } | ||
| const internalContext = { | ||
| storage: { auth: storage }, | ||
| revokeAuthToken: (token) => revokeAuthToken(context, storage, token), | ||
| remoteTokens, | ||
| allocateRemoteToken(dockId, origin, originLock) { | ||
| const token = randomToken(); | ||
| remoteTokens.set(token, { | ||
| dockId, | ||
| origin, | ||
| originLock | ||
| }); | ||
| return token; | ||
| }, | ||
| revokeRemoteToken, | ||
| revokeRemoteTokensForDock(dockId) { | ||
| const tokensToRevoke = []; | ||
| for (const [token, record] of remoteTokens) if (record.dockId === dockId) tokensToRevoke.push(token); | ||
| for (const token of tokensToRevoke) revokeRemoteToken(token); | ||
| }, | ||
| isRemoteTokenTrusted(token, requestOrigin) { | ||
| const record = remoteTokens.get(token); | ||
| if (!record) return false; | ||
| if (!record.originLock) return true; | ||
| return !!requestOrigin && record.origin === requestOrigin; | ||
| } | ||
| }; | ||
| internalContextMap.set(context, internalContext); | ||
| } | ||
| return internalContextMap.get(context); | ||
| } | ||
| //#endregion | ||
| export { internalContextMap as n, getInternalContext as t }; |
| import { b as DevframeNodeContext, ht as SharedState } from "./devframe-qgCKL683.mjs"; | ||
| //#region src/node/hub-internals/context.d.ts | ||
| interface InternalAnonymousAuthStorage { | ||
| trusted: Record<string, { | ||
| authToken: string; | ||
| ua: string; | ||
| origin: string; | ||
| timestamp: number; | ||
| } | undefined>; | ||
| } | ||
| interface RemoteTokenRecord { | ||
| dockId: string; | ||
| /** Dock URL origin — matched against WS handshake `Origin` header when `originLock` is on. */ | ||
| origin: string; | ||
| originLock: boolean; | ||
| } | ||
| interface DevframeInternalContext { | ||
| storage: { | ||
| auth: SharedState<InternalAnonymousAuthStorage>; | ||
| }; | ||
| /** | ||
| * Revoke an auth token: remove from storage and notify all connected clients | ||
| * using this token that they are no longer trusted. | ||
| */ | ||
| revokeAuthToken: (token: string) => Promise<void>; | ||
| /** | ||
| * Session-only tokens issued to remote-UI iframe docks. Not persisted — | ||
| * regenerated on every dev-server restart. | ||
| */ | ||
| remoteTokens: Map<string, RemoteTokenRecord>; | ||
| allocateRemoteToken: (dockId: string, origin: string, originLock: boolean) => string; | ||
| revokeRemoteToken: (token: string) => void; | ||
| revokeRemoteTokensForDock: (dockId: string) => void; | ||
| /** | ||
| * Returns true if `token` is a valid remote token and, when `originLock` is | ||
| * on, `requestOrigin` matches the recorded dock origin. | ||
| */ | ||
| isRemoteTokenTrusted: (token: string, requestOrigin?: string) => boolean; | ||
| /** | ||
| * Populated by `createWsServer` once the WS port is bound. Consumed by the | ||
| * docks host when enriching remote iframe URLs with a connection descriptor. | ||
| */ | ||
| wsEndpoint?: { | ||
| /** Full `ws://` or `wss://` URL with host and port. */ | ||
| url: string; | ||
| }; | ||
| } | ||
| declare const internalContextMap: WeakMap<DevframeNodeContext, DevframeInternalContext>; | ||
| declare function getInternalContext(context: DevframeNodeContext): DevframeInternalContext; | ||
| //#endregion | ||
| export { internalContextMap as a, getInternalContext as i, InternalAnonymousAuthStorage as n, RemoteTokenRecord as r, DevframeInternalContext as t }; |
| //#region src/utils/crypto-token.ts | ||
| const HEX = "0123456789abcdef"; | ||
| /** | ||
| * Generate a high-entropy, URL-safe (hex) random token suitable for use as a | ||
| * bearer credential — e.g. the persistent client auth token or an ephemeral | ||
| * remote-dock token. Defaults to 16 bytes (128 bits) of entropy. | ||
| */ | ||
| function randomToken(byteLength = 16) { | ||
| const bytes = new Uint8Array(byteLength); | ||
| globalThis.crypto.getRandomValues(bytes); | ||
| let out = ""; | ||
| for (let i = 0; i < bytes.length; i++) out += HEX[bytes[i] >> 4] + HEX[bytes[i] & 15]; | ||
| return out; | ||
| } | ||
| /** | ||
| * Generate a uniformly-distributed string of decimal digits using rejection | ||
| * sampling to avoid modulo bias. Intended for short, human-typed one-time | ||
| * codes (e.g. a 6-digit authentication code). Leading zeros are preserved. | ||
| */ | ||
| function randomDigits(length) { | ||
| const limit = 250; | ||
| const buf = /* @__PURE__ */ new Uint8Array(1); | ||
| let out = ""; | ||
| while (out.length < length) { | ||
| globalThis.crypto.getRandomValues(buf); | ||
| if (buf[0] < limit) out += String(buf[0] % 10); | ||
| } | ||
| return out; | ||
| } | ||
| /** | ||
| * Constant-time string equality. Compares every character so the comparison | ||
| * time does not depend on the position of the first mismatch, mitigating | ||
| * timing side-channels when verifying secrets. | ||
| * | ||
| * Length is treated as public (it short-circuits on differing lengths), which | ||
| * is appropriate for fixed-length codes and tokens. | ||
| */ | ||
| function timingSafeEqual(a, b) { | ||
| if (a.length !== b.length) return false; | ||
| let mismatch = 0; | ||
| for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); | ||
| return mismatch === 0; | ||
| } | ||
| //#endregion | ||
| export { randomToken as n, timingSafeEqual as r, randomDigits as t }; |
| import { DEVFRAME_CONNECTION_META_FILENAME } from "./constants.mjs"; | ||
| import { n as createHostContext, t as createH3DevframeHost } from "./host-h3-Kz7t5Xab.mjs"; | ||
| import { t as diagnostics } from "./diagnostics-B5-qHeqD.mjs"; | ||
| import { r as registerDevframeInstance } from "./instance-registry-D6fxYu36.mjs"; | ||
| import { i as normalizeHttpServerUrl, t as startHttpAndWs } from "./server-CXaDDUl0.mjs"; | ||
| import { n as resolveBasePath, t as normalizeBasePath } from "./_shared-bWRzeSa0.mjs"; | ||
| import { t as open } from "./open-Deb5xmIT.mjs"; | ||
| import { mountStaticHandler } from "./utils/serve-static.mjs"; | ||
| import { createInteractiveAuth } from "./recipes/interactive-auth.mjs"; | ||
| import { resolve } from "pathe"; | ||
| import process$1 from "node:process"; | ||
| import { networkInterfaces } from "node:os"; | ||
| import { H3 } from "h3"; | ||
| import { createServer } from "node:net"; | ||
| import { joinURL, withBase, withLeadingSlash, withoutLeadingSlash } from "ufo"; | ||
| //#region ../../node_modules/.pnpm/get-port-please@3.2.0/node_modules/get-port-please/dist/index.mjs | ||
| 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/dev.ts | ||
| const DEFAULT_PORT = 9999; | ||
| /** | ||
| * Resolve the listening port for {@link createDevServer}, 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); | ||
| } | ||
| /** | ||
| * 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 | ||
| * `viteDevBridge({ devMiddleware })`. | ||
| * | ||
| * 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 = {}) { | ||
| const distDir = options.distDir ?? def.cli?.distDir; | ||
| const host = options.host ?? def.cli?.host ?? "localhost"; | ||
| const port = 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 h3Host = createH3DevframeHost({ | ||
| origin: normalizeHttpServerUrl(host, port), | ||
| appName: def.id, | ||
| mount: (base, dir) => { | ||
| mountStaticHandler(app, base, dir); | ||
| } | ||
| }); | ||
| const ctx = await createHostContext({ | ||
| cwd: process$1.cwd(), | ||
| mode: "dev", | ||
| host: h3Host | ||
| }); | ||
| const setupInfo = { flags }; | ||
| await def.setup(ctx, setupInfo); | ||
| const mcpConfig = resolveMcpConfig(options.mcp ?? def.cli?.mcp); | ||
| let mcpDispose; | ||
| let mcpMeta; | ||
| if (mcpConfig) { | ||
| const mcpRoute = withoutLeadingSlash(mcpConfig.path ?? "__mcp"); | ||
| const mcpPath = joinURL(basePath, mcpRoute); | ||
| let mountMcpHttp; | ||
| try { | ||
| ({mountMcpHttp} = await import("./http-Dvgxujcu.mjs")); | ||
| } catch (error) { | ||
| const reason = error instanceof Error ? error.message : String(error); | ||
| throw diagnostics.DF0017({ | ||
| transport: "http", | ||
| reason, | ||
| cause: error | ||
| }); | ||
| } | ||
| mcpDispose = mountMcpHttp(app, ctx, mcpPath, { | ||
| serverName: `${def.id} (devframe)`, | ||
| serverVersion: def.version ?? "0.0.0", | ||
| exposeSharedState: true, | ||
| allowedOrigins: mcpConfig.allowedOrigins | ||
| }).dispose; | ||
| mcpMeta = { path: mcpRoute }; | ||
| } | ||
| const { bindPath, wsPort, meta } = resolveWsConnection(def, options, basePath); | ||
| const connectionMetaPath = joinURL(basePath, DEVFRAME_CONNECTION_META_FILENAME); | ||
| app.use(connectionMetaPath, () => ({ | ||
| backend: "websocket", | ||
| websocket: meta, | ||
| ...mcpMeta ? { mcp: mcpMeta } : {} | ||
| })); | ||
| if (distDir) mountStaticHandler(app, basePath, resolve(distDir)); | ||
| const authOption = flags.auth === false ? false : options.auth !== void 0 ? options.auth : def.cli?.auth; | ||
| let authHandler; | ||
| let resolvedAuth; | ||
| if (authOption === false) resolvedAuth = false; | ||
| else if (typeof authOption === "object") { | ||
| authHandler = authOption; | ||
| resolvedAuth = authOption; | ||
| } else { | ||
| authHandler = createInteractiveAuth(ctx); | ||
| resolvedAuth = authHandler; | ||
| } | ||
| const started = await startHttpAndWs({ | ||
| context: ctx, | ||
| host, | ||
| port, | ||
| app, | ||
| path: bindPath, | ||
| wsPort, | ||
| auth: resolvedAuth, | ||
| onReady: async (info) => { | ||
| authHandler?.printBanner(); | ||
| await options.onReady?.(info); | ||
| await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser, authHandler); | ||
| } | ||
| }); | ||
| const registration = registerDevframeInstance({ | ||
| pid: process$1.pid, | ||
| port: started.port, | ||
| origin: normalizeHttpServerUrl(host, started.port), | ||
| basePath, | ||
| id: def.id, | ||
| name: def.name, | ||
| rootDir: process$1.cwd(), | ||
| mcp: mcpConfig ? { path: joinURL(basePath, withoutLeadingSlash(mcpConfig.path ?? "__mcp")) } : null, | ||
| startedAt: Date.now() | ||
| }); | ||
| const closeServer = started.close; | ||
| started.close = async () => { | ||
| registration.unregister(); | ||
| await mcpDispose?.(); | ||
| await closeServer(); | ||
| }; | ||
| return started; | ||
| } | ||
| /** | ||
| * 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 {@link createDevServer}), or `undefined` when the route is | ||
| * disabled. | ||
| * | ||
| * Hosted bridges that hand-roll their connection meta (`viteDevBridge`, | ||
| * `@devframes/next`'s handler) 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). | ||
| * | ||
| * @experimental | ||
| */ | ||
| 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 }; | ||
| } | ||
| /** | ||
| * Resolve the three WS connection scenarios from the definition / call-site | ||
| * config into a concrete server bind path, optional dedicated port, and the | ||
| * `__connection.json` descriptor the browser resolves. | ||
| */ | ||
| function resolveWsConnection(def, options, basePath) { | ||
| const ws = options.ws ?? def.cli?.ws ?? {}; | ||
| const route = withoutLeadingSlash(ws.route ?? "__devframe_ws"); | ||
| if (ws.url) return { | ||
| bindPath: joinURL(basePath, route), | ||
| wsPort: void 0, | ||
| meta: ws.url | ||
| }; | ||
| if (ws.port != null) return { | ||
| bindPath: withLeadingSlash(route), | ||
| wsPort: ws.port, | ||
| meta: { | ||
| port: ws.port, | ||
| path: route | ||
| } | ||
| }; | ||
| return { | ||
| bindPath: joinURL(basePath, route), | ||
| wsPort: void 0, | ||
| meta: { path: route } | ||
| }; | ||
| } | ||
| 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 { resolveDevServerPort as n, resolveMcpConnectionMeta as r, createDevServer as t }; |
Sorry, the diff of this file is too big to display
| import { isAllowedOrigin } from "./rpc/transports/ws-server.mjs"; | ||
| import { n as createHostContext } from "./host-h3-Kz7t5Xab.mjs"; | ||
| import { t as diagnostics } from "./diagnostics-B5-qHeqD.mjs"; | ||
| import { t as toAgentToolName } from "./agent-tool-name-C3b5vEwJ.mjs"; | ||
| import { randomUUID } from "node:crypto"; | ||
| import { Diagnostic } from "nostics"; | ||
| import { join } from "pathe"; | ||
| import process from "node:process"; | ||
| import { homedir } from "node:os"; | ||
| 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. | ||
| * | ||
| * @experimental The agent-native surface is experimental and may change | ||
| * without a major version bump until it stabilizes. | ||
| */ | ||
| 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. | ||
| * | ||
| * @experimental | ||
| */ | ||
| 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 | ||
| export { createMcpServer as n, createMcpFetchHandler as t }; |
| import { t as createMcpFetchHandler } from "./fetch-CK0S253E.mjs"; | ||
| import { defineHandler } from "h3"; | ||
| //#region src/adapters/mcp/http.ts | ||
| /** | ||
| * 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). | ||
| * | ||
| * @experimental | ||
| */ | ||
| 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 { mountMcpHttp }; |
| import { W as DevframeNodeRpcSession, b as DevframeNodeContext, ht as SharedState } from "./devframe-qgCKL683.mjs"; | ||
| import { n as InternalAnonymousAuthStorage } from "./context-DFzmxCLa.mjs"; | ||
| //#region src/node/auth/revoke.d.ts | ||
| /** | ||
| * Flip `isTrusted` to false on any live WS clients connected with `token` | ||
| * and broadcast the `auth:revoked` event so they can react. | ||
| * | ||
| * Shared between persisted-auth revocation and remote-dock token revocation. | ||
| */ | ||
| declare function revokeActiveConnectionsForToken(context: DevframeNodeContext, token: string): Promise<void>; | ||
| /** | ||
| * Revoke an auth token: remove from storage and notify all connected clients | ||
| * using this token that they are no longer trusted. | ||
| */ | ||
| declare function revokeAuthToken(context: DevframeNodeContext, storage: SharedState<InternalAnonymousAuthStorage>, token: string): Promise<void>; | ||
| //#endregion | ||
| //#region src/node/auth/state.d.ts | ||
| /** | ||
| * The current one-time authentication code. Display this to the user (e.g. in | ||
| * the dev-server terminal) so they can type it into the browser to authenticate. | ||
| */ | ||
| declare function getTempAuthCode(): string; | ||
| /** | ||
| * Rotate the authentication code, resetting its expiry window and failed-attempt | ||
| * counter. Call this when a new authentication flow begins (e.g. when an | ||
| * untrusted client starts authenticating) so the displayed code is freshly | ||
| * valid for its full TTL. | ||
| */ | ||
| declare function refreshTempAuthCode(): string; | ||
| /** | ||
| * Build a "magic link" authentication URL that embeds a one-time code (OTP) in | ||
| * the URL **fragment**. Opening it authenticates the client without typing — | ||
| * print it on startup (devframe stays headless, so the host prints its own | ||
| * banner). Defaults to the current code; the link is subject to the same TTL. | ||
| * | ||
| * The code rides the fragment (`#devframe_otp=…`), not the query string, so it | ||
| * is never sent to the server, written to an access log, or leaked in a | ||
| * `Referer` header — the browser client reads it locally (see | ||
| * `consumeOtpFromUrl`). Any existing fragment parameters are preserved. | ||
| */ | ||
| declare function buildOtpAuthUrl(baseUrl: string, code?: string): string; | ||
| /** | ||
| * Re-authenticate a connection that presents a previously-issued bearer token. | ||
| * Returns `true` and marks the session trusted when the token is known. | ||
| * | ||
| * Used by the `anonymous:devframe:auth` handler so a client that already | ||
| * authenticated (token persisted in the browser) is trusted on reconnect | ||
| * without entering the code again. | ||
| */ | ||
| declare function verifyAuthToken(token: string, session: DevframeNodeRpcSession, storage: SharedState<InternalAnonymousAuthStorage>): boolean; | ||
| /** | ||
| * Exchange a one-time authentication code for a fresh, node-issued bearer token. | ||
| * | ||
| * On success this mints a high-entropy token, records it in the trusted store, | ||
| * marks the calling session trusted, rotates the code, and returns the token | ||
| * for the client to persist. Returns `null` on any failure. | ||
| * | ||
| * Because the code is short and human-typed, verification is hardened against | ||
| * brute force: it enforces a time-to-live, compares in constant time, and | ||
| * rotates the code after {@link TEMP_AUTH_MAX_ATTEMPTS} failed attempts so an | ||
| * attacker cannot keep guessing against the same code. | ||
| */ | ||
| declare function exchangeTempAuthCode(code: string, session: DevframeNodeRpcSession, info: { | ||
| ua: string; | ||
| origin: string; | ||
| }, storage: SharedState<InternalAnonymousAuthStorage>): string | null; | ||
| //#endregion | ||
| export { verifyAuthToken as a, refreshTempAuthCode as i, exchangeTempAuthCode as n, revokeActiveConnectionsForToken as o, getTempAuthCode as r, revokeAuthToken as s, buildOtpAuthUrl as t }; |
| //#region src/node/auth/revoke.ts | ||
| /** | ||
| * Flip `isTrusted` to false on any live WS clients connected with `token` | ||
| * and broadcast the `auth:revoked` event so they can react. | ||
| * | ||
| * Shared between persisted-auth revocation and remote-dock token revocation. | ||
| */ | ||
| async function revokeActiveConnectionsForToken(context, token) { | ||
| const rpcHost = context.rpc; | ||
| if (!rpcHost?._rpcGroup) return; | ||
| const affectedSessionIds = /* @__PURE__ */ new Set(); | ||
| for (const client of rpcHost._rpcGroup.clients) if (client.$meta.clientAuthToken === token) { | ||
| affectedSessionIds.add(client.$meta.id); | ||
| client.$meta.isTrusted = false; | ||
| client.$meta.clientAuthToken = void 0; | ||
| } | ||
| if (affectedSessionIds.size === 0) return; | ||
| await rpcHost.broadcast({ | ||
| method: "devframe:auth:revoked", | ||
| args: [], | ||
| filter: (client) => affectedSessionIds.has(client.$meta.id) | ||
| }); | ||
| } | ||
| /** | ||
| * Revoke an auth token: remove from storage and notify all connected clients | ||
| * using this token that they are no longer trusted. | ||
| */ | ||
| async function revokeAuthToken(context, storage, token) { | ||
| storage.mutate((state) => { | ||
| delete state.trusted[token]; | ||
| }); | ||
| await revokeActiveConnectionsForToken(context, token); | ||
| } | ||
| //#endregion | ||
| export { revokeAuthToken as n, revokeActiveConnectionsForToken as t }; |
| import { createRpcServer } from "./rpc/server.mjs"; | ||
| import { attachWsRpcTransport } from "./rpc/transports/ws-server.mjs"; | ||
| import { t as diagnostics } from "./diagnostics-B5-qHeqD.mjs"; | ||
| import { t as getInternalContext } from "./context-CQefP2jR.mjs"; | ||
| import { createServer } from "node:http"; | ||
| import { AsyncLocalStorage } from "node:async_hooks"; | ||
| import { H3, toNodeHandler } from "h3"; | ||
| import { isIP } from "node:net"; | ||
| //#region src/node/utils.ts | ||
| function isObject(value) { | ||
| return Object.prototype.toString.call(value) === "[object Object]"; | ||
| } | ||
| 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/server.ts | ||
| /** | ||
| * Compose an h3 + WebSocket server for a devframe context. The RPC | ||
| * group is bound to `context.rpc.functions`; the WS endpoint lives on | ||
| * the same port as the HTTP server. | ||
| */ | ||
| async function startHttpAndWs(options) { | ||
| const { context, port } = options; | ||
| const bindHost = options.host ?? "localhost"; | ||
| const app = options.app ?? new H3(); | ||
| const ownsHttpServer = !options.server; | ||
| const httpServer = options.server ?? createServer(toNodeHandler(app)); | ||
| const rpcHost = context.rpc; | ||
| const asyncStorage = new AsyncLocalStorage(); | ||
| const authHandler = typeof options.auth === "object" ? options.auth : void 0; | ||
| const effectiveAuthorize = options.authorize ?? authHandler?.authorize; | ||
| if (authHandler) { | ||
| for (const fn of authHandler.rpcFunctions) if (!rpcHost.definitions.has(fn.name)) rpcHost.register(fn); | ||
| } | ||
| const rpcGroup = createRpcServer(rpcHost.functions, { rpcOptions: { | ||
| onFunctionError: options.rpcOptions?.onFunctionError, | ||
| onGeneralError: options.rpcOptions?.onGeneralError, | ||
| resolver(name, fn) { | ||
| const rpc = this; | ||
| if (!fn) return void 0; | ||
| return async function(...args) { | ||
| const meta = rpc.$meta; | ||
| if (effectiveAuthorize && !effectiveAuthorize(name, { | ||
| meta, | ||
| rpc | ||
| })) throw diagnostics.DF0036({ name }); | ||
| return await asyncStorage.run({ | ||
| rpc, | ||
| meta | ||
| }, async () => { | ||
| return (await fn).apply(this, args); | ||
| }); | ||
| }; | ||
| } | ||
| } }); | ||
| const separateWsPort = ownsHttpServer && options.wsPort != null && options.wsPort !== port ? options.wsPort : void 0; | ||
| const { ws, close: closeWs } = attachWsRpcTransport(rpcGroup, { | ||
| ...separateWsPort != null ? { | ||
| port: separateWsPort, | ||
| host: bindHost | ||
| } : { server: httpServer }, | ||
| path: options.path, | ||
| destroyUnmatched: ownsHttpServer, | ||
| allowedOrigins: options.allowedOrigins, | ||
| onConnected: authHandler || options.onPeerConnect ? (peer, meta) => { | ||
| const session = { | ||
| meta, | ||
| rpc: rpcGroup.clients.find((client) => client.$meta === meta) | ||
| }; | ||
| authHandler?.onConnect(peer, session); | ||
| options.onPeerConnect?.(peer, session); | ||
| } : void 0, | ||
| onDisconnected: (_peer, meta) => { | ||
| rpcHost._emitSessionDisconnected(meta); | ||
| } | ||
| }); | ||
| rpcHost._rpcGroup = rpcGroup; | ||
| rpcHost._asyncStorage = asyncStorage; | ||
| rpcHost._authDisabled = options.auth === false; | ||
| if (options.auth === false && !rpcHost.definitions.has("anonymous:devframe:auth")) rpcHost.register({ | ||
| name: "anonymous:devframe:auth", | ||
| type: "action", | ||
| handler: () => { | ||
| const session = rpcHost.getCurrentRpcSession(); | ||
| if (session) session.meta.isTrusted = true; | ||
| return { isTrusted: true }; | ||
| } | ||
| }); | ||
| if (ownsHttpServer) await new Promise((resolveListen) => { | ||
| httpServer.listen(port, bindHost, () => resolveListen()); | ||
| }); | ||
| const address = httpServer.address(); | ||
| const resolvedPort = typeof address === "object" && address ? address.port : port; | ||
| const origin = normalizeHttpServerUrl(bindHost, resolvedPort); | ||
| const internal = getInternalContext(context); | ||
| const wsPortForUrl = separateWsPort ?? resolvedPort; | ||
| const wsUrl = `ws://${formatHostForUrl(bindHost)}:${wsPortForUrl}${options.path ?? ""}`; | ||
| internal.wsEndpoint = { url: wsUrl }; | ||
| if (options.onReady) await options.onReady({ | ||
| origin, | ||
| port: resolvedPort, | ||
| app | ||
| }); | ||
| function connectionMeta() { | ||
| const jsonSerializableMethods = []; | ||
| for (const def of rpcHost.definitions.values()) if (def.jsonSerializable === true) jsonSerializableMethods.push(def.name); | ||
| return { | ||
| backend: "websocket", | ||
| websocket: separateWsPort != null ? { | ||
| port: separateWsPort, | ||
| path: options.path | ||
| } : { path: options.path }, | ||
| jsonSerializableMethods | ||
| }; | ||
| } | ||
| return { | ||
| origin, | ||
| port: resolvedPort, | ||
| app, | ||
| ws, | ||
| rpcGroup, | ||
| connectionMeta, | ||
| async close() { | ||
| await closeWs(); | ||
| if (ownsHttpServer) await new Promise((r) => httpServer.close(() => r())); | ||
| if (getInternalContext(context).wsEndpoint?.url === wsUrl) getInternalContext(context).wsEndpoint = void 0; | ||
| } | ||
| }; | ||
| } | ||
| //#endregion | ||
| export { toDialableHost as a, normalizeHttpServerUrl as i, formatHostForUrl as n, isObject as r, startHttpAndWs as t }; |
| import { $ as DevframeRpcServerFunctions, Q as DevframeRpcClientFunctions, W as DevframeNodeRpcSession, _ as ConnectionMeta, b as DevframeNodeContext, p as DevframeAuthHandler } from "./devframe-qgCKL683.mjs"; | ||
| import { r as WsOriginRegistry } from "./ws-server-J-zkiOyp.mjs"; | ||
| import "./index-B7RgLw--.mjs"; | ||
| import { Peer } from "crossws"; | ||
| import { BirpcGroup, EventOptions } from "birpc"; | ||
| import { NodeAdapter } from "crossws/adapters/node"; | ||
| import { Server } from "node:http"; | ||
| import { H3 } from "h3"; | ||
| //#region src/node/server.d.ts | ||
| interface StartHttpAndWsOptions { | ||
| context: DevframeNodeContext; | ||
| host?: string; | ||
| port: number; | ||
| /** | ||
| * Optional h3 app to mount on. When omitted a fresh one is created; | ||
| * when provided, callers can add their own routes (static handlers, | ||
| * auth middleware, etc.) first. | ||
| */ | ||
| app?: H3; | ||
| /** | ||
| * Bind the WS endpoint to a single upgrade route (e.g. `/__devframe_ws`) instead of | ||
| * claiming every upgrade on the port. This lets the socket share a server | ||
| * with other upgrade handlers (Vite HMR, a host framework's own sockets) | ||
| * and is what the SPA's `__connection.json` points at. When omitted, the WS | ||
| * server handles every upgrade on the port (legacy behaviour). | ||
| */ | ||
| path?: string; | ||
| /** | ||
| * Bind the WS endpoint on its own port instead of sharing the HTTP server's. | ||
| * The HTTP/SPA server still listens on `port`; the socket gets a dedicated | ||
| * `ws` server on `wsPort` (same `host`). Use this for the "different port" | ||
| * connection scenario. Ignored when a `server` is supplied. | ||
| */ | ||
| wsPort?: number; | ||
| /** | ||
| * Mount the WS endpoint onto an existing HTTP server, sharing its port, | ||
| * rather than creating and listening on a fresh one. Use this to embed | ||
| * devframe's RPC socket inside a host server (e.g. a Vite dev server) — pair | ||
| * it with `path` so it coexists with the host's routes. The caller owns the | ||
| * server's lifecycle: {@link StartedServer.close} detaches devframe's upgrade | ||
| * listener but leaves the host server running. When set, `host`/`port` are | ||
| * only used to report the resolved origin. | ||
| */ | ||
| server?: Server; | ||
| /** | ||
| * Authentication for the server: | ||
| * | ||
| * - `true` (default) — no gate; every registered method is callable | ||
| * regardless of trust (today's behavior, unchanged). | ||
| * - `false` — the RPC server is started without a trust handshake. | ||
| * Intended for single-user localhost tools where an auth round-trip | ||
| * would only get in the way. A noop `anonymous:devframe:auth` handler | ||
| * is registered so the browser client's unconditional handshake call | ||
| * succeeds and auto-trusts. | ||
| * - A {@link DevframeAuthHandler} (e.g. from | ||
| * `devframe/recipes/interactive-auth`'s `createInteractiveAuth`) — | ||
| * registers its `rpcFunctions`, wires its `authorize` as the resolver | ||
| * gate, and wires its `onConnect` on every new peer. This is the | ||
| * fully-authenticated server: an untrusted caller can only reach | ||
| * `anonymous:`-prefixed methods (see `isAnonymousRpcMethod`). | ||
| */ | ||
| auth?: boolean | DevframeAuthHandler; | ||
| /** | ||
| * Lower-level escape hatch: gate individual RPC calls by method name and | ||
| * session without a full {@link DevframeAuthHandler}. Ignored when `auth` | ||
| * is a handler object (its own `authorize` is used); combine with `auth: | ||
| * true` to layer a custom policy on top of an otherwise ungated server. | ||
| */ | ||
| authorize?: (methodName: string, session: DevframeNodeRpcSession) => boolean; | ||
| /** | ||
| * Called once per new WS connection, right after its session is created | ||
| * (before any RPC call is dispatched). Runs after the auth handler's own | ||
| * `onConnect` (when `auth` is a {@link DevframeAuthHandler}), so it can | ||
| * observe — but not override — the connect-time trust decision. | ||
| */ | ||
| onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void; | ||
| /** | ||
| * Forwarded verbatim to the internal `createRpcServer`'s birpc | ||
| * `rpcOptions`, alongside the resolver `startHttpAndWs` installs for | ||
| * auth/session wiring. Use this so a host that owns its own structured | ||
| * diagnostics (e.g. a coded error reporter) keeps seeing RPC failures | ||
| * instead of them being silently absorbed by delegating to | ||
| * `startHttpAndWs`. Returning `true` from either callback suppresses | ||
| * birpc's own error response to the caller — see birpc's | ||
| * `EventOptions` for the full contract. | ||
| */ | ||
| rpcOptions?: Pick<EventOptions<DevframeRpcClientFunctions, DevframeRpcServerFunctions, false>, 'onFunctionError' | 'onGeneralError'>; | ||
| /** | ||
| * Extra origins to accept on the WS upgrade beyond the loopback default | ||
| * (`localhost`/`127.0.0.1`/`::1` and any `Origin`-less request from a | ||
| * native client). Add your LAN/tunnel origin here when reaching the tool | ||
| * from another host. Pass `false` to disable origin checking entirely | ||
| * (not recommended). Default: loopback-only. | ||
| */ | ||
| allowedOrigins?: readonly string[] | WsOriginRegistry | false; | ||
| /** | ||
| * Called once the WS server is bound so callers can mount static | ||
| * handlers whose origin depends on the resolved port, or print their | ||
| * own startup banner. Devframe does not print one itself. | ||
| */ | ||
| onReady?: (info: { | ||
| origin: string; | ||
| port: number; | ||
| app: H3; | ||
| }) => void | Promise<void>; | ||
| } | ||
| interface StartedServer { | ||
| /** Listening origin, e.g. `http://localhost:9999`. */ | ||
| origin: string; | ||
| port: number; | ||
| app: H3; | ||
| /** The crossws node adapter driving the RPC socket (connected peers, pub/sub). */ | ||
| ws: NodeAdapter; | ||
| rpcGroup: BirpcGroup<DevframeRpcClientFunctions, DevframeRpcServerFunctions, false>; | ||
| /** | ||
| * The {@link ConnectionMeta} descriptor for this server — the same shape | ||
| * a `__connection.json` route should serve so a devframe client's | ||
| * `resolveWsUrl` can dial back in. Reflects the `path` / `wsPort` this | ||
| * server was started with and the `jsonSerializable` methods currently | ||
| * registered on `context.rpc`. | ||
| */ | ||
| connectionMeta: () => ConnectionMeta; | ||
| close: () => Promise<void>; | ||
| } | ||
| /** | ||
| * Compose an h3 + WebSocket server for a devframe context. The RPC | ||
| * group is bound to `context.rpc.functions`; the WS endpoint lives on | ||
| * the same port as the HTTP server. | ||
| */ | ||
| declare function startHttpAndWs(options: StartHttpAndWsOptions): Promise<StartedServer>; | ||
| //#endregion | ||
| export { StartedServer as n, startHttpAndWs as r, StartHttpAndWsOptions as t }; |
| import { DEVFRAME_OTP_URL_PARAM } from "./constants.mjs"; | ||
| import { n as randomToken, r as timingSafeEqual, t as randomDigits } from "./crypto-token-XCqTSMg9.mjs"; | ||
| //#region src/node/auth/state.ts | ||
| /** Number of decimal digits in a human-typed one-time authentication code. */ | ||
| const TEMP_AUTH_CODE_LENGTH = 6; | ||
| /** | ||
| * How long an authentication code stays valid after it is (re)generated. A | ||
| * 6-digit code only has ~20 bits of entropy, so a short lifetime plus the | ||
| * attempt cap below are what keep it brute-force resistant. | ||
| */ | ||
| const TEMP_AUTH_CODE_TTL = 5 * 6e4; | ||
| /** Failed attempts allowed against a single code before it is rotated. */ | ||
| const TEMP_AUTH_MAX_ATTEMPTS = 5; | ||
| let tempAuthCode = generateTempCode(); | ||
| let tempAuthCodeExpiresAt = Date.now() + TEMP_AUTH_CODE_TTL; | ||
| let tempAuthFailedAttempts = 0; | ||
| function generateTempCode() { | ||
| return randomDigits(TEMP_AUTH_CODE_LENGTH); | ||
| } | ||
| /** | ||
| * The current one-time authentication code. Display this to the user (e.g. in | ||
| * the dev-server terminal) so they can type it into the browser to authenticate. | ||
| */ | ||
| function getTempAuthCode() { | ||
| return tempAuthCode; | ||
| } | ||
| /** | ||
| * Rotate the authentication code, resetting its expiry window and failed-attempt | ||
| * counter. Call this when a new authentication flow begins (e.g. when an | ||
| * untrusted client starts authenticating) so the displayed code is freshly | ||
| * valid for its full TTL. | ||
| */ | ||
| function refreshTempAuthCode() { | ||
| tempAuthCode = generateTempCode(); | ||
| tempAuthCodeExpiresAt = Date.now() + TEMP_AUTH_CODE_TTL; | ||
| tempAuthFailedAttempts = 0; | ||
| return tempAuthCode; | ||
| } | ||
| /** | ||
| * Build a "magic link" authentication URL that embeds a one-time code (OTP) in | ||
| * the URL **fragment**. Opening it authenticates the client without typing — | ||
| * print it on startup (devframe stays headless, so the host prints its own | ||
| * banner). Defaults to the current code; the link is subject to the same TTL. | ||
| * | ||
| * The code rides the fragment (`#devframe_otp=…`), not the query string, so it | ||
| * is never sent to the server, written to an access log, or leaked in a | ||
| * `Referer` header — the browser client reads it locally (see | ||
| * `consumeOtpFromUrl`). Any existing fragment parameters are preserved. | ||
| */ | ||
| function buildOtpAuthUrl(baseUrl, code = tempAuthCode) { | ||
| const url = new URL(baseUrl); | ||
| const fragment = new URLSearchParams(url.hash.replace(/^#/, "")); | ||
| fragment.set(DEVFRAME_OTP_URL_PARAM, code); | ||
| url.hash = fragment.toString(); | ||
| return url.href; | ||
| } | ||
| /** | ||
| * Re-authenticate a connection that presents a previously-issued bearer token. | ||
| * Returns `true` and marks the session trusted when the token is known. | ||
| * | ||
| * Used by the `anonymous:devframe:auth` handler so a client that already | ||
| * authenticated (token persisted in the browser) is trusted on reconnect | ||
| * without entering the code again. | ||
| */ | ||
| function verifyAuthToken(token, session, storage) { | ||
| if (!token || !storage.value().trusted[token]) return false; | ||
| session.meta.clientAuthToken = token; | ||
| session.meta.isTrusted = true; | ||
| return true; | ||
| } | ||
| /** | ||
| * Exchange a one-time authentication code for a fresh, node-issued bearer token. | ||
| * | ||
| * On success this mints a high-entropy token, records it in the trusted store, | ||
| * marks the calling session trusted, rotates the code, and returns the token | ||
| * for the client to persist. Returns `null` on any failure. | ||
| * | ||
| * Because the code is short and human-typed, verification is hardened against | ||
| * brute force: it enforces a time-to-live, compares in constant time, and | ||
| * rotates the code after {@link TEMP_AUTH_MAX_ATTEMPTS} failed attempts so an | ||
| * attacker cannot keep guessing against the same code. | ||
| */ | ||
| function exchangeTempAuthCode(code, session, info, storage) { | ||
| if (Date.now() > tempAuthCodeExpiresAt) { | ||
| refreshTempAuthCode(); | ||
| return null; | ||
| } | ||
| if (!timingSafeEqual(code, tempAuthCode)) { | ||
| tempAuthFailedAttempts += 1; | ||
| if (tempAuthFailedAttempts >= TEMP_AUTH_MAX_ATTEMPTS) refreshTempAuthCode(); | ||
| return null; | ||
| } | ||
| const authToken = randomToken(); | ||
| storage.mutate((state) => { | ||
| state.trusted[authToken] = { | ||
| authToken, | ||
| ua: info.ua, | ||
| origin: info.origin, | ||
| timestamp: Date.now() | ||
| }; | ||
| }); | ||
| session.meta.clientAuthToken = authToken; | ||
| session.meta.isTrusted = true; | ||
| refreshTempAuthCode(); | ||
| return authToken; | ||
| } | ||
| //#endregion | ||
| export { verifyAuthToken as a, refreshTempAuthCode as i, exchangeTempAuthCode as n, getTempAuthCode as r, buildOtpAuthUrl as t }; |
| import { v as RpcFunctionDefinitionAny } from "./types-CnJSgRVa.mjs"; | ||
| import { Peer } from "crossws"; | ||
| import { BirpcGroup, ChannelOptions } from "birpc"; | ||
| import { NodeAdapter } from "crossws/adapters/node"; | ||
| import { Server } from "node:http"; | ||
| import { Server as Server$1, ServerOptions } from "node:https"; | ||
| //#region src/rpc/transports/ws-server.d.ts | ||
| interface DevframeNodeRpcSessionMeta { | ||
| id: number; | ||
| /** The crossws peer backing this session's socket. */ | ||
| peer?: Peer; | ||
| clientAuthToken?: string; | ||
| isTrusted?: boolean; | ||
| subscribedStates: Set<string>; | ||
| /** | ||
| * Streams this session has subscribed to via | ||
| * `rpc.streaming.subscribe(channel, id)`. Tracked here for O(1) cleanup | ||
| * on disconnect; the wire format is `${channel}\x1F${id}`. | ||
| */ | ||
| subscribedStreams?: Set<string>; | ||
| /** | ||
| * Inbound streams this session is currently uploading to (via | ||
| * `rpc.streaming.upload(channel, id)`). Tracked for cleanup on | ||
| * disconnect; same wire format as `subscribedStreams`. | ||
| */ | ||
| uploadingStreams?: Set<string>; | ||
| } | ||
| interface WsRpcTransportOptions { | ||
| /** | ||
| * Attach to an existing HTTP(S) server, sharing its port. Combine with | ||
| * `path` to bind the WS endpoint to a single route so it coexists with | ||
| * other upgrade handlers on the same server (e.g. a Vite dev server's HMR | ||
| * socket). The shared server's lifecycle is owned by the caller — closing | ||
| * this transport detaches the upgrade listener without closing the server. | ||
| */ | ||
| server?: Server | Server$1; | ||
| /** Port for a newly-created standalone WS server. */ | ||
| port?: number; | ||
| /** Host for a newly-created standalone WS server. Defaults to `localhost`. */ | ||
| host?: string; | ||
| /** | ||
| * Restrict the WS endpoint to a single upgrade route (e.g. `/__devframe_ws`). When | ||
| * sharing a `server`, non-matching upgrade requests are left untouched for | ||
| * other listeners to handle, so devframe's socket can sit alongside | ||
| * framework sockets (Vite HMR, etc.). | ||
| */ | ||
| path?: string; | ||
| /** | ||
| * Destroy upgrade requests that don't match `path` instead of leaving them | ||
| * for other listeners. Enable this when devframe owns the shared server | ||
| * outright (nothing else handles its upgrades), so an off-route client is | ||
| * rejected promptly rather than left hanging. Default: `false` | ||
| * (coexist-friendly); servers this transport creates itself always | ||
| * destroy unmatched upgrades. | ||
| */ | ||
| destroyUnmatched?: boolean; | ||
| /** When set, a new https.Server is created and the WS endpoint is attached to it. */ | ||
| https?: ServerOptions; | ||
| /** | ||
| * Extra origins to accept on the WS upgrade beyond the loopback default. | ||
| * Add your LAN/tunnel origin here when reaching the tool from another host. | ||
| * Pass `false` to disable origin checking entirely (not recommended). | ||
| * Default: loopback-only. | ||
| */ | ||
| allowedOrigins?: readonly string[] | WsOriginRegistry | false; | ||
| /** | ||
| * RPC function definitions, used by the per-call wire serializer to | ||
| * dispatch between strict-JSON and structured-clone encoding based | ||
| * on each function's `jsonSerializable` flag. | ||
| * | ||
| * When omitted, all messages fall back to structured-clone — safe but | ||
| * loses dev-time validation for `jsonSerializable: true` declarations. | ||
| */ | ||
| definitions?: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>>; | ||
| onConnected?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void; | ||
| onDisconnected?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void; | ||
| /** Override the default per-call serializer. Most callers should leave this unset. */ | ||
| serialize?: ChannelOptions['serialize']; | ||
| /** Override the default per-call deserializer. Most callers should leave this unset. */ | ||
| deserialize?: ChannelOptions['deserialize']; | ||
| } | ||
| interface CreateWsOriginRegistryOptions { | ||
| /** Origins allowed before any external viewers are registered. */ | ||
| allowedOrigins?: readonly string[]; | ||
| /** Additional validation to run after the registration token is verified. */ | ||
| validateOrigin?: (origin: string) => boolean; | ||
| } | ||
| interface WsOriginRegistry { | ||
| /** Registration token to include in connection metadata. */ | ||
| readonly token: string; | ||
| /** Read and register an origin from a connection bootstrap URL. */ | ||
| registerFromUrl: (url: string) => string | undefined; | ||
| /** Check whether an origin is currently allowed. */ | ||
| isAllowed: (origin: string | undefined) => boolean; | ||
| } | ||
| /** | ||
| * Create a live, token-protected origin allowlist for external browser | ||
| * viewers. Pass it to {@link WsRpcTransportOptions.allowedOrigins}, then use | ||
| * `registerFromUrl()` in the connection metadata handler to authorize a | ||
| * viewer without sharing a mutable array or disabling DNS-rebinding protection. | ||
| */ | ||
| declare function createWsOriginRegistry(options?: CreateWsOriginRegistryOptions): WsOriginRegistry; | ||
| interface WsRpcTransport { | ||
| /** | ||
| * The crossws node adapter driving the socket — exposes the connected | ||
| * `peers` and pub/sub. See https://crossws.h3.dev. | ||
| */ | ||
| ws: NodeAdapter; | ||
| /** Remove the upgrade listener from a shared `server` (a no-op otherwise). */ | ||
| detach: () => void; | ||
| /** | ||
| * Tear the transport down deterministically: detach from a shared server, | ||
| * force-terminate every connected peer, and close any server this | ||
| * transport created itself (`port` / `https` modes). | ||
| */ | ||
| close: () => Promise<void>; | ||
| } | ||
| declare function isLoopbackHostname(hostname: string): boolean; | ||
| /** | ||
| * 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. | ||
| */ | ||
| declare function isAllowedOrigin(origin: string | undefined, allowedOrigins: readonly string[]): boolean; | ||
| /** | ||
| * 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 plus `detach` (remove the upgrade | ||
| * listener from a shared `server`) and `close` (full deterministic | ||
| * teardown). | ||
| */ | ||
| declare function attachWsRpcTransport<ClientFunctions extends object, ServerFunctions extends object>(rpcGroup: BirpcGroup<ClientFunctions, ServerFunctions, false>, options?: WsRpcTransportOptions): WsRpcTransport; | ||
| //#endregion | ||
| export { WsRpcTransportOptions as a, isAllowedOrigin as c, WsRpcTransport as i, isLoopbackHostname as l, DevframeNodeRpcSessionMeta as n, attachWsRpcTransport as o, WsOriginRegistry as r, createWsOriginRegistry as s, CreateWsOriginRegistryOptions as t }; |
@@ -1,2 +0,2 @@ | ||
| import { r as DevframeDefinition } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { r as DevframeDefinition } from "../devframe-qgCKL683.mjs"; | ||
| //#region src/adapters/build.d.ts | ||
@@ -3,0 +3,0 @@ interface CreateBuildOptions { |
@@ -1,2 +0,2 @@ | ||
| import { Ft as InferCliFlags, It as defineCliFlags, Lt as parseCliFlags, Pt as CliFlagsSchema, r as DevframeDefinition } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { Ft as InferCliFlags, It as defineCliFlags, Lt as parseCliFlags, Pt as CliFlagsSchema, r as DevframeDefinition } from "../devframe-qgCKL683.mjs"; | ||
| import { CAC } from "cac"; | ||
@@ -3,0 +3,0 @@ import { H3 } from "h3"; |
@@ -1,2 +0,2 @@ | ||
| import { n as defineCliFlags, r as parseCliFlags, t as createCac } from "../cac-B-R_tDGJ.mjs"; | ||
| import { n as defineCliFlags, r as parseCliFlags, t as createCac } from "../cac-B1LM3zit.mjs"; | ||
| export { createCac, defineCliFlags, parseCliFlags }; |
@@ -1,2 +0,2 @@ | ||
| import { Ft as InferCliFlags, It as defineCliFlags, Lt as parseCliFlags, Pt as CliFlagsSchema } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { Ft as InferCliFlags, It as defineCliFlags, Lt as parseCliFlags, Pt as CliFlagsSchema } from "../devframe-qgCKL683.mjs"; | ||
| import { CacHandle, CreateCacOptions, createCac } from "./cac.mjs"; | ||
@@ -3,0 +3,0 @@ //#region src/adapters/cli.d.ts |
@@ -1,2 +0,2 @@ | ||
| import { n as defineCliFlags, r as parseCliFlags, t as createCac } from "../cac-B-R_tDGJ.mjs"; | ||
| import { n as defineCliFlags, r as parseCliFlags, t as createCac } from "../cac-B1LM3zit.mjs"; | ||
| //#region src/adapters/cli.ts | ||
@@ -3,0 +3,0 @@ /** @deprecated Use `createCac` from `devframe/adapters/cac` instead. */ |
@@ -1,3 +0,3 @@ | ||
| import { _ as ConnectionMeta, d as McpRouteOptions, p as DevframeAuthHandler, r as DevframeDefinition, u as DevframeWsOptions } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { n as StartedServer } from "../server-VLQJouOO.mjs"; | ||
| import { _ as ConnectionMeta, d as McpRouteOptions, p as DevframeAuthHandler, r as DevframeDefinition, u as DevframeWsOptions } from "../devframe-qgCKL683.mjs"; | ||
| import { n as StartedServer } from "../server-E9z4ZWbd.mjs"; | ||
| import { H3 } from "h3"; | ||
@@ -4,0 +4,0 @@ //#region src/adapters/dev.d.ts |
@@ -1,2 +0,2 @@ | ||
| import { n as resolveDevServerPort, r as resolveMcpConnectionMeta, t as createDevServer } from "../dev-D4M7Veo7.mjs"; | ||
| import { n as resolveDevServerPort, r as resolveMcpConnectionMeta, t as createDevServer } from "../dev-BSHFZGZr.mjs"; | ||
| export { createDevServer, resolveDevServerPort, resolveMcpConnectionMeta }; |
@@ -1,2 +0,2 @@ | ||
| import { b as DevframeNodeContext, r as DevframeDefinition } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { b as DevframeNodeContext, r as DevframeDefinition } from "../devframe-qgCKL683.mjs"; | ||
| //#region src/adapters/embedded.d.ts | ||
@@ -3,0 +3,0 @@ interface CreateEmbeddedOptions { |
@@ -1,2 +0,2 @@ | ||
| import { b as DevframeNodeContext, r as DevframeDefinition } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { b as DevframeNodeContext, r as DevframeDefinition } from "../devframe-qgCKL683.mjs"; | ||
| import "@modelcontextprotocol/server"; | ||
@@ -50,3 +50,8 @@ //#region src/adapters/mcp/build-server.d.ts | ||
| * Origin allow-list beyond the loopback default. `false` disables the | ||
| * origin gate entirely. Default: loopback-only (mirrors the WS transport). | ||
| * origin gate entirely. Default: loopback-only. | ||
| * | ||
| * Unlike the WS transport, the MCP route does **not** allow `Origin`-less | ||
| * requests: a route-based endpoint is reachable by any local process, so a | ||
| * request must carry an `Origin` that passes the gate. Native clients | ||
| * (e.g. `devframe connect`) send their loopback origin explicitly. | ||
| */ | ||
@@ -75,5 +80,6 @@ allowedOrigins?: readonly string[] | false; | ||
| * `initialize` POST spins up a session; later requests route to it; a `DELETE` | ||
| * (or client disconnect) tears it down. The origin gate applies devframe's | ||
| * loopback-default DNS-rebinding protection (identical semantics to the WS | ||
| * upgrade's `isAllowedOrigin`). | ||
| * (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. | ||
| * | ||
@@ -80,0 +86,0 @@ * @experimental |
@@ -1,2 +0,2 @@ | ||
| import { n as createMcpServer, t as createMcpFetchHandler } from "../fetch-BZyK4v6W.mjs"; | ||
| import { n as createMcpServer, t as createMcpFetchHandler } from "../fetch-CK0S253E.mjs"; | ||
| export { createMcpFetchHandler, createMcpServer }; |
@@ -203,3 +203,4 @@ import { t as diagnostics } from "../diagnostics-B5-qHeqD.mjs"; | ||
| async function withInstanceClient(sdk, url, fn) { | ||
| const transport = new sdk.StreamableHTTPClientTransport(new URL(url)); | ||
| const origin = new URL(url).origin; | ||
| const transport = new sdk.StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { origin } } }); | ||
| const client = new sdk.Client({ | ||
@@ -206,0 +207,0 @@ name: "devframe-connect", |
@@ -1,2 +0,2 @@ | ||
| import { $ as DevframeRpcServerFunctions, F as ScopedSharedStates, I as SettingsForNamespace, J as RpcSharedStateHost, N as ScopedRpcFn, O as DevframeSettings, P as ScopedServerFunctions, Q as DevframeRpcClientFunctions, _ as ConnectionMeta, at as StreamReader, ht as SharedState, jt as EventEmitter, ot as StreamSink, q as RpcSharedStateGetOptions } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { $ as DevframeRpcServerFunctions, F as ScopedSharedStates, I as SettingsForNamespace, J as RpcSharedStateHost, N as ScopedRpcFn, O as DevframeSettings, P as ScopedServerFunctions, Q as DevframeRpcClientFunctions, _ as ConnectionMeta, at as StreamReader, ht as SharedState, jt as EventEmitter, ot as StreamSink, q as RpcSharedStateGetOptions } from "../devframe-qgCKL683.mjs"; | ||
| import { _ as RpcFunctionDefinition, w as RpcFunctionsCollector } from "../types-CnJSgRVa.mjs"; | ||
@@ -30,2 +30,8 @@ import { E as RpcCacheOptions, T as RpcCacheManager } from "../index-Dbw1p5ch.mjs"; | ||
| /** | ||
| * Allow an external viewer to connect by registering its browser origin with | ||
| * the Devframe host. Returns `false` if the host did not provide an origin | ||
| * registration token. | ||
| */ | ||
| declare function registerDevframeViewerOrigin(connection: DevframeConnection, origin?: any): Promise<boolean>; | ||
| /** | ||
| * Return connection information previously prepared in this window or an | ||
@@ -381,11 +387,12 @@ * accessible parent window. | ||
| /** | ||
| * Read a one-time authentication code (OTP) from the current page URL's query | ||
| * string, without side effects. Returns `undefined` when the parameter is absent. | ||
| * Read a one-time authentication code (OTP) from the current page URL's | ||
| * fragment, without side effects. Returns `undefined` when the parameter is | ||
| * absent. | ||
| */ | ||
| declare function readOtpFromUrl(param?: string): string | undefined; | ||
| /** | ||
| * Read the one-time code from the page URL and remove it from the address bar | ||
| * (and the current history entry), so the single-use code isn't left in the | ||
| * URL, browser history, or a `Referer`. Returns the code, or `undefined` when | ||
| * absent. | ||
| * Read the one-time code from the page URL fragment and remove it from the | ||
| * address bar (and the current history entry), so the single-use code isn't | ||
| * left in the URL, browser history, or a `Referer`. Returns the code, or | ||
| * `undefined` when absent. | ||
| */ | ||
@@ -407,2 +414,24 @@ declare function consumeOtpFromUrl(param?: string): string | undefined; | ||
| //#endregion | ||
| //#region src/client/rpc-ws.d.ts | ||
| /** Minimal subset of `window.location` needed to resolve a WS URL. */ | ||
| interface WsUrlLocation { | ||
| protocol: string; | ||
| host: string; | ||
| hostname: string; | ||
| href: string; | ||
| } | ||
| /** | ||
| * Resolve a {@link ConnectionMeta.websocket} descriptor into a concrete | ||
| * `ws(s)://` URL. | ||
| * | ||
| * The object / relative-path forms connect to the page's own origin (only the | ||
| * `http`→`ws` protocol swap is applied), resolving the path against where | ||
| * `__connection.json` was loaded. This is deliberately host-agnostic so the | ||
| * connection survives a reverse proxy that changes the domain or port — the | ||
| * client trusts its own location, never a server-baked hostname. An explicit | ||
| * `port`/`host` (or a full `ws(s)://` URL string) opts into a cross-origin | ||
| * endpoint, e.g. a side-car server on its own port. | ||
| */ | ||
| declare function resolveWsUrl(websocket: ConnectionMeta['websocket'], metaBaseUrl: string, loc: WsUrlLocation): string; | ||
| //#endregion | ||
| //#region src/client/settings.d.ts | ||
@@ -418,2 +447,2 @@ /** | ||
| //#endregion | ||
| export { DevframeClientRpcHost, DevframeConnection, DevframeConnectionError, DevframeConnectionErrorKind, DevframeConnectionStatus, DevframeRpcClient, DevframeRpcClientCall, DevframeRpcClientCallEvent, DevframeRpcClientCallOptional, DevframeRpcClientMode, DevframeRpcClientOptions, DevframeRpcContext, DevframeScopedClientContext, DevframeScopedClientRpc, DevframeScopedClientStreamingHost, RpcClientEvents, RpcStreamingClientHost, SetupDevframeConnectionOptions, StreamingSubscribeOptions, authenticateWithUrlOtp, connectDevframe, consumeOtpFromUrl, createClientSettings, createRpcStreamingClientHost, createScopedClientContext, getDevframeConnection, getDevframeRpcClient, isCallableStatus, readOtpFromUrl, setupDevframeConnection }; | ||
| export { DevframeClientRpcHost, DevframeConnection, DevframeConnectionError, DevframeConnectionErrorKind, DevframeConnectionStatus, DevframeRpcClient, DevframeRpcClientCall, DevframeRpcClientCallEvent, DevframeRpcClientCallOptional, DevframeRpcClientMode, DevframeRpcClientOptions, DevframeRpcContext, DevframeScopedClientContext, DevframeScopedClientRpc, DevframeScopedClientStreamingHost, RpcClientEvents, RpcStreamingClientHost, SetupDevframeConnectionOptions, StreamingSubscribeOptions, type WsUrlLocation, authenticateWithUrlOtp, connectDevframe, consumeOtpFromUrl, createClientSettings, createRpcStreamingClientHost, createScopedClientContext, getDevframeConnection, getDevframeRpcClient, isCallableStatus, readOtpFromUrl, registerDevframeViewerOrigin, resolveWsUrl, setupDevframeConnection }; |
+13
-6
@@ -38,7 +38,10 @@ //#region src/constants.d.ts | ||
| /** | ||
| * Page-URL query parameter carrying a one-time authentication code (OTP) for | ||
| * "magic link" auth. A host can print a link like `<origin>/?devframe_otp=<code>`; | ||
| * the client reads the code, exchanges it for a token, and strips the parameter | ||
| * from the URL. See `buildOtpAuthUrl` (node) and the `authenticateWithUrlOtp` / | ||
| * `consumeOtpFromUrl` client utilities (or `connectDevframe`'s `otpParam`). | ||
| * Page-URL **fragment** parameter carrying a one-time authentication code (OTP) | ||
| * for "magic link" auth. A host can print a link like | ||
| * `<origin>/#devframe_otp=<code>`; the client reads the code, exchanges it for a | ||
| * token, and strips the parameter from the URL. The code rides the fragment | ||
| * (never the query string) so the browser never transmits it to the server, | ||
| * keeping it out of access logs and `Referer` headers. See `buildOtpAuthUrl` | ||
| * (node) and the `authenticateWithUrlOtp` / `consumeOtpFromUrl` client utilities | ||
| * (or `connectDevframe`'s `otpParam`). | ||
| */ | ||
@@ -54,2 +57,6 @@ declare const DEVFRAME_OTP_URL_PARAM = "devframe_otp"; | ||
| declare const DEVFRAME_AUTH_TOKEN_QUERY_PARAM = "devframe_auth_token"; | ||
| /** External viewer origin requested during connection bootstrap. */ | ||
| declare const DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM = "devframe_viewer_origin"; | ||
| /** Token that authorizes an external viewer origin registration. */ | ||
| declare const DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM = "devframe_viewer_origin_token"; | ||
| /** | ||
@@ -71,2 +78,2 @@ * Prefix that marks an RPC method as callable before a connection is | ||
| //#endregion | ||
| export { ANONYMOUS_RPC_PREFIX, DEVFRAME_AUTH_TOKEN_QUERY_PARAM, DEVFRAME_CONNECTION_KEY, DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DIRNAME, DEVFRAME_DOCK_IMPORTS_FILENAME, DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID, DEVFRAME_MCP_ROUTE, DEVFRAME_MOUNT_PATH, DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH, DEVFRAME_OTP_URL_PARAM, DEVFRAME_RPC_DUMP_DIRNAME, DEVFRAME_RPC_DUMP_MANIFEST_FILENAME, DEVFRAME_WS_ROUTE, REMOTE_CONNECTION_KEY, isAnonymousRpcMethod }; | ||
| export { ANONYMOUS_RPC_PREFIX, DEVFRAME_AUTH_TOKEN_QUERY_PARAM, DEVFRAME_CONNECTION_KEY, DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DIRNAME, DEVFRAME_DOCK_IMPORTS_FILENAME, DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID, DEVFRAME_MCP_ROUTE, DEVFRAME_MOUNT_PATH, DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH, DEVFRAME_OTP_URL_PARAM, DEVFRAME_RPC_DUMP_DIRNAME, DEVFRAME_RPC_DUMP_MANIFEST_FILENAME, DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM, DEVFRAME_WS_ROUTE, REMOTE_CONNECTION_KEY, isAnonymousRpcMethod }; |
+13
-6
@@ -38,7 +38,10 @@ //#region src/constants.ts | ||
| /** | ||
| * Page-URL query parameter carrying a one-time authentication code (OTP) for | ||
| * "magic link" auth. A host can print a link like `<origin>/?devframe_otp=<code>`; | ||
| * the client reads the code, exchanges it for a token, and strips the parameter | ||
| * from the URL. See `buildOtpAuthUrl` (node) and the `authenticateWithUrlOtp` / | ||
| * `consumeOtpFromUrl` client utilities (or `connectDevframe`'s `otpParam`). | ||
| * Page-URL **fragment** parameter carrying a one-time authentication code (OTP) | ||
| * for "magic link" auth. A host can print a link like | ||
| * `<origin>/#devframe_otp=<code>`; the client reads the code, exchanges it for a | ||
| * token, and strips the parameter from the URL. The code rides the fragment | ||
| * (never the query string) so the browser never transmits it to the server, | ||
| * keeping it out of access logs and `Referer` headers. See `buildOtpAuthUrl` | ||
| * (node) and the `authenticateWithUrlOtp` / `consumeOtpFromUrl` client utilities | ||
| * (or `connectDevframe`'s `otpParam`). | ||
| */ | ||
@@ -54,2 +57,6 @@ const DEVFRAME_OTP_URL_PARAM = "devframe_otp"; | ||
| const DEVFRAME_AUTH_TOKEN_QUERY_PARAM = "devframe_auth_token"; | ||
| /** External viewer origin requested during connection bootstrap. */ | ||
| const DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM = "devframe_viewer_origin"; | ||
| /** Token that authorizes an external viewer origin registration. */ | ||
| const DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM = "devframe_viewer_origin_token"; | ||
| /** | ||
@@ -73,2 +80,2 @@ * Prefix that marks an RPC method as callable before a connection is | ||
| //#endregion | ||
| export { ANONYMOUS_RPC_PREFIX, DEVFRAME_AUTH_TOKEN_QUERY_PARAM, DEVFRAME_CONNECTION_KEY, DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DIRNAME, DEVFRAME_DOCK_IMPORTS_FILENAME, DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID, DEVFRAME_MCP_ROUTE, DEVFRAME_MOUNT_PATH, DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH, DEVFRAME_OTP_URL_PARAM, DEVFRAME_RPC_DUMP_DIRNAME, DEVFRAME_RPC_DUMP_MANIFEST_FILENAME, DEVFRAME_WS_ROUTE, REMOTE_CONNECTION_KEY, isAnonymousRpcMethod }; | ||
| export { ANONYMOUS_RPC_PREFIX, DEVFRAME_AUTH_TOKEN_QUERY_PARAM, DEVFRAME_CONNECTION_KEY, DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DIRNAME, DEVFRAME_DOCK_IMPORTS_FILENAME, DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID, DEVFRAME_MCP_ROUTE, DEVFRAME_MOUNT_PATH, DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH, DEVFRAME_OTP_URL_PARAM, DEVFRAME_RPC_DUMP_DIRNAME, DEVFRAME_RPC_DUMP_MANIFEST_FILENAME, DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM, DEVFRAME_WS_ROUTE, REMOTE_CONNECTION_KEY, isAnonymousRpcMethod }; |
+15
-14
@@ -1,2 +0,2 @@ | ||
| import { d as McpRouteOptions, p as DevframeAuthHandler, r as DevframeDefinition } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { d as McpRouteOptions, p as DevframeAuthHandler, r as DevframeDefinition } from "../devframe-qgCKL683.mjs"; | ||
| //#region src/helpers/vite.d.ts | ||
@@ -34,12 +34,13 @@ interface ViteDevBridgeOptions { | ||
| /** | ||
| * Whether the bridged devframe runs its own auth gate. This is a **hosted** | ||
| * adapter — the devframe shares the host app's origin and the host owns | ||
| * authentication — so it defaults to `false`: the plugin's own gate never | ||
| * fires and its `cli.auth` default is ignored (matching devframe's | ||
| * hosted-deployment contract). Pass `true` to force devframe's interactive | ||
| * OTP gate on, or a {@link DevframeAuthHandler} to install a custom scheme. | ||
| * Only applies in bridge mode (`devMiddleware`); the static-mount mode | ||
| * Whether the bridged devframe runs its own auth gate. The side-car RPC | ||
| * server is reachable by anything that can open its socket, so it **gates by | ||
| * default**: when unset, authentication resolves through `createDevServer` | ||
| * (devframe's interactive OTP gate unless the definition's `cli.auth` opts | ||
| * out), and the side-car prints its code/link banner to stdout. Pass a | ||
| * {@link DevframeAuthHandler} to install a custom scheme, or `false` to opt | ||
| * out for a single-user localhost host that owns the trust boundary another | ||
| * way. Only applies in bridge mode (`devMiddleware`); the static-mount mode | ||
| * starts no RPC server. | ||
| * | ||
| * @default false | ||
| * @default gated (devframe's interactive OTP, unless `cli.auth` opts out) | ||
| */ | ||
@@ -88,7 +89,7 @@ auth?: boolean | DevframeAuthHandler; | ||
| * | ||
| * As a hosted adapter the bridge defers authentication to the host: its | ||
| * side-car RPC server runs with the plugin's own auth gate **off** by | ||
| * default (ignoring `def.cli?.auth`), so a plugin mounted this way never | ||
| * triggers its standalone OTP prompt. Opt back in per-mount with | ||
| * `options.auth` (`true` for devframe's interactive gate, or a handler). | ||
| * The side-car RPC server **gates by default** (devframe's interactive OTP | ||
| * unless the definition's `cli.auth` opts out), printing its code/link banner | ||
| * to stdout, so a bridged devframe isn't silently reachable by anything that | ||
| * can open its socket. Pass `options.auth: false` to opt out for a single-user | ||
| * localhost host, or a {@link DevframeAuthHandler} for a custom scheme. | ||
| * | ||
@@ -95,0 +96,0 @@ * Use bridge mode when integrating with frameworks that own the SPA |
@@ -5,3 +5,3 @@ import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from "../constants.mjs"; | ||
| import { serveStaticNodeMiddleware } from "../utils/serve-static.mjs"; | ||
| import { n as resolveDevServerPort, r as resolveMcpConnectionMeta, t as createDevServer } from "../dev-D4M7Veo7.mjs"; | ||
| import { n as resolveDevServerPort, r as resolveMcpConnectionMeta, t as createDevServer } from "../dev-BSHFZGZr.mjs"; | ||
| import { resolve } from "pathe"; | ||
@@ -23,7 +23,7 @@ //#region src/helpers/vite.ts | ||
| * | ||
| * As a hosted adapter the bridge defers authentication to the host: its | ||
| * side-car RPC server runs with the plugin's own auth gate **off** by | ||
| * default (ignoring `def.cli?.auth`), so a plugin mounted this way never | ||
| * triggers its standalone OTP prompt. Opt back in per-mount with | ||
| * `options.auth` (`true` for devframe's interactive gate, or a handler). | ||
| * The side-car RPC server **gates by default** (devframe's interactive OTP | ||
| * unless the definition's `cli.auth` opts out), printing its code/link banner | ||
| * to stdout, so a bridged devframe isn't silently reachable by anything that | ||
| * can open its socket. Pass `options.auth: false` to opt out for a single-user | ||
| * localhost host, or a {@link DevframeAuthHandler} for a custom scheme. | ||
| * | ||
@@ -63,3 +63,3 @@ * Use bridge mode when integrating with frameworks that own the SPA | ||
| openBrowser: false, | ||
| auth: options.auth ?? false, | ||
| auth: options.auth, | ||
| mcp: options.mcp | ||
@@ -66,0 +66,0 @@ }); |
+2
-2
@@ -1,5 +0,5 @@ | ||
| import { $ as DevframeRpcServerFunctions, A as DevframeSettingsStore, At as DevframeAgentHostEvents, B as DevframeDefineDiagnosticsOptions, C as DevframeServicesHost, Ct as AgentResourceContent, D as DevframeScopedStreamingHost, Dt as AgentToolProvider, E as DevframeScopedNodeRpc, Et as AgentToolInput, F as ScopedSharedStates, G as RpcBroadcastOptions, H as DevframeDiagnosticsHost, I as SettingsForNamespace, J as RpcSharedStateHost, K as RpcFunctionsHost, L as DevframeViewHost, M as ScopedClientFunctions, Mt as EventUnsubscribe, N as ScopedRpcFn, Nt as EventsMap, O as DevframeSettings, Ot as AgentToolProviderHandle, P as ScopedServerFunctions, Q as DevframeRpcClientFunctions, R as DevframeHost, S as DevframeServiceOf, St as AgentResource, T as DevframeScopedNodeContext, Tt as AgentTool, U as DevframeDiagnosticsLogger, V as DevframeDiagnosticsDefinition, W as DevframeNodeRpcSession, X as RpcStreamingChannelOptions, Y as RpcStreamingChannel, Z as RpcStreamingHost, _ as ConnectionMeta, a as DevframeDockDefaults, b as DevframeNodeContext, bt as AgentHandle, c as DevframeSetupInfo, d as McpRouteOptions, et as DevframeRpcSharedStates, f as defineDevframe, g as Thenable, h as PartialWithoutId, i as DevframeDeploymentKind, j as ScopedBroadcastOptions, jt as EventEmitter, k as DevframeSettingsRegistry, kt as DevframeAgentHost, l as DevframeSpaOptions, m as EntriesToObject, n as DevframeCliOptions, o as DevframeDuplicationStrategy, q as RpcSharedStateGetOptions, r as DevframeDefinition, s as DevframeRuntime, t as DevframeBrowserContext, u as DevframeWsOptions, v as ConnectionMetaWebsocket, w as DevframeServicesRegistry, wt as AgentResourceInput, x as DevframeServiceId, xt as AgentManifest, y as DevframeCapabilities, z as DevframeStorageScope } from "./devframe-Dsjn_Xtq.mjs"; | ||
| import { $ as DevframeRpcServerFunctions, A as DevframeSettingsStore, At as DevframeAgentHostEvents, B as DevframeDefineDiagnosticsOptions, C as DevframeServicesHost, Ct as AgentResourceContent, D as DevframeScopedStreamingHost, Dt as AgentToolProvider, E as DevframeScopedNodeRpc, Et as AgentToolInput, F as ScopedSharedStates, G as RpcBroadcastOptions, H as DevframeDiagnosticsHost, I as SettingsForNamespace, J as RpcSharedStateHost, K as RpcFunctionsHost, L as DevframeViewHost, M as ScopedClientFunctions, Mt as EventUnsubscribe, N as ScopedRpcFn, Nt as EventsMap, O as DevframeSettings, Ot as AgentToolProviderHandle, P as ScopedServerFunctions, Q as DevframeRpcClientFunctions, R as DevframeHost, S as DevframeServiceOf, St as AgentResource, T as DevframeScopedNodeContext, Tt as AgentTool, U as DevframeDiagnosticsLogger, V as DevframeDiagnosticsDefinition, W as DevframeNodeRpcSession, X as RpcStreamingChannelOptions, Y as RpcStreamingChannel, Z as RpcStreamingHost, _ as ConnectionMeta, a as DevframeDockDefaults, b as DevframeNodeContext, bt as AgentHandle, c as DevframeSetupInfo, d as McpRouteOptions, et as DevframeRpcSharedStates, f as defineDevframe, g as Thenable, h as PartialWithoutId, i as DevframeDeploymentKind, j as ScopedBroadcastOptions, jt as EventEmitter, k as DevframeSettingsRegistry, kt as DevframeAgentHost, l as DevframeSpaOptions, m as EntriesToObject, n as DevframeCliOptions, o as DevframeDuplicationStrategy, q as RpcSharedStateGetOptions, r as DevframeDefinition, s as DevframeRuntime, t as DevframeBrowserContext, u as DevframeWsOptions, v as ConnectionMetaWebsocket, w as DevframeServicesRegistry, wt as AgentResourceInput, x as DevframeServiceId, xt as AgentManifest, y as DevframeCapabilities, z as DevframeStorageScope } from "./devframe-qgCKL683.mjs"; | ||
| import { C as RpcFunctionType, T as RpcReturnSchema, _ as RpcFunctionDefinition, g as RpcFunctionAgentOptions, i as RpcArgsSchema } from "./types-CnJSgRVa.mjs"; | ||
| import "./index-Dbw1p5ch.mjs"; | ||
| import { t as DevframeNodeRpcSessionMeta } from "./ws-server-D_Vjtums.mjs"; | ||
| import { n as DevframeNodeRpcSessionMeta } from "./ws-server-J-zkiOyp.mjs"; | ||
| //#region src/define.d.ts | ||
@@ -6,0 +6,0 @@ declare const defineRpcFunction: <NAME extends string, TYPE extends RpcFunctionType, ARGS extends any[], RETURN = void, const AS extends RpcArgsSchema | undefined = undefined, const RS extends RpcReturnSchema | undefined = undefined>(definition: RpcFunctionDefinition<NAME, TYPE, ARGS, RETURN, AS, RS, DevframeNodeContext>) => RpcFunctionDefinition<NAME, TYPE, ARGS, RETURN, AS, RS, DevframeNodeContext>; |
@@ -1,3 +0,3 @@ | ||
| import { p as DevframeAuthHandler } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { a as verifyAuthToken, i as refreshTempAuthCode, n as exchangeTempAuthCode, o as revokeActiveConnectionsForToken, r as getTempAuthCode, s as revokeAuthToken, t as buildOtpAuthUrl } from "../index-CeBbry0R.mjs"; | ||
| import { p as DevframeAuthHandler } from "../devframe-qgCKL683.mjs"; | ||
| import { a as verifyAuthToken, i as refreshTempAuthCode, n as exchangeTempAuthCode, o as revokeActiveConnectionsForToken, r as getTempAuthCode, s as revokeAuthToken, t as buildOtpAuthUrl } from "../index-B7RgLw--.mjs"; | ||
| export { DevframeAuthHandler, buildOtpAuthUrl, exchangeTempAuthCode, getTempAuthCode, refreshTempAuthCode, revokeActiveConnectionsForToken, revokeAuthToken, verifyAuthToken }; |
@@ -1,3 +0,3 @@ | ||
| import { n as revokeAuthToken, t as revokeActiveConnectionsForToken } from "../revoke-BtQDKTp7.mjs"; | ||
| import { a as verifyAuthToken, i as refreshTempAuthCode, n as exchangeTempAuthCode, r as getTempAuthCode, t as buildOtpAuthUrl } from "../state-WX3HT5a3.mjs"; | ||
| import { n as revokeAuthToken, t as revokeActiveConnectionsForToken } from "../revoke-DZbJ7Cl0.mjs"; | ||
| import { a as verifyAuthToken, i as refreshTempAuthCode, n as exchangeTempAuthCode, r as getTempAuthCode, t as buildOtpAuthUrl } from "../state-BeUHDDjk.mjs"; | ||
| export { buildOtpAuthUrl, exchangeTempAuthCode, getTempAuthCode, refreshTempAuthCode, revokeActiveConnectionsForToken, revokeAuthToken, verifyAuthToken }; |
@@ -1,3 +0,3 @@ | ||
| import { i as DevframeDeploymentKind, r as DevframeDefinition } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { a as internalContextMap, i as getInternalContext, n as InternalAnonymousAuthStorage, r as RemoteTokenRecord, t as DevframeInternalContext } from "../context-7BbaIUSI.mjs"; | ||
| import { i as DevframeDeploymentKind, r as DevframeDefinition } from "../devframe-qgCKL683.mjs"; | ||
| import { a as internalContextMap, i as getInternalContext, n as InternalAnonymousAuthStorage, r as RemoteTokenRecord, t as DevframeInternalContext } from "../context-DFzmxCLa.mjs"; | ||
| //#region src/adapters/_shared.d.ts | ||
@@ -4,0 +4,0 @@ /** |
@@ -1,3 +0,3 @@ | ||
| import { n as internalContextMap, t as getInternalContext } from "../context-C9Cgm1hP.mjs"; | ||
| import { n as internalContextMap, t as getInternalContext } from "../context-CQefP2jR.mjs"; | ||
| import { n as resolveBasePath, t as normalizeBasePath } from "../_shared-bWRzeSa0.mjs"; | ||
| export { getInternalContext, internalContextMap, normalizeBasePath, resolveBasePath }; |
@@ -1,5 +0,5 @@ | ||
| import { At as DevframeAgentHostEvents, C as DevframeServicesHost, Ct as AgentResourceContent, Dt as AgentToolProvider, Et as AgentToolInput, H as DevframeDiagnosticsHost$1, J as RpcSharedStateHost, K as RpcFunctionsHost, L as DevframeViewHost$1, O as DevframeSettings, Ot as AgentToolProviderHandle, R as DevframeHost, S as DevframeServiceOf, St as AgentResource, T as DevframeScopedNodeContext, Tt as AgentTool, U as DevframeDiagnosticsLogger, Z as RpcStreamingHost, b as DevframeNodeContext, bt as AgentHandle, ht as SharedState, jt as EventEmitter, kt as DevframeAgentHost$1, wt as AgentResourceInput, x as DevframeServiceId, xt as AgentManifest } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { At as DevframeAgentHostEvents, C as DevframeServicesHost, Ct as AgentResourceContent, Dt as AgentToolProvider, Et as AgentToolInput, H as DevframeDiagnosticsHost$1, J as RpcSharedStateHost, K as RpcFunctionsHost, L as DevframeViewHost$1, O as DevframeSettings, Ot as AgentToolProviderHandle, R as DevframeHost, S as DevframeServiceOf, St as AgentResource, T as DevframeScopedNodeContext, Tt as AgentTool, U as DevframeDiagnosticsLogger, Z as RpcStreamingHost, b as DevframeNodeContext, bt as AgentHandle, ht as SharedState, jt as EventEmitter, kt as DevframeAgentHost$1, wt as AgentResourceInput, x as DevframeServiceId, xt as AgentManifest } from "../devframe-qgCKL683.mjs"; | ||
| import { v as RpcFunctionDefinitionAny } from "../types-CnJSgRVa.mjs"; | ||
| import "../index-Dbw1p5ch.mjs"; | ||
| import { n as StartedServer, r as startHttpAndWs, t as StartHttpAndWsOptions } from "../server-VLQJouOO.mjs"; | ||
| import { n as StartedServer, r as startHttpAndWs, t as StartHttpAndWsOptions } from "../server-E9z4ZWbd.mjs"; | ||
| import { BirpcGroup } from "birpc"; | ||
@@ -235,2 +235,22 @@ //#region src/node/agent-args.d.ts | ||
| }): DevframeInstanceRegistration; | ||
| /** | ||
| * Read the registry and split records into live and dead by probing each | ||
| * one's `__connection.json`, deleting dead records (prune-on-read). Live | ||
| * records carry the dialable origin the probe confirmed (a `localhost` | ||
| * record may come back as `127.0.0.1` / `[::1]`). | ||
| * | ||
| * A liveness probe only proves *something* answers on the record's port, so | ||
| * records left behind by killed processes shadow the server currently bound | ||
| * there: per `(port, basePath)` only the newest record survives, older | ||
| * ghosts are pruned with the dead. | ||
| * | ||
| * @experimental | ||
| */ | ||
| declare function listLiveDevframeInstances(options?: { | ||
| instancesDir?: string; | ||
| timeoutMs?: number; | ||
| }): Promise<{ | ||
| live: DevframeInstanceRecord[]; | ||
| pruned: DevframeInstanceRecord[]; | ||
| }>; | ||
| //#endregion | ||
@@ -282,2 +302,2 @@ //#region src/node/rpc-shared-state.d.ts | ||
| //#endregion | ||
| export { AgentArgsFallback, CreateH3DevframeHostOptions, CreateHostContextOptions, CreateStorageOptions, DevframeAgentHost, DevframeDiagnosticsHost, type DevframeInstanceRecord, type DevframeInstanceRegistration, DevframeServicesHostImpl, DevframeViewHost, type RpcFunctionsHost, StartHttpAndWsOptions, StartedServer, coerceAgentPositionalArgs, createH3DevframeHost, createHostContext, createNodeSettings, createRpcSharedStateServerHost, createRpcStreamingServerHost, createScopedNodeContext, createStorage, formatHostForUrl, isObject, normalizeHttpServerUrl, registerDevframeInstance, startHttpAndWs, toDialableHost }; | ||
| export { AgentArgsFallback, CreateH3DevframeHostOptions, CreateHostContextOptions, CreateStorageOptions, DevframeAgentHost, DevframeDiagnosticsHost, type DevframeInstanceRecord, type DevframeInstanceRegistration, DevframeServicesHostImpl, DevframeViewHost, type RpcFunctionsHost, StartHttpAndWsOptions, StartedServer, coerceAgentPositionalArgs, createH3DevframeHost, createHostContext, createNodeSettings, createRpcSharedStateServerHost, createRpcStreamingServerHost, createScopedNodeContext, createStorage, formatHostForUrl, isObject, listLiveDevframeInstances, normalizeHttpServerUrl, registerDevframeInstance, startHttpAndWs, toDialableHost }; |
| import { a as DevframeViewHost, c as createRpcSharedStateServerHost, d as coerceAgentPositionalArgs, i as createNodeSettings, l as DevframeDiagnosticsHost, n as createHostContext, o as DevframeServicesHostImpl, r as createScopedNodeContext, s as createRpcStreamingServerHost, t as createH3DevframeHost, u as DevframeAgentHost } from "../host-h3-Kz7t5Xab.mjs"; | ||
| import { t as createStorage } from "../storage-Dzoc3NVC.mjs"; | ||
| import { r as registerDevframeInstance } from "../instance-registry-D6fxYu36.mjs"; | ||
| import { a as toDialableHost, i as normalizeHttpServerUrl, n as formatHostForUrl, r as isObject, t as startHttpAndWs } from "../server-CcAPxuoT.mjs"; | ||
| export { DevframeAgentHost, DevframeDiagnosticsHost, DevframeServicesHostImpl, DevframeViewHost, coerceAgentPositionalArgs, createH3DevframeHost, createHostContext, createNodeSettings, createRpcSharedStateServerHost, createRpcStreamingServerHost, createScopedNodeContext, createStorage, formatHostForUrl, isObject, normalizeHttpServerUrl, registerDevframeInstance, startHttpAndWs, toDialableHost }; | ||
| import { r as registerDevframeInstance, t as listLiveDevframeInstances } from "../instance-registry-D6fxYu36.mjs"; | ||
| import { a as toDialableHost, i as normalizeHttpServerUrl, n as formatHostForUrl, r as isObject, t as startHttpAndWs } from "../server-CXaDDUl0.mjs"; | ||
| export { DevframeAgentHost, DevframeDiagnosticsHost, DevframeServicesHostImpl, DevframeViewHost, coerceAgentPositionalArgs, createH3DevframeHost, createHostContext, createNodeSettings, createRpcSharedStateServerHost, createRpcStreamingServerHost, createScopedNodeContext, createStorage, formatHostForUrl, isObject, listLiveDevframeInstances, normalizeHttpServerUrl, registerDevframeInstance, startHttpAndWs, toDialableHost }; |
@@ -1,2 +0,2 @@ | ||
| import "../devframe-Dsjn_Xtq.mjs"; | ||
| import "../devframe-qgCKL683.mjs"; | ||
| import { E as Thenable, S as RpcFunctionSetupResult, c as RpcDump, g as RpcFunctionAgentOptions } from "../types-CnJSgRVa.mjs"; | ||
@@ -3,0 +3,0 @@ import "../index-Dbw1p5ch.mjs"; |
@@ -1,3 +0,3 @@ | ||
| import { b as DevframeNodeContext, p as DevframeAuthHandler } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import "../index-CeBbry0R.mjs"; | ||
| import { b as DevframeNodeContext, p as DevframeAuthHandler } from "../devframe-qgCKL683.mjs"; | ||
| import "../index-B7RgLw--.mjs"; | ||
| //#region src/recipes/interactive-auth.d.ts | ||
@@ -4,0 +4,0 @@ interface CreateInteractiveAuthOptions { |
| import { n as colors } from "../diagnostics-reporter-CsIG85Q5.mjs"; | ||
| import { isAnonymousRpcMethod } from "../constants.mjs"; | ||
| import { n as defineRpcFunction } from "../define-BLWPsH6y.mjs"; | ||
| import { t as getInternalContext } from "../context-C9Cgm1hP.mjs"; | ||
| import { a as verifyAuthToken, n as exchangeTempAuthCode, r as getTempAuthCode, t as buildOtpAuthUrl } from "../state-WX3HT5a3.mjs"; | ||
| import { t as getInternalContext } from "../context-CQefP2jR.mjs"; | ||
| import { a as verifyAuthToken, n as exchangeTempAuthCode, r as getTempAuthCode, t as buildOtpAuthUrl } from "../state-BeUHDDjk.mjs"; | ||
| import { t as s } from "../simple-schema-DQPZrAaZ.mjs"; | ||
@@ -110,5 +110,9 @@ //#region src/recipes/interactive-auth.ts | ||
| let token; | ||
| let requestOrigin; | ||
| try { | ||
| token = new URL(peer.request?.url ?? "", "http://localhost").searchParams.get("devframe_auth_token") ?? void 0; | ||
| } catch {} | ||
| try { | ||
| requestOrigin = peer.request?.headers?.get?.("origin") ?? void 0; | ||
| } catch {} | ||
| if (!token) return; | ||
@@ -120,3 +124,7 @@ if (isStaticToken(token)) { | ||
| } | ||
| verifyAuthToken(token, session, storage); | ||
| if (verifyAuthToken(token, session, storage)) return; | ||
| if (internal.isRemoteTokenTrusted(token, requestOrigin)) { | ||
| session.meta.clientAuthToken = token; | ||
| session.meta.isTrusted = true; | ||
| } | ||
| } | ||
@@ -123,0 +131,0 @@ function buildOpenUrl(url) { |
@@ -1,2 +0,2 @@ | ||
| import { a as isAllowedOrigin, i as attachWsRpcTransport, n as WsRpcTransport, o as isLoopbackHostname, r as WsRpcTransportOptions, t as DevframeNodeRpcSessionMeta } from "../../ws-server-D_Vjtums.mjs"; | ||
| export { DevframeNodeRpcSessionMeta, WsRpcTransport, WsRpcTransportOptions, attachWsRpcTransport, isAllowedOrigin, isLoopbackHostname }; | ||
| import { a as WsRpcTransportOptions, c as isAllowedOrigin, i as WsRpcTransport, l as isLoopbackHostname, n as DevframeNodeRpcSessionMeta, o as attachWsRpcTransport, r as WsOriginRegistry, s as createWsOriginRegistry, t as CreateWsOriginRegistryOptions } from "../../ws-server-J-zkiOyp.mjs"; | ||
| export { CreateWsOriginRegistryOptions, DevframeNodeRpcSessionMeta, WsOriginRegistry, WsRpcTransport, WsRpcTransportOptions, attachWsRpcTransport, createWsOriginRegistry, isAllowedOrigin, isLoopbackHostname }; |
@@ -0,3 +1,5 @@ | ||
| import "../../constants.mjs"; | ||
| import { n as strictJsonStringify } from "../../serialization-C8Mnw9hK.mjs"; | ||
| import { n as structuredCloneStringify, t as structuredCloneParse } from "../../structured-clone-CbAV5rFI.mjs"; | ||
| import { n as randomToken, r as timingSafeEqual } from "../../crypto-token-XCqTSMg9.mjs"; | ||
| import { createServer } from "node:http"; | ||
@@ -7,2 +9,43 @@ import { createServer as createServer$1 } from "node:https"; | ||
| //#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]); | ||
| } | ||
| }; | ||
| } | ||
| let sessionId = 0; | ||
@@ -36,2 +79,5 @@ const EMPTY_DEFS = /* @__PURE__ */ new Map(); | ||
| } | ||
| function isWsOriginRegistry(value) { | ||
| return !!value && !Array.isArray(value); | ||
| } | ||
| /** | ||
@@ -60,3 +106,4 @@ * Route `upgrade` events on a server to the crossws adapter, optionally | ||
| } | ||
| if (allowedOrigins !== false && !isAllowedOrigin(req.headers.origin, allowedOrigins ?? [])) { | ||
| const originAllowed = isWsOriginRegistry(allowedOrigins) ? allowedOrigins.isAllowed(req.headers.origin) : isAllowedOrigin(req.headers.origin, allowedOrigins || []); | ||
| if (allowedOrigins !== false && !originAllowed) { | ||
| socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); | ||
@@ -170,2 +217,2 @@ socket.destroy(); | ||
| //#endregion | ||
| export { attachWsRpcTransport, isAllowedOrigin, isLoopbackHostname }; | ||
| export { attachWsRpcTransport, createWsOriginRegistry, isAllowedOrigin, isLoopbackHostname }; |
@@ -1,4 +0,4 @@ | ||
| import { $ as DevframeRpcServerFunctions, A as DevframeSettingsStore, At as DevframeAgentHostEvents, B as DevframeDefineDiagnosticsOptions, C as DevframeServicesHost, Ct as AgentResourceContent, D as DevframeScopedStreamingHost, Dt as AgentToolProvider, E as DevframeScopedNodeRpc, Et as AgentToolInput, F as ScopedSharedStates, G as RpcBroadcastOptions, H as DevframeDiagnosticsHost, I as SettingsForNamespace, J as RpcSharedStateHost, K as RpcFunctionsHost, L as DevframeViewHost, M as ScopedClientFunctions, Mt as EventUnsubscribe, N as ScopedRpcFn, Nt as EventsMap, O as DevframeSettings, Ot as AgentToolProviderHandle, P as ScopedServerFunctions, Q as DevframeRpcClientFunctions, R as DevframeHost, S as DevframeServiceOf, St as AgentResource, T as DevframeScopedNodeContext, Tt as AgentTool, U as DevframeDiagnosticsLogger, V as DevframeDiagnosticsDefinition, W as DevframeNodeRpcSession, X as RpcStreamingChannelOptions, Y as RpcStreamingChannel, Z as RpcStreamingHost, _ as ConnectionMeta, a as DevframeDockDefaults, b as DevframeNodeContext, bt as AgentHandle, c as DevframeSetupInfo, d as McpRouteOptions, et as DevframeRpcSharedStates, f as defineDevframe, g as Thenable, h as PartialWithoutId, i as DevframeDeploymentKind, j as ScopedBroadcastOptions, jt as EventEmitter, k as DevframeSettingsRegistry, kt as DevframeAgentHost, l as DevframeSpaOptions, m as EntriesToObject, n as DevframeCliOptions, o as DevframeDuplicationStrategy, q as RpcSharedStateGetOptions, r as DevframeDefinition, s as DevframeRuntime, t as DevframeBrowserContext, u as DevframeWsOptions, v as ConnectionMetaWebsocket, w as DevframeServicesRegistry, wt as AgentResourceInput, x as DevframeServiceId, xt as AgentManifest, y as DevframeCapabilities, z as DevframeStorageScope } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { $ as DevframeRpcServerFunctions, A as DevframeSettingsStore, At as DevframeAgentHostEvents, B as DevframeDefineDiagnosticsOptions, C as DevframeServicesHost, Ct as AgentResourceContent, D as DevframeScopedStreamingHost, Dt as AgentToolProvider, E as DevframeScopedNodeRpc, Et as AgentToolInput, F as ScopedSharedStates, G as RpcBroadcastOptions, H as DevframeDiagnosticsHost, I as SettingsForNamespace, J as RpcSharedStateHost, K as RpcFunctionsHost, L as DevframeViewHost, M as ScopedClientFunctions, Mt as EventUnsubscribe, N as ScopedRpcFn, Nt as EventsMap, O as DevframeSettings, Ot as AgentToolProviderHandle, P as ScopedServerFunctions, Q as DevframeRpcClientFunctions, R as DevframeHost, S as DevframeServiceOf, St as AgentResource, T as DevframeScopedNodeContext, Tt as AgentTool, U as DevframeDiagnosticsLogger, V as DevframeDiagnosticsDefinition, W as DevframeNodeRpcSession, X as RpcStreamingChannelOptions, Y as RpcStreamingChannel, Z as RpcStreamingHost, _ as ConnectionMeta, a as DevframeDockDefaults, b as DevframeNodeContext, bt as AgentHandle, c as DevframeSetupInfo, d as McpRouteOptions, et as DevframeRpcSharedStates, f as defineDevframe, g as Thenable, h as PartialWithoutId, i as DevframeDeploymentKind, j as ScopedBroadcastOptions, jt as EventEmitter, k as DevframeSettingsRegistry, kt as DevframeAgentHost, l as DevframeSpaOptions, m as EntriesToObject, n as DevframeCliOptions, o as DevframeDuplicationStrategy, q as RpcSharedStateGetOptions, r as DevframeDefinition, s as DevframeRuntime, t as DevframeBrowserContext, u as DevframeWsOptions, v as ConnectionMetaWebsocket, w as DevframeServicesRegistry, wt as AgentResourceInput, x as DevframeServiceId, xt as AgentManifest, y as DevframeCapabilities, z as DevframeStorageScope } from "../devframe-qgCKL683.mjs"; | ||
| import { g as RpcFunctionAgentOptions } from "../types-CnJSgRVa.mjs"; | ||
| import { t as DevframeNodeRpcSessionMeta } from "../ws-server-D_Vjtums.mjs"; | ||
| import { n as DevframeNodeRpcSessionMeta } from "../ws-server-J-zkiOyp.mjs"; | ||
| export { AgentHandle, AgentManifest, AgentResource, AgentResourceContent, AgentResourceInput, AgentTool, AgentToolInput, AgentToolProvider, AgentToolProviderHandle, ConnectionMeta, ConnectionMetaWebsocket, DevframeAgentHost, DevframeAgentHostEvents, DevframeBrowserContext, DevframeCapabilities, DevframeCliOptions, DevframeDefineDiagnosticsOptions, DevframeDefinition, DevframeDeploymentKind, DevframeDiagnosticsDefinition, DevframeDiagnosticsHost, DevframeDiagnosticsLogger, DevframeDockDefaults, DevframeDuplicationStrategy, DevframeHost, DevframeNodeContext, DevframeNodeRpcSession, type DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions, DevframeRpcSharedStates, DevframeRuntime, DevframeScopedNodeContext, DevframeScopedNodeRpc, DevframeScopedStreamingHost, DevframeServiceId, DevframeServiceOf, DevframeServicesHost, DevframeServicesRegistry, DevframeSettings, DevframeSettingsRegistry, DevframeSettingsStore, DevframeSetupInfo, DevframeSpaOptions, DevframeStorageScope, DevframeViewHost, DevframeWsOptions, EntriesToObject, EventEmitter, EventUnsubscribe, EventsMap, McpRouteOptions, PartialWithoutId, RpcBroadcastOptions, type RpcFunctionAgentOptions, RpcFunctionsHost, RpcSharedStateGetOptions, RpcSharedStateHost, RpcStreamingChannel, RpcStreamingChannelOptions, RpcStreamingHost, ScopedBroadcastOptions, ScopedClientFunctions, ScopedRpcFn, ScopedServerFunctions, ScopedSharedStates, SettingsForNamespace, Thenable, defineDevframe }; |
@@ -1,2 +0,2 @@ | ||
| import { Nt as EventsMap, jt as EventEmitter } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { Nt as EventsMap, jt as EventEmitter } from "../devframe-qgCKL683.mjs"; | ||
| //#region src/utils/events.d.ts | ||
@@ -3,0 +3,0 @@ /** |
@@ -1,2 +0,2 @@ | ||
| import { _t as SharedStateOptions, dt as ImmutableArray, ft as ImmutableMap, gt as SharedStateEvents, ht as SharedState, mt as ImmutableSet, pt as ImmutableObject, ut as Immutable, vt as SharedStatePatch, yt as createSharedState } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { _t as SharedStateOptions, dt as ImmutableArray, ft as ImmutableMap, gt as SharedStateEvents, ht as SharedState, mt as ImmutableSet, pt as ImmutableObject, ut as Immutable, vt as SharedStatePatch, yt as createSharedState } from "../devframe-qgCKL683.mjs"; | ||
| export { Immutable, ImmutableArray, ImmutableMap, ImmutableObject, ImmutableSet, SharedState, SharedStateEvents, SharedStateOptions, SharedStatePatch, createSharedState }; |
@@ -1,2 +0,2 @@ | ||
| import { at as StreamReader, ct as createStreamReader, it as StreamErrorPayload, lt as createStreamSink, nt as CreateStreamReaderOptions, ot as StreamSink, rt as CreateStreamSinkOptions, st as StreamSinkEvents, tt as BufferedChunk } from "../devframe-Dsjn_Xtq.mjs"; | ||
| import { at as StreamReader, ct as createStreamReader, it as StreamErrorPayload, lt as createStreamSink, nt as CreateStreamReaderOptions, ot as StreamSink, rt as CreateStreamSinkOptions, st as StreamSinkEvents, tt as BufferedChunk } from "../devframe-qgCKL683.mjs"; | ||
| export { BufferedChunk, CreateStreamReaderOptions, CreateStreamSinkOptions, StreamErrorPayload, StreamReader, StreamSink, StreamSinkEvents, createStreamReader, createStreamSink }; |
+1
-1
| { | ||
| "name": "devframe", | ||
| "type": "module", | ||
| "version": "0.8.0", | ||
| "version": "0.8.1", | ||
| "description": "Framework for building generic devframes", | ||
@@ -6,0 +6,0 @@ "author": "Anthony Fu <anthonyfu117@hotmail.com>", |
@@ -572,6 +572,7 @@ --- | ||
| - **Authentication** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token via `requestTrustWithCode(code)`. The code is single-use, expires in 5 min, compared in constant time, and rotates after repeated failures — show it only in the terminal, never over the network. | ||
| - **Magic-link (optional):** print `buildOtpAuthUrl(origin)` — `<origin>/?devframe_otp=<code>`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. The standalone CLI's `--open` does this automatically via `DevframeAuthHandler.buildOpenUrl` — the launched tab already carries the OTP, no prompt needed. | ||
| - **Magic-link (optional):** print `buildOtpAuthUrl(origin)` — `<origin>/#devframe_otp=<code>`. The code rides the URL **fragment**, which the browser never sends to the server, so it stays out of access logs and `Referer`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. The standalone CLI's `--open` does this automatically via `DevframeAuthHandler.buildOpenUrl` — the launched tab already carries the OTP, no prompt needed. | ||
| - **Tokens are secrets.** The bearer token rides the WS URL (`?devframe_auth_token=…`) — serve over `wss://`/`https://` beyond loopback. Never log the token or code, never bake them into build output. Revoke via `revokeAuthToken(...)`; clients drop to untrusted on `devframe:auth:revoked`. | ||
| - **Authorize handlers.** Any trusted client can call any registered function — validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first. | ||
| - **Origin-lock remote docks** (`originLock`) so a dock token is honored only from its expected origin. | ||
| - **Origin-lock remote docks** (`originLock`, on by default) so a dock's session token is honored only on a connection whose `Origin` matches the dock — the connect-time gate enforces it. | ||
| - **The MCP route requires an Origin.** The route-based MCP server (`cli.mcp`, `viteDevBridge`/Next handler `mcp`, `createMcpFetchHandler`) rejects `Origin`-less requests — a request must carry a loopback (or allow-listed) `Origin`, so it isn't reachable by an arbitrary local process. `devframe connect` sends each instance's own loopback origin automatically. | ||
@@ -578,0 +579,0 @@ See [Security](https://devfra.me/security) for the full reference. |
| import { n as colors } from "./diagnostics-reporter-CsIG85Q5.mjs"; | ||
| import { createBuild } from "./adapters/build.mjs"; | ||
| import { n as resolveDevServerPort, t as createDevServer } from "./dev-D4M7Veo7.mjs"; | ||
| import process from "node:process"; | ||
| import cac$1 from "cac"; | ||
| //#region src/adapters/flags.ts | ||
| /** | ||
| * Identity helper that preserves the literal schema-map type — use this | ||
| * so `InferCliFlags<typeof myFlags>` resolves to the right object shape. | ||
| * | ||
| * ```ts | ||
| * const appFlags = defineCliFlags({ | ||
| * depth: v.pipe(v.number(), v.integer()), | ||
| * config: v.optional(v.string()), | ||
| * }) | ||
| * | ||
| * defineDevframe({ | ||
| * cli: { flags: appFlags }, | ||
| * setup(ctx, info) { | ||
| * const flags = info.flags as InferCliFlags<typeof appFlags> | ||
| * flags.depth // number | ||
| * flags.config // string | undefined | ||
| * }, | ||
| * }) | ||
| * ``` | ||
| */ | ||
| function defineCliFlags(flags) { | ||
| return flags; | ||
| } | ||
| /** | ||
| * Best-effort, dependency-free probe of a schema to decide whether the | ||
| * corresponding CAC option takes a value. Duck-types the `type` / | ||
| * `wrapped` / `inner` / `pipe` fields exposed by valibot and by devframe's | ||
| * built-in `s` builder, unwrapping `optional` / `nullable` / `nullish` / | ||
| * `pipe` wrappers then matching on the inner kind. Validators that don't | ||
| * expose these fields (e.g. zod) fall through to a value-taking option. | ||
| */ | ||
| function getSchemaKind(schema) { | ||
| let current = schema; | ||
| while (current) { | ||
| const kind = current.type; | ||
| if (kind === "optional" || kind === "nullable" || kind === "nullish" || kind === "undefined") { | ||
| current = current.wrapped ?? current.inner; | ||
| continue; | ||
| } | ||
| if (kind === "pipe" && Array.isArray(current.pipe) && current.pipe.length > 0) { | ||
| current = current.pipe[0]; | ||
| continue; | ||
| } | ||
| return kind ?? "unknown"; | ||
| } | ||
| return "unknown"; | ||
| } | ||
| /** Whether the CAC option for this schema should be a boolean flag. */ | ||
| function isBooleanFlag(schema) { | ||
| return getSchemaKind(schema) === "boolean"; | ||
| } | ||
| /** Validate the raw cac-parsed bag against a {@link CliFlagsSchema}. */ | ||
| function parseCliFlags(schema, raw) { | ||
| const flags = {}; | ||
| const issues = []; | ||
| for (const [key, fieldSchema] of Object.entries(schema)) { | ||
| const result = fieldSchema["~standard"].validate(raw[key]); | ||
| if (result instanceof Promise) { | ||
| issues.push(`--${toKebab(key)}: async flag validation is not supported`); | ||
| continue; | ||
| } | ||
| if (result.issues) issues.push(`--${toKebab(key)}: ${result.issues.map((i) => i.message).join(", ")}`); | ||
| else flags[key] = result.value; | ||
| } | ||
| for (const [key, value] of Object.entries(raw)) if (!(key in schema) && !(key in flags)) flags[key] = value; | ||
| return issues.length ? { | ||
| flags, | ||
| issues | ||
| } : { flags }; | ||
| } | ||
| function toKebab(camel) { | ||
| return camel.replaceAll(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); | ||
| } | ||
| /** Kebab-case a schema key for CAC option registration. */ | ||
| function flagKeyToOption(camel) { | ||
| return toKebab(camel); | ||
| } | ||
| //#endregion | ||
| //#region src/adapters/cac.ts | ||
| /** | ||
| * Wrap a {@link DevframeDefinition} in a `cac`-powered command-line | ||
| * interface exposing `dev` / `build` / `mcp` subcommands. | ||
| * | ||
| * Requires the optional `cac` peer dependency. | ||
| */ | ||
| function createCac(d, options = {}) { | ||
| const defaultPort = options.defaultPort ?? d.cli?.port ?? 9999; | ||
| const defaultHost = d.cli?.host ?? "localhost"; | ||
| const cli = cac$1(d.cli?.command ?? d.id); | ||
| const devCommand = cli.command("[...args]", "Start a local dev server").option("--port <port>", "Port to listen on").option("--host <host>", "Host to bind to", { default: defaultHost }).option("--open", "Open the browser on start").option("--no-open", "Do not open the browser").option("--no-auth", "Disable the interactive authentication gate").option("--mcp", "Expose an MCP server over HTTP at /__mcp (use --no-mcp to disable) [experimental]"); | ||
| if (d.cli?.flags) for (const [key, schema] of Object.entries(d.cli.flags)) { | ||
| const optionName = flagKeyToOption(key); | ||
| const description = schema.description ?? ""; | ||
| if (isBooleanFlag(schema)) devCommand.option(`--${optionName}`, description); | ||
| else devCommand.option(`--${optionName} <value>`, description); | ||
| } | ||
| devCommand.action(async (_args, rawFlags) => { | ||
| const flags = resolveTypedFlags(d, rawFlags); | ||
| const host = flags.host ?? defaultHost; | ||
| const port = flags.port ?? await resolveDevServerPort(d, { | ||
| host, | ||
| defaultPort | ||
| }); | ||
| const mcp = flags.mcp; | ||
| await createDevServer(d, { | ||
| host, | ||
| port, | ||
| flags, | ||
| mcp, | ||
| onReady: options.onReady | ||
| }); | ||
| }); | ||
| if (d.capabilities?.build !== false) cli.command("build", "Build a self-contained static deploy of the devframe").option("--out-dir <outDir>", "Output directory", { default: "dist-static" }).option("--base <base>", "URL base", { default: "/" }).option("--pretty", "Pretty-print dump JSON (larger on disk)").action(async (flags) => { | ||
| await createBuild(d, { | ||
| outDir: flags.outDir, | ||
| base: flags.base, | ||
| pretty: flags.pretty | ||
| }); | ||
| }); | ||
| cli.command("mcp", "Start an MCP server exposing agent-facing tools (stdio) [experimental]").action(async () => { | ||
| const { createMcpServer } = await import("./adapters/mcp.mjs"); | ||
| await createMcpServer(d, { | ||
| transport: "stdio", | ||
| onReady: ({ transport }) => { | ||
| console.error(`[devframe] "${d.id}" MCP server ready (${transport})`); | ||
| } | ||
| }); | ||
| }); | ||
| d.cli?.configure?.(cli); | ||
| options.configureCli?.(cli); | ||
| cli.help(); | ||
| cli.version("0.0.0"); | ||
| return { | ||
| cli, | ||
| async parse(argv = process.argv) { | ||
| cli.parse(argv, { run: false }); | ||
| await cli.runMatchedCommand(); | ||
| } | ||
| }; | ||
| } | ||
| function resolveTypedFlags(d, raw) { | ||
| if (!d.cli?.flags) return raw; | ||
| const { flags, issues } = parseCliFlags(d.cli.flags, raw); | ||
| if (issues?.length) { | ||
| for (const issue of issues) console.error(colors.red`[devframe] invalid flag — ${issue}`); | ||
| process.exit(1); | ||
| } | ||
| return flags; | ||
| } | ||
| //#endregion | ||
| export { defineCliFlags as n, parseCliFlags as r, createCac as t }; |
| import { b as DevframeNodeContext, ht as SharedState } from "./devframe-Dsjn_Xtq.mjs"; | ||
| //#region src/node/hub-internals/context.d.ts | ||
| interface InternalAnonymousAuthStorage { | ||
| trusted: Record<string, { | ||
| authToken: string; | ||
| ua: string; | ||
| origin: string; | ||
| timestamp: number; | ||
| } | undefined>; | ||
| } | ||
| interface RemoteTokenRecord { | ||
| dockId: string; | ||
| /** Dock URL origin — matched against WS handshake `Origin` header when `originLock` is on. */ | ||
| origin: string; | ||
| originLock: boolean; | ||
| } | ||
| interface DevframeInternalContext { | ||
| storage: { | ||
| auth: SharedState<InternalAnonymousAuthStorage>; | ||
| }; | ||
| /** | ||
| * Revoke an auth token: remove from storage and notify all connected clients | ||
| * using this token that they are no longer trusted. | ||
| */ | ||
| revokeAuthToken: (token: string) => Promise<void>; | ||
| /** | ||
| * Session-only tokens issued to remote-UI iframe docks. Not persisted — | ||
| * regenerated on every dev-server restart. | ||
| */ | ||
| remoteTokens: Map<string, RemoteTokenRecord>; | ||
| allocateRemoteToken: (dockId: string, origin: string, originLock: boolean) => string; | ||
| revokeRemoteToken: (token: string) => void; | ||
| revokeRemoteTokensForDock: (dockId: string) => void; | ||
| /** | ||
| * Returns true if `token` is a valid remote token and, when `originLock` is | ||
| * on, `requestOrigin` matches the recorded dock origin. | ||
| */ | ||
| isRemoteTokenTrusted: (token: string, requestOrigin?: string) => boolean; | ||
| /** | ||
| * Populated by `createWsServer` once the WS port is bound. Consumed by the | ||
| * docks host when enriching remote iframe URLs with a connection descriptor. | ||
| */ | ||
| wsEndpoint?: { | ||
| /** Full `ws://` or `wss://` URL with host and port. */ | ||
| url: string; | ||
| }; | ||
| } | ||
| declare const internalContextMap: WeakMap<DevframeNodeContext, DevframeInternalContext>; | ||
| declare function getInternalContext(context: DevframeNodeContext): DevframeInternalContext; | ||
| //#endregion | ||
| export { internalContextMap as a, getInternalContext as i, InternalAnonymousAuthStorage as n, RemoteTokenRecord as r, DevframeInternalContext as t }; |
| import { t as createStorage } from "./storage-Dzoc3NVC.mjs"; | ||
| import { i as randomToken, n as revokeAuthToken, t as revokeActiveConnectionsForToken } from "./revoke-BtQDKTp7.mjs"; | ||
| import { join } from "pathe"; | ||
| //#region src/node/hub-internals/context.ts | ||
| const internalContextMap = /* @__PURE__ */ new WeakMap(); | ||
| function getInternalContext(context) { | ||
| if (!internalContextMap.has(context)) { | ||
| const storage = createStorage({ | ||
| filepath: join(context.host.getStorageDir("global"), "auth.json"), | ||
| initialValue: { trusted: {} } | ||
| }); | ||
| const remoteTokens = /* @__PURE__ */ new Map(); | ||
| function revokeRemoteToken(token) { | ||
| if (!remoteTokens.delete(token)) return; | ||
| revokeActiveConnectionsForToken(context, token); | ||
| } | ||
| const internalContext = { | ||
| storage: { auth: storage }, | ||
| revokeAuthToken: (token) => revokeAuthToken(context, storage, token), | ||
| remoteTokens, | ||
| allocateRemoteToken(dockId, origin, originLock) { | ||
| const token = randomToken(); | ||
| remoteTokens.set(token, { | ||
| dockId, | ||
| origin, | ||
| originLock | ||
| }); | ||
| return token; | ||
| }, | ||
| revokeRemoteToken, | ||
| revokeRemoteTokensForDock(dockId) { | ||
| const tokensToRevoke = []; | ||
| for (const [token, record] of remoteTokens) if (record.dockId === dockId) tokensToRevoke.push(token); | ||
| for (const token of tokensToRevoke) revokeRemoteToken(token); | ||
| }, | ||
| isRemoteTokenTrusted(token, requestOrigin) { | ||
| const record = remoteTokens.get(token); | ||
| if (!record) return false; | ||
| if (!record.originLock) return true; | ||
| return !!requestOrigin && record.origin === requestOrigin; | ||
| } | ||
| }; | ||
| internalContextMap.set(context, internalContext); | ||
| } | ||
| return internalContextMap.get(context); | ||
| } | ||
| //#endregion | ||
| export { internalContextMap as n, getInternalContext as t }; |
| import { DEVFRAME_CONNECTION_META_FILENAME } from "./constants.mjs"; | ||
| import { n as createHostContext, t as createH3DevframeHost } from "./host-h3-Kz7t5Xab.mjs"; | ||
| import { t as diagnostics } from "./diagnostics-B5-qHeqD.mjs"; | ||
| import { r as registerDevframeInstance } from "./instance-registry-D6fxYu36.mjs"; | ||
| import { i as normalizeHttpServerUrl, t as startHttpAndWs } from "./server-CcAPxuoT.mjs"; | ||
| import { n as resolveBasePath, t as normalizeBasePath } from "./_shared-bWRzeSa0.mjs"; | ||
| import { t as open } from "./open-Deb5xmIT.mjs"; | ||
| import { mountStaticHandler } from "./utils/serve-static.mjs"; | ||
| import { createInteractiveAuth } from "./recipes/interactive-auth.mjs"; | ||
| import { resolve } from "pathe"; | ||
| import process$1 from "node:process"; | ||
| import { networkInterfaces } from "node:os"; | ||
| import { H3 } from "h3"; | ||
| import { createServer } from "node:net"; | ||
| import { joinURL, withBase, withLeadingSlash, withoutLeadingSlash } from "ufo"; | ||
| //#region ../../node_modules/.pnpm/get-port-please@3.2.0/node_modules/get-port-please/dist/index.mjs | ||
| 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/dev.ts | ||
| const DEFAULT_PORT = 9999; | ||
| /** | ||
| * Resolve the listening port for {@link createDevServer}, 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); | ||
| } | ||
| /** | ||
| * 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 | ||
| * `viteDevBridge({ devMiddleware })`. | ||
| * | ||
| * 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 = {}) { | ||
| const distDir = options.distDir ?? def.cli?.distDir; | ||
| const host = options.host ?? def.cli?.host ?? "localhost"; | ||
| const port = 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 h3Host = createH3DevframeHost({ | ||
| origin: normalizeHttpServerUrl(host, port), | ||
| appName: def.id, | ||
| mount: (base, dir) => { | ||
| mountStaticHandler(app, base, dir); | ||
| } | ||
| }); | ||
| const ctx = await createHostContext({ | ||
| cwd: process$1.cwd(), | ||
| mode: "dev", | ||
| host: h3Host | ||
| }); | ||
| const setupInfo = { flags }; | ||
| await def.setup(ctx, setupInfo); | ||
| const mcpConfig = resolveMcpConfig(options.mcp ?? def.cli?.mcp); | ||
| let mcpDispose; | ||
| let mcpMeta; | ||
| if (mcpConfig) { | ||
| const mcpRoute = withoutLeadingSlash(mcpConfig.path ?? "__mcp"); | ||
| const mcpPath = joinURL(basePath, mcpRoute); | ||
| let mountMcpHttp; | ||
| try { | ||
| ({mountMcpHttp} = await import("./http-BghOqfTD.mjs")); | ||
| } catch (error) { | ||
| const reason = error instanceof Error ? error.message : String(error); | ||
| throw diagnostics.DF0017({ | ||
| transport: "http", | ||
| reason, | ||
| cause: error | ||
| }); | ||
| } | ||
| mcpDispose = mountMcpHttp(app, ctx, mcpPath, { | ||
| serverName: `${def.id} (devframe)`, | ||
| serverVersion: def.version ?? "0.0.0", | ||
| exposeSharedState: true, | ||
| allowedOrigins: mcpConfig.allowedOrigins | ||
| }).dispose; | ||
| mcpMeta = { path: mcpRoute }; | ||
| } | ||
| const { bindPath, wsPort, meta } = resolveWsConnection(def, options, basePath); | ||
| const connectionMetaPath = joinURL(basePath, DEVFRAME_CONNECTION_META_FILENAME); | ||
| app.use(connectionMetaPath, () => ({ | ||
| backend: "websocket", | ||
| websocket: meta, | ||
| ...mcpMeta ? { mcp: mcpMeta } : {} | ||
| })); | ||
| if (distDir) mountStaticHandler(app, basePath, resolve(distDir)); | ||
| const authOption = flags.auth === false ? false : options.auth !== void 0 ? options.auth : def.cli?.auth; | ||
| let authHandler; | ||
| let resolvedAuth; | ||
| if (authOption === false) resolvedAuth = false; | ||
| else if (typeof authOption === "object") { | ||
| authHandler = authOption; | ||
| resolvedAuth = authOption; | ||
| } else { | ||
| authHandler = createInteractiveAuth(ctx); | ||
| resolvedAuth = authHandler; | ||
| } | ||
| const started = await startHttpAndWs({ | ||
| context: ctx, | ||
| host, | ||
| port, | ||
| app, | ||
| path: bindPath, | ||
| wsPort, | ||
| auth: resolvedAuth, | ||
| onReady: async (info) => { | ||
| authHandler?.printBanner(); | ||
| await options.onReady?.(info); | ||
| await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser, authHandler); | ||
| } | ||
| }); | ||
| const registration = registerDevframeInstance({ | ||
| pid: process$1.pid, | ||
| port: started.port, | ||
| origin: normalizeHttpServerUrl(host, started.port), | ||
| basePath, | ||
| id: def.id, | ||
| name: def.name, | ||
| rootDir: process$1.cwd(), | ||
| mcp: mcpConfig ? { path: joinURL(basePath, withoutLeadingSlash(mcpConfig.path ?? "__mcp")) } : null, | ||
| startedAt: Date.now() | ||
| }); | ||
| const closeServer = started.close; | ||
| started.close = async () => { | ||
| registration.unregister(); | ||
| await mcpDispose?.(); | ||
| await closeServer(); | ||
| }; | ||
| return started; | ||
| } | ||
| /** | ||
| * 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 {@link createDevServer}), or `undefined` when the route is | ||
| * disabled. | ||
| * | ||
| * Hosted bridges that hand-roll their connection meta (`viteDevBridge`, | ||
| * `@devframes/next`'s handler) 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). | ||
| * | ||
| * @experimental | ||
| */ | ||
| 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 }; | ||
| } | ||
| /** | ||
| * Resolve the three WS connection scenarios from the definition / call-site | ||
| * config into a concrete server bind path, optional dedicated port, and the | ||
| * `__connection.json` descriptor the browser resolves. | ||
| */ | ||
| function resolveWsConnection(def, options, basePath) { | ||
| const ws = options.ws ?? def.cli?.ws ?? {}; | ||
| const route = withoutLeadingSlash(ws.route ?? "__devframe_ws"); | ||
| if (ws.url) return { | ||
| bindPath: joinURL(basePath, route), | ||
| wsPort: void 0, | ||
| meta: ws.url | ||
| }; | ||
| if (ws.port != null) return { | ||
| bindPath: withLeadingSlash(route), | ||
| wsPort: ws.port, | ||
| meta: { | ||
| port: ws.port, | ||
| path: route | ||
| } | ||
| }; | ||
| return { | ||
| bindPath: joinURL(basePath, route), | ||
| wsPort: void 0, | ||
| meta: { path: route } | ||
| }; | ||
| } | ||
| 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 { resolveDevServerPort as n, resolveMcpConnectionMeta as r, createDevServer as t }; |
Sorry, the diff of this file is too big to display
| import { isAllowedOrigin } from "./rpc/transports/ws-server.mjs"; | ||
| import { n as createHostContext } from "./host-h3-Kz7t5Xab.mjs"; | ||
| import { t as diagnostics } from "./diagnostics-B5-qHeqD.mjs"; | ||
| import { t as toAgentToolName } from "./agent-tool-name-C3b5vEwJ.mjs"; | ||
| import { randomUUID } from "node:crypto"; | ||
| import { Diagnostic } from "nostics"; | ||
| import { join } from "pathe"; | ||
| import process from "node:process"; | ||
| import { homedir } from "node:os"; | ||
| 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. | ||
| * | ||
| * @experimental The agent-native surface is experimental and may change | ||
| * without a major version bump until it stabilizes. | ||
| */ | ||
| 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 applies devframe's | ||
| * loopback-default DNS-rebinding protection (identical semantics to the WS | ||
| * upgrade's `isAllowedOrigin`). | ||
| * | ||
| * @experimental | ||
| */ | ||
| 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 && !isAllowedOrigin(origin, allowedOrigins ?? [])) return new Response("Forbidden: origin not allowed", { 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 | ||
| export { createMcpServer as n, createMcpFetchHandler as t }; |
| import { t as createMcpFetchHandler } from "./fetch-BZyK4v6W.mjs"; | ||
| import { defineHandler } from "h3"; | ||
| //#region src/adapters/mcp/http.ts | ||
| /** | ||
| * 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). | ||
| * | ||
| * @experimental | ||
| */ | ||
| 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 { mountMcpHttp }; |
| import { W as DevframeNodeRpcSession, b as DevframeNodeContext, ht as SharedState } from "./devframe-Dsjn_Xtq.mjs"; | ||
| import { n as InternalAnonymousAuthStorage } from "./context-7BbaIUSI.mjs"; | ||
| //#region src/node/auth/revoke.d.ts | ||
| /** | ||
| * Flip `isTrusted` to false on any live WS clients connected with `token` | ||
| * and broadcast the `auth:revoked` event so they can react. | ||
| * | ||
| * Shared between persisted-auth revocation and remote-dock token revocation. | ||
| */ | ||
| declare function revokeActiveConnectionsForToken(context: DevframeNodeContext, token: string): Promise<void>; | ||
| /** | ||
| * Revoke an auth token: remove from storage and notify all connected clients | ||
| * using this token that they are no longer trusted. | ||
| */ | ||
| declare function revokeAuthToken(context: DevframeNodeContext, storage: SharedState<InternalAnonymousAuthStorage>, token: string): Promise<void>; | ||
| //#endregion | ||
| //#region src/node/auth/state.d.ts | ||
| /** | ||
| * The current one-time authentication code. Display this to the user (e.g. in | ||
| * the dev-server terminal) so they can type it into the browser to authenticate. | ||
| */ | ||
| declare function getTempAuthCode(): string; | ||
| /** | ||
| * Rotate the authentication code, resetting its expiry window and failed-attempt | ||
| * counter. Call this when a new authentication flow begins (e.g. when an | ||
| * untrusted client starts authenticating) so the displayed code is freshly | ||
| * valid for its full TTL. | ||
| */ | ||
| declare function refreshTempAuthCode(): string; | ||
| /** | ||
| * Build a "magic link" authentication URL that embeds a one-time code (OTP) as | ||
| * a query parameter. Opening it authenticates the client without typing — print | ||
| * it on startup (devframe stays headless, so the host prints its own banner). | ||
| * Defaults to the current code; the link is subject to the same TTL. | ||
| */ | ||
| declare function buildOtpAuthUrl(baseUrl: string, code?: string): string; | ||
| /** | ||
| * Re-authenticate a connection that presents a previously-issued bearer token. | ||
| * Returns `true` and marks the session trusted when the token is known. | ||
| * | ||
| * Used by the `anonymous:devframe:auth` handler so a client that already | ||
| * authenticated (token persisted in the browser) is trusted on reconnect | ||
| * without entering the code again. | ||
| */ | ||
| declare function verifyAuthToken(token: string, session: DevframeNodeRpcSession, storage: SharedState<InternalAnonymousAuthStorage>): boolean; | ||
| /** | ||
| * Exchange a one-time authentication code for a fresh, node-issued bearer token. | ||
| * | ||
| * On success this mints a high-entropy token, records it in the trusted store, | ||
| * marks the calling session trusted, rotates the code, and returns the token | ||
| * for the client to persist. Returns `null` on any failure. | ||
| * | ||
| * Because the code is short and human-typed, verification is hardened against | ||
| * brute force: it enforces a time-to-live, compares in constant time, and | ||
| * rotates the code after {@link TEMP_AUTH_MAX_ATTEMPTS} failed attempts so an | ||
| * attacker cannot keep guessing against the same code. | ||
| */ | ||
| declare function exchangeTempAuthCode(code: string, session: DevframeNodeRpcSession, info: { | ||
| ua: string; | ||
| origin: string; | ||
| }, storage: SharedState<InternalAnonymousAuthStorage>): string | null; | ||
| //#endregion | ||
| export { verifyAuthToken as a, refreshTempAuthCode as i, exchangeTempAuthCode as n, revokeActiveConnectionsForToken as o, getTempAuthCode as r, revokeAuthToken as s, buildOtpAuthUrl as t }; |
| //#region src/utils/crypto-token.ts | ||
| const HEX = "0123456789abcdef"; | ||
| /** | ||
| * Generate a high-entropy, URL-safe (hex) random token suitable for use as a | ||
| * bearer credential — e.g. the persistent client auth token or an ephemeral | ||
| * remote-dock token. Defaults to 16 bytes (128 bits) of entropy. | ||
| */ | ||
| function randomToken(byteLength = 16) { | ||
| const bytes = new Uint8Array(byteLength); | ||
| globalThis.crypto.getRandomValues(bytes); | ||
| let out = ""; | ||
| for (let i = 0; i < bytes.length; i++) out += HEX[bytes[i] >> 4] + HEX[bytes[i] & 15]; | ||
| return out; | ||
| } | ||
| /** | ||
| * Generate a uniformly-distributed string of decimal digits using rejection | ||
| * sampling to avoid modulo bias. Intended for short, human-typed one-time | ||
| * codes (e.g. a 6-digit authentication code). Leading zeros are preserved. | ||
| */ | ||
| function randomDigits(length) { | ||
| const limit = 250; | ||
| const buf = /* @__PURE__ */ new Uint8Array(1); | ||
| let out = ""; | ||
| while (out.length < length) { | ||
| globalThis.crypto.getRandomValues(buf); | ||
| if (buf[0] < limit) out += String(buf[0] % 10); | ||
| } | ||
| return out; | ||
| } | ||
| /** | ||
| * Constant-time string equality. Compares every character so the comparison | ||
| * time does not depend on the position of the first mismatch, mitigating | ||
| * timing side-channels when verifying secrets. | ||
| * | ||
| * Length is treated as public (it short-circuits on differing lengths), which | ||
| * is appropriate for fixed-length codes and tokens. | ||
| */ | ||
| function timingSafeEqual(a, b) { | ||
| if (a.length !== b.length) return false; | ||
| let mismatch = 0; | ||
| for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); | ||
| return mismatch === 0; | ||
| } | ||
| //#endregion | ||
| //#region src/node/auth/revoke.ts | ||
| /** | ||
| * Flip `isTrusted` to false on any live WS clients connected with `token` | ||
| * and broadcast the `auth:revoked` event so they can react. | ||
| * | ||
| * Shared between persisted-auth revocation and remote-dock token revocation. | ||
| */ | ||
| async function revokeActiveConnectionsForToken(context, token) { | ||
| const rpcHost = context.rpc; | ||
| if (!rpcHost?._rpcGroup) return; | ||
| const affectedSessionIds = /* @__PURE__ */ new Set(); | ||
| for (const client of rpcHost._rpcGroup.clients) if (client.$meta.clientAuthToken === token) { | ||
| affectedSessionIds.add(client.$meta.id); | ||
| client.$meta.isTrusted = false; | ||
| client.$meta.clientAuthToken = void 0; | ||
| } | ||
| if (affectedSessionIds.size === 0) return; | ||
| await rpcHost.broadcast({ | ||
| method: "devframe:auth:revoked", | ||
| args: [], | ||
| filter: (client) => affectedSessionIds.has(client.$meta.id) | ||
| }); | ||
| } | ||
| /** | ||
| * Revoke an auth token: remove from storage and notify all connected clients | ||
| * using this token that they are no longer trusted. | ||
| */ | ||
| async function revokeAuthToken(context, storage, token) { | ||
| storage.mutate((state) => { | ||
| delete state.trusted[token]; | ||
| }); | ||
| await revokeActiveConnectionsForToken(context, token); | ||
| } | ||
| //#endregion | ||
| export { timingSafeEqual as a, randomToken as i, revokeAuthToken as n, randomDigits as r, revokeActiveConnectionsForToken as t }; |
| import { createRpcServer } from "./rpc/server.mjs"; | ||
| import { attachWsRpcTransport } from "./rpc/transports/ws-server.mjs"; | ||
| import { t as diagnostics } from "./diagnostics-B5-qHeqD.mjs"; | ||
| import { t as getInternalContext } from "./context-C9Cgm1hP.mjs"; | ||
| import { createServer } from "node:http"; | ||
| import { AsyncLocalStorage } from "node:async_hooks"; | ||
| import { H3, toNodeHandler } from "h3"; | ||
| import { isIP } from "node:net"; | ||
| //#region src/node/utils.ts | ||
| function isObject(value) { | ||
| return Object.prototype.toString.call(value) === "[object Object]"; | ||
| } | ||
| 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/server.ts | ||
| /** | ||
| * Compose an h3 + WebSocket server for a devframe context. The RPC | ||
| * group is bound to `context.rpc.functions`; the WS endpoint lives on | ||
| * the same port as the HTTP server. | ||
| */ | ||
| async function startHttpAndWs(options) { | ||
| const { context, port } = options; | ||
| const bindHost = options.host ?? "localhost"; | ||
| const app = options.app ?? new H3(); | ||
| const ownsHttpServer = !options.server; | ||
| const httpServer = options.server ?? createServer(toNodeHandler(app)); | ||
| const rpcHost = context.rpc; | ||
| const asyncStorage = new AsyncLocalStorage(); | ||
| const authHandler = typeof options.auth === "object" ? options.auth : void 0; | ||
| const effectiveAuthorize = options.authorize ?? authHandler?.authorize; | ||
| if (authHandler) { | ||
| for (const fn of authHandler.rpcFunctions) if (!rpcHost.definitions.has(fn.name)) rpcHost.register(fn); | ||
| } | ||
| const rpcGroup = createRpcServer(rpcHost.functions, { rpcOptions: { | ||
| onFunctionError: options.rpcOptions?.onFunctionError, | ||
| onGeneralError: options.rpcOptions?.onGeneralError, | ||
| resolver(name, fn) { | ||
| const rpc = this; | ||
| if (!fn) return void 0; | ||
| return async function(...args) { | ||
| const meta = rpc.$meta; | ||
| if (effectiveAuthorize && !effectiveAuthorize(name, { | ||
| meta, | ||
| rpc | ||
| })) throw diagnostics.DF0036({ name }); | ||
| return await asyncStorage.run({ | ||
| rpc, | ||
| meta | ||
| }, async () => { | ||
| return (await fn).apply(this, args); | ||
| }); | ||
| }; | ||
| } | ||
| } }); | ||
| const separateWsPort = ownsHttpServer && options.wsPort != null && options.wsPort !== port ? options.wsPort : void 0; | ||
| const { ws, close: closeWs } = attachWsRpcTransport(rpcGroup, { | ||
| ...separateWsPort != null ? { | ||
| port: separateWsPort, | ||
| host: bindHost | ||
| } : { server: httpServer }, | ||
| path: options.path, | ||
| destroyUnmatched: ownsHttpServer, | ||
| allowedOrigins: options.allowedOrigins, | ||
| onConnected: authHandler || options.onPeerConnect ? (peer, meta) => { | ||
| const session = { | ||
| meta, | ||
| rpc: rpcGroup.clients.find((client) => client.$meta === meta) | ||
| }; | ||
| authHandler?.onConnect(peer, session); | ||
| options.onPeerConnect?.(peer, session); | ||
| } : void 0, | ||
| onDisconnected: (_peer, meta) => { | ||
| rpcHost._emitSessionDisconnected(meta); | ||
| } | ||
| }); | ||
| rpcHost._rpcGroup = rpcGroup; | ||
| rpcHost._asyncStorage = asyncStorage; | ||
| rpcHost._authDisabled = options.auth === false; | ||
| if (options.auth === false && !rpcHost.definitions.has("anonymous:devframe:auth")) rpcHost.register({ | ||
| name: "anonymous:devframe:auth", | ||
| type: "action", | ||
| handler: () => { | ||
| const session = rpcHost.getCurrentRpcSession(); | ||
| if (session) session.meta.isTrusted = true; | ||
| return { isTrusted: true }; | ||
| } | ||
| }); | ||
| if (ownsHttpServer) await new Promise((resolveListen) => { | ||
| httpServer.listen(port, bindHost, () => resolveListen()); | ||
| }); | ||
| const address = httpServer.address(); | ||
| const resolvedPort = typeof address === "object" && address ? address.port : port; | ||
| const origin = normalizeHttpServerUrl(bindHost, resolvedPort); | ||
| const internal = getInternalContext(context); | ||
| const wsPortForUrl = separateWsPort ?? resolvedPort; | ||
| const wsUrl = `ws://${formatHostForUrl(bindHost)}:${wsPortForUrl}${options.path ?? ""}`; | ||
| internal.wsEndpoint = { url: wsUrl }; | ||
| if (options.onReady) await options.onReady({ | ||
| origin, | ||
| port: resolvedPort, | ||
| app | ||
| }); | ||
| function connectionMeta() { | ||
| const jsonSerializableMethods = []; | ||
| for (const def of rpcHost.definitions.values()) if (def.jsonSerializable === true) jsonSerializableMethods.push(def.name); | ||
| return { | ||
| backend: "websocket", | ||
| websocket: separateWsPort != null ? { | ||
| port: separateWsPort, | ||
| path: options.path | ||
| } : { path: options.path }, | ||
| jsonSerializableMethods | ||
| }; | ||
| } | ||
| return { | ||
| origin, | ||
| port: resolvedPort, | ||
| app, | ||
| ws, | ||
| rpcGroup, | ||
| connectionMeta, | ||
| async close() { | ||
| await closeWs(); | ||
| if (ownsHttpServer) await new Promise((r) => httpServer.close(() => r())); | ||
| if (getInternalContext(context).wsEndpoint?.url === wsUrl) getInternalContext(context).wsEndpoint = void 0; | ||
| } | ||
| }; | ||
| } | ||
| //#endregion | ||
| export { toDialableHost as a, normalizeHttpServerUrl as i, formatHostForUrl as n, isObject as r, startHttpAndWs as t }; |
| import { $ as DevframeRpcServerFunctions, Q as DevframeRpcClientFunctions, W as DevframeNodeRpcSession, _ as ConnectionMeta, b as DevframeNodeContext, p as DevframeAuthHandler } from "./devframe-Dsjn_Xtq.mjs"; | ||
| import "./index-CeBbry0R.mjs"; | ||
| import { Peer } from "crossws"; | ||
| import { BirpcGroup, EventOptions } from "birpc"; | ||
| import { NodeAdapter } from "crossws/adapters/node"; | ||
| import { Server } from "node:http"; | ||
| import { H3 } from "h3"; | ||
| //#region src/node/server.d.ts | ||
| interface StartHttpAndWsOptions { | ||
| context: DevframeNodeContext; | ||
| host?: string; | ||
| port: number; | ||
| /** | ||
| * Optional h3 app to mount on. When omitted a fresh one is created; | ||
| * when provided, callers can add their own routes (static handlers, | ||
| * auth middleware, etc.) first. | ||
| */ | ||
| app?: H3; | ||
| /** | ||
| * Bind the WS endpoint to a single upgrade route (e.g. `/__devframe_ws`) instead of | ||
| * claiming every upgrade on the port. This lets the socket share a server | ||
| * with other upgrade handlers (Vite HMR, a host framework's own sockets) | ||
| * and is what the SPA's `__connection.json` points at. When omitted, the WS | ||
| * server handles every upgrade on the port (legacy behaviour). | ||
| */ | ||
| path?: string; | ||
| /** | ||
| * Bind the WS endpoint on its own port instead of sharing the HTTP server's. | ||
| * The HTTP/SPA server still listens on `port`; the socket gets a dedicated | ||
| * `ws` server on `wsPort` (same `host`). Use this for the "different port" | ||
| * connection scenario. Ignored when a `server` is supplied. | ||
| */ | ||
| wsPort?: number; | ||
| /** | ||
| * Mount the WS endpoint onto an existing HTTP server, sharing its port, | ||
| * rather than creating and listening on a fresh one. Use this to embed | ||
| * devframe's RPC socket inside a host server (e.g. a Vite dev server) — pair | ||
| * it with `path` so it coexists with the host's routes. The caller owns the | ||
| * server's lifecycle: {@link StartedServer.close} detaches devframe's upgrade | ||
| * listener but leaves the host server running. When set, `host`/`port` are | ||
| * only used to report the resolved origin. | ||
| */ | ||
| server?: Server; | ||
| /** | ||
| * Authentication for the server: | ||
| * | ||
| * - `true` (default) — no gate; every registered method is callable | ||
| * regardless of trust (today's behavior, unchanged). | ||
| * - `false` — the RPC server is started without a trust handshake. | ||
| * Intended for single-user localhost tools where an auth round-trip | ||
| * would only get in the way. A noop `anonymous:devframe:auth` handler | ||
| * is registered so the browser client's unconditional handshake call | ||
| * succeeds and auto-trusts. | ||
| * - A {@link DevframeAuthHandler} (e.g. from | ||
| * `devframe/recipes/interactive-auth`'s `createInteractiveAuth`) — | ||
| * registers its `rpcFunctions`, wires its `authorize` as the resolver | ||
| * gate, and wires its `onConnect` on every new peer. This is the | ||
| * fully-authenticated server: an untrusted caller can only reach | ||
| * `anonymous:`-prefixed methods (see `isAnonymousRpcMethod`). | ||
| */ | ||
| auth?: boolean | DevframeAuthHandler; | ||
| /** | ||
| * Lower-level escape hatch: gate individual RPC calls by method name and | ||
| * session without a full {@link DevframeAuthHandler}. Ignored when `auth` | ||
| * is a handler object (its own `authorize` is used); combine with `auth: | ||
| * true` to layer a custom policy on top of an otherwise ungated server. | ||
| */ | ||
| authorize?: (methodName: string, session: DevframeNodeRpcSession) => boolean; | ||
| /** | ||
| * Called once per new WS connection, right after its session is created | ||
| * (before any RPC call is dispatched). Runs after the auth handler's own | ||
| * `onConnect` (when `auth` is a {@link DevframeAuthHandler}), so it can | ||
| * observe — but not override — the connect-time trust decision. | ||
| */ | ||
| onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void; | ||
| /** | ||
| * Forwarded verbatim to the internal `createRpcServer`'s birpc | ||
| * `rpcOptions`, alongside the resolver `startHttpAndWs` installs for | ||
| * auth/session wiring. Use this so a host that owns its own structured | ||
| * diagnostics (e.g. a coded error reporter) keeps seeing RPC failures | ||
| * instead of them being silently absorbed by delegating to | ||
| * `startHttpAndWs`. Returning `true` from either callback suppresses | ||
| * birpc's own error response to the caller — see birpc's | ||
| * `EventOptions` for the full contract. | ||
| */ | ||
| rpcOptions?: Pick<EventOptions<DevframeRpcClientFunctions, DevframeRpcServerFunctions, false>, 'onFunctionError' | 'onGeneralError'>; | ||
| /** | ||
| * Extra origins to accept on the WS upgrade beyond the loopback default | ||
| * (`localhost`/`127.0.0.1`/`::1` and any `Origin`-less request from a | ||
| * native client). Add your LAN/tunnel origin here when reaching the tool | ||
| * from another host. Pass `false` to disable origin checking entirely | ||
| * (not recommended). Default: loopback-only. | ||
| */ | ||
| allowedOrigins?: readonly string[] | false; | ||
| /** | ||
| * Called once the WS server is bound so callers can mount static | ||
| * handlers whose origin depends on the resolved port, or print their | ||
| * own startup banner. Devframe does not print one itself. | ||
| */ | ||
| onReady?: (info: { | ||
| origin: string; | ||
| port: number; | ||
| app: H3; | ||
| }) => void | Promise<void>; | ||
| } | ||
| interface StartedServer { | ||
| /** Listening origin, e.g. `http://localhost:9999`. */ | ||
| origin: string; | ||
| port: number; | ||
| app: H3; | ||
| /** The crossws node adapter driving the RPC socket (connected peers, pub/sub). */ | ||
| ws: NodeAdapter; | ||
| rpcGroup: BirpcGroup<DevframeRpcClientFunctions, DevframeRpcServerFunctions, false>; | ||
| /** | ||
| * The {@link ConnectionMeta} descriptor for this server — the same shape | ||
| * a `__connection.json` route should serve so a devframe client's | ||
| * `resolveWsUrl` can dial back in. Reflects the `path` / `wsPort` this | ||
| * server was started with and the `jsonSerializable` methods currently | ||
| * registered on `context.rpc`. | ||
| */ | ||
| connectionMeta: () => ConnectionMeta; | ||
| close: () => Promise<void>; | ||
| } | ||
| /** | ||
| * Compose an h3 + WebSocket server for a devframe context. The RPC | ||
| * group is bound to `context.rpc.functions`; the WS endpoint lives on | ||
| * the same port as the HTTP server. | ||
| */ | ||
| declare function startHttpAndWs(options: StartHttpAndWsOptions): Promise<StartedServer>; | ||
| //#endregion | ||
| export { StartedServer as n, startHttpAndWs as r, StartHttpAndWsOptions as t }; |
| import { DEVFRAME_OTP_URL_PARAM } from "./constants.mjs"; | ||
| import { a as timingSafeEqual, i as randomToken, r as randomDigits } from "./revoke-BtQDKTp7.mjs"; | ||
| //#region src/node/auth/state.ts | ||
| /** Number of decimal digits in a human-typed one-time authentication code. */ | ||
| const TEMP_AUTH_CODE_LENGTH = 6; | ||
| /** | ||
| * How long an authentication code stays valid after it is (re)generated. A | ||
| * 6-digit code only has ~20 bits of entropy, so a short lifetime plus the | ||
| * attempt cap below are what keep it brute-force resistant. | ||
| */ | ||
| const TEMP_AUTH_CODE_TTL = 5 * 6e4; | ||
| /** Failed attempts allowed against a single code before it is rotated. */ | ||
| const TEMP_AUTH_MAX_ATTEMPTS = 5; | ||
| let tempAuthCode = generateTempCode(); | ||
| let tempAuthCodeExpiresAt = Date.now() + TEMP_AUTH_CODE_TTL; | ||
| let tempAuthFailedAttempts = 0; | ||
| function generateTempCode() { | ||
| return randomDigits(TEMP_AUTH_CODE_LENGTH); | ||
| } | ||
| /** | ||
| * The current one-time authentication code. Display this to the user (e.g. in | ||
| * the dev-server terminal) so they can type it into the browser to authenticate. | ||
| */ | ||
| function getTempAuthCode() { | ||
| return tempAuthCode; | ||
| } | ||
| /** | ||
| * Rotate the authentication code, resetting its expiry window and failed-attempt | ||
| * counter. Call this when a new authentication flow begins (e.g. when an | ||
| * untrusted client starts authenticating) so the displayed code is freshly | ||
| * valid for its full TTL. | ||
| */ | ||
| function refreshTempAuthCode() { | ||
| tempAuthCode = generateTempCode(); | ||
| tempAuthCodeExpiresAt = Date.now() + TEMP_AUTH_CODE_TTL; | ||
| tempAuthFailedAttempts = 0; | ||
| return tempAuthCode; | ||
| } | ||
| /** | ||
| * Build a "magic link" authentication URL that embeds a one-time code (OTP) as | ||
| * a query parameter. Opening it authenticates the client without typing — print | ||
| * it on startup (devframe stays headless, so the host prints its own banner). | ||
| * Defaults to the current code; the link is subject to the same TTL. | ||
| */ | ||
| function buildOtpAuthUrl(baseUrl, code = tempAuthCode) { | ||
| const url = new URL(baseUrl); | ||
| url.searchParams.set(DEVFRAME_OTP_URL_PARAM, code); | ||
| return url.href; | ||
| } | ||
| /** | ||
| * Re-authenticate a connection that presents a previously-issued bearer token. | ||
| * Returns `true` and marks the session trusted when the token is known. | ||
| * | ||
| * Used by the `anonymous:devframe:auth` handler so a client that already | ||
| * authenticated (token persisted in the browser) is trusted on reconnect | ||
| * without entering the code again. | ||
| */ | ||
| function verifyAuthToken(token, session, storage) { | ||
| if (!token || !storage.value().trusted[token]) return false; | ||
| session.meta.clientAuthToken = token; | ||
| session.meta.isTrusted = true; | ||
| return true; | ||
| } | ||
| /** | ||
| * Exchange a one-time authentication code for a fresh, node-issued bearer token. | ||
| * | ||
| * On success this mints a high-entropy token, records it in the trusted store, | ||
| * marks the calling session trusted, rotates the code, and returns the token | ||
| * for the client to persist. Returns `null` on any failure. | ||
| * | ||
| * Because the code is short and human-typed, verification is hardened against | ||
| * brute force: it enforces a time-to-live, compares in constant time, and | ||
| * rotates the code after {@link TEMP_AUTH_MAX_ATTEMPTS} failed attempts so an | ||
| * attacker cannot keep guessing against the same code. | ||
| */ | ||
| function exchangeTempAuthCode(code, session, info, storage) { | ||
| if (Date.now() > tempAuthCodeExpiresAt) { | ||
| refreshTempAuthCode(); | ||
| return null; | ||
| } | ||
| if (!timingSafeEqual(code, tempAuthCode)) { | ||
| tempAuthFailedAttempts += 1; | ||
| if (tempAuthFailedAttempts >= TEMP_AUTH_MAX_ATTEMPTS) refreshTempAuthCode(); | ||
| return null; | ||
| } | ||
| const authToken = randomToken(); | ||
| storage.mutate((state) => { | ||
| state.trusted[authToken] = { | ||
| authToken, | ||
| ua: info.ua, | ||
| origin: info.origin, | ||
| timestamp: Date.now() | ||
| }; | ||
| }); | ||
| session.meta.clientAuthToken = authToken; | ||
| session.meta.isTrusted = true; | ||
| refreshTempAuthCode(); | ||
| return authToken; | ||
| } | ||
| //#endregion | ||
| export { verifyAuthToken as a, refreshTempAuthCode as i, exchangeTempAuthCode as n, getTempAuthCode as r, buildOtpAuthUrl as t }; |
| import { v as RpcFunctionDefinitionAny } from "./types-CnJSgRVa.mjs"; | ||
| import { Peer } from "crossws"; | ||
| import { BirpcGroup, ChannelOptions } from "birpc"; | ||
| import { NodeAdapter } from "crossws/adapters/node"; | ||
| import { Server } from "node:http"; | ||
| import { Server as Server$1, ServerOptions } from "node:https"; | ||
| //#region src/rpc/transports/ws-server.d.ts | ||
| interface DevframeNodeRpcSessionMeta { | ||
| id: number; | ||
| /** The crossws peer backing this session's socket. */ | ||
| peer?: Peer; | ||
| clientAuthToken?: string; | ||
| isTrusted?: boolean; | ||
| subscribedStates: Set<string>; | ||
| /** | ||
| * Streams this session has subscribed to via | ||
| * `rpc.streaming.subscribe(channel, id)`. Tracked here for O(1) cleanup | ||
| * on disconnect; the wire format is `${channel}\x1F${id}`. | ||
| */ | ||
| subscribedStreams?: Set<string>; | ||
| /** | ||
| * Inbound streams this session is currently uploading to (via | ||
| * `rpc.streaming.upload(channel, id)`). Tracked for cleanup on | ||
| * disconnect; same wire format as `subscribedStreams`. | ||
| */ | ||
| uploadingStreams?: Set<string>; | ||
| } | ||
| interface WsRpcTransportOptions { | ||
| /** | ||
| * Attach to an existing HTTP(S) server, sharing its port. Combine with | ||
| * `path` to bind the WS endpoint to a single route so it coexists with | ||
| * other upgrade handlers on the same server (e.g. a Vite dev server's HMR | ||
| * socket). The shared server's lifecycle is owned by the caller — closing | ||
| * this transport detaches the upgrade listener without closing the server. | ||
| */ | ||
| server?: Server | Server$1; | ||
| /** Port for a newly-created standalone WS server. */ | ||
| port?: number; | ||
| /** Host for a newly-created standalone WS server. Defaults to `localhost`. */ | ||
| host?: string; | ||
| /** | ||
| * Restrict the WS endpoint to a single upgrade route (e.g. `/__devframe_ws`). When | ||
| * sharing a `server`, non-matching upgrade requests are left untouched for | ||
| * other listeners to handle, so devframe's socket can sit alongside | ||
| * framework sockets (Vite HMR, etc.). | ||
| */ | ||
| path?: string; | ||
| /** | ||
| * Destroy upgrade requests that don't match `path` instead of leaving them | ||
| * for other listeners. Enable this when devframe owns the shared server | ||
| * outright (nothing else handles its upgrades), so an off-route client is | ||
| * rejected promptly rather than left hanging. Default: `false` | ||
| * (coexist-friendly); servers this transport creates itself always | ||
| * destroy unmatched upgrades. | ||
| */ | ||
| destroyUnmatched?: boolean; | ||
| /** When set, a new https.Server is created and the WS endpoint is attached to it. */ | ||
| https?: ServerOptions; | ||
| /** | ||
| * Extra origins to accept on the WS upgrade beyond the loopback default. | ||
| * Add your LAN/tunnel origin here when reaching the tool from another host. | ||
| * Pass `false` to disable origin checking entirely (not recommended). | ||
| * Default: loopback-only. | ||
| */ | ||
| allowedOrigins?: readonly string[] | false; | ||
| /** | ||
| * RPC function definitions, used by the per-call wire serializer to | ||
| * dispatch between strict-JSON and structured-clone encoding based | ||
| * on each function's `jsonSerializable` flag. | ||
| * | ||
| * When omitted, all messages fall back to structured-clone — safe but | ||
| * loses dev-time validation for `jsonSerializable: true` declarations. | ||
| */ | ||
| definitions?: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>>; | ||
| onConnected?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void; | ||
| onDisconnected?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void; | ||
| /** Override the default per-call serializer. Most callers should leave this unset. */ | ||
| serialize?: ChannelOptions['serialize']; | ||
| /** Override the default per-call deserializer. Most callers should leave this unset. */ | ||
| deserialize?: ChannelOptions['deserialize']; | ||
| } | ||
| interface WsRpcTransport { | ||
| /** | ||
| * The crossws node adapter driving the socket — exposes the connected | ||
| * `peers` and pub/sub. See https://crossws.h3.dev. | ||
| */ | ||
| ws: NodeAdapter; | ||
| /** Remove the upgrade listener from a shared `server` (a no-op otherwise). */ | ||
| detach: () => void; | ||
| /** | ||
| * Tear the transport down deterministically: detach from a shared server, | ||
| * force-terminate every connected peer, and close any server this | ||
| * transport created itself (`port` / `https` modes). | ||
| */ | ||
| close: () => Promise<void>; | ||
| } | ||
| declare function isLoopbackHostname(hostname: string): boolean; | ||
| /** | ||
| * 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. | ||
| */ | ||
| declare function isAllowedOrigin(origin: string | undefined, allowedOrigins: readonly string[]): boolean; | ||
| /** | ||
| * 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 plus `detach` (remove the upgrade | ||
| * listener from a shared `server`) and `close` (full deterministic | ||
| * teardown). | ||
| */ | ||
| declare function attachWsRpcTransport<ClientFunctions extends object, ServerFunctions extends object>(rpcGroup: BirpcGroup<ClientFunctions, ServerFunctions, false>, options?: WsRpcTransportOptions): WsRpcTransport; | ||
| //#endregion | ||
| export { isAllowedOrigin as a, attachWsRpcTransport as i, WsRpcTransport as n, isLoopbackHostname as o, WsRpcTransportOptions as r, DevframeNodeRpcSessionMeta as t }; |
Sorry, the diff of this file is too big to display
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.
706020
1.53%126
0.8%13822
0.72%17
6.25%