import { createApp } from "vue"; import { createRouter, createWebHistory } from "vue-router"; import { VueQueryPlugin } from "@tanstack/vue-query"; import App from "./App.vue"; import "./style.css"; import { getActiveProjectCwd, loadCatalog } from "./lib/project-catalog"; // Multi-project Studio (Shape A) — every Studio-internal URL needs to // carry `?project=` so the server routes it to the right project's // manifest / processes / wire. Wrap `fetch` once at boot rather than // touching every call site. Only the `/__nwire/*` and `/_nwire/*` paths // are decorated; everything else (Vite HMR, third-party APIs) passes // through unchanged. const originalFetch = window.fetch.bind(window); window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { const cwd = getActiveProjectCwd(); if (!cwd) return originalFetch(input as RequestInfo, init); const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url; if (!url.startsWith("/__nwire/") && !url.startsWith("/_nwire/")) { return originalFetch(input as RequestInfo, init); } const sep = url.includes("?") ? "&" : "?"; const next = `${url}${sep}project=${encodeURIComponent(cwd)}`; return originalFetch(next, init); }) as typeof window.fetch; // EventSource is used for SSE (Run logs, live event stream, trace // telemetry). It bypasses fetch, so we patch its constructor to inject // the same query param. Same allowlist as the fetch wrapper. const OriginalEventSource = window.EventSource; class ScopedEventSource extends OriginalEventSource { constructor(url: string | URL, init?: EventSourceInit) { const cwd = getActiveProjectCwd(); const str = typeof url === "string" ? url : url.toString(); if (cwd && (str.startsWith("/__nwire/") || str.startsWith("/_nwire/"))) { const sep = str.includes("?") ? "&" : "?"; super(`${str}${sep}project=${encodeURIComponent(cwd)}`, init); } else { super(url, init); } } } (window as unknown as { EventSource: typeof OriginalEventSource }).EventSource = ScopedEventSource; import Home from "./pages/Home.vue"; import MapView from "./pages/Map.vue"; import Topology from "./pages/Topology.vue"; import Trace from "./pages/Trace.vue"; import Hooks from "./pages/Hooks.vue"; import Plugins from "./pages/Plugins.vue"; import Streams from "./pages/Streams.vue"; import Logs from "./pages/Logs.vue"; import Operate from "./pages/Operate.vue"; import Projects from "./pages/Projects.vue"; import Errors from "./pages/Errors.vue"; import Inspect from "./pages/Inspect.vue"; /** * Routes are namespaced under `/projects/:slug` so every URL is * project-pinned. Bookmarks and shared links carry the project * identity; multiple Studio tabs can hold different projects without * stomping each other. * * `/` and bare-page routes resolve the active project from * localStorage and redirect into the slug-pinned route. The bare * `/projects` catalog page is the one URL that's intentionally not * project-pinned. */ const pageRoutes = [ { path: "", component: Home, name: "home" }, { path: "map", component: MapView, name: "map" }, { path: "inspect", component: Inspect, name: "inspect" }, { path: "topology", component: Topology, name: "topology" }, { path: "trace", component: Trace, name: "trace" }, { path: "errors", component: Errors, name: "errors" }, { path: "hooks", component: Hooks, name: "hooks" }, { path: "plugins", component: Plugins, name: "plugins" }, { path: "streams", component: Streams, name: "streams" }, { path: "logs", component: Logs, name: "logs" }, { path: "operate", component: Operate, name: "operate" }, ]; // Per-kind pages collapsed into the unified Inspect view. Old routes (bare + // project-pinned) redirect to `/inspect?kind=` so bookmarks + CI keep // working. const KIND_REDIRECTS: ReadonlyArray = [ ["apps", "app"], ["actions", "action"], ["events", "event"], ["workflows", "workflow"], ["projections", "projection"], ["queries", "query"], ["sinks", "sinkStage"], ]; // Dispatch / Run / Commands collapsed into the unified Operate view. Old routes // (bare + project-pinned) redirect to `/operate?mode=` so bookmarks + CI // keep working. const OPERATE_REDIRECTS: ReadonlyArray = [ ["dispatch", "dispatch"], ["run", "run"], ["commands", "commands"], ]; const router = createRouter({ history: createWebHistory(), routes: [ // Project catalog — global, not project-pinned. { path: "/projects", component: Projects, name: "projects" }, // Project-pinned routes. Names get a `p-` prefix so they don't // collide with the bare-path variants below. { path: "/projects/:slug", children: [ ...pageRoutes.map((r) => ({ ...r, name: `p-${r.name}` })), ...KIND_REDIRECTS.map(([path, kind]) => ({ path, redirect: (to: { params: Record }) => ({ path: `/projects/${to.params.slug}/inspect`, query: { kind }, }), })), ...OPERATE_REDIRECTS.map(([path, mode]) => ({ path, redirect: (to: { params: Record }) => ({ path: `/projects/${to.params.slug}/operate`, query: { mode }, }), })), ], }, // Bare paths render the same pages. App.vue upgrades the URL to // /projects//... once the active project slug is known, so a // user who lands on /trace ends up bookmark-friendly without // breaking the load. Old bookmarks + CI test suites continue to // work unchanged. { path: "/", component: Home, name: "home" }, ...pageRoutes .filter((r) => r.path !== "") .map((r) => ({ path: `/${r.path}`, component: r.component, name: r.name })), ...KIND_REDIRECTS.map(([path, kind]) => ({ path: `/${path}`, redirect: { path: "/inspect", query: { kind } }, })), ...OPERATE_REDIRECTS.map(([path, mode]) => ({ path: `/${path}`, redirect: { path: "/operate", query: { mode } }, })), { path: "/eventstorm", redirect: "/trace" }, { path: "/live", redirect: "/streams" }, { path: "/modules", redirect: { path: "/inspect", query: { kind: "app" } } }, ], }); /** * Register every catalog cwd with the running Studio process BEFORE mounting * Vue. The fetch shim above appends `?project=` to every nwire request, * and the server's allowlist only includes `NWIRE_CWD` by default — so the * first manifest fetch from useManifest() would 400 without this hand-shake. */ async function bootstrap() { const catalog = loadCatalog(); const cwds = Object.keys(catalog); const active = getActiveProjectCwd(); if (active && !cwds.includes(active)) cwds.push(active); await Promise.all( cwds.map((cwd) => originalFetch("/__nwire/projects/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd }), }).catch(() => { /* stale localStorage entry — server rejects; catalog page lets user forget */ }), ), ); createApp(App).use(router).use(VueQueryPlugin).mount("#app"); } void bootstrap();