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
|
PORT=3000 bun run start
|
||||||
```
|
```
|
||||||
|
|
||||||
`start` runs the production component site and an export-only Studio sidecar
|
`start` runs one production server. Design Studio is mounted directly at
|
||||||
bound to loopback port 4320. Include `deploy/nginx-design-studio.conf` inside your
|
`/design-studio/` by `app/middleware/design-studio.ts`, so the same route works
|
||||||
existing nginx server block, retain its existing component-site proxy, validate
|
with `bun dist/server.js` and with the development server. No extra port or
|
||||||
with `nginx -t`, then reload nginx. The proxy must preserve `/design-studio/` and
|
Studio-specific proxy rule is needed. Your existing reverse proxy should forward
|
||||||
Host, and pass Origin unchanged. If port 4320 is occupied, set DESIGN_STUDIO_PORT
|
all paths to the normal app port and preserve Host, Origin and X-Forwarded-Proto.
|
||||||
and update the proxy upstream to the same port. Manage the `bun run start`
|
If you installed the older Studio-only nginx location pointing to port 4320,
|
||||||
process using your existing service manager and restart it after each build.
|
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
|
The private `@wrnexus` registry is configured in `.npmrc`. Supply registry
|
||||||
credentials on the VM through its user configuration or secret store; no tokens
|
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 {
|
interface ApiContracts {
|
||||||
"/api/teams": { GET: ApiContract<typeof import("../api/teams.ts")["GET"]> };
|
"/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 DatabaseQueries = Record<string, never>;
|
||||||
type RealtimeMessages = Record<string, never>;
|
type RealtimeMessages = Record<string, never>;
|
||||||
type QueuePayloads = Record<string, never>;
|
type QueuePayloads = Record<string, never>;
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
# Include INSIDE the existing component site's nginx server block.
|
# Studio is now mounted in the component app on its existing port.
|
||||||
# The existing location / continues to proxy the component app on its usual port.
|
# No separate nginx location is required.
|
||||||
location = /design-studio {
|
# Remove the old /design-studio/ location that proxied to port 4320.
|
||||||
return 308 /design-studio/;
|
# 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.
|
||||||
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;
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
+4
-3
@@ -9,15 +9,16 @@
|
|||||||
"build": "bun run generate && wrnexus build .",
|
"build": "bun run generate && wrnexus build .",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"test": "bun test test",
|
"test": "bun test test",
|
||||||
"check": "bun run generate && bun run typecheck && bun run test && bun run build",
|
"check": "bun run generate && bun run typecheck && bun run test && bun run build && bun run check:deployment",
|
||||||
"start": "bun scripts/start.ts",
|
"start": "bun dist/server.js",
|
||||||
"production": "bun run build && bun run start",
|
"production": "bun run build && bun run start",
|
||||||
"doctor": "wrnexus doctor .",
|
"doctor": "wrnexus doctor .",
|
||||||
"config:explain": "wrnexus config . --explain",
|
"config:explain": "wrnexus config . --explain",
|
||||||
"analyze": "wrnexus analyze .",
|
"analyze": "wrnexus analyze .",
|
||||||
"update:preview": "wrnexus update . --dry-run",
|
"update:preview": "wrnexus update . --dry-run",
|
||||||
"start:site": "bun dist/server.js",
|
"start:site": "bun dist/server.js",
|
||||||
"studio": "bun scripts/studio.ts"
|
"studio": "bun scripts/studio.ts",
|
||||||
|
"check:deployment": "bun scripts/verify-deployment.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "0.8.25",
|
"@wrnexus/core": "0.8.25",
|
||||||
|
|||||||
+4
-18
@@ -1,18 +1,4 @@
|
|||||||
import { startStudio } from "./studio.ts";
|
// Compatibility entry point: Studio is mounted in the app middleware now.
|
||||||
|
const entry = "../dist/server.js";
|
||||||
const studio = await startStudio();
|
await import(entry);
|
||||||
const site = Bun.spawn([process.execPath, "dist/server.js"], {
|
export {};
|
||||||
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);
|
|
||||||
@@ -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 { expect, test } from "bun:test";
|
||||||
import { createStudioHandler } from "@wrnexus/design-studio/server";
|
import { createStudioHandler } from "@wrnexus/design-studio/server";
|
||||||
import { CORE_DESIGN_SYSTEMS } from "@wrnexus/design-system";
|
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 () => {
|
test("deployed Studio serves assets and isolated previews without application writes", async () => {
|
||||||
const handler = await createStudioHandler({
|
const handler = await createStudioHandler({
|
||||||
|
|||||||
+6
-1
@@ -15,6 +15,11 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "@wrnexus/core"
|
"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"]
|
"exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"]
|
||||||
}
|
}
|
||||||
+12
-3
@@ -5,14 +5,21 @@ const config: AppConfig = {
|
|||||||
seo: {
|
seo: {
|
||||||
title: "WRNexus UI Component Showcase",
|
title: "WRNexus UI Component Showcase",
|
||||||
titleTemplate: "%s | UI 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",
|
robots: "noindex,nofollow",
|
||||||
themeColor: "#7c3aed",
|
themeColor: "#7c3aed",
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
|
// Studio supplies its own editor and sandboxed-preview security policies.
|
||||||
|
routeContentSecurityPolicy: true,
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
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: {
|
fonts: {
|
||||||
sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif',
|
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: {
|
styles: {
|
||||||
entry: "app/styles/global.css",
|
entry: "app/styles/global.css",
|
||||||
|
|||||||
Reference in new issue
Block a user