diff --git a/README.md b/README.md index ae3c2b3..c1164d1 100644 --- a/README.md +++ b/README.md @@ -88,13 +88,15 @@ bun run check PORT=3000 bun run start ``` -`start` runs the production component site and an export-only Studio sidecar -bound to loopback port 4320. Include `deploy/nginx-design-studio.conf` inside your -existing nginx server block, retain its existing component-site proxy, validate -with `nginx -t`, then reload nginx. The proxy must preserve `/design-studio/` and -Host, and pass Origin unchanged. If port 4320 is occupied, set DESIGN_STUDIO_PORT -and update the proxy upstream to the same port. Manage the `bun run start` -process using your existing service manager and restart it after each build. +`start` runs one production server. Design Studio is mounted directly at +`/design-studio/` by `app/middleware/design-studio.ts`, so the same route works +with `bun dist/server.js` and with the development server. No extra port or +Studio-specific proxy rule is needed. Your existing reverse proxy should forward +all paths to the normal app port and preserve Host, Origin and X-Forwarded-Proto. +If you installed the older Studio-only nginx location pointing to port 4320, +remove it so `/design-studio/` follows the app's normal proxy route. Validate with +`nginx -t`, then reload nginx. Rebuild and restart the application after updating. +The optional `bun run studio` command is only for running a separate demo server. The private `@wrnexus` registry is configured in `.npmrc`. Supply registry credentials on the VM through its user configuration or secret store; no tokens diff --git a/app/middleware/design-studio.ts b/app/middleware/design-studio.ts new file mode 100644 index 0000000..4a3b310 --- /dev/null +++ b/app/middleware/design-studio.ts @@ -0,0 +1,29 @@ +import type { Middleware } from "@wrnexus/core"; +import type { createStudioHandler } from "@wrnexus/design-studio/server"; + +let studio: ReturnType | undefined; + +const designStudio: Middleware = async (ctx, next) => { + const url = new URL(ctx.req.url); + if (url.pathname === "/design-studio") + return new Response(null, { + status: 308, + headers: { location: "/design-studio/" }, + }); + if (!url.pathname.startsWith("/design-studio/")) return next(); + // Load the installed package at runtime so its assets keep their package-relative URLs. + const studioModule = import.meta.resolve("@wrnexus/design-studio/server"); + studio ??= import(studioModule) + .then(({ createStudioHandler }) => + createStudioHandler({ + hosted: { basePath: "/design-studio" }, + }), + ) + .catch((error) => { + studio = undefined; + throw error; + }); + return (await studio)(ctx.req); +}; + +export default designStudio; diff --git a/app/types/wrnexus.generated.d.ts b/app/types/wrnexus.generated.d.ts index e2151ad..81c58c7 100644 --- a/app/types/wrnexus.generated.d.ts +++ b/app/types/wrnexus.generated.d.ts @@ -24,7 +24,9 @@ declare namespace WRNexusGenerated { interface ApiContracts { "/api/teams": { GET: ApiContract }; } - type MiddlewareContexts = Record; + interface MiddlewareContexts { + "design-studio": MiddlewareContext<(typeof import("../middleware/design-studio.ts"))["default"]>; + } type DatabaseQueries = Record; type RealtimeMessages = Record; type QueuePayloads = Record; diff --git a/deploy/nginx-design-studio.conf b/deploy/nginx-design-studio.conf index 6e9a7df..58517c7 100644 --- a/deploy/nginx-design-studio.conf +++ b/deploy/nginx-design-studio.conf @@ -1,14 +1,5 @@ -# Include INSIDE the existing component site's nginx server block. -# The existing location / continues to proxy the component app on its usual port. -location = /design-studio { - return 308 /design-studio/; -} -location /design-studio/ { - # Preserve the path and the external Host so same-origin validation works. - proxy_pass http://127.0.0.1:4320; - proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_http_version 1.1; - proxy_read_timeout 60s; - client_max_body_size 2m; -} +# Studio is now mounted in the component app on its existing port. +# No separate nginx location is required. +# Remove the old /design-studio/ location that proxied to port 4320. +# Let the existing location / forward Studio and normal pages to the same app. +# Preserve Host, Origin and X-Forwarded-Proto in that existing proxy location. diff --git a/deploy/studio-single-port.jpg b/deploy/studio-single-port.jpg new file mode 100644 index 0000000..e984623 Binary files /dev/null and b/deploy/studio-single-port.jpg differ diff --git a/package.json b/package.json index 3d9753c..c740696 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,16 @@ "build": "bun run generate && wrnexus build .", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "bun test test", - "check": "bun run generate && bun run typecheck && bun run test && bun run build", - "start": "bun scripts/start.ts", + "check": "bun run generate && bun run typecheck && bun run test && bun run build && bun run check:deployment", + "start": "bun dist/server.js", "production": "bun run build && bun run start", "doctor": "wrnexus doctor .", "config:explain": "wrnexus config . --explain", "analyze": "wrnexus analyze .", "update:preview": "wrnexus update . --dry-run", "start:site": "bun dist/server.js", - "studio": "bun scripts/studio.ts" + "studio": "bun scripts/studio.ts", + "check:deployment": "bun scripts/verify-deployment.ts" }, "dependencies": { "@wrnexus/core": "0.8.25", diff --git a/scripts/start.ts b/scripts/start.ts index 6f9002c..446d4dd 100644 --- a/scripts/start.ts +++ b/scripts/start.ts @@ -1,18 +1,4 @@ -import { startStudio } from "./studio.ts"; - -const studio = await startStudio(); -const site = Bun.spawn([process.execPath, "dist/server.js"], { - stdout: "inherit", - stderr: "inherit", - stdin: "inherit", - env: process.env, -}); -const stop = () => { - studio.stop(true); - site.kill("SIGTERM"); -}; -process.on("SIGINT", stop); -process.on("SIGTERM", stop); -const code = await site.exited; -studio.stop(true); -process.exit(code); +// Compatibility entry point: Studio is mounted in the app middleware now. +const entry = "../dist/server.js"; +await import(entry); +export {}; diff --git a/scripts/verify-deployment.ts b/scripts/verify-deployment.ts new file mode 100644 index 0000000..6be69c1 --- /dev/null +++ b/scripts/verify-deployment.ts @@ -0,0 +1,78 @@ +import { CORE_DESIGN_SYSTEMS } from "@wrnexus/design-system"; + +// Exercise the compiled entry point, including package-relative Studio assets. +const reservation = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response(), +}); +const port = reservation.port!; +reservation.stop(true); +const server = Bun.spawn([process.execPath, "dist/server.js"], { + env: { ...process.env, PORT: String(port) }, + stdout: "pipe", + stderr: "pipe", +}); +const origin = `http://127.0.0.1:${port}`; +try { + let ready = false; + for (let attempt = 0; attempt < 60; attempt++) { + try { + ready = (await fetch(origin)).ok; + } catch { + /* Wait for the server to listen. */ + } + if (ready) break; + await Bun.sleep(100); + } + if (!ready) throw Error("Compiled server did not become ready."); + for (const path of [ + "/", + "/components/button", + "/design-studio/", + "/design-studio/studio.js", + "/design-studio/studio.css", + ]) { + const response = await fetch(origin + path); + if (!response.ok) throw Error(`${path} returned ${response.status}`); + const policy = response.headers.get("content-security-policy") ?? ""; + if (path === "/" && !policy.includes("require-trusted-types-for")) + throw Error( + "Normal component pages lost the production security policy.", + ); + if ( + path === "/design-studio/" && + (!policy.includes("frame-ancestors 'none'") || + policy.includes("require-trusted-types-for")) + ) + throw Error("The app replaced Studio's isolated editor security policy."); + } + const application = await ( + await fetch(origin + "/design-studio/api/application") + ).json(); + if (application.enabled !== false) + throw Error("Hosted Studio must remain export-only."); + const preview = await fetch(origin + "/design-studio/api/preview", { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ system: CORE_DESIGN_SYSTEMS.soft }), + }); + if (!preview.ok) + throw Error( + `Studio preview returned ${preview.status}: ${await preview.text()}`, + ); + const { url } = await preview.json(); + if ( + !url.startsWith("/design-studio/preview/") || + !(await fetch(origin + url)).ok + ) + throw Error("Studio preview was not mounted under the app path."); + console.log( + "Compiled server serves component pages, Studio assets, export-only APIs and previews on one port.", + ); +} finally { + server.kill("SIGTERM"); + await server.exited; + const errors = await new Response(server.stderr).text(); + if (errors.trim()) console.error(errors); +} diff --git a/test/hosted-studio.test.ts b/test/hosted-studio.test.ts index 1421af5..5d0af34 100644 --- a/test/hosted-studio.test.ts +++ b/test/hosted-studio.test.ts @@ -1,6 +1,30 @@ import { expect, test } from "bun:test"; import { createStudioHandler } from "@wrnexus/design-studio/server"; import { CORE_DESIGN_SYSTEMS } from "@wrnexus/design-system"; +import { createContext } from "@wrnexus/core"; +import designStudio from "../app/middleware/design-studio.ts"; + +test("application middleware mounts Studio before unmatched routes and preserves normal pages", async () => { + const send = (path: string) => { + const req = new Request("https://components.example" + path); + return designStudio( + createContext(req, new URL(req.url)), + () => new Response("normal page"), + ); + }; + expect(await (await send("/components/button")).text()).toBe("normal page"); + expect(await (await send("/design-studio-other")).text()).toBe("normal page"); + const redirect = await send("/design-studio"); + expect(redirect.status).toBe(308); + expect(redirect.headers.get("location")).toBe("/design-studio/"); + expect(await (await send("/design-studio/")).text()).toContain( + "WrNexus Design Studio", + ); + expect((await send("/design-studio/studio.js")).status).toBe(200); + expect( + (await (await send("/design-studio/api/application")).json()).enabled, + ).toBe(false); +}); test("deployed Studio serves assets and isolated previews without application writes", async () => { const handler = await createStudioHandler({ diff --git a/tsconfig.json b/tsconfig.json index 9d9804b..5a64686 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,11 @@ "jsx": "react-jsx", "jsxImportSource": "@wrnexus/core" }, - "include": ["wrnexus.config.ts", "test/**/*.ts", "scripts/**/*.ts"], + "include": [ + "wrnexus.config.ts", + "test/**/*.ts", + "scripts/**/*.ts", + "app/middleware/**/*.ts" + ], "exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"] } diff --git a/wrnexus.config.ts b/wrnexus.config.ts index 73c0628..770c91d 100644 --- a/wrnexus.config.ts +++ b/wrnexus.config.ts @@ -5,14 +5,21 @@ const config: AppConfig = { seo: { title: "WRNexus UI Component Showcase", titleTemplate: "%s | UI Showcase", - description: "A generated, exhaustive showcase of every component in @wrnexus/ui.", + description: + "A generated, exhaustive showcase of every component in @wrnexus/ui.", robots: "noindex,nofollow", themeColor: "#7c3aed", }, security: { + // Studio supplies its own editor and sandboxed-preview security policies. + routeContentSecurityPolicy: true, contentSecurityPolicy: { directives: { - "frame-src": ["'self'", "https://www.openstreetmap.org", "https://www.google.com"], + "frame-src": [ + "'self'", + "https://www.openstreetmap.org", + "https://www.google.com", + ], }, }, }, @@ -22,7 +29,9 @@ const config: AppConfig = { }, fonts: { sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif', - google: [{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700, 800] }], + google: [ + { family: "Plus Jakarta Sans", weights: [400, 500, 600, 700, 800] }, + ], }, styles: { entry: "app/styles/global.css",