@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.
- package/README.md +264 -0
- package/package.json +50 -0
- package/src/application/react/index.ts +294 -0
- package/src/application/react/templates/.storybook/decorators/index.ts +1 -0
- package/src/application/react/templates/.storybook/decorators/withRouter.tsx +44 -0
- package/src/application/react/templates/.storybook/main.ts +5 -0
- package/src/application/react/templates/.storybook/preview.ts +10 -0
- package/src/application/react/templates/README.md.ejs +82 -0
- package/src/application/react/templates/biome.json.ejs +6 -0
- package/src/application/react/templates/index.html.ejs +14 -0
- package/src/application/react/templates/package.json.ejs +72 -0
- package/src/application/react/templates/public/.gitkeep +0 -0
- package/src/application/react/templates/public/robots.txt +2 -0
- package/src/application/react/templates/src/assets/.gitkeep +0 -0
- package/src/application/react/templates/src/client/entry.tsx +25 -0
- package/src/application/react/templates/src/domains/account/AccountPage.tsx +13 -0
- package/src/application/react/templates/src/domains/account/LoginPage.tsx +27 -0
- package/src/application/react/templates/src/domains/account/routes.ts +44 -0
- package/src/application/react/templates/src/domains/contact/ContactPage.tsx +44 -0
- package/src/application/react/templates/src/domains/contact/routes.ts +11 -0
- package/src/application/react/templates/src/domains/marketing/GuidePage.tsx +17 -0
- package/src/application/react/templates/src/domains/marketing/HomePage.tsx +33 -0
- package/src/application/react/templates/src/domains/marketing/routes.ts +16 -0
- package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.stories.tsx +59 -0
- package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.tests.tsx +17 -0
- package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.tsx +29 -0
- package/src/application/react/templates/src/lib/ExampleComponent/index.ts +3 -0
- package/src/application/react/templates/src/lib/ExampleComponent/styles.css +7 -0
- package/src/application/react/templates/src/lib/ExampleComponent/types.ts +13 -0
- package/src/application/react/templates/src/lib/LazyComponent/LazyComponent.stories.tsx +23 -0
- package/src/application/react/templates/src/lib/LazyComponent/LazyComponent.tsx +32 -0
- package/src/application/react/templates/src/lib/LazyComponent/index.ts +1 -0
- package/src/application/react/templates/src/lib/Navigation/Navigation.tsx.ejs +21 -0
- package/src/application/react/templates/src/lib/Navigation/index.ts +1 -0
- package/src/application/react/templates/src/lib/ThemeSelector/ThemeSelector.tsx +30 -0
- package/src/application/react/templates/src/lib/ThemeSelector/index.ts +1 -0
- package/src/application/react/templates/src/lib/index.ts +4 -0
- package/src/application/react/templates/src/routes.tsx.ejs +129 -0
- package/src/application/react/templates/src/server/entry.tsx +45 -0
- package/src/application/react/templates/src/server/preview.bun.ts +79 -0
- package/src/application/react/templates/src/server/preview.express.ts +69 -0
- package/src/application/react/templates/src/server/renderer.tsx +50 -0
- package/src/application/react/templates/src/server/server.bun.ts +105 -0
- package/src/application/react/templates/src/server/server.express.ts +102 -0
- package/src/application/react/templates/src/sitemap/getSitemapItems.ts.ejs +31 -0
- package/src/application/react/templates/src/sitemap/renderer.ts +40 -0
- package/src/application/react/templates/src/styles/app.css +16 -0
- package/src/application/react/templates/src/styles/index.css.ejs +5 -0
- package/src/application/react/templates/src/vite-env.d.ts +1 -0
- package/src/application/react/templates/test/e2e/serverHarness.ts +153 -0
- package/src/application/react/templates/test/e2e/servers.e2e.ts +99 -0
- package/src/application/react/templates/tsconfig.json +32 -0
- package/src/application/react/templates/vite.config.ts +45 -0
- package/src/application/react/templates/vitest.config.ts +31 -0
- package/src/application/react/templates/vitest.e2e.config.ts +17 -0
- package/src/application/react/templates/vitest.setup.ts +9 -0
- package/src/domain/index.ts +119 -0
- package/src/index.test.ts +398 -0
- package/src/index.ts +14 -0
- package/src/route/index.ts +154 -0
- package/src/route/insertRoute.test.ts +98 -0
- package/src/route/insertRoute.ts +236 -0
- package/src/shared/casing.ts +14 -0
- package/src/shared/versions.ts +48 -0
- 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,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
|
+
}
|