zudoku 0.78.0 → 0.78.2
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/dist/cli/cli.js +602 -160
- package/dist/cli/worker.js +6 -4
- package/dist/declarations/app/adapter.d.ts +12 -0
- package/dist/declarations/app/adapters/cloudflare.d.ts +8 -0
- package/dist/declarations/app/adapters/lambda.d.ts +13 -0
- package/dist/declarations/app/adapters/node.d.ts +12 -0
- package/dist/declarations/app/adapters/vercel.d.ts +14 -0
- package/dist/declarations/app/entry.client.d.ts +2 -0
- package/dist/declarations/app/entry.server.d.ts +13 -0
- package/dist/declarations/app/protectChunks.d.ts +17 -0
- package/dist/declarations/app/wrapProtectedRoutes.d.ts +4 -0
- package/dist/declarations/config/validators/HeaderNavigationSchema.d.ts +216 -80
- package/dist/declarations/config/validators/ZudokuConfig.d.ts +81 -30
- package/dist/declarations/config/validators/icon-types.d.ts +1 -1
- package/dist/declarations/lib/authentication/authentication.d.ts +7 -0
- package/dist/declarations/lib/authentication/cookie-sync.d.ts +3 -0
- package/dist/declarations/lib/authentication/cookies.d.ts +10 -0
- package/dist/declarations/lib/authentication/providers/azureb2c.d.ts +6 -1
- package/dist/declarations/lib/authentication/providers/clerk.d.ts +1 -0
- package/dist/declarations/lib/authentication/providers/openid.d.ts +2 -1
- package/dist/declarations/lib/authentication/providers/supabase.d.ts +2 -0
- package/dist/declarations/lib/authentication/session-handler.d.ts +81 -0
- package/dist/declarations/lib/authentication/state.d.ts +7 -0
- package/dist/declarations/lib/authentication/verify-cache.d.ts +2 -0
- package/dist/declarations/lib/components/Bootstrap.d.ts +2 -2
- package/dist/declarations/lib/components/Heading.d.ts +1 -1
- package/dist/declarations/lib/components/context/RenderContext.d.ts +6 -0
- package/dist/declarations/lib/core/RouteGuard.d.ts +11 -0
- package/dist/declarations/lib/core/ZudokuContext.d.ts +5 -2
- package/dist/declarations/lib/manifest.d.ts +25 -0
- package/dist/declarations/lib/util/url.d.ts +2 -0
- package/dist/flat-config.d.ts +1 -1
- package/docs/configuration/protected-routes.md +21 -4
- package/docs/guides/server-side-content-protection.md +207 -0
- package/package.json +26 -4
- package/src/app/adapter.ts +16 -0
- package/src/app/adapters/cloudflare.ts +18 -0
- package/src/app/adapters/lambda.ts +36 -0
- package/src/app/adapters/node.ts +32 -0
- package/src/app/adapters/vercel.ts +39 -0
- package/src/app/demo.tsx +2 -2
- package/src/app/entry.client.tsx +19 -7
- package/src/app/entry.server.tsx +133 -9
- package/src/app/main.tsx +21 -3
- package/src/app/protectChunks.ts +64 -0
- package/src/app/standalone.tsx +2 -2
- package/src/app/wrapProtectedRoutes.ts +82 -0
- package/src/config/validators/icon-types.ts +17 -0
- package/src/lib/authentication/authentication.ts +15 -0
- package/src/lib/authentication/cookie-sync.ts +90 -0
- package/src/lib/authentication/cookies.ts +54 -0
- package/src/lib/authentication/hook.ts +13 -0
- package/src/lib/authentication/providers/azureb2c.tsx +70 -2
- package/src/lib/authentication/providers/clerk.tsx +49 -0
- package/src/lib/authentication/providers/openid.tsx +46 -0
- package/src/lib/authentication/providers/supabase.tsx +30 -2
- package/src/lib/authentication/session-handler.ts +164 -0
- package/src/lib/authentication/state.ts +36 -5
- package/src/lib/authentication/verify-cache.ts +32 -0
- package/src/lib/components/Bootstrap.tsx +20 -14
- package/src/lib/components/Header.tsx +56 -57
- package/src/lib/components/MobileTopNavigation.tsx +66 -67
- package/src/lib/components/Zudoku.tsx +14 -1
- package/src/lib/components/context/RenderContext.ts +8 -0
- package/src/lib/components/context/ZudokuContext.ts +2 -1
- package/src/lib/core/RouteGuard.tsx +50 -29
- package/src/lib/core/ZudokuContext.ts +39 -6
- package/src/lib/errors/RouterError.tsx +43 -1
- package/src/lib/manifest.ts +62 -0
- package/src/lib/oas/parser/dereference/index.ts +2 -1
- package/src/lib/oas/parser/dereference/resolveRef.ts +2 -1
- package/src/lib/oas/parser/index.ts +1 -1
- package/src/lib/plugins/openapi/client/createServer.ts +13 -4
- package/src/lib/plugins/search-pagefind/index.tsx +1 -4
- package/src/lib/util/os.ts +1 -0
- package/src/lib/util/url.ts +13 -0
- package/src/vite/build.ts +84 -24
- package/src/vite/config.ts +51 -5
- package/src/vite/dev-server.ts +61 -8
- package/src/vite/manifest.ts +15 -0
- package/src/vite/plugin-api.ts +3 -1
- package/src/vite/plugin-markdown-export.ts +3 -9
- package/src/vite/prerender/worker.ts +2 -4
- package/src/vite/protected/annotator.ts +136 -0
- package/src/vite/protected/build.ts +151 -0
- package/src/vite/protected/registry.ts +82 -0
- package/src/vite/ssr-templates/cloudflare.ts +5 -18
- package/src/vite/ssr-templates/lambda.ts +4 -0
- package/src/vite/ssr-templates/node.ts +7 -22
- package/src/vite/ssr-templates/vercel.ts +6 -20
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { serveStatic as defaultServeStatic } from "@hono/node-server/serve-static";
|
|
4
|
+
import type { Adapter } from "../adapter.js";
|
|
5
|
+
import type { protectChunks } from "../protectChunks.js";
|
|
6
|
+
|
|
7
|
+
type ProtectChunksOpts = Parameters<typeof protectChunks>[0];
|
|
8
|
+
type ServeStaticFactory = Extract<
|
|
9
|
+
ProtectChunksOpts,
|
|
10
|
+
{ serveStatic: unknown }
|
|
11
|
+
>["serveStatic"];
|
|
12
|
+
|
|
13
|
+
export type NodeAdapterOptions = {
|
|
14
|
+
serverDir?: string;
|
|
15
|
+
serveStatic?: ServeStaticFactory;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const node = (opts: NodeAdapterOptions = {}): Adapter => ({
|
|
19
|
+
setup: (app, ctx) => {
|
|
20
|
+
const serverDir = opts.serverDir ?? dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const serveStatic = opts.serveStatic ?? defaultServeStatic;
|
|
22
|
+
app.all("/server/*", (c) => c.notFound());
|
|
23
|
+
app.use(
|
|
24
|
+
ctx.protectChunks({
|
|
25
|
+
basePath: ctx.basePath,
|
|
26
|
+
serverDir,
|
|
27
|
+
serveStatic,
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
app.use("*", serveStatic({ root: dirname(serverDir) }));
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { serveStatic as defaultServeStatic } from "@hono/node-server/serve-static";
|
|
4
|
+
import { handle } from "hono/vercel";
|
|
5
|
+
import type { Adapter } from "../adapter.js";
|
|
6
|
+
import type { protectChunks } from "../protectChunks.js";
|
|
7
|
+
|
|
8
|
+
type ProtectChunksOpts = Parameters<typeof protectChunks>[0];
|
|
9
|
+
type ServeStaticFactory = Extract<
|
|
10
|
+
ProtectChunksOpts,
|
|
11
|
+
{ serveStatic: unknown }
|
|
12
|
+
>["serveStatic"];
|
|
13
|
+
|
|
14
|
+
export type VercelAdapterOptions = {
|
|
15
|
+
serverDir?: string;
|
|
16
|
+
staticDir?: string;
|
|
17
|
+
serveStatic?: ServeStaticFactory;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const vercel = (
|
|
21
|
+
opts: VercelAdapterOptions = {},
|
|
22
|
+
): Adapter<ReturnType<typeof handle>> => ({
|
|
23
|
+
setup: (app, ctx) => {
|
|
24
|
+
const serverDir =
|
|
25
|
+
opts.serverDir ?? path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const staticDir = opts.staticDir ?? path.join(serverDir, "..");
|
|
27
|
+
const serveStatic = opts.serveStatic ?? defaultServeStatic;
|
|
28
|
+
app.all("/server/*", (c) => c.notFound());
|
|
29
|
+
app.use(
|
|
30
|
+
ctx.protectChunks({
|
|
31
|
+
basePath: ctx.basePath,
|
|
32
|
+
serverDir,
|
|
33
|
+
serveStatic,
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
app.use("*", serveStatic({ root: staticDir }));
|
|
37
|
+
},
|
|
38
|
+
finalize: (app) => handle(app),
|
|
39
|
+
});
|
package/src/app/demo.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import logger from "loglevel";
|
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
3
|
import { createBrowserRouter } from "react-router";
|
|
4
4
|
import type { ZudokuConfig } from "../config/validators/ZudokuConfig.js";
|
|
5
|
-
import {
|
|
5
|
+
import { BootstrapClient } from "../lib/components/Bootstrap.js";
|
|
6
6
|
import DemoAnnouncement from "../lib/demo/DemoAnnouncement.js";
|
|
7
7
|
import "../lib/util/logInit.js";
|
|
8
8
|
import "./main.css";
|
|
@@ -74,4 +74,4 @@ const routes = getRoutesByConfig(config);
|
|
|
74
74
|
const router = createBrowserRouter(routes, {
|
|
75
75
|
basename: window.location.pathname,
|
|
76
76
|
});
|
|
77
|
-
createRoot(root).render(<
|
|
77
|
+
createRoot(root).render(<BootstrapClient router={router} />);
|
package/src/app/entry.client.tsx
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
+
import type { DehydratedState } from "@tanstack/react-query";
|
|
1
2
|
import { createRoot, hydrateRoot } from "react-dom/client";
|
|
2
3
|
import {
|
|
3
4
|
createBrowserRouter,
|
|
4
5
|
matchRoutes,
|
|
5
6
|
type RouteObject,
|
|
6
7
|
} from "react-router";
|
|
7
|
-
import config from "virtual:zudoku-config";
|
|
8
8
|
import "vite/modulepreload-polyfill";
|
|
9
|
-
import
|
|
9
|
+
import config from "virtual:zudoku-config";
|
|
10
|
+
import { setupCookieSync } from "../lib/authentication/cookie-sync.js";
|
|
11
|
+
import { authState } from "../lib/authentication/state.js";
|
|
12
|
+
import { BootstrapClient } from "../lib/components/Bootstrap.js";
|
|
13
|
+
import { joinUrl } from "../lib/util/joinUrl.js";
|
|
10
14
|
import { getRoutesByConfig, shikiReady } from "./main.js";
|
|
11
15
|
|
|
16
|
+
if (import.meta.env.ZUDOKU_HAS_SERVER) {
|
|
17
|
+
setupCookieSync(authState, joinUrl(config.basePath, "/__z/auth/session"));
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
const routes = getRoutesByConfig(config);
|
|
13
21
|
// biome-ignore lint/style/noNonNullAssertion: We know the root element exists
|
|
14
22
|
const root = document.getElementById("root")!;
|
|
@@ -16,6 +24,7 @@ const root = document.getElementById("root")!;
|
|
|
16
24
|
declare global {
|
|
17
25
|
interface Window {
|
|
18
26
|
ZUDOKU_VERSION: string;
|
|
27
|
+
ZUDOKU_DATA?: DehydratedState;
|
|
19
28
|
}
|
|
20
29
|
}
|
|
21
30
|
|
|
@@ -96,7 +105,7 @@ function render(routes: RouteObject[]) {
|
|
|
96
105
|
const router = createBrowserRouter(routes, {
|
|
97
106
|
basename: config.basePath,
|
|
98
107
|
});
|
|
99
|
-
createRoot(root).render(<
|
|
108
|
+
createRoot(root).render(<BootstrapClient router={router} />);
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
async function hydrate(routes: RouteObject[]) {
|
|
@@ -106,13 +115,16 @@ async function hydrate(routes: RouteObject[]) {
|
|
|
106
115
|
basename: config.basePath,
|
|
107
116
|
});
|
|
108
117
|
|
|
109
|
-
hydrateRoot(root, <
|
|
118
|
+
hydrateRoot(root, <BootstrapClient hydrate router={router} />);
|
|
110
119
|
}
|
|
111
120
|
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
121
|
+
// Reload on chunk preload failures to recover from version skew.
|
|
122
|
+
// Throttled so a non-recoverable error (e.g. auth-gated chunk 401) doesn't loop infinitely.
|
|
123
|
+
// https://vite.dev/guide/build.html#load-error-handling
|
|
115
124
|
window.addEventListener("vite:preloadError", (e) => {
|
|
116
125
|
e.preventDefault();
|
|
126
|
+
const last = Number(sessionStorage.getItem("zudoku:preload-reload-ts") ?? 0);
|
|
127
|
+
if (Date.now() - last < 30_000) return;
|
|
128
|
+
sessionStorage.setItem("zudoku:preload-reload-ts", String(Date.now()));
|
|
117
129
|
window.location.reload();
|
|
118
130
|
});
|
package/src/app/entry.server.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { dehydrate, QueryClient } from "@tanstack/react-query";
|
|
2
2
|
import type { HelmetData } from "@zudoku/react-helmet-async";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { compress } from "hono/compress";
|
|
3
5
|
import logger from "loglevel";
|
|
4
6
|
import { renderToReadableStream, renderToStaticMarkup } from "react-dom/server";
|
|
5
7
|
import {
|
|
@@ -9,12 +11,73 @@ import {
|
|
|
9
11
|
type RouteObject,
|
|
10
12
|
} from "react-router";
|
|
11
13
|
import "vite/modulepreload-polyfill";
|
|
14
|
+
import { configuredAuthProvider } from "virtual:zudoku-auth";
|
|
15
|
+
import config from "virtual:zudoku-config";
|
|
16
|
+
import { parseCookies } from "../lib/authentication/cookies.js";
|
|
17
|
+
import { createSessionHandler } from "../lib/authentication/session-handler.js";
|
|
18
|
+
import { cachedVerifyAccessToken } from "../lib/authentication/verify-cache.js";
|
|
12
19
|
import { BootstrapStatic } from "../lib/components/Bootstrap.js";
|
|
13
20
|
import { NO_DEHYDRATE } from "../lib/components/cache.js";
|
|
21
|
+
import type { SSRAuthState } from "../lib/components/context/RenderContext.js";
|
|
14
22
|
import { ServerError } from "../lib/errors/ServerError.js";
|
|
23
|
+
import { buildManifest } from "../lib/manifest.js";
|
|
15
24
|
import { highlighterPromise } from "../lib/shiki.js";
|
|
25
|
+
import type { Adapter } from "./adapter.js";
|
|
16
26
|
import { getRoutesByConfig } from "./main.js";
|
|
27
|
+
import { protectChunks as rawProtectChunks } from "./protectChunks.js";
|
|
28
|
+
import { wrapProtectedRoutes } from "./wrapProtectedRoutes.js";
|
|
29
|
+
|
|
17
30
|
export { getRoutesByConfig };
|
|
31
|
+
export type { Adapter, AdapterContext } from "./adapter.js";
|
|
32
|
+
|
|
33
|
+
// Shared cached verifier for session handler, SSR render, and protected-chunk gate.
|
|
34
|
+
const verifier = configuredAuthProvider?.verifyAccessToken
|
|
35
|
+
? (token: string) =>
|
|
36
|
+
cachedVerifyAccessToken(
|
|
37
|
+
// biome-ignore lint/style/noNonNullAssertion: verifyAccessToken is guaranteed to be defined
|
|
38
|
+
configuredAuthProvider!.verifyAccessToken!.bind(configuredAuthProvider),
|
|
39
|
+
token,
|
|
40
|
+
)
|
|
41
|
+
: undefined;
|
|
42
|
+
|
|
43
|
+
// Wire verifier here so adapters remain auth-agnostic.
|
|
44
|
+
export const protectChunks: typeof rawProtectChunks = (opts) =>
|
|
45
|
+
rawProtectChunks({
|
|
46
|
+
...opts,
|
|
47
|
+
verifyAccessToken: opts.verifyAccessToken ?? verifier,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const safeSerialize = (data: unknown) =>
|
|
51
|
+
JSON.stringify(data)
|
|
52
|
+
.replace(/</g, "\\u003c")
|
|
53
|
+
.replace(/>/g, "\\u003e")
|
|
54
|
+
.replace(/\u2028/g, "\\u2028")
|
|
55
|
+
.replace(/\u2029/g, "\\u2029");
|
|
56
|
+
|
|
57
|
+
// Profile must come from the verifier so a forged cookie can't render
|
|
58
|
+
// protected HTML. SSG has no runtime cookie and short-circuits.
|
|
59
|
+
const resolveSsrAuth = async (
|
|
60
|
+
request: Request,
|
|
61
|
+
): Promise<SSRAuthState | undefined> => {
|
|
62
|
+
if (!import.meta.env.ZUDOKU_HAS_SERVER || !configuredAuthProvider) return;
|
|
63
|
+
|
|
64
|
+
const { accessToken } = parseCookies(request);
|
|
65
|
+
if (!accessToken || !verifier)
|
|
66
|
+
return { accessToken: undefined, profile: null };
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const verified = await verifier(accessToken);
|
|
70
|
+
return verified
|
|
71
|
+
? { accessToken, profile: verified.profile }
|
|
72
|
+
: { accessToken: undefined, profile: null };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
logger.error(
|
|
75
|
+
`SSR auth verifier error (${request.method} ${request.url}):`,
|
|
76
|
+
error,
|
|
77
|
+
);
|
|
78
|
+
return { accessToken: undefined, profile: null };
|
|
79
|
+
}
|
|
80
|
+
};
|
|
18
81
|
|
|
19
82
|
// Statically importing shiki.ts here ensures it's in the SSR bundle.
|
|
20
83
|
// main.tsx dynamically imports it instead to enable lazy loading on the client.
|
|
@@ -37,7 +100,18 @@ export const handleRequest = async ({
|
|
|
37
100
|
basePath?: string;
|
|
38
101
|
bypassProtection?: boolean;
|
|
39
102
|
}): Promise<Response> => {
|
|
40
|
-
const
|
|
103
|
+
const ssrAuth = await resolveSsrAuth(request);
|
|
104
|
+
|
|
105
|
+
// No-op lazy() on protected subtrees for unauthed requests so loaders
|
|
106
|
+
// don't run for a 401 render.
|
|
107
|
+
const effectiveRoutes = wrapProtectedRoutes(
|
|
108
|
+
routes,
|
|
109
|
+
config.protectedRoutes,
|
|
110
|
+
!!ssrAuth?.profile,
|
|
111
|
+
basePath,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const { query, dataRoutes } = createStaticHandler(effectiveRoutes, {
|
|
41
115
|
basename: basePath,
|
|
42
116
|
});
|
|
43
117
|
const queryClient = new QueryClient();
|
|
@@ -67,6 +141,7 @@ export const handleRequest = async ({
|
|
|
67
141
|
const renderContext = {
|
|
68
142
|
status: 200,
|
|
69
143
|
bypassProtection: bypassProtection ?? false,
|
|
144
|
+
ssrAuth,
|
|
70
145
|
};
|
|
71
146
|
|
|
72
147
|
const App = (
|
|
@@ -84,9 +159,7 @@ export const handleRequest = async ({
|
|
|
84
159
|
const reactStream = await renderToReadableStream(App, {
|
|
85
160
|
onError(error) {
|
|
86
161
|
status = 500;
|
|
87
|
-
|
|
88
|
-
logger.error("SSR Error:", error);
|
|
89
|
-
}
|
|
162
|
+
logger.error(`SSR Error (${request.method} ${request.url}):`, error);
|
|
90
163
|
},
|
|
91
164
|
});
|
|
92
165
|
|
|
@@ -124,9 +197,6 @@ export const handleRequest = async ({
|
|
|
124
197
|
const dehydrated = dehydrate(queryClient, {
|
|
125
198
|
shouldDehydrateQuery: (q) => !q.queryKey.includes(NO_DEHYDRATE),
|
|
126
199
|
});
|
|
127
|
-
const serialized = JSON.stringify(dehydrated)
|
|
128
|
-
.replace(/</g, "\\u003c")
|
|
129
|
-
.replace(/>/g, "\\u003e");
|
|
130
200
|
|
|
131
201
|
const closingTag = "</body>";
|
|
132
202
|
const idx = htmlEnd.lastIndexOf(closingTag);
|
|
@@ -135,8 +205,14 @@ export const handleRequest = async ({
|
|
|
135
205
|
controller.enqueue(encoder.encode(htmlEnd));
|
|
136
206
|
} else {
|
|
137
207
|
controller.enqueue(encoder.encode(htmlEnd.slice(0, idx)));
|
|
208
|
+
const scripts = [`window.ZUDOKU_DATA=${safeSerialize(dehydrated)}`];
|
|
209
|
+
if (ssrAuth) {
|
|
210
|
+
scripts.push(
|
|
211
|
+
`window.ZUDOKU_SSR_AUTH=${safeSerialize({ profile: ssrAuth.profile })}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
138
214
|
controller.enqueue(
|
|
139
|
-
encoder.encode(`<script
|
|
215
|
+
encoder.encode(`<script>${scripts.join(";")}</script>`),
|
|
140
216
|
);
|
|
141
217
|
controller.enqueue(encoder.encode(htmlEnd.slice(idx)));
|
|
142
218
|
}
|
|
@@ -146,11 +222,24 @@ export const handleRequest = async ({
|
|
|
146
222
|
},
|
|
147
223
|
});
|
|
148
224
|
|
|
225
|
+
const headers: HeadersInit = {
|
|
226
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
227
|
+
};
|
|
228
|
+
// Only suppress caching for pages that embed a per-user profile.
|
|
229
|
+
// Anonymous renders (auth configured but no session) stay cacheable.
|
|
230
|
+
if (ssrAuth?.profile) {
|
|
231
|
+
headers["Cache-Control"] = "private, no-store";
|
|
232
|
+
}
|
|
233
|
+
|
|
149
234
|
return new Response(stream, {
|
|
150
235
|
status: renderContext.status !== 200 ? renderContext.status : status,
|
|
151
|
-
headers
|
|
236
|
+
headers,
|
|
152
237
|
});
|
|
153
238
|
} catch (error) {
|
|
239
|
+
logger.error(
|
|
240
|
+
`SSR fatal render error (${request.method} ${request.url}):`,
|
|
241
|
+
error,
|
|
242
|
+
);
|
|
154
243
|
const html = renderToStaticMarkup(<ServerError error={error} />);
|
|
155
244
|
return new Response(html, {
|
|
156
245
|
status: 500,
|
|
@@ -158,3 +247,38 @@ export const handleRequest = async ({
|
|
|
158
247
|
});
|
|
159
248
|
}
|
|
160
249
|
};
|
|
250
|
+
|
|
251
|
+
export const manifest = buildManifest({
|
|
252
|
+
basePath: config.basePath,
|
|
253
|
+
protectedRoutes: config.protectedRoutes,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
export const createApp = () => new Hono();
|
|
257
|
+
|
|
258
|
+
export type MountOptions<T = Hono> = {
|
|
259
|
+
adapter?: Adapter<T>;
|
|
260
|
+
template?: string;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
declare const __ZUDOKU_TEMPLATE__: string;
|
|
264
|
+
|
|
265
|
+
export const mount = <T = Hono>(
|
|
266
|
+
app: Hono,
|
|
267
|
+
options: MountOptions<T> = {},
|
|
268
|
+
): T => {
|
|
269
|
+
const template = options.template ?? __ZUDOKU_TEMPLATE__;
|
|
270
|
+
const basePath = config.basePath;
|
|
271
|
+
const routes = getRoutesByConfig(config);
|
|
272
|
+
|
|
273
|
+
app.use(compress());
|
|
274
|
+
app.route(manifest.auth.sessionEndpoint, createSessionHandler(verifier));
|
|
275
|
+
options.adapter?.setup?.(app, { basePath, manifest, protectChunks });
|
|
276
|
+
app.all("*", (c) =>
|
|
277
|
+
handleRequest({ template, request: c.req.raw, routes, basePath }),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
return (options.adapter?.finalize?.(app) ?? app) as T;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export const createServer = <T = Hono>(options: MountOptions<T> = {}): T =>
|
|
284
|
+
mount(createApp(), options);
|
package/src/app/main.tsx
CHANGED
|
@@ -18,11 +18,12 @@ import "virtual:zudoku-theme.css";
|
|
|
18
18
|
import { Zudoku } from "zudoku/components";
|
|
19
19
|
import { Outlet } from "zudoku/router";
|
|
20
20
|
import type { ZudokuConfig } from "../config/config.js";
|
|
21
|
+
import { authState } from "../lib/authentication/state.js";
|
|
21
22
|
import { BuildCheck } from "../lib/components/BuildCheck.js";
|
|
22
|
-
import "./main.css";
|
|
23
23
|
import { Meta } from "../lib/components/Meta.js";
|
|
24
|
-
import "./
|
|
24
|
+
import "./main.css";
|
|
25
25
|
import { StatusPage } from "../lib/components/StatusPage.js";
|
|
26
|
+
import "./polyfills.js";
|
|
26
27
|
import { isNavigationPlugin } from "../lib/core/plugins.js";
|
|
27
28
|
import { RouteGuard } from "../lib/core/RouteGuard.js";
|
|
28
29
|
import type { ZudokuContextOptions } from "../lib/core/ZudokuContext.js";
|
|
@@ -30,6 +31,10 @@ import { RouterError } from "../lib/errors/RouterError.js";
|
|
|
30
31
|
import { ZuploEnv } from "./env.js";
|
|
31
32
|
import { processRoutes } from "./processRoutes.js";
|
|
32
33
|
import { createRedirectRoutes } from "./utils/createRedirectRoutes.js";
|
|
34
|
+
import {
|
|
35
|
+
warnInlineProtectedRoutes,
|
|
36
|
+
wrapProtectedRoutes,
|
|
37
|
+
} from "./wrapProtectedRoutes.js";
|
|
33
38
|
|
|
34
39
|
export const shikiReady: Promise<HighlighterCore> =
|
|
35
40
|
import("../lib/shiki.js").then(async ({ highlighterPromise }) => {
|
|
@@ -122,6 +127,10 @@ export const getRoutesByConfig = (config: ZudokuConfig): RouteObject[] => {
|
|
|
122
127
|
import.meta.env.IS_ZUPLO || config.enableStatusPages,
|
|
123
128
|
);
|
|
124
129
|
|
|
130
|
+
if (import.meta.env.DEV && typeof window !== "undefined") {
|
|
131
|
+
warnInlineProtectedRoutes(routes, config.protectedRoutes, config.basePath);
|
|
132
|
+
}
|
|
133
|
+
|
|
125
134
|
return [
|
|
126
135
|
...createRedirectRoutes(config.redirects),
|
|
127
136
|
{
|
|
@@ -142,7 +151,16 @@ export const getRoutesByConfig = (config: ZudokuConfig): RouteObject[] => {
|
|
|
142
151
|
</Meta>
|
|
143
152
|
),
|
|
144
153
|
errorElement: <RouterError />,
|
|
145
|
-
|
|
154
|
+
// Chunk-gate protected routes only in SSR; SSG has no 401 to avoid.
|
|
155
|
+
children:
|
|
156
|
+
typeof window === "undefined" || !import.meta.env.ZUDOKU_HAS_SERVER
|
|
157
|
+
? processRoutes(routes)
|
|
158
|
+
: wrapProtectedRoutes(
|
|
159
|
+
processRoutes(routes),
|
|
160
|
+
config.protectedRoutes,
|
|
161
|
+
authState.getState().isAuthenticated,
|
|
162
|
+
config.basePath,
|
|
163
|
+
),
|
|
146
164
|
},
|
|
147
165
|
],
|
|
148
166
|
},
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
|
+
import logger from "loglevel";
|
|
3
|
+
import { parseCookies } from "../lib/authentication/cookies.js";
|
|
4
|
+
import { PROTECTED_CHUNK_DIR } from "../lib/manifest.js";
|
|
5
|
+
import { joinUrl } from "../lib/util/joinUrl.js";
|
|
6
|
+
|
|
7
|
+
// Shape of @hono/node-server's serveStatic used by filesystem adapters.
|
|
8
|
+
// Typed structurally so app/ doesn't import the node-server package.
|
|
9
|
+
type ServeStaticFactory = (opts: {
|
|
10
|
+
root: string;
|
|
11
|
+
rewriteRequestPath?: (p: string) => string;
|
|
12
|
+
}) => MiddlewareHandler;
|
|
13
|
+
|
|
14
|
+
type FilesystemServe = { serverDir: string; serveStatic: ServeStaticFactory };
|
|
15
|
+
type CustomServe = { serve: MiddlewareHandler };
|
|
16
|
+
|
|
17
|
+
// Adapter-agnostic guard for /_protected/* chunks. Handles both filesystem ({ serverDir, serveStatic }) and custom serve ({ serve }) options.
|
|
18
|
+
// If verifyAccessToken is provided, re-validates the access token cookie. Any falsy or thrown result denies access.
|
|
19
|
+
export const protectChunks = (
|
|
20
|
+
opts: {
|
|
21
|
+
basePath?: string;
|
|
22
|
+
verifyAccessToken?: (token: string) => Promise<unknown>;
|
|
23
|
+
} & (FilesystemServe | CustomServe),
|
|
24
|
+
): MiddlewareHandler => {
|
|
25
|
+
const prefix = joinUrl(opts.basePath, PROTECTED_CHUNK_DIR);
|
|
26
|
+
|
|
27
|
+
const serve: MiddlewareHandler =
|
|
28
|
+
"serve" in opts
|
|
29
|
+
? opts.serve
|
|
30
|
+
: opts.serveStatic({
|
|
31
|
+
root: `${opts.serverDir}/${PROTECTED_CHUNK_DIR}`,
|
|
32
|
+
rewriteRequestPath: (p) => p.slice(prefix.length) || "/",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const sendUnauthorized = (c: Context) =>
|
|
36
|
+
c.text("Unauthorized", 401, {
|
|
37
|
+
"Cache-Control": "private, no-store",
|
|
38
|
+
Vary: "Cookie",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return async (c, next) => {
|
|
42
|
+
if (!c.req.path.startsWith(`${prefix}/`)) return next();
|
|
43
|
+
|
|
44
|
+
const { accessToken } = parseCookies(c.req.raw);
|
|
45
|
+
if (!accessToken) return sendUnauthorized(c);
|
|
46
|
+
|
|
47
|
+
if (opts.verifyAccessToken) {
|
|
48
|
+
try {
|
|
49
|
+
const verified = await opts.verifyAccessToken(accessToken);
|
|
50
|
+
if (!verified) return sendUnauthorized(c);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
logger.error(
|
|
53
|
+
`protectChunks: verifyAccessToken threw for ${c.req.path}:`,
|
|
54
|
+
error,
|
|
55
|
+
);
|
|
56
|
+
return sendUnauthorized(c);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
c.header("Cache-Control", "private, no-store");
|
|
61
|
+
c.header("Vary", "Cookie");
|
|
62
|
+
return serve(c, next);
|
|
63
|
+
};
|
|
64
|
+
};
|
package/src/app/standalone.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRoot } from "react-dom/client";
|
|
2
2
|
import { createBrowserRouter } from "react-router";
|
|
3
3
|
import type { ZudokuConfig } from "../config/validators/ZudokuConfig.js";
|
|
4
|
-
import {
|
|
4
|
+
import { BootstrapClient } from "../lib/components/Bootstrap.js";
|
|
5
5
|
import { openApiPlugin } from "../lib/plugins/openapi/index.js";
|
|
6
6
|
import "../lib/util/logInit.js";
|
|
7
7
|
import "./main.css";
|
|
@@ -57,4 +57,4 @@ const routes = getRoutesByConfig(config);
|
|
|
57
57
|
const router = createBrowserRouter(routes, {
|
|
58
58
|
basename: window.location.pathname,
|
|
59
59
|
});
|
|
60
|
-
createRoot(root).render(<
|
|
60
|
+
createRoot(root).render(<BootstrapClient router={router} />);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { RouteObject } from "react-router";
|
|
2
|
+
import type { ProtectedRoutesInput } from "../config/validators/ProtectedRoutesSchema.js";
|
|
3
|
+
import { normalizeProtectedRoutes } from "../lib/core/ZudokuContext.js";
|
|
4
|
+
import { joinUrl } from "../lib/util/joinUrl.js";
|
|
5
|
+
import {
|
|
6
|
+
matchesAnyProtectedPattern,
|
|
7
|
+
matchesProtectedPattern,
|
|
8
|
+
} from "../lib/util/url.js";
|
|
9
|
+
|
|
10
|
+
type Visitor = (route: RouteObject, fullPath: string) => RouteObject;
|
|
11
|
+
|
|
12
|
+
const visitRoutes = (
|
|
13
|
+
rs: RouteObject[],
|
|
14
|
+
visit: Visitor,
|
|
15
|
+
parent = "",
|
|
16
|
+
): RouteObject[] =>
|
|
17
|
+
rs.map((r) => {
|
|
18
|
+
const fullPath = r.path ? joinUrl(parent, r.path) : parent;
|
|
19
|
+
const next = visit(r, fullPath);
|
|
20
|
+
|
|
21
|
+
return next.children
|
|
22
|
+
? { ...next, children: visitRoutes(next.children, visit, fullPath) }
|
|
23
|
+
: next;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const noop: RouteObject["lazy"] = async () => ({ element: null });
|
|
27
|
+
|
|
28
|
+
// Stub lazy() on routes matching a protected pattern when unauthed so the
|
|
29
|
+
// gated chunk doesn't fetch. Public routes stay intact. RouteGuard renders
|
|
30
|
+
// the sign-in UI.
|
|
31
|
+
export const wrapProtectedRoutes = (
|
|
32
|
+
routes: RouteObject[],
|
|
33
|
+
protectedRoutes: ProtectedRoutesInput,
|
|
34
|
+
isAuthenticated: boolean,
|
|
35
|
+
basePath?: string,
|
|
36
|
+
): RouteObject[] => {
|
|
37
|
+
const patterns = Object.keys(normalizeProtectedRoutes(protectedRoutes) ?? {});
|
|
38
|
+
if (patterns.length === 0 || isAuthenticated) return routes;
|
|
39
|
+
|
|
40
|
+
const fullPatterns = patterns.map((p) => joinUrl(basePath, p));
|
|
41
|
+
|
|
42
|
+
return visitRoutes(routes, (r, fullPath) => {
|
|
43
|
+
if (typeof r.lazy !== "function") return r;
|
|
44
|
+
return matchesAnyProtectedPattern(fullPatterns, fullPath)
|
|
45
|
+
? { ...r, lazy: noop }
|
|
46
|
+
: r;
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Inline elements can't be chunk-isolated; RouteGuard still blocks render,
|
|
51
|
+
// but the JS ships in the main bundle. Only meaningful in dev.
|
|
52
|
+
export const warnInlineProtectedRoutes = (
|
|
53
|
+
routes: RouteObject[],
|
|
54
|
+
protectedRoutes: ProtectedRoutesInput,
|
|
55
|
+
basePath?: string,
|
|
56
|
+
) => {
|
|
57
|
+
const patterns = Object.keys(normalizeProtectedRoutes(protectedRoutes) ?? {});
|
|
58
|
+
if (patterns.length === 0) return;
|
|
59
|
+
|
|
60
|
+
const fullPatterns = patterns.map((p) => joinUrl(basePath, p));
|
|
61
|
+
|
|
62
|
+
visitRoutes(routes, (r, fullPath) => {
|
|
63
|
+
const isInline =
|
|
64
|
+
(r.element != null || r.Component != null) &&
|
|
65
|
+
typeof r.lazy !== "function";
|
|
66
|
+
|
|
67
|
+
if (!r.path || !isInline) return r;
|
|
68
|
+
|
|
69
|
+
const matched = fullPatterns.find((p) =>
|
|
70
|
+
matchesProtectedPattern(p, fullPath),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (!matched) return r;
|
|
74
|
+
|
|
75
|
+
// biome-ignore lint/suspicious/noConsole: dev-only advisory
|
|
76
|
+
console.warn(
|
|
77
|
+
`[zudoku] Route "${fullPath}" matches protected pattern "${matched}" but uses an inline element instead of lazy(). RouteGuard blocks rendering, but the JS ships in the main bundle. Use \`lazy: () => import(...)\` to gate at the chunk level.`,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return r;
|
|
81
|
+
});
|
|
82
|
+
};
|
|
@@ -138,6 +138,7 @@ export const IconNames = [
|
|
|
138
138
|
"arrows-up-from-line",
|
|
139
139
|
"asterisk",
|
|
140
140
|
"asterisk-square",
|
|
141
|
+
"astroid",
|
|
141
142
|
"at-sign",
|
|
142
143
|
"atom",
|
|
143
144
|
"audio-lines",
|
|
@@ -205,6 +206,7 @@ export const IconNames = [
|
|
|
205
206
|
"beer",
|
|
206
207
|
"beer-off",
|
|
207
208
|
"bell",
|
|
209
|
+
"bell-check",
|
|
208
210
|
"bell-dot",
|
|
209
211
|
"bell-electric",
|
|
210
212
|
"bell-minus",
|
|
@@ -226,6 +228,7 @@ export const IconNames = [
|
|
|
226
228
|
"birdhouse",
|
|
227
229
|
"bitcoin",
|
|
228
230
|
"blend",
|
|
231
|
+
"blender",
|
|
229
232
|
"blinds",
|
|
230
233
|
"blocks",
|
|
231
234
|
"bluetooth",
|
|
@@ -291,6 +294,7 @@ export const IconNames = [
|
|
|
291
294
|
"briefcase-conveyor-belt",
|
|
292
295
|
"briefcase-medical",
|
|
293
296
|
"bring-to-front",
|
|
297
|
+
"broccoli",
|
|
294
298
|
"brush",
|
|
295
299
|
"brush-cleaning",
|
|
296
300
|
"bubbles",
|
|
@@ -783,6 +787,7 @@ export const IconNames = [
|
|
|
783
787
|
"fold-vertical",
|
|
784
788
|
"folder",
|
|
785
789
|
"folder-archive",
|
|
790
|
+
"folder-bookmark",
|
|
786
791
|
"folder-check",
|
|
787
792
|
"folder-clock",
|
|
788
793
|
"folder-closed",
|
|
@@ -932,6 +937,7 @@ export const IconNames = [
|
|
|
932
937
|
"heart-off",
|
|
933
938
|
"heart-plus",
|
|
934
939
|
"heart-pulse",
|
|
940
|
+
"heart-x",
|
|
935
941
|
"heater",
|
|
936
942
|
"helicopter",
|
|
937
943
|
"help-circle",
|
|
@@ -1009,6 +1015,7 @@ export const IconNames = [
|
|
|
1009
1015
|
"layers",
|
|
1010
1016
|
"layers-2",
|
|
1011
1017
|
"layers-3",
|
|
1018
|
+
"layers-minus",
|
|
1012
1019
|
"layers-plus",
|
|
1013
1020
|
"layout",
|
|
1014
1021
|
"layout-dashboard",
|
|
@@ -1417,6 +1424,7 @@ export const IconNames = [
|
|
|
1417
1424
|
"repeat",
|
|
1418
1425
|
"repeat-1",
|
|
1419
1426
|
"repeat-2",
|
|
1427
|
+
"repeat-off",
|
|
1420
1428
|
"replace",
|
|
1421
1429
|
"replace-all",
|
|
1422
1430
|
"reply",
|
|
@@ -1676,6 +1684,12 @@ export const IconNames = [
|
|
|
1676
1684
|
"stethoscope",
|
|
1677
1685
|
"sticker",
|
|
1678
1686
|
"sticky-note",
|
|
1687
|
+
"sticky-note-check",
|
|
1688
|
+
"sticky-note-minus",
|
|
1689
|
+
"sticky-note-off",
|
|
1690
|
+
"sticky-note-plus",
|
|
1691
|
+
"sticky-note-x",
|
|
1692
|
+
"sticky-notes",
|
|
1679
1693
|
"stone",
|
|
1680
1694
|
"stop-circle",
|
|
1681
1695
|
"store",
|
|
@@ -1756,6 +1770,7 @@ export const IconNames = [
|
|
|
1756
1770
|
"ticket-x",
|
|
1757
1771
|
"tickets",
|
|
1758
1772
|
"tickets-plane",
|
|
1773
|
+
"timeline",
|
|
1759
1774
|
"timer",
|
|
1760
1775
|
"timer-off",
|
|
1761
1776
|
"timer-reset",
|
|
@@ -1895,7 +1910,9 @@ export const IconNames = [
|
|
|
1895
1910
|
"waves",
|
|
1896
1911
|
"waves-arrow-down",
|
|
1897
1912
|
"waves-arrow-up",
|
|
1913
|
+
"waves-horizontal",
|
|
1898
1914
|
"waves-ladder",
|
|
1915
|
+
"waves-vertical",
|
|
1899
1916
|
"waypoints",
|
|
1900
1917
|
"webcam",
|
|
1901
1918
|
"webhook",
|