fix: serve design studio through the component app production route

This commit is contained in:
Clintchiz committed 2026-10-19 13:34:08 +05:30
1 parent 3987ddbcfe
commit 9a6a2e0a8d
11 files changed
+174 -47

No files matched your search

+9 -7
View File
@@ -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
+29
View File
@@ -0,0 +1,29 @@
import type { Middleware } from "@wrnexus/core";
import type { createStudioHandler } from "@wrnexus/design-studio/server";
let studio: ReturnType<typeof createStudioHandler> | 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;
+3 -1
View File
@@ -24,7 +24,9 @@ declare namespace WRNexusGenerated {
interface ApiContracts {
"/api/teams": { GET: ApiContract<typeof import("../api/teams.ts")["GET"]> };
}
type MiddlewareContexts = Record<string, never>;
interface MiddlewareContexts {
"design-studio": MiddlewareContext<(typeof import("../middleware/design-studio.ts"))["default"]>;
}
type DatabaseQueries = Record<string, never>;
type RealtimeMessages = Record<string, never>;
type QueuePayloads = Record<string, never>;
+5 -14
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+4 -3
View File
@@ -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",
+4 -18
View File
@@ -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 {};
+78
View File
@@ -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);
}
+24
View File
@@ -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({
+6 -1
View File
@@ -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"]
}
+12 -3
View File
@@ -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",