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
package/src/vite/config.ts
CHANGED
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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",
|
package/src/vite/dev-server.ts
CHANGED
|
@@ -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
|
|
242
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
+
};
|
package/src/vite/plugin-api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 (
|
|
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)
|
|
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
|
+
);
|