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.
Files changed (91) hide show
  1. package/dist/cli/cli.js +602 -160
  2. package/dist/cli/worker.js +6 -4
  3. package/dist/declarations/app/adapter.d.ts +12 -0
  4. package/dist/declarations/app/adapters/cloudflare.d.ts +8 -0
  5. package/dist/declarations/app/adapters/lambda.d.ts +13 -0
  6. package/dist/declarations/app/adapters/node.d.ts +12 -0
  7. package/dist/declarations/app/adapters/vercel.d.ts +14 -0
  8. package/dist/declarations/app/entry.client.d.ts +2 -0
  9. package/dist/declarations/app/entry.server.d.ts +13 -0
  10. package/dist/declarations/app/protectChunks.d.ts +17 -0
  11. package/dist/declarations/app/wrapProtectedRoutes.d.ts +4 -0
  12. package/dist/declarations/config/validators/HeaderNavigationSchema.d.ts +216 -80
  13. package/dist/declarations/config/validators/ZudokuConfig.d.ts +81 -30
  14. package/dist/declarations/config/validators/icon-types.d.ts +1 -1
  15. package/dist/declarations/lib/authentication/authentication.d.ts +7 -0
  16. package/dist/declarations/lib/authentication/cookie-sync.d.ts +3 -0
  17. package/dist/declarations/lib/authentication/cookies.d.ts +10 -0
  18. package/dist/declarations/lib/authentication/providers/azureb2c.d.ts +6 -1
  19. package/dist/declarations/lib/authentication/providers/clerk.d.ts +1 -0
  20. package/dist/declarations/lib/authentication/providers/openid.d.ts +2 -1
  21. package/dist/declarations/lib/authentication/providers/supabase.d.ts +2 -0
  22. package/dist/declarations/lib/authentication/session-handler.d.ts +81 -0
  23. package/dist/declarations/lib/authentication/state.d.ts +7 -0
  24. package/dist/declarations/lib/authentication/verify-cache.d.ts +2 -0
  25. package/dist/declarations/lib/components/Bootstrap.d.ts +2 -2
  26. package/dist/declarations/lib/components/Heading.d.ts +1 -1
  27. package/dist/declarations/lib/components/context/RenderContext.d.ts +6 -0
  28. package/dist/declarations/lib/core/RouteGuard.d.ts +11 -0
  29. package/dist/declarations/lib/core/ZudokuContext.d.ts +5 -2
  30. package/dist/declarations/lib/manifest.d.ts +25 -0
  31. package/dist/declarations/lib/util/url.d.ts +2 -0
  32. package/dist/flat-config.d.ts +1 -1
  33. package/docs/configuration/protected-routes.md +21 -4
  34. package/docs/guides/server-side-content-protection.md +207 -0
  35. package/package.json +26 -4
  36. package/src/app/adapter.ts +16 -0
  37. package/src/app/adapters/cloudflare.ts +18 -0
  38. package/src/app/adapters/lambda.ts +36 -0
  39. package/src/app/adapters/node.ts +32 -0
  40. package/src/app/adapters/vercel.ts +39 -0
  41. package/src/app/demo.tsx +2 -2
  42. package/src/app/entry.client.tsx +19 -7
  43. package/src/app/entry.server.tsx +133 -9
  44. package/src/app/main.tsx +21 -3
  45. package/src/app/protectChunks.ts +64 -0
  46. package/src/app/standalone.tsx +2 -2
  47. package/src/app/wrapProtectedRoutes.ts +82 -0
  48. package/src/config/validators/icon-types.ts +17 -0
  49. package/src/lib/authentication/authentication.ts +15 -0
  50. package/src/lib/authentication/cookie-sync.ts +90 -0
  51. package/src/lib/authentication/cookies.ts +54 -0
  52. package/src/lib/authentication/hook.ts +13 -0
  53. package/src/lib/authentication/providers/azureb2c.tsx +70 -2
  54. package/src/lib/authentication/providers/clerk.tsx +49 -0
  55. package/src/lib/authentication/providers/openid.tsx +46 -0
  56. package/src/lib/authentication/providers/supabase.tsx +30 -2
  57. package/src/lib/authentication/session-handler.ts +164 -0
  58. package/src/lib/authentication/state.ts +36 -5
  59. package/src/lib/authentication/verify-cache.ts +32 -0
  60. package/src/lib/components/Bootstrap.tsx +20 -14
  61. package/src/lib/components/Header.tsx +56 -57
  62. package/src/lib/components/MobileTopNavigation.tsx +66 -67
  63. package/src/lib/components/Zudoku.tsx +14 -1
  64. package/src/lib/components/context/RenderContext.ts +8 -0
  65. package/src/lib/components/context/ZudokuContext.ts +2 -1
  66. package/src/lib/core/RouteGuard.tsx +50 -29
  67. package/src/lib/core/ZudokuContext.ts +39 -6
  68. package/src/lib/errors/RouterError.tsx +43 -1
  69. package/src/lib/manifest.ts +62 -0
  70. package/src/lib/oas/parser/dereference/index.ts +2 -1
  71. package/src/lib/oas/parser/dereference/resolveRef.ts +2 -1
  72. package/src/lib/oas/parser/index.ts +1 -1
  73. package/src/lib/plugins/openapi/client/createServer.ts +13 -4
  74. package/src/lib/plugins/search-pagefind/index.tsx +1 -4
  75. package/src/lib/util/os.ts +1 -0
  76. package/src/lib/util/url.ts +13 -0
  77. package/src/vite/build.ts +84 -24
  78. package/src/vite/config.ts +51 -5
  79. package/src/vite/dev-server.ts +61 -8
  80. package/src/vite/manifest.ts +15 -0
  81. package/src/vite/plugin-api.ts +3 -1
  82. package/src/vite/plugin-markdown-export.ts +3 -9
  83. package/src/vite/prerender/worker.ts +2 -4
  84. package/src/vite/protected/annotator.ts +136 -0
  85. package/src/vite/protected/build.ts +151 -0
  86. package/src/vite/protected/registry.ts +82 -0
  87. package/src/vite/ssr-templates/cloudflare.ts +5 -18
  88. package/src/vite/ssr-templates/lambda.ts +4 -0
  89. package/src/vite/ssr-templates/node.ts +7 -22
  90. package/src/vite/ssr-templates/vercel.ts +6 -20
  91. package/src/vite-env.d.ts +1 -0
@@ -5,6 +5,7 @@ import {
5
5
  type ConfigEnv,
6
6
  type InlineConfig,
7
7
  type LogLevel,
8
+ type Rolldown,
8
9
  loadConfigFromFile,
9
10
  mergeConfig,
10
11
  } from "vite";
@@ -14,9 +15,13 @@ import { logger } from "../cli/common/logger.js";
14
15
  import { getZudokuRootDir } from "../cli/common/package-json.js";
15
16
  import { loadZudokuConfig } from "../config/loader.js";
16
17
  import { CdnUrlSchema } from "../config/validators/ZudokuConfig.js";
18
+ import { PROTECTED_CHUNK_DIR } from "../lib/manifest.js";
17
19
  import { joinUrl } from "../lib/util/joinUrl.js";
20
+ import type { SSRAdapter } from "./build.js";
18
21
  import { findPackageRoot } from "./package-root.js";
19
22
  import vitePlugin from "./plugin.js";
23
+ import { protectedAnnotatorPlugin } from "./protected/annotator.js";
24
+ import { getProtectedSourceMatcher } from "./protected/registry.js";
20
25
  import { getZuploSystemConfigurations } from "./zuplo.js";
21
26
 
22
27
  export type ZudokuConfigEnv = ConfigEnv & {
@@ -45,12 +50,23 @@ const defineEnvVars = (vars: string[]) =>
45
50
  export async function getViteConfig(
46
51
  dir: string,
47
52
  configEnv: ZudokuConfigEnv,
53
+ options: { adapter?: SSRAdapter; ssr?: boolean } = {},
48
54
  ): Promise<InlineConfig> {
49
55
  const { config, publicEnv, envPrefix } = await loadZudokuConfig(
50
56
  configEnv,
51
57
  dir,
52
58
  );
59
+ const { match: isProtectedSource, enabled: hasProtectedSources } =
60
+ getProtectedSourceMatcher(config);
61
+ const shouldProtectChunks = hasProtectedSources && options.ssr === true;
53
62
 
63
+ // Check facadeModuleId too: codeSplitting may move the body into a captured
64
+ // group, leaving a stub whose moduleIds no longer reference the protected source.
65
+ const isProtectedChunk = (chunk: Rolldown.PreRenderedChunk) =>
66
+ (chunk.facadeModuleId && isProtectedSource(chunk.facadeModuleId)) ||
67
+ chunk.moduleIds.some(isProtectedSource);
68
+
69
+ const isWorker = options.adapter === "cloudflare";
54
70
  const cdnUrl = CdnUrlSchema.parse(config.cdnUrl);
55
71
 
56
72
  const base = cdnUrl?.base
@@ -88,6 +104,10 @@ export async function getViteConfig(
88
104
  root: dir,
89
105
  base,
90
106
  appType: "custom",
107
+ // Cloudflare Workers: `webworker` makes Vite pick the browser platform
108
+ // for rolldown and avoids emitting `createRequire(import.meta.url)`,
109
+ // which is undefined in Workers. See vitejs/vite#21969 (fix in 8.0.4+).
110
+ ssr: isWorker ? { target: "webworker" } : undefined,
91
111
  configFile: false,
92
112
  clearScreen: false,
93
113
  logLevel: (process.env.LOG_LEVEL ?? "info") as LogLevel,
@@ -103,6 +123,7 @@ export async function getViteConfig(
103
123
  "process.env.ZUDOKU_VERSION": JSON.stringify(packageJson.version),
104
124
  "process.env.IS_ZUPLO": ZuploEnv.isZuplo,
105
125
  "import.meta.env.IS_ZUPLO": ZuploEnv.isZuplo,
126
+ "import.meta.env.ZUDOKU_HAS_SERVER": JSON.stringify(options.ssr === true),
106
127
  "import.meta.env.ZUPLO_PUBLIC_DEPLOYMENT_NAME":
107
128
  JSON.stringify(deploymentName),
108
129
  ...defineEnvVars([
@@ -146,21 +167,40 @@ export async function getViteConfig(
146
167
  environments: {
147
168
  client: {
148
169
  build: {
170
+ manifest: true,
149
171
  rolldownOptions: {
150
172
  input: "zudoku/app/entry.client.tsx",
173
+ output: shouldProtectChunks
174
+ ? {
175
+ entryFileNames: (chunk) =>
176
+ isProtectedChunk(chunk)
177
+ ? `${PROTECTED_CHUNK_DIR}/[name]-[hash].js`
178
+ : "assets/[name]-[hash].js",
179
+ chunkFileNames: (chunk) =>
180
+ isProtectedChunk(chunk)
181
+ ? `${PROTECTED_CHUNK_DIR}/[name]-[hash].js`
182
+ : "assets/[name]-[hash].js",
183
+ }
184
+ : undefined,
151
185
  },
152
186
  },
153
187
  },
154
188
  ssr: {
155
- resolve: {
156
- noExternal: [/zudoku/, "@mdx-js/react"],
157
- external: ["@shikijs/themes", "@shikijs/langs"],
158
- },
189
+ // Build: bundle all for self-contained SSR output; dev uses minimal externals for speed.
190
+ resolve:
191
+ configEnv.command === "build"
192
+ ? { noExternal: true }
193
+ : {
194
+ noExternal: [/zudoku/, "@mdx-js/react"],
195
+ external: ["@shikijs/themes", "@shikijs/langs"],
196
+ },
159
197
  build: {
160
198
  outDir: path.resolve(
161
199
  path.join(dir, "dist", config.basePath ?? "", "server"),
162
200
  ),
201
+ copyPublicDir: false,
163
202
  rolldownOptions: {
203
+ logLevel: "warn",
164
204
  input: ["zudoku/app/entry.server.tsx", config.__meta.configPath],
165
205
  },
166
206
  },
@@ -168,6 +208,12 @@ export async function getViteConfig(
168
208
  },
169
209
  experimental: {
170
210
  renderBuiltUrl(filename) {
211
+ // Protected chunks must resolve through the SSR origin so the auth
212
+ // gate runs; never prefix with a CDN.
213
+ if (filename.startsWith(`${PROTECTED_CHUNK_DIR}/`)) {
214
+ return joinUrl(config.basePath, `/${filename}`);
215
+ }
216
+
171
217
  if (cdnUrl?.base && [".js", ".css"].includes(path.extname(filename))) {
172
218
  return joinUrl(cdnUrl.base, filename);
173
219
  }
@@ -194,7 +240,7 @@ export async function getViteConfig(
194
240
  "/__z/entry.client.tsx",
195
241
  "**/pagefind.js",
196
242
  ],
197
- plugins: [vitePlugin()],
243
+ plugins: [protectedAnnotatorPlugin(), vitePlugin()],
198
244
  future: {
199
245
  removeServerModuleGraph: "warn",
200
246
  removeSsrLoadModule: "warn",
@@ -16,6 +16,7 @@ import { findAvailablePort } from "../cli/common/utils/ports.js";
16
16
  import type { LoadedConfig } from "../config/config.js";
17
17
  import { loadZudokuConfig } from "../config/loader.js";
18
18
  import { createGraphQLServer } from "../lib/oas/graphql/index.js";
19
+ import { joinUrl } from "../lib/util/joinUrl.js";
19
20
  import {
20
21
  getAppClientEntryPath,
21
22
  getAppServerEntryPath,
@@ -131,6 +132,57 @@ export class DevServer {
131
132
 
132
133
  vite.middlewares.use(graphql.graphqlEndpoint, graphql);
133
134
 
135
+ // Auth session endpoint for cookie sync
136
+ const sessionEndpoint = joinUrl(config.basePath, "/__z/auth/session");
137
+ vite.middlewares.use(sessionEndpoint, async (req, res) => {
138
+ if (req.method !== "POST" && req.method !== "DELETE") {
139
+ res.writeHead(405, { Allow: "POST, DELETE" });
140
+ res.end();
141
+ return;
142
+ }
143
+
144
+ const ssrEnvironment = vite.environments.ssr;
145
+ if (!isRunnableDevEnvironment(ssrEnvironment)) {
146
+ res.writeHead(500);
147
+ res.end("SSR environment not available");
148
+ return;
149
+ }
150
+
151
+ const entryServer = await ssrEnvironment.runner.import<EntryServerImport>(
152
+ getAppServerEntryPath(),
153
+ );
154
+
155
+ const url = `${this.protocol}://${req.headers.host}${req.originalUrl ?? req.url}`;
156
+ const body =
157
+ req.method === "POST"
158
+ ? await new Promise<string>((resolve) => {
159
+ let data = "";
160
+ req.on("data", (chunk: Buffer) => (data += chunk));
161
+ req.on("end", () => resolve(data));
162
+ })
163
+ : undefined;
164
+
165
+ const request = new Request(url, {
166
+ method: req.method,
167
+ headers: req.headers as HeadersInit,
168
+ body,
169
+ });
170
+
171
+ const app = entryServer.createServer({ template: "" });
172
+ const response = await app.fetch(request);
173
+
174
+ for (const [name, value] of response.headers) {
175
+ if (name.toLowerCase() === "set-cookie") continue;
176
+ res.setHeader(name, value);
177
+ }
178
+ for (const cookie of response.headers.getSetCookie()) {
179
+ res.appendHeader("Set-Cookie", cookie);
180
+ }
181
+ res.writeHead(response.status);
182
+ const text = await response.text();
183
+ res.end(text);
184
+ });
185
+
134
186
  // Pagefind reindex endpoint (SSE)
135
187
  vite.middlewares.use("/__z/pagefind-reindex", async (_req, res) => {
136
188
  res.writeHead(200, {
@@ -238,15 +290,16 @@ export class DevServer {
238
290
  },
239
291
  );
240
292
 
241
- const response = await entryServer.handleRequest({
242
- template,
243
- request,
244
- routes: entryServer.getRoutesByConfig(currentConfig),
245
- basePath: currentConfig.basePath,
246
- });
293
+ const app = entryServer.createServer({ template });
294
+ const response = await app.fetch(request);
247
295
 
248
- for (const [key, value] of response.headers) {
249
- res.appendHeader(key, value);
296
+ response.headers.forEach((value, key) => {
297
+ if (key.toLowerCase() !== "set-cookie") {
298
+ res.setHeader(key, value);
299
+ }
300
+ });
301
+ for (const cookie of response.headers.getSetCookie()) {
302
+ res.appendHeader("Set-Cookie", cookie);
250
303
  }
251
304
  res.writeHead(response.status);
252
305
 
@@ -0,0 +1,15 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { ZudokuConfig } from "../config/config.js";
4
+ import { buildManifest, MANIFEST_FILENAME } from "../lib/manifest.js";
5
+
6
+ export const writeManifest = async (
7
+ distDir: string,
8
+ config: Pick<ZudokuConfig, "basePath" | "protectedRoutes">,
9
+ ) => {
10
+ await writeFile(
11
+ path.join(distDir, MANIFEST_FILENAME),
12
+ `${JSON.stringify(buildManifest(config), null, 2)}\n`,
13
+ "utf-8",
14
+ );
15
+ };
@@ -20,6 +20,8 @@ import { SchemaManager } from "./api/SchemaManager.js";
20
20
  import { reload } from "./plugin-config-reload.js";
21
21
  import { invalidate as invalidateNavigation } from "./plugin-navigation.js";
22
22
 
23
+ const PROCESSED_STORE_SUBPATH = "node_modules/.zudoku/processed";
24
+
23
25
  const viteApiPlugin = async (): Promise<Plugin> => {
24
26
  const virtualModuleId = "virtual:zudoku-api-plugins";
25
27
  const resolvedVirtualModuleId = `\0${virtualModuleId}`;
@@ -38,7 +40,7 @@ const viteApiPlugin = async (): Promise<Plugin> => {
38
40
 
39
41
  const tmpStoreDir = path.posix.join(
40
42
  initialConfig.__meta.rootDir,
41
- "node_modules/.zudoku/processed",
43
+ PROCESSED_STORE_SUBPATH,
42
44
  );
43
45
 
44
46
  const processors = [...buildProcessors, ...zuploProcessors];
@@ -1,11 +1,11 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { matchPath } from "react-router";
4
3
  import type { Plugin } from "vite";
5
4
  import { getCurrentConfig } from "../config/loader.js";
6
5
  import { ProtectedRoutesSchema } from "../config/validators/ProtectedRoutesSchema.js";
7
6
  import { joinUrl } from "../lib/util/joinUrl.js";
8
7
  import { readFrontmatter } from "../lib/util/readFrontmatter.js";
8
+ import { matchesAnyProtectedPattern } from "../lib/util/url.js";
9
9
  import {
10
10
  globMarkdownFiles,
11
11
  resolveCustomNavigationPaths,
@@ -107,15 +107,9 @@ const viteMarkdownExportPlugin = (): Plugin => {
107
107
  config.protectedRoutes,
108
108
  );
109
109
  if (protectedRoutes) {
110
- const isProtectedRoute = (routePath: string): boolean => {
111
- return Object.keys(protectedRoutes).some((route) =>
112
- matchPath({ path: route, end: true }, routePath),
113
- );
114
- };
115
-
116
- // Remove protected routes from the mapping
110
+ const patterns = Object.keys(protectedRoutes);
117
111
  for (const routePath of Object.keys(markdownFiles)) {
118
- if (isProtectedRoute(routePath)) {
112
+ if (matchesAnyProtectedPattern(patterns, routePath)) {
119
113
  delete markdownFiles[routePath];
120
114
  }
121
115
  }
@@ -1,11 +1,11 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import Piscina from "piscina";
4
- import { matchPath } from "react-router";
5
4
  import { ProtectedRoutesSchema } from "../../config/validators/ProtectedRoutesSchema.js";
6
5
  import type { ZudokuConfig } from "../../config/validators/ZudokuConfig.js";
7
6
  import { runPluginTransformConfig } from "../../lib/core/transform-config.js";
8
7
  import { joinUrl } from "../../lib/util/joinUrl.js";
8
+ import { matchesAnyProtectedPattern } from "../../lib/util/url.js";
9
9
  import type { WorkerResult } from "./prerender.js";
10
10
 
11
11
  type EntryServer = typeof import("../../app/entry.server.js");
@@ -42,9 +42,7 @@ const renderPage = async ({ urlPath }: WorkerData): Promise<WorkerResult> => {
42
42
 
43
43
  const protectedRoutes = ProtectedRoutesSchema.parse(config.protectedRoutes);
44
44
  const isProtectedRoute = protectedRoutes
45
- ? Object.keys(protectedRoutes).some((route) =>
46
- matchPath({ path: route, end: true }, urlPath),
47
- )
45
+ ? matchesAnyProtectedPattern(Object.keys(protectedRoutes), urlPath)
48
46
  : false;
49
47
 
50
48
  // Get the main response
@@ -0,0 +1,136 @@
1
+ import type { Plugin } from "vite";
2
+ import { clearProtectedRegistry, registerProtectedScope } from "./registry.js";
3
+
4
+ // Minimal ESTree walker. Visits every node; skips position fields.
5
+ // biome-ignore lint/suspicious/noExplicitAny: working against a loose ESTree shape
6
+ type AstNode = any;
7
+
8
+ const walk = (node: AstNode, visit: (n: AstNode) => void) => {
9
+ if (!node || typeof node !== "object") return;
10
+ if (typeof node.type === "string") visit(node);
11
+ for (const key of Object.keys(node)) {
12
+ if (key === "loc" || key === "start" || key === "end" || key === "range") {
13
+ continue;
14
+ }
15
+ const val = node[key];
16
+ if (Array.isArray(val)) {
17
+ for (const v of val) walk(v, visit);
18
+ } else if (val && typeof val === "object") {
19
+ walk(val, visit);
20
+ }
21
+ }
22
+ };
23
+
24
+ const literalString = (node: AstNode): string | undefined =>
25
+ node?.type === "Literal" && typeof node.value === "string"
26
+ ? node.value
27
+ : undefined;
28
+
29
+ const propKey = (prop: AstNode): string | undefined =>
30
+ prop.key?.type === "Identifier" ? prop.key.name : literalString(prop.key);
31
+
32
+ // All dynamic-import specifiers found anywhere inside a subtree.
33
+ const collectImportSpecs = (node: AstNode): string[] => {
34
+ const out: string[] = [];
35
+ walk(node, (n) => {
36
+ if (n.type === "ImportExpression") {
37
+ const spec = literalString(n.source);
38
+ if (spec) out.push(spec);
39
+ }
40
+ });
41
+ return out;
42
+ };
43
+
44
+ // Shape A: `{path: "/X", ...}` with any nested dynamic imports. Covers
45
+ // RR route objects and plugin-api's `openApiPlugin({path, schemaImports})`.
46
+ export const matchPathObject = (
47
+ node: AstNode,
48
+ ): { root: string; specs: string[] } | undefined => {
49
+ if (node.type !== "ObjectExpression") return;
50
+ let root: string | undefined;
51
+ const siblingValues: AstNode[] = [];
52
+ for (const prop of node.properties ?? []) {
53
+ if (prop.type !== "Property") continue;
54
+ const name = propKey(prop);
55
+ const strValue = literalString(prop.value);
56
+ if (name === "path" && strValue) root = strValue;
57
+ else siblingValues.push(prop.value);
58
+ }
59
+ if (!root) return;
60
+ const specs = siblingValues.flatMap(collectImportSpecs);
61
+ if (specs.length === 0) return;
62
+ return { root, specs };
63
+ };
64
+
65
+ // Shape B: `{ "/foo": () => import(...), ... }`. Keys must be path-like
66
+ // (leading `/`, no `.`) to avoid matching file-path dicts.
67
+ export const matchRouteDict = (
68
+ node: AstNode,
69
+ ): Array<{ root: string; spec: string }> | undefined => {
70
+ if (node.type !== "ObjectExpression") return;
71
+ const props = node.properties ?? [];
72
+ if (props.length === 0) return;
73
+ const pairs: Array<{ root: string; spec: string }> = [];
74
+ for (const prop of props) {
75
+ if (prop.type !== "Property") return;
76
+ const key = literalString(prop.key);
77
+ if (!key?.startsWith("/") || key.includes(".")) return;
78
+ if (
79
+ prop.value?.type !== "ArrowFunctionExpression" ||
80
+ prop.value.body?.type !== "ImportExpression"
81
+ ) {
82
+ return;
83
+ }
84
+ const spec = literalString(prop.value.body.source);
85
+ if (!spec) return;
86
+ pairs.push({ root: key, spec });
87
+ }
88
+ return pairs;
89
+ };
90
+
91
+ // Auto-registers route-shaped dynamic imports. Covers plugin-docs,
92
+ // plugin-api, and user custom pages without plugin-side changes.
93
+ export const protectedAnnotatorPlugin = (): Plugin => ({
94
+ name: "zudoku:protected-annotator",
95
+ buildStart() {
96
+ clearProtectedRegistry();
97
+ },
98
+ async transform(code, id) {
99
+ if (id.includes("/node_modules/")) return;
100
+ if (!code.includes("import(")) return;
101
+
102
+ let ast: AstNode;
103
+ try {
104
+ ast = this.parse(code);
105
+ } catch (err) {
106
+ // Parse failure leaves this module unregistered, so any protected
107
+ // dynamic imports inside it would ship ungated. Warn loudly.
108
+ this.warn(
109
+ `protected-annotator: failed to parse ${id}: ${err instanceof Error ? err.message : String(err)}. Protected gating will NOT apply to this module.`,
110
+ );
111
+ return;
112
+ }
113
+
114
+ const tasks: Array<{ spec: string; root: string }> = [];
115
+ walk(ast, (node) => {
116
+ const a = matchPathObject(node);
117
+ if (a) for (const spec of a.specs) tasks.push({ spec, root: a.root });
118
+ const b = matchRouteDict(node);
119
+ if (b) for (const { spec, root } of b) tasks.push({ spec, root });
120
+ });
121
+
122
+ for (const { spec, root } of tasks) {
123
+ const resolved = await this.resolve(spec, id);
124
+ if (!resolved || resolved.external) {
125
+ this.warn(
126
+ `Route-shaped import "${spec}" in ${id} did not resolve to a first-party module; protected gating will not apply.`,
127
+ );
128
+ continue;
129
+ }
130
+ registerProtectedScope(resolved.id.split("?")[0] ?? resolved.id, {
131
+ type: "subtree",
132
+ root,
133
+ });
134
+ }
135
+ },
136
+ });
@@ -0,0 +1,151 @@
1
+ import { mkdir, readdir, readFile, rename, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { Rolldown } from "vite";
4
+ import type { ConfigWithMeta } from "../../config/loader.js";
5
+ import { PROTECTED_CHUNK_DIR } from "../../lib/manifest.js";
6
+ import { joinUrl } from "../../lib/util/joinUrl.js";
7
+ import {
8
+ findUnmatchedProtectedPatterns,
9
+ getProtectedSourceMatcher,
10
+ } from "./registry.js";
11
+
12
+ // Build-time helpers that enforce the protected-chunk invariant: gated content never lands in the publicly-served output.
13
+
14
+ // Unmatched patterns mean a typo or a route served by an inline element / dynamic path (not chunk-isolated).
15
+ // Without a registered scope the JS for that route ships in the public bundle even though RouteGuard blocks rendering.
16
+ export const assertProtectedPatternsCovered = (config: ConfigWithMeta) => {
17
+ const { patterns } = getProtectedSourceMatcher(config);
18
+ const unmatched = findUnmatchedProtectedPatterns(patterns);
19
+ if (unmatched.length === 0) return;
20
+ throw new Error(
21
+ `[zudoku] protectedRoutes patterns with no matching content: ${unmatched
22
+ .map((p) => `"${p}"`)
23
+ .join(", ")}.\n` +
24
+ ` Either the pattern is a typo, or the route uses an inline element / dynamic path that isn't code-split. ` +
25
+ `Load the route via dynamic import so it gets its own chunk, otherwise its JS ships in the public bundle.`,
26
+ );
27
+ };
28
+
29
+ type BundleOutput = readonly (Rolldown.OutputChunk | Rolldown.OutputAsset)[];
30
+
31
+ // Fails the build if a public entry chunk statically reaches a protected
32
+ // chunk. Dynamic imports are expected (route-split lazy) and are skipped.
33
+ export const findProtectedLeaks = (output: BundleOutput): string[] => {
34
+ const isProtected = (fileName: string) =>
35
+ fileName.startsWith(`${PROTECTED_CHUNK_DIR}/`);
36
+ const chunks = output.filter((o) => o.type === "chunk");
37
+ const byFileName = new Map(chunks.map((c) => [c.fileName, c]));
38
+
39
+ const leaks: string[] = [];
40
+ for (const entry of chunks.filter(
41
+ (c) => c.isEntry && !isProtected(c.fileName),
42
+ )) {
43
+ const visited = new Set<string>();
44
+ const stack = [{ fileName: entry.fileName, path: [entry.fileName] }];
45
+ while (stack.length > 0) {
46
+ // biome-ignore lint/style/noNonNullAssertion: Length check ensures this is not null
47
+ const { fileName, path } = stack.pop()!;
48
+ if (visited.has(fileName)) continue;
49
+ visited.add(fileName);
50
+ for (const imp of byFileName.get(fileName)?.imports ?? []) {
51
+ const next = [...path, imp];
52
+ if (isProtected(imp)) {
53
+ leaks.push(next.join(" -> "));
54
+ continue;
55
+ }
56
+ stack.push({ fileName: imp, path: next });
57
+ }
58
+ }
59
+ }
60
+ return leaks;
61
+ };
62
+
63
+ export const assertNoProtectedLeaks = (output: BundleOutput) => {
64
+ // A protected chunk that's also an entry would be loaded by the runtime
65
+ // independently of the static-import graph and bypass the gate.
66
+ const protectedEntries = output
67
+ .filter((o): o is Rolldown.OutputChunk => o.type === "chunk")
68
+ .filter(
69
+ (c) => c.isEntry && c.fileName.startsWith(`${PROTECTED_CHUNK_DIR}/`),
70
+ )
71
+ .map((c) => c.fileName);
72
+ if (protectedEntries.length > 0) {
73
+ throw new Error(
74
+ `Protected chunk(s) marked as entries:\n ${protectedEntries.join("\n ")}\n` +
75
+ `Entry chunks are loaded outside the gated import path. ` +
76
+ `Move the entry to a public chunk that dynamically imports the protected one.`,
77
+ );
78
+ }
79
+
80
+ const leaks = findProtectedLeaks(output);
81
+ if (leaks.length === 0) return;
82
+ throw new Error(
83
+ `Protected chunk(s) statically reachable from public entry:\n ${leaks.join("\n ")}\n` +
84
+ `This eagerly pulls gated content into the public bundle. ` +
85
+ `Check that nothing in non-protected entry code statically imports the protected module.`,
86
+ );
87
+ };
88
+
89
+ // Moves the protected chunk directory from the client output into the server bundle so static file serving can't reach it.
90
+ export const moveProtectedChunks = async (
91
+ clientOutDir: string,
92
+ serverOutDir: string,
93
+ ) => {
94
+ const srcDir = path.join(clientOutDir, PROTECTED_CHUNK_DIR);
95
+ const files = await readdir(srcDir).catch((err) => {
96
+ if (err.code === "ENOENT") return null;
97
+ throw err;
98
+ });
99
+ if (!files) return;
100
+ const destDir = path.join(serverOutDir, PROTECTED_CHUNK_DIR);
101
+ await mkdir(destDir, { recursive: true });
102
+ await Promise.all(
103
+ files.map((file) =>
104
+ rename(path.join(srcDir, file), path.join(destDir, file)),
105
+ ),
106
+ );
107
+ // Verify nothing was left behind. A partial rename would otherwise
108
+ // ship gated chunks publicly without erroring.
109
+ const leftover = await readdir(srcDir).catch(() => []);
110
+ if (leftover.length > 0) {
111
+ throw new Error(
112
+ `moveProtectedChunks left ${leftover.length} file(s) in ${srcDir}: ${leftover.join(", ")}.\n` +
113
+ `These would be served publicly. Aborting build.`,
114
+ );
115
+ }
116
+ await rm(srcDir, { recursive: true, force: true });
117
+ };
118
+
119
+ // Ensure protected chunks aren't publicly served under Cloudflare.
120
+ // If `run_worker_first` is missing in wrangler config, protected assets are exposed.
121
+ // Uses plain string matching; false positives may occur if `run_worker_first` is present but commented out.
122
+ export const assertCloudflareWranglerGatesProtected = async (
123
+ dir: string,
124
+ config: ConfigWithMeta,
125
+ ) => {
126
+ const { enabled } = getProtectedSourceMatcher(config);
127
+ if (!enabled) return;
128
+
129
+ const protectedPrefix = `${joinUrl(config.basePath, PROTECTED_CHUNK_DIR)}/`;
130
+ const candidates = ["wrangler.toml", "wrangler.jsonc", "wrangler.json"];
131
+ for (const name of candidates) {
132
+ const file = await readFile(path.join(dir, name), "utf-8").catch(
133
+ () => undefined,
134
+ );
135
+ if (file === undefined) continue;
136
+ if (file.includes("run_worker_first") && file.includes(protectedPrefix)) {
137
+ return;
138
+ }
139
+ throw new Error(
140
+ `[zudoku] ${name} must configure \`run_worker_first\` to include \`${protectedPrefix}*\` ` +
141
+ `so the auth gate runs before the assets binding serves protected chunks. ` +
142
+ `Without it, ${protectedPrefix}* is publicly readable. ` +
143
+ `See https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first.`,
144
+ );
145
+ }
146
+
147
+ throw new Error(
148
+ `[zudoku] No wrangler config found in ${dir} (looked for ${candidates.join(", ")}). ` +
149
+ `Cloudflare adapter requires wrangler config with \`run_worker_first\` covering \`${protectedPrefix}*\`.`,
150
+ );
151
+ };
@@ -0,0 +1,82 @@
1
+ import { matchPath } from "react-router";
2
+ import type { ConfigWithMeta } from "../../config/loader.js";
3
+ import { ProtectedRoutesSchema } from "../../config/validators/ProtectedRoutesSchema.js";
4
+ import { joinUrl } from "../../lib/util/joinUrl.js";
5
+ import { matchesProtectedPattern } from "../../lib/util/url.js";
6
+
7
+ // Module routes contributed by auto-detection or registerProtectedScope.
8
+ export type ModuleScope =
9
+ | { type: "route"; path: string }
10
+ | { type: "subtree"; root: string };
11
+
12
+ const scopes = new Map<string, ModuleScope[]>();
13
+
14
+ export const clearProtectedRegistry = () => scopes.clear();
15
+
16
+ export const registerProtectedScope = (
17
+ moduleId: string,
18
+ scope: ModuleScope,
19
+ ) => {
20
+ const list = scopes.get(moduleId);
21
+ if (list) list.push(scope);
22
+ else scopes.set(moduleId, [scope]);
23
+ };
24
+
25
+ export const getProtectedScopes = (moduleId: string) => scopes.get(moduleId);
26
+
27
+ export const protectedRegistryEntries = (): ReadonlyMap<
28
+ string,
29
+ readonly ModuleScope[]
30
+ > => scopes;
31
+
32
+ export type ProtectedSourceMatcher = (moduleId: string) => boolean;
33
+
34
+ // Returns true if `pattern` matches `scope`, using react-router's matchPath:
35
+ // "/admin" matches only the exact path; "/admin/*" matches /admin and all nested paths.
36
+ export const scopeMatchesPattern = (
37
+ scope: ModuleScope,
38
+ pattern: string,
39
+ ): boolean => {
40
+ if (scope.type === "route") {
41
+ return matchesProtectedPattern(pattern, joinUrl(scope.path));
42
+ }
43
+ // Subtree scopes only match patterns with '*' (e.g. '/*') to require explicit descendant gating, like react-router.
44
+ const root = joinUrl(scope.root);
45
+ if (!pattern.includes("*")) {
46
+ return matchesProtectedPattern(pattern, root);
47
+ }
48
+ return matchPath({ path: pattern, end: false }, root) != null;
49
+ };
50
+
51
+ // Returns true if a module ID is gated by any configured protected pattern.
52
+ // Populates registry automatically or via registerProtectedScope.
53
+ export const getProtectedSourceMatcher = (
54
+ config: ConfigWithMeta,
55
+ ): { match: ProtectedSourceMatcher; enabled: boolean; patterns: string[] } => {
56
+ const protectedRoutes = ProtectedRoutesSchema.parse(config.protectedRoutes);
57
+ const patterns = protectedRoutes ? Object.keys(protectedRoutes) : [];
58
+ if (patterns.length === 0) {
59
+ return { match: () => false, enabled: false, patterns };
60
+ }
61
+ return {
62
+ enabled: true,
63
+ patterns,
64
+ match: (id) => {
65
+ const pathOnly = id.split("?")[0] ?? id;
66
+ const moduleScopes = scopes.get(pathOnly);
67
+ if (!moduleScopes) return false;
68
+ return moduleScopes.some((s) =>
69
+ patterns.some((p) => scopeMatchesPattern(s, p)),
70
+ );
71
+ },
72
+ };
73
+ };
74
+
75
+ // Finds patterns not matched by any registered module scope. Indicates typos or missing registerProtectedScope calls.
76
+ export const findUnmatchedProtectedPatterns = (patterns: string[]): string[] =>
77
+ patterns.filter(
78
+ (p) =>
79
+ ![...scopes.values()].some((scopeList) =>
80
+ scopeList.some((s) => scopeMatchesPattern(s, p)),
81
+ ),
82
+ );