@canonical/summon-application 0.29.0-experimental.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +264 -0
  2. package/package.json +50 -0
  3. package/src/application/react/index.ts +294 -0
  4. package/src/application/react/templates/.storybook/decorators/index.ts +1 -0
  5. package/src/application/react/templates/.storybook/decorators/withRouter.tsx +44 -0
  6. package/src/application/react/templates/.storybook/main.ts +5 -0
  7. package/src/application/react/templates/.storybook/preview.ts +10 -0
  8. package/src/application/react/templates/README.md.ejs +82 -0
  9. package/src/application/react/templates/biome.json.ejs +6 -0
  10. package/src/application/react/templates/index.html.ejs +14 -0
  11. package/src/application/react/templates/package.json.ejs +72 -0
  12. package/src/application/react/templates/public/.gitkeep +0 -0
  13. package/src/application/react/templates/public/robots.txt +2 -0
  14. package/src/application/react/templates/src/assets/.gitkeep +0 -0
  15. package/src/application/react/templates/src/client/entry.tsx +25 -0
  16. package/src/application/react/templates/src/domains/account/AccountPage.tsx +13 -0
  17. package/src/application/react/templates/src/domains/account/LoginPage.tsx +27 -0
  18. package/src/application/react/templates/src/domains/account/routes.ts +44 -0
  19. package/src/application/react/templates/src/domains/contact/ContactPage.tsx +44 -0
  20. package/src/application/react/templates/src/domains/contact/routes.ts +11 -0
  21. package/src/application/react/templates/src/domains/marketing/GuidePage.tsx +17 -0
  22. package/src/application/react/templates/src/domains/marketing/HomePage.tsx +33 -0
  23. package/src/application/react/templates/src/domains/marketing/routes.ts +16 -0
  24. package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.stories.tsx +59 -0
  25. package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.tests.tsx +17 -0
  26. package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.tsx +29 -0
  27. package/src/application/react/templates/src/lib/ExampleComponent/index.ts +3 -0
  28. package/src/application/react/templates/src/lib/ExampleComponent/styles.css +7 -0
  29. package/src/application/react/templates/src/lib/ExampleComponent/types.ts +13 -0
  30. package/src/application/react/templates/src/lib/LazyComponent/LazyComponent.stories.tsx +23 -0
  31. package/src/application/react/templates/src/lib/LazyComponent/LazyComponent.tsx +32 -0
  32. package/src/application/react/templates/src/lib/LazyComponent/index.ts +1 -0
  33. package/src/application/react/templates/src/lib/Navigation/Navigation.tsx.ejs +21 -0
  34. package/src/application/react/templates/src/lib/Navigation/index.ts +1 -0
  35. package/src/application/react/templates/src/lib/ThemeSelector/ThemeSelector.tsx +30 -0
  36. package/src/application/react/templates/src/lib/ThemeSelector/index.ts +1 -0
  37. package/src/application/react/templates/src/lib/index.ts +4 -0
  38. package/src/application/react/templates/src/routes.tsx.ejs +129 -0
  39. package/src/application/react/templates/src/server/entry.tsx +45 -0
  40. package/src/application/react/templates/src/server/preview.bun.ts +79 -0
  41. package/src/application/react/templates/src/server/preview.express.ts +69 -0
  42. package/src/application/react/templates/src/server/renderer.tsx +50 -0
  43. package/src/application/react/templates/src/server/server.bun.ts +105 -0
  44. package/src/application/react/templates/src/server/server.express.ts +102 -0
  45. package/src/application/react/templates/src/sitemap/getSitemapItems.ts.ejs +31 -0
  46. package/src/application/react/templates/src/sitemap/renderer.ts +40 -0
  47. package/src/application/react/templates/src/styles/app.css +16 -0
  48. package/src/application/react/templates/src/styles/index.css.ejs +5 -0
  49. package/src/application/react/templates/src/vite-env.d.ts +1 -0
  50. package/src/application/react/templates/test/e2e/serverHarness.ts +153 -0
  51. package/src/application/react/templates/test/e2e/servers.e2e.ts +99 -0
  52. package/src/application/react/templates/tsconfig.json +32 -0
  53. package/src/application/react/templates/vite.config.ts +45 -0
  54. package/src/application/react/templates/vitest.config.ts +31 -0
  55. package/src/application/react/templates/vitest.e2e.config.ts +17 -0
  56. package/src/application/react/templates/vitest.setup.ts +9 -0
  57. package/src/domain/index.ts +119 -0
  58. package/src/index.test.ts +398 -0
  59. package/src/index.ts +14 -0
  60. package/src/route/index.ts +154 -0
  61. package/src/route/insertRoute.test.ts +98 -0
  62. package/src/route/insertRoute.ts +236 -0
  63. package/src/shared/casing.ts +14 -0
  64. package/src/shared/versions.ts +48 -0
  65. package/src/wrapper/index.ts +100 -0
@@ -0,0 +1,30 @@
1
+ import { usePreferredTheme } from "@canonical/react-hooks";
2
+ import type { ChangeEvent, ReactElement } from "react";
3
+
4
+ export default function ThemeSelector(): ReactElement {
5
+ const { value, source, set, reset } = usePreferredTheme();
6
+
7
+ function handleChange(event: ChangeEvent<HTMLSelectElement>) {
8
+ const selected = event.target.value;
9
+
10
+ if (selected === "system") {
11
+ reset();
12
+ } else {
13
+ set(selected as "light" | "dark");
14
+ }
15
+ }
16
+
17
+ const selectValue = source === "system" ? "system" : value;
18
+
19
+ return (
20
+ <select
21
+ aria-label="Color theme"
22
+ onChange={handleChange}
23
+ value={selectValue}
24
+ >
25
+ <option value="system">System</option>
26
+ <option value="light">Light</option>
27
+ <option value="dark">Dark</option>
28
+ </select>
29
+ );
30
+ }
@@ -0,0 +1 @@
1
+ export { default } from "./ThemeSelector.js";
@@ -0,0 +1,4 @@
1
+ export { ExampleComponent } from "./ExampleComponent/index.js";
2
+ export { default as LazyComponent } from "./LazyComponent/index.js";
3
+ export { default as Navigation } from "./Navigation/index.js";
4
+ export { default as ThemeSelector } from "./ThemeSelector/index.js";
@@ -0,0 +1,129 @@
1
+ import {
2
+ type AnyRoute,
3
+ group,
4
+ type NavigationContext,
5
+ type RouteMiddleware,
6
+ type RouteParamValues,
7
+ redirect,
8
+ route,
9
+ wrapper,
10
+ } from "@canonical/router-core";
11
+ import type { ReactElement, ReactNode } from "react";
12
+ import accountRoutes from "#domains/account/routes.js";
13
+ <% if (forms) { -%>
14
+ import contactRoutes from "#domains/contact/routes.js";
15
+ <% } -%>
16
+ import marketingRoutes from "#domains/marketing/routes.js";
17
+ import Navigation from "#lib/Navigation/index.js";
18
+
19
+ const protectedPaths = new Set(["/account"]);
20
+
21
+ function hasDemoAuth(search: unknown): boolean {
22
+ const authValue = (search as Record<string, unknown>)?.auth;
23
+
24
+ return authValue === "1";
25
+ }
26
+
27
+ export function getAuthRedirectHref(input: string | URL): string | null {
28
+ // Absolute URLs are used as-is; relative paths resolve against a dummy base.
29
+ const url =
30
+ input instanceof URL ? input : new URL(input, "https://router.local");
31
+
32
+ if (
33
+ !protectedPaths.has(url.pathname) ||
34
+ hasDemoAuth({ auth: url.searchParams.get("auth") })
35
+ ) {
36
+ return null;
37
+ }
38
+
39
+ return `/login?from=${encodeURIComponent(url.pathname)}`;
40
+ }
41
+
42
+ export function withAuth(loginPath: string): RouteMiddleware {
43
+ return ((currentRoute: AnyRoute) => {
44
+ if (!protectedPaths.has(currentRoute.url)) {
45
+ return currentRoute;
46
+ }
47
+
48
+ const currentPrefetch = currentRoute.prefetch;
49
+
50
+ return {
51
+ ...currentRoute,
52
+ prefetch: (
53
+ params: unknown,
54
+ search: unknown,
55
+ context: NavigationContext,
56
+ ) => {
57
+ if (!hasDemoAuth(search)) {
58
+ const from = currentRoute.render(
59
+ (params ?? {}) as RouteParamValues | Record<string, never>,
60
+ );
61
+
62
+ redirect(`${loginPath}?from=${encodeURIComponent(from)}`, 302);
63
+ }
64
+
65
+ if (currentPrefetch) {
66
+ return currentPrefetch(params, search, context);
67
+ }
68
+ },
69
+ };
70
+ }) as RouteMiddleware;
71
+ }
72
+
73
+ const publicLayout = wrapper<ReactElement>({
74
+ id: "public-layout",
75
+ component: ({ children }: { children: ReactNode }) => (
76
+ <div className="subgrid app-shell">
77
+ <header className="subgrid shell-header">
78
+ <Navigation />
79
+ </header>
80
+ <main className="subgrid">{children}</main>
81
+ </div>
82
+ ),
83
+ });
84
+
85
+ const notFoundRoute = route({
86
+ url: "/not-found",
87
+ content: () => (
88
+ <section>
89
+ <h1>Page not found</h1>
90
+ <p>The page you are looking for does not exist.</p>
91
+ </section>
92
+ ),
93
+ });
94
+
95
+ const [guide, home] = group(publicLayout, [
96
+ marketingRoutes.guide,
97
+ marketingRoutes.home,
98
+ ] as const);
99
+
100
+ const [account, login] = group(publicLayout, [
101
+ accountRoutes.account,
102
+ accountRoutes.login,
103
+ ] as const);
104
+
105
+ <% if (forms) { -%>
106
+ const [contact] = group(publicLayout, [contactRoutes.contact] as const);
107
+
108
+ <% } -%>
109
+ const appRoutes = {
110
+ guide,
111
+ home,
112
+ account,
113
+ login,
114
+ <% if (forms) { -%>
115
+ contact,
116
+ <% } -%>
117
+ } as const;
118
+
119
+ export type AppRoutes = typeof appRoutes;
120
+
121
+ declare module "@canonical/router-react" {
122
+ interface RouterRegister {
123
+ routes: AppRoutes;
124
+ }
125
+ }
126
+
127
+ export const middleware = [withAuth("/login")] as const;
128
+
129
+ export { appRoutes, notFoundRoute };
@@ -0,0 +1,45 @@
1
+ import { HeadProvider } from "@canonical/react-head";
2
+ import type { ServerEntrypointProps } from "@canonical/react-ssr/renderer";
3
+ import { createStaticRouter } from "@canonical/router-core";
4
+ import { Outlet, RouterProvider } from "@canonical/router-react";
5
+ import { appRoutes, middleware, notFoundRoute } from "../routes.js";
6
+ import "#styles/app.css";
7
+
8
+ interface InitialData extends Record<string, unknown> {
9
+ readonly url?: string;
10
+ /** Colour-scheme preference resolved from the request cookie, if any. */
11
+ readonly theme?: "light" | "dark";
12
+ }
13
+
14
+ export default function EntryServer(props: ServerEntrypointProps<InitialData>) {
15
+ const initialData = props.initialData ?? {};
16
+ const url = initialData.url ?? "/";
17
+ const router = createStaticRouter(appRoutes, url, {
18
+ middleware: [...middleware],
19
+ notFound: notFoundRoute,
20
+ });
21
+
22
+ // Paint the cookie-resolved theme on <html> for a flash-free first render —
23
+ // the same element `usePreferredTheme` toggles on the client, and one React
24
+ // does not hydrate (only `#root` is), so there is no mismatch to reconcile.
25
+ return (
26
+ <html lang={props.lang} className={initialData.theme}>
27
+ <head>
28
+ {props.otherHeadElements}
29
+ {props.scriptElements}
30
+ {props.linkElements}
31
+ </head>
32
+ <body>
33
+ <div id="root">
34
+ <HeadProvider>
35
+ <RouterProvider router={router}>
36
+ <Outlet fallback={<p>Loading…</p>} />
37
+ </RouterProvider>
38
+ </HeadProvider>
39
+ </div>
40
+ </body>
41
+ </html>
42
+ );
43
+ }
44
+
45
+ export type { InitialData };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Bun preview server — serves the compiled production artifact.
3
+ *
4
+ * Read this `fetch` handler top-to-bottom as a chain of small, independent
5
+ * pieces snapped together — the first that can handle the request wins:
6
+ *
7
+ * 1. static file — any extension-bearing path that exists under
8
+ * `dist/client` (`/robots.txt`, `/favicon.*`, the
9
+ * hashed `/assets/*`); extensionless routes fall through.
10
+ * 2. sitemap renderer — `/sitemap.xml` → the XML `SitemapRenderer`.
11
+ * 3. JSX app renderer — everything else → the HTML app.
12
+ *
13
+ * The two renderers are separate Lego bricks, each built and imported
14
+ * individually (`createAppRenderer` from the compiled `renderer.js`,
15
+ * `createSitemapRenderer` from the compiled `sitemap/renderer.js`). They know
16
+ * nothing about each other or about routing — this server is the only thing
17
+ * that looks at the URL and picks one, hard-coded right here. The dev server
18
+ * (`server.bun.ts`) makes the same pick the same way; only the module source
19
+ * differs (Vite `ssrLoadModule` there, compiled `dist` here).
20
+ *
21
+ * Run after `build:client` + `build:server` (the `preview:bun` script does
22
+ * both). This mirrors how a production deploy serves the same build, so
23
+ * `preview:bun` is a faithful pre-deploy check. Production itself uses platform
24
+ * adapters (Vercel, Cloudflare, …), not this server.
25
+ */
26
+ import * as process from "node:process";
27
+ import {
28
+ parseStaticPair,
29
+ resolveStaticFile,
30
+ } from "@canonical/react-ssr/server";
31
+ // The compiled renderers (built by `build:server`) — the same bundled artifacts
32
+ // a production deploy ships. The build output has no `.d.ts`; each default
33
+ // export matches its source factory.
34
+ // @ts-expect-error — importing a build artifact with no declarations
35
+ import createAppRenderer from "../../dist/server/renderer.js";
36
+ // @ts-expect-error — importing a build artifact with no declarations
37
+ import createSitemapRenderer from "../../dist/server/sitemap.js";
38
+
39
+ type CreateAppRenderer = typeof import("./renderer.js").default;
40
+ type CreateSitemapRenderer = typeof import("../sitemap/renderer.js").default;
41
+
42
+ const PORT = Number(process.env.PORT) || 5174;
43
+ const staticMount = parseStaticPair(":dist/client");
44
+
45
+ declare const Bun: {
46
+ serve(options: {
47
+ port: number;
48
+ fetch: (req: Request) => Response | Promise<Response>;
49
+ }): unknown;
50
+ file(path: string): Blob & { exists(): Promise<boolean> };
51
+ };
52
+
53
+ Bun.serve({
54
+ port: PORT,
55
+ async fetch(req: Request) {
56
+ const url = new URL(req.url);
57
+
58
+ const filePath = resolveStaticFile(url.pathname, staticMount);
59
+ if (filePath) {
60
+ const file = Bun.file(filePath);
61
+ if (await file.exists()) {
62
+ return new Response(file);
63
+ }
64
+ }
65
+
66
+ const renderer =
67
+ url.pathname === "/sitemap.xml"
68
+ ? (createSitemapRenderer as CreateSitemapRenderer)()
69
+ : (createAppRenderer as CreateAppRenderer)(req);
70
+ const stream = await renderer.renderToReadableStream(req.signal);
71
+
72
+ return new Response(stream, {
73
+ status: renderer.statusCode,
74
+ headers: { "Content-Type": renderer.contentType },
75
+ });
76
+ },
77
+ });
78
+
79
+ console.log(`Bun preview server on http://localhost:${PORT}/`);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Express preview server — serves the compiled production artifact.
3
+ *
4
+ * The request path is a chain of small, independent pieces snapped together —
5
+ * the first that can handle the request wins:
6
+ *
7
+ * 1. static file — any extension-bearing path that exists under
8
+ * `dist/client` (`/robots.txt`, `/favicon.*`, the
9
+ * hashed `/assets/*`); extensionless routes fall through.
10
+ * 2. sitemap renderer — `/sitemap.xml` → the XML `SitemapRenderer`.
11
+ * 3. JSX app renderer — everything else → the HTML app.
12
+ *
13
+ * The two renderers are separate Lego bricks, each built and imported
14
+ * individually (`createAppRenderer` from the compiled `renderer.js`,
15
+ * `createSitemapRenderer` from the compiled `sitemap/renderer.js`). They know
16
+ * nothing about each other or about routing — this server is the only thing
17
+ * that looks at the URL and picks one, hard-coded right here. The dev server
18
+ * (`server.express.ts`) makes the same pick the same way; only the module
19
+ * source differs (Vite `ssrLoadModule` there, compiled `dist` here), and only
20
+ * the transport differs from `preview.bun.ts` (express streams via
21
+ * `renderToPipeableStream`, Bun via `renderToReadableStream`).
22
+ *
23
+ * Run after `build:client` + `build:server` (the `preview:express` script does
24
+ * both). Production itself uses platform adapters (Vercel, Cloudflare, …), not
25
+ * this server.
26
+ */
27
+ import * as process from "node:process";
28
+ import express from "express";
29
+ // The compiled renderers (built by `build:server`) — the same bundled artifacts
30
+ // a production deploy ships. The build output has no `.d.ts`; each default
31
+ // export matches its source factory.
32
+ // @ts-expect-error — importing a build artifact with no declarations
33
+ import createAppRenderer from "../../dist/server/renderer.js";
34
+ // @ts-expect-error — importing a build artifact with no declarations
35
+ import createSitemapRenderer from "../../dist/server/sitemap.js";
36
+
37
+ type CreateAppRenderer = typeof import("./renderer.js").default;
38
+ type CreateSitemapRenderer = typeof import("../sitemap/renderer.js").default;
39
+
40
+ const PORT = Number(process.env.PORT) || 5174;
41
+
42
+ const app = express();
43
+
44
+ // Serve any real file under `dist/client` — `/robots.txt`, `/favicon.*`, the
45
+ // hashed `/assets/*`. `express.static` falls through (calls `next`) when the
46
+ // file does not exist, so extensionless routes and rendered ones like
47
+ // `/sitemap.xml` (never written to `dist`) reach the renderers below.
48
+ app.use(express.static("dist/client", { index: false }));
49
+
50
+ app.use(async (req, res, next) => {
51
+ try {
52
+ const renderer =
53
+ req.path === "/sitemap.xml"
54
+ ? (createSitemapRenderer as CreateSitemapRenderer)()
55
+ : (createAppRenderer as CreateAppRenderer)(req);
56
+ const result = renderer.renderToPipeableStream();
57
+
58
+ await renderer.statusReady;
59
+ res.status(renderer.statusCode);
60
+ res.setHeader("content-type", renderer.contentType);
61
+ result.pipe(res);
62
+ } catch (error) {
63
+ next(error);
64
+ }
65
+ });
66
+
67
+ app.listen(PORT, () => {
68
+ console.log(`Express preview server on http://localhost:${PORT}/`);
69
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Compiled JSX app renderer for production / preview.
3
+ *
4
+ * Read once at module load, the built `dist/client/index.html` shell carries
5
+ * the hashed `<script>`/`<link>` tags Vite injected at build time; the renderer
6
+ * extracts them and injects them into the streamed output.
7
+ *
8
+ * This is one Lego brick — a pure renderer. It knows nothing about routing or
9
+ * about the sitemap renderer; it just turns a request into the rendered app.
10
+ * The server (`src/server/index.ts` for preview, `server.bun.ts` /
11
+ * `server.express.ts` for dev) is what looks at the URL and picks a renderer.
12
+ *
13
+ * It reuses the same `EntryServer` (`src/server/entry.tsx`) the dev servers
14
+ * load via `ssrLoadModule` — the renderer is the invariant across dev and
15
+ * production; only the HTML shell source differs.
16
+ */
17
+ import fs from "node:fs";
18
+ import type { IncomingMessage } from "node:http";
19
+ import path from "node:path";
20
+ import { extractPreferences } from "@canonical/react-hooks";
21
+ import { JSXRenderer } from "@canonical/react-ssr/renderer";
22
+ import { getRequestUrl } from "@canonical/react-ssr/server";
23
+ import EntryServer, { type InitialData } from "./entry.js";
24
+
25
+ const htmlString = fs.readFileSync(
26
+ path.join(process.cwd(), "dist", "client", "index.html"),
27
+ "utf-8",
28
+ );
29
+
30
+ /** Read the `Cookie` header from either a Web `Request` or a Node request. */
31
+ function cookieHeader(request: Request | IncomingMessage): string | null {
32
+ return typeof (request as Request).headers?.get === "function"
33
+ ? (request as Request).headers.get("cookie")
34
+ : ((request as IncomingMessage).headers?.cookie ?? null);
35
+ }
36
+
37
+ /**
38
+ * Per-request factory for the JSX app renderer. Accepts either a Web `Request`
39
+ * (`serve-bun`) or a Node `IncomingMessage` (`serve-express`); it derives the
40
+ * URL for routing and the cookie-backed theme so the first paint matches the
41
+ * user's preference, passing both as the renderer's initial data.
42
+ */
43
+ export default function createAppRenderer(request: Request | IncomingMessage) {
44
+ const { theme } = extractPreferences(cookieHeader(request));
45
+ const initialData: InitialData = {
46
+ url: getRequestUrl(request),
47
+ theme: theme === "light" || theme === "dark" ? theme : undefined,
48
+ };
49
+ return new JSXRenderer(EntryServer, initialData, { htmlString });
50
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Bun development server with SSR and streaming.
3
+ *
4
+ * Read this `fetch` handler top-to-bottom as a chain of small, independent
5
+ * pieces snapped together — each tries to handle the request, and the first
6
+ * that can, wins:
7
+ *
8
+ * 1. `handleAsset` — Vite client assets + HMR (/@vite/client, /src/**,
9
+ * /@id/**, /@fs/**, /@react-refresh, /node_modules/.vite/**).
10
+ * 2. sitemap renderer — `/sitemap.xml` → the XML `SitemapRenderer`.
11
+ * 3. JSX app renderer — everything else → the HTML app.
12
+ *
13
+ * The two renderers are separate Lego bricks: the sitemap renderer
14
+ * (`src/sitemap/renderer.ts`) and the app renderer (`src/server/renderer.tsx`)
15
+ * know nothing about each other or about routing — this server is the only
16
+ * thing that looks at the URL and picks one. Swap a brick, add a `/robots.txt`
17
+ * brick, or reorder them without touching the others. The same three pieces
18
+ * appear in the same order in `server.express.ts` and in the compiled server
19
+ * entrypoint (`src/server/index.ts`) the preview bins use, so dev and preview
20
+ * behave identically.
21
+ *
22
+ * Bun.serve() is the HTTP layer; Vite runs in middleware mode for transforms +
23
+ * HMR; server modules load via vite.ssrLoadModule() so edits are picked up
24
+ * without a restart. Production deploys use platform adapters (Vercel,
25
+ * Cloudflare, …), not this server.
26
+ */
27
+ import fs from "node:fs";
28
+ import * as process from "node:process";
29
+ import { viteFetchMiddleware } from "@canonical/react-ssr/server";
30
+ import { createServer as createViteServer } from "vite";
31
+
32
+ const PORT = Number(process.env.PORT) || 5174;
33
+
34
+ const vite = await createViteServer({
35
+ server: { middlewareMode: true },
36
+ appType: "custom",
37
+ });
38
+
39
+ // Serve Vite's client assets/HMR; returns null for page routes (→ SSR below).
40
+ const handleAsset = viteFetchMiddleware(vite);
41
+
42
+ Bun.serve({
43
+ port: PORT,
44
+ async fetch(req: Request) {
45
+ const url = new URL(req.url);
46
+ const requestUrl = url.pathname + url.search;
47
+
48
+ try {
49
+ const asset = await handleAsset(req);
50
+ if (asset) return asset;
51
+
52
+ if (url.pathname === "/sitemap.xml") {
53
+ const { default: createSitemapRenderer } = await vite.ssrLoadModule(
54
+ "/src/sitemap/renderer.ts",
55
+ );
56
+ const renderer = createSitemapRenderer();
57
+ const stream = await renderer.renderToReadableStream(req.signal);
58
+
59
+ return new Response(stream, {
60
+ status: renderer.statusCode,
61
+ headers: { "Content-Type": renderer.contentType },
62
+ });
63
+ }
64
+
65
+ const template = fs.readFileSync("index.html", "utf-8");
66
+ const html = await vite.transformIndexHtml(requestUrl, template);
67
+
68
+ const { default: EntryServer } = await vite.ssrLoadModule(
69
+ "/src/server/entry.tsx",
70
+ );
71
+ const { JSXRenderer } = await vite.ssrLoadModule(
72
+ "@canonical/react-ssr/renderer",
73
+ );
74
+ const { extractPreferences } = await vite.ssrLoadModule(
75
+ "@canonical/react-hooks",
76
+ );
77
+
78
+ const { theme } = extractPreferences(req.headers.get("cookie"));
79
+ const renderer = new JSXRenderer(
80
+ EntryServer,
81
+ // The cookie is client-controlled, so only the known theme values reach
82
+ // the SSR `<html class>` — anything else is dropped (matches the
83
+ // compiled renderer in `renderer.tsx`).
84
+ {
85
+ url: requestUrl,
86
+ theme: theme === "light" || theme === "dark" ? theme : undefined,
87
+ },
88
+ { htmlString: html },
89
+ );
90
+ const stream = await renderer.renderToReadableStream(req.signal);
91
+
92
+ return new Response(stream, {
93
+ status: renderer.statusCode,
94
+ headers: { "Content-Type": renderer.contentType },
95
+ });
96
+ } catch (error) {
97
+ vite.ssrFixStacktrace(error as Error);
98
+ console.error(error);
99
+
100
+ return new Response("Internal server error", { status: 500 });
101
+ }
102
+ },
103
+ });
104
+
105
+ console.log(`Bun dev server on http://localhost:${PORT}/`);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Express development server with SSR and HMR.
3
+ *
4
+ * The request path is a chain of small, independent pieces snapped together —
5
+ * the first that can handle the request wins:
6
+ *
7
+ * 1. `vite.middlewares` — Vite client assets, module transforms, HMR.
8
+ * 2. sitemap renderer — `/sitemap.xml` → the XML `SitemapRenderer`.
9
+ * 3. JSX app renderer — everything else → the HTML app.
10
+ *
11
+ * The two renderers are separate Lego bricks: the sitemap renderer
12
+ * (`src/sitemap/renderer.ts`) and the app renderer (`src/server/renderer.tsx`)
13
+ * know nothing about each other or about routing — this middleware is the only
14
+ * thing that looks at the URL and picks one. Add a `/robots.txt` brick or swap
15
+ * a renderer without touching the others. The same pieces, in the same order,
16
+ * appear in `server.bun.ts` and in the compiled server entrypoint
17
+ * (`src/server/index.ts`) the preview bins use, so dev and preview behave
18
+ * identically — only the transport differs (express streams via
19
+ * `renderToPipeableStream`, Bun via `renderToReadableStream`).
20
+ *
21
+ * Vite handles client HMR + module transforms; server modules load via
22
+ * vite.ssrLoadModule() so edits are picked up without a restart. Production
23
+ * deploys use platform adapters (Vercel, Cloudflare, …), not this server.
24
+ */
25
+ import fs from "node:fs";
26
+ import * as process from "node:process";
27
+ import express from "express";
28
+ import { createServer as createViteServer } from "vite";
29
+
30
+ const PORT = Number(process.env.PORT) || 5174;
31
+
32
+ async function start() {
33
+ const app = express();
34
+
35
+ const vite = await createViteServer({
36
+ server: { middlewareMode: true },
37
+ appType: "custom",
38
+ });
39
+
40
+ app.use(vite.middlewares);
41
+
42
+ app.use(async (req, res, next) => {
43
+ const url = req.originalUrl || "/";
44
+
45
+ try {
46
+ if (url.split("?")[0] === "/sitemap.xml") {
47
+ const { default: createSitemapRenderer } = await vite.ssrLoadModule(
48
+ "/src/sitemap/renderer.ts",
49
+ );
50
+ const renderer = createSitemapRenderer();
51
+ const result = renderer.renderToPipeableStream();
52
+
53
+ await renderer.statusReady;
54
+ res.status(renderer.statusCode);
55
+ res.setHeader("content-type", renderer.contentType);
56
+ result.pipe(res);
57
+ return;
58
+ }
59
+
60
+ const template = fs.readFileSync("index.html", "utf-8");
61
+ const html = await vite.transformIndexHtml(url, template);
62
+
63
+ const { default: EntryServer } = await vite.ssrLoadModule(
64
+ "/src/server/entry.tsx",
65
+ );
66
+ const { JSXRenderer } = await vite.ssrLoadModule(
67
+ "@canonical/react-ssr/renderer",
68
+ );
69
+ const { extractPreferences } = await vite.ssrLoadModule(
70
+ "@canonical/react-hooks",
71
+ );
72
+
73
+ const { theme } = extractPreferences(req.headers.cookie ?? null);
74
+ const renderer = new JSXRenderer(
75
+ EntryServer,
76
+ // The cookie is client-controlled, so only the known theme values reach
77
+ // the SSR `<html class>` — anything else is dropped (matches the
78
+ // compiled renderer in `renderer.tsx`).
79
+ {
80
+ url,
81
+ theme: theme === "light" || theme === "dark" ? theme : undefined,
82
+ },
83
+ { htmlString: html },
84
+ );
85
+ const result = renderer.renderToPipeableStream();
86
+
87
+ await renderer.statusReady;
88
+ res.status(renderer.statusCode);
89
+ res.setHeader("content-type", renderer.contentType);
90
+ result.pipe(res);
91
+ } catch (error) {
92
+ vite.ssrFixStacktrace(error as Error);
93
+ next(error);
94
+ }
95
+ });
96
+
97
+ app.listen(PORT, () => {
98
+ console.log(`Express dev server on http://localhost:${PORT}/`);
99
+ });
100
+ }
101
+
102
+ start();
@@ -0,0 +1,31 @@
1
+ import type { SitemapItem } from "@canonical/react-ssr/renderer";
2
+
3
+ /**
4
+ * Returns sitemap entries for all known routes.
5
+ *
6
+ * Update this list as you add domains and routes. For dynamic content
7
+ * (blog posts, product pages), fetch entries from your data source.
8
+ *
9
+ * @example
10
+ * const posts = await fetchPosts();
11
+ * return [
12
+ * ...staticEntries,
13
+ * ...posts.map((post) => ({
14
+ * loc: `/blog/${post.slug}`,
15
+ * lastmod: post.updatedAt,
16
+ * changefreq: "weekly" as const,
17
+ * priority: 0.7,
18
+ * })),
19
+ * ];
20
+ */
21
+ export default async function getSitemapItems(): Promise<SitemapItem[]> {
22
+ return [
23
+ { loc: "/", changefreq: "weekly", priority: 1.0 },
24
+ { loc: "/guides/router-core", changefreq: "monthly", priority: 0.8 },
25
+ <% if (forms) { -%>
26
+ { loc: "/contact", changefreq: "monthly", priority: 0.7 },
27
+ <% } -%>
28
+ { loc: "/account", changefreq: "monthly", priority: 0.5 },
29
+ { loc: "/login", changefreq: "yearly", priority: 0.3 },
30
+ ];
31
+ }