// The `frontend` feature: the product-level configure screen, served by the
// PRODUCT container at /configure and iframed by ctl at
// manifest.ui.configScreenUrl.
//
// Not to be confused with either operator screen (views.ts, dashboard.ts):
// those are per-INSTANCE and served by Studio. This one is served once, by the
// single control-plane container the daemon runs, and its job is to collect the
// config a product-template is built from.
//
// The trap is registration-time. The daemon probes configScreenUrl and refuses
// the product with CONFIG_SCREEN_UNREACHABLE unless it answers 2xx — which is
// why declaring it, serving it and building it are one feature rather than
// three independent edits, and why the dev script builds dist before starting
// the backend that serves it.
import type { GeneratedFile, ShapeContext } from "../create-product.ts";
import { TEST_SETUP_TS, UI_TEST_DEV_DEPENDENCIES, VITEST_CONFIG_TS } from "./ui-tests.ts";
export const CONFIG_SCREEN_URL = "/configure";
function packageJson(ctx: ShapeContext): string {
return `${JSON.stringify(
{
name: `${ctx.scope}/frontend`,
version: "0.0.1",
private: true,
type: "module",
scripts: {
// Vite's own dev server, for iterating on the screen with HMR. The
// BACKEND serves dist/, so this is not what a registered dev product
// shows — see the root `dev` script.
dev: "bunx vite",
build: "bunx vite build",
clean: "rm -rf dist",
typecheck: "bunx tsc --noEmit",
test: "vitest run",
},
dependencies: {
[`${ctx.scope}/shared`]: "workspace:*",
react: "^19.0.0",
"react-dom": "^19.0.0",
},
devDependencies: {
"@types/node": "^22.10.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^6.0.1",
typescript: "^5.7.2",
vite: "^8.0.0",
...UI_TEST_DEV_DEPENDENCIES,
},
},
null,
2,
)}\n`;
}
// vite.config.ts stays out of `include` — it imports vite itself, and checking
// the app's own sources should not need the bundler installed.
const TSCONFIG = `{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src"]
}
`;
const VITE_CONFIG_TS = `import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// \`base: "./"\` — the screen is iframed under the product's own path prefix in
// the norsk-ctl UI, so absolute asset URLs escape it.
export default defineConfig({
base: "./",
plugins: [react()],
build: { outDir: "dist", emptyOutDir: true },
});
`;
function indexHtml(ctx: ShapeContext): string {
return `
${ctx.name} — configure
`;
}
const MAIN_TSX = `import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
const root = document.getElementById("root");
if (!root) throw new Error("root element missing");
ReactDOM.createRoot(root).render(
,
);
`;
function appTsx(ctx: ShapeContext): string {
return `// PLACEHOLDER configure screen. The starter template needs no configuration,
// so this says so rather than pretending to collect something.
//
// When the product grows real config: POST the ProductConfig this screen
// collects to /api/product-template and the backend returns the built tar.
// ProductConfigSchema (shared/src/schemas/config.ts) is the contract for what
// belongs here — it is the same schema the route validates against, so this
// workspace depends on ${ctx.scope}/shared rather than restating the shape.
export default function App() {
return (
${ctx.name}
The starter template needs no configuration. Launch it from norsk-ctl to bring up the Studio + Norsk stack this
product composes.
Replace this screen when the product grows launch-time config, or drop the frontend workspace if it never does.
);
}
`;
}
function appTestTsx(ctx: ShapeContext): string {
return `import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import App from "./App.tsx";
test("names the product", () => {
render();
expect(screen.getByRole("heading", { name: "${ctx.name}" })).toBeInTheDocument();
});
`;
}
const INDEX_CSS = `:root {
color-scheme: light dark;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
body {
margin: 0;
padding: 2rem;
}
`;
export function frontendFiles(ctx: ShapeContext): GeneratedFile[] {
return [
{ path: "frontend/package.json", content: packageJson(ctx) },
{ path: "frontend/tsconfig.json", content: TSCONFIG },
{ path: "frontend/vite.config.ts", content: VITE_CONFIG_TS },
{ path: "frontend/vitest.config.ts", content: VITEST_CONFIG_TS },
{ path: "frontend/index.html", content: indexHtml(ctx) },
{ path: "frontend/src/main.tsx", content: MAIN_TSX },
{ path: "frontend/src/App.tsx", content: appTsx(ctx) },
{ path: "frontend/src/App.test.tsx", content: appTestTsx(ctx) },
{ path: "frontend/src/test/setup.ts", content: TEST_SETUP_TS },
{ path: "frontend/src/index.css", content: INDEX_CSS },
];
}
/** The express block serving the built screen at configScreenUrl. */
export const FRONTEND_SERVER_BLOCK = ` // The configure screen at manifest.ui.configScreenUrl. The daemon probes this
// at \`product add\` and refuses the product with CONFIG_SCREEN_UNREACHABLE if
// it does not answer 2xx — so it must be BUILT (\`bun run build:frontend\`,
// which the image build and the root \`dev\` script both do) before the
// product is registered.
app.use("${CONFIG_SCREEN_URL}", express.static(FRONTEND_DIST));
app.get("${CONFIG_SCREEN_URL}/*splat", (_req, res) => {
res.sendFile(path.join(FRONTEND_DIST, "index.html"));
});`;