fix: serve design studio through the component app production route
This commit is contained in:
1 parent
3987ddbcfe
commit
9a6a2e0a8d
11 files changed
+174
-47
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Vendored
+3
-1
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user