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
@@ -69,9 +69,10 @@ export const useCurrentNavigation = () => {
69
69
  }
70
70
  });
71
71
 
72
+ const { isAuthenticated } = useAuthState();
72
73
  const { data } = useSuspenseQuery({
73
74
  queryFn: () => getPluginNavigation(pathname),
74
- queryKey: ["plugin-navigation", pathname],
75
+ queryKey: ["plugin-navigation", pathname, isAuthenticated],
75
76
  });
76
77
 
77
78
  let topNavItem = navItem;
@@ -1,12 +1,6 @@
1
1
  import { Helmet } from "@zudoku/react-helmet-async";
2
2
  import { use, useCallback, useEffect, useMemo } from "react";
3
- import {
4
- matchPath,
5
- Outlet,
6
- useBlocker,
7
- useLocation,
8
- useNavigate,
9
- } from "react-router";
3
+ import { Outlet, useBlocker, useLocation } from "react-router";
10
4
  import { Button } from "zudoku/ui/Button.js";
11
5
  import {
12
6
  Dialog,
@@ -22,11 +16,11 @@ import { RenderContext } from "../components/context/RenderContext.js";
22
16
  import { useZudoku } from "../components/context/ZudokuContext.js";
23
17
  import { Layout } from "../components/Layout.js";
24
18
  import { ZudokuError } from "../util/invariant.js";
25
- import { stripBasePath } from "../util/url.js";
19
+ import { matchesProtectedPattern, stripBasePath } from "../util/url.js";
26
20
 
27
21
  export const SEARCH_PROTECTED_SECTION = "protected";
28
22
 
29
- type LoginDialogProps = {
23
+ export type LoginDialogProps = {
30
24
  open: boolean;
31
25
  onCancel: () => void;
32
26
  onLogin: () => void;
@@ -34,7 +28,7 @@ type LoginDialogProps = {
34
28
  showRegister: boolean;
35
29
  };
36
30
 
37
- const LoginDialog = ({
31
+ export const LoginDialog = ({
38
32
  open,
39
33
  onCancel,
40
34
  onLogin,
@@ -94,10 +88,38 @@ const ForbiddenPage = () => {
94
88
  );
95
89
  };
96
90
 
91
+ // Inline sign-in prompt for initial page loads. Not a Dialog because those
92
+ // portal to document.body and would leave SSR output empty.
93
+ export const SignInRequiredPage = ({ redirectTo }: { redirectTo: string }) => {
94
+ const auth = useAuth();
95
+ return (
96
+ <Layout>
97
+ <div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
98
+ <h1 className="text-2xl font-bold">Sign in to continue</h1>
99
+ <p className="text-muted-foreground">
100
+ Please sign in to access this page.
101
+ </p>
102
+ <div className="flex gap-2">
103
+ <Button onClick={() => void auth.login({ redirectTo })}>
104
+ Sign in
105
+ </Button>
106
+ {auth.isAuthEnabled && !auth.disableSignUp && (
107
+ <Button
108
+ variant="outline"
109
+ onClick={() => void auth.signup({ redirectTo })}
110
+ >
111
+ Register
112
+ </Button>
113
+ )}
114
+ </div>
115
+ </div>
116
+ </Layout>
117
+ );
118
+ };
119
+
97
120
  export const RouteGuard = () => {
98
121
  const auth = useAuth();
99
122
  const zudoku = useZudoku();
100
- const navigate = useNavigate();
101
123
  const location = useLocation();
102
124
  const renderContext = use(RenderContext);
103
125
  const shouldBypass = renderContext.bypassProtection;
@@ -112,7 +134,7 @@ export const RouteGuard = () => {
112
134
  (pathname: string) => {
113
135
  if (!protectedRoutes) return;
114
136
  for (const [pattern, check] of Object.entries(protectedRoutes)) {
115
- if (matchPath({ path: pattern, end: true }, pathname)) {
137
+ if (matchesProtectedPattern(pattern, pathname)) {
116
138
  return check;
117
139
  }
118
140
  }
@@ -122,8 +144,11 @@ export const RouteGuard = () => {
122
144
 
123
145
  const currentAuthCheck = getAuthCheck(location.pathname);
124
146
  const isProtectedRoute = currentAuthCheck !== undefined;
125
- const rawResult = currentAuthCheck?.(authCheckContext) ?? true;
126
- // Normalize: false is equivalent to UNAUTHORIZED
147
+ // Fail-closed: an unprotected route returns true; a protected route whose
148
+ // check returns undefined is treated as UNAUTHORIZED, not authorized.
149
+ const rawResult = isProtectedRoute
150
+ ? (currentAuthCheck(authCheckContext) ?? REASON_CODES.UNAUTHORIZED)
151
+ : true;
127
152
  const authResult =
128
153
  rawResult === false ? REASON_CODES.UNAUTHORIZED : rawResult;
129
154
  const isForbidden = authResult === REASON_CODES.FORBIDDEN;
@@ -139,7 +164,6 @@ export const RouteGuard = () => {
139
164
  });
140
165
  const isBlocked = blocker.state === "blocked";
141
166
 
142
- // Proceed after successful login
143
167
  useEffect(() => {
144
168
  if (!auth.isAuthenticated || !isBlocked) return;
145
169
  const check = getAuthCheck(blocker.location.pathname);
@@ -160,13 +184,8 @@ export const RouteGuard = () => {
160
184
  getAuthCheck,
161
185
  ]);
162
186
 
163
- if (isForbidden) {
164
- return <ForbiddenPage />;
165
- }
166
-
167
- if (shouldBypass) {
168
- return <BypassRoute isProtectedRoute={isProtectedRoute} />;
169
- }
187
+ if (isForbidden) return <ForbiddenPage />;
188
+ if (shouldBypass) return <BypassRoute isProtectedRoute={isProtectedRoute} />;
170
189
 
171
190
  if (isProtectedRoute && !auth.isAuthEnabled) {
172
191
  throw new ZudokuError("Authentication is not enabled", {
@@ -176,12 +195,14 @@ export const RouteGuard = () => {
176
195
  });
177
196
  }
178
197
 
179
- if (needsToSignIn && auth.isPending && typeof window !== "undefined") {
180
- return null;
198
+ if (needsToSignIn) {
199
+ if (typeof window === "undefined") renderContext.status = 401;
200
+ if (auth.isPending) return null;
201
+ return (
202
+ <SignInRequiredPage redirectTo={location.pathname + location.search} />
203
+ );
181
204
  }
182
205
 
183
- const showDialog = needsToSignIn || isBlocked;
184
-
185
206
  // Workaround: `blocker.location.pathname` includes basename, but `useLocation` does not.
186
207
  // Remove this when React Router fixes the issue. The canary test
187
208
  // `react-router-useblocker-basepath-bug.test.tsx` will fail when that happens.
@@ -192,10 +213,10 @@ export const RouteGuard = () => {
192
213
 
193
214
  return (
194
215
  <>
195
- {!needsToSignIn && <Outlet />}
216
+ <Outlet />
196
217
  <LoginDialog
197
- open={showDialog}
198
- onCancel={needsToSignIn ? () => navigate(-1) : () => blocker.reset?.()}
218
+ open={isBlocked}
219
+ onCancel={() => blocker.reset?.()}
199
220
  onLogin={() => void auth.login({ redirectTo })}
200
221
  onRegister={() => void auth.signup({ redirectTo })}
201
222
  showRegister={!auth.disableSignUp}
@@ -20,10 +20,12 @@ import type {
20
20
  } from "../../config/validators/ZudokuConfig.js";
21
21
  import type { AuthenticationPlugin } from "../authentication/authentication.js";
22
22
  import { type AuthState, useAuthState } from "../authentication/state.js";
23
+ import type { SSRAuthState } from "../components/context/RenderContext.js";
23
24
  import type { SlotType } from "../components/context/SlotProvider.js";
24
25
  import { joinUrl } from "../util/joinUrl.js";
25
26
  import type { MdxComponentsType } from "../util/MdxComponents.js";
26
27
  import { objectEntries } from "../util/objectEntries.js";
28
+ import { matchesAnyProtectedPattern } from "../util/url.js";
27
29
  import {
28
30
  isApiIdentityPlugin,
29
31
  isAuthenticationPlugin,
@@ -158,13 +160,18 @@ export class ZudokuContext {
158
160
  public readonly protectedRoutes: ReturnType<typeof normalizeProtectedRoutes>;
159
161
  private readonly plugins: NonNullable<ZudokuContextOptions["plugins"]>;
160
162
  private readonly emitter = createNanoEvents<ZudokuEvents>();
163
+ // Per-request server auth. Zustand store is module-global so we can't
164
+ // mutate it per request.
165
+ readonly ssrAuth?: SSRAuthState;
161
166
  readonly initialize: Promise<void> | undefined;
162
167
 
163
168
  constructor(
164
169
  options: ZudokuContextOptions,
165
170
  queryClient: QueryClient,
166
171
  env: Record<string, string | undefined>,
172
+ ssrAuth?: SSRAuthState,
167
173
  ) {
174
+ this.ssrAuth = ssrAuth;
168
175
  this.queryClient = queryClient;
169
176
  this.env = env;
170
177
  this.options = options;
@@ -207,12 +214,15 @@ export class ZudokuContext {
207
214
  });
208
215
  });
209
216
 
210
- useAuthState.subscribe((state, prevState) => {
211
- this.emitEvent("auth", {
212
- prev: prevState,
213
- next: state,
217
+ // Client-only: Avoid subscribing in SSR to prevent memory leaks from persistent subscribers.
218
+ if (typeof window !== "undefined") {
219
+ useAuthState.subscribe((state, prevState) => {
220
+ this.emitEvent("auth", {
221
+ prev: prevState,
222
+ next: state,
223
+ });
214
224
  });
215
- });
225
+ }
216
226
  }
217
227
 
218
228
  getApiIdentities = async () => {
@@ -239,7 +249,10 @@ export class ZudokuContext {
239
249
  return this.emitter.emit(event, ...data);
240
250
  };
241
251
 
242
- getPluginNavigation = async (path: string) => {
252
+ // Skip plugins for unauthed users on protected paths. Plugins often load
253
+ // protected resources inside getNavigation; that work would leak.
254
+ getPluginNavigation = async (path: string): Promise<Navigation> => {
255
+ if (this.shouldSkipNavigationForProtected(path)) return [];
243
256
  const navigations = await Promise.all(
244
257
  this.plugins
245
258
  .filter(isNavigationPlugin)
@@ -249,6 +262,18 @@ export class ZudokuContext {
249
262
  return navigations.flatMap((nav) => nav ?? []);
250
263
  };
251
264
 
265
+ private shouldSkipNavigationForProtected(path: string): boolean {
266
+ const patterns = Object.keys(this.protectedRoutes ?? {});
267
+ if (patterns.length === 0) return false;
268
+ if (!matchesAnyProtectedPattern(patterns, path)) return false;
269
+ // Server: per-request ssrAuth. Client: live zustand state.
270
+ const isAuthed =
271
+ typeof window === "undefined"
272
+ ? !!this.ssrAuth?.profile
273
+ : this.getAuthState().isAuthenticated;
274
+ return !isAuthed;
275
+ }
276
+
252
277
  getProfileMenuItems = () => {
253
278
  const accountItems = this.plugins
254
279
  .filter((p) => isProfileMenuPlugin(p))
@@ -260,6 +285,14 @@ export class ZudokuContext {
260
285
  };
261
286
 
262
287
  signRequest = async (request: Request) => {
288
+ if (this.ssrAuth?.accessToken) {
289
+ request.headers.set(
290
+ "Authorization",
291
+ `Bearer ${this.ssrAuth.accessToken}`,
292
+ );
293
+ return request;
294
+ }
295
+
263
296
  if (!this.authentication) {
264
297
  throw new Error("No authentication provider configured");
265
298
  }
@@ -1,10 +1,52 @@
1
- import { isRouteErrorResponse, useRouteError } from "react-router";
1
+ import { isRouteErrorResponse, useLocation, useRouteError } from "react-router";
2
+ import { useAuth } from "../authentication/hook.js";
3
+ import { useZudoku } from "../components/context/ZudokuContext.js";
2
4
  import { NotFoundPage } from "../components/NotFoundPage.js";
5
+ import { SignInRequiredPage } from "../core/RouteGuard.js";
3
6
  import { cn } from "../util/cn.js";
7
+ import { matchesAnyProtectedPattern } from "../util/url.js";
4
8
  import { ErrorAlert } from "./ErrorAlert.js";
5
9
 
10
+ // Chunk-load failure shape (what lazy() rejects with when protectChunks
11
+ // serves a 401). Matches the three browser messages plus the webpack-style
12
+ // ChunkLoadError name.
13
+ const isChunkLoadError = (error: unknown): boolean =>
14
+ error instanceof Error &&
15
+ (error.name === "ChunkLoadError" ||
16
+ /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed/.test(
17
+ error.message,
18
+ ));
19
+
20
+ // Safety net when a lazy() rejection trips errorElement instead of
21
+ // RouteGuard (session expired mid-nav, etc.). Only fires for errors that
22
+ // look like chunk-load failures or an explicit 401 route response so
23
+ // genuine errors on protected routes still surface.
24
+ const useSignInPromptIfProtectedUnauth = (error: unknown) => {
25
+ const location = useLocation();
26
+ const auth = useAuth();
27
+ const { protectedRoutes } = useZudoku();
28
+ if (!protectedRoutes || !auth.isAuthEnabled) return null;
29
+ if (auth.isAuthenticated || auth.isPending) return null;
30
+
31
+ const isAuthError =
32
+ (isRouteErrorResponse(error) && error.status === 401) ||
33
+ isChunkLoadError(error);
34
+
35
+ if (!isAuthError) return null;
36
+
37
+ const patterns = Object.keys(protectedRoutes);
38
+ if (!matchesAnyProtectedPattern(patterns, location.pathname)) return null;
39
+
40
+ return (
41
+ <SignInRequiredPage redirectTo={location.pathname + location.search} />
42
+ );
43
+ };
44
+
6
45
  export function RouterError({ className }: { className?: string }) {
7
46
  const error = useRouteError();
47
+ const signInPrompt = useSignInPromptIfProtectedUnauth(error);
48
+
49
+ if (signInPrompt) return signInPrompt;
8
50
 
9
51
  if (isRouteErrorResponse(error) && error.status === 404) {
10
52
  return <NotFoundPage />;
@@ -0,0 +1,62 @@
1
+ import type { ZudokuConfig } from "../config/config.js";
2
+ import { ProtectedRoutesSchema } from "../config/validators/ProtectedRoutesSchema.js";
3
+ import {
4
+ ACCESS_TOKEN_COOKIE,
5
+ AUTH_PROFILE_COOKIE,
6
+ REFRESH_TOKEN_COOKIE,
7
+ } from "./authentication/cookies.js";
8
+ import { joinUrl } from "./util/joinUrl.js";
9
+
10
+ export const PROTECTED_CHUNK_DIR = "_protected";
11
+
12
+ // Bump on schema-breaking changes; external tooling keys off this.
13
+ export const MANIFEST_VERSION = 1;
14
+
15
+ export const MANIFEST_FILENAME = "zudoku-manifest.json";
16
+
17
+ export type ZudokuManifest = {
18
+ version: number;
19
+ basePath: string;
20
+ ssrEntry: string;
21
+ // URL prefixes without trailing slash; consumers append `/*` for globs.
22
+ static: { prefixes: string[] };
23
+ protected: {
24
+ chunkPrefix: string;
25
+ routePatterns: string[];
26
+ };
27
+ auth: {
28
+ sessionEndpoint: string;
29
+ cookies: {
30
+ access: string;
31
+ refresh: string;
32
+ profile: string;
33
+ };
34
+ };
35
+ };
36
+
37
+ export const buildManifest = (
38
+ config: Pick<ZudokuConfig, "basePath" | "protectedRoutes">,
39
+ ): ZudokuManifest => {
40
+ const protectedRoutes = ProtectedRoutesSchema.parse(config.protectedRoutes);
41
+ const routePatterns = protectedRoutes ? Object.keys(protectedRoutes) : [];
42
+ return {
43
+ version: MANIFEST_VERSION,
44
+ basePath: config.basePath ?? "/",
45
+ ssrEntry: "server/entry.js",
46
+ static: {
47
+ prefixes: [joinUrl(config.basePath, "assets")],
48
+ },
49
+ protected: {
50
+ chunkPrefix: joinUrl(config.basePath, PROTECTED_CHUNK_DIR),
51
+ routePatterns,
52
+ },
53
+ auth: {
54
+ sessionEndpoint: joinUrl(config.basePath, "/__z/auth/session"),
55
+ cookies: {
56
+ access: ACCESS_TOKEN_COOKIE,
57
+ refresh: REFRESH_TOKEN_COOKIE,
58
+ profile: AUTH_PROFILE_COOKIE,
59
+ },
60
+ },
61
+ };
62
+ };
@@ -6,7 +6,8 @@ export type JSONSchema = JSONSchema4 | JSONSchema6;
6
6
 
7
7
  type CustomResolver = (ref: string) => Promise<JSONSchema | undefined>;
8
8
 
9
- const cache = new Map<JSONSchema, JSONSchema>();
9
+ // WeakMap so cached schemas get GC'd once nothing else references them.
10
+ const cache = new WeakMap<JSONSchema & object, JSONSchema>();
10
11
 
11
12
  // biome-ignore lint/suspicious/noExplicitAny: Allow any type
12
13
  const isIndexableObject = (obj: any): obj is Record<string, any> =>
@@ -1,6 +1,7 @@
1
1
  import type { JSONSchema } from "./index.js";
2
2
 
3
- const cache = new Map<JSONSchema, Map<string, unknown>>();
3
+ // WeakMap so cached schemas get GC'd once nothing else references them.
4
+ const cache = new WeakMap<JSONSchema & object, Map<string, unknown>>();
4
5
 
5
6
  /**
6
7
  * Resolves a $ref pointer in a schema and returns the referenced value.
@@ -53,7 +53,7 @@ const parseSchemaInput = async (
53
53
  let response: Response;
54
54
  try {
55
55
  response = await fetch(schemaInput, {
56
- cache: "force-cache",
56
+ cache: typeof window !== "undefined" ? "force-cache" : undefined,
57
57
  });
58
58
  } catch (err) {
59
59
  throw new GraphQLError("Failed to fetch schema", {
@@ -2,7 +2,9 @@ import { useLogger } from "@envelop/core";
2
2
  import { createGraphQLServer } from "../../../oas/graphql/index.js";
3
3
  import type { OpenApiPluginOptions } from "../index.js";
4
4
 
5
- const map = new Map<string, number>();
5
+ // Bounded so a query that never reaches -end doesn't accumulate (dev-only).
6
+ const MAX_PENDING = 200;
7
+ const pending = new Map<string, number>();
6
8
 
7
9
  export const createServer = (config: OpenApiPluginOptions) =>
8
10
  createGraphQLServer({
@@ -15,16 +17,23 @@ export const createServer = (config: OpenApiPluginOptions) =>
15
17
  if (import.meta.env.PROD) return;
16
18
 
17
19
  if (eventName.endsWith("-start")) {
18
- map.set(`${eventName}-${args.operationName}`, performance.now());
20
+ if (pending.size >= MAX_PENDING) {
21
+ // Map iterates in insertion order, so the first key is oldest.
22
+ const oldest = pending.keys().next().value;
23
+ if (oldest) pending.delete(oldest);
24
+ }
25
+ const key = `${eventName}-${args.operationName}`;
26
+ pending.set(key, performance.now());
19
27
  } else if (eventName.endsWith("-end")) {
20
28
  const startEvent = eventName.replace("-end", "-start");
21
- const start = map.get(`${startEvent}-${args.operationName}`);
29
+ const key = `${startEvent}-${args.operationName}`;
30
+ const start = pending.get(key);
22
31
  if (start) {
23
32
  // biome-ignore lint/suspicious/noConsole: Logging allowed here
24
33
  console.log(
25
34
  `[zudoku:debug] ${args.operationName} query took ${performance.now() - start}ms`,
26
35
  );
27
- map.delete(`${startEvent}-${args.operationName}`);
36
+ pending.delete(key);
28
37
  }
29
38
  }
30
39
  },
@@ -1,5 +1,4 @@
1
1
  import type { ZudokuConfig } from "../../../config/validators/ZudokuConfig.js";
2
- import { ClientOnly } from "../../components/ClientOnly.js";
3
2
  import type { ZudokuPlugin } from "../../core/plugins.js";
4
3
  import { PagefindSearch } from "./PagefindSearch.js";
5
4
 
@@ -13,9 +12,7 @@ export const pagefindSearchPlugin = (
13
12
  ): ZudokuPlugin => {
14
13
  return {
15
14
  renderSearch: ({ isOpen, onClose }) => (
16
- <ClientOnly>
17
- <PagefindSearch isOpen={isOpen} onClose={onClose} options={options} />
18
- </ClientOnly>
15
+ <PagefindSearch isOpen={isOpen} onClose={onClose} options={options} />
19
16
  ),
20
17
  };
21
18
  };
@@ -1,4 +1,5 @@
1
1
  export function getOS(): "apple" | "linux" | "unix" | "windows" | undefined {
2
+ if (typeof navigator === "undefined") return;
2
3
  const notFound = -1;
3
4
  const userAgent = navigator.userAgent.toLowerCase();
4
5
  if (userAgent.indexOf("win") !== notFound) {
@@ -1,3 +1,16 @@
1
+ import { matchPath } from "react-router";
2
+
3
+ // `/admin` matches only `/admin`; use `/admin/*` for subtree coverage.
4
+ export const matchesProtectedPattern = (
5
+ pattern: string,
6
+ path: string,
7
+ ): boolean => matchPath({ path: pattern, end: true }, path) != null;
8
+
9
+ export const matchesAnyProtectedPattern = (
10
+ patterns: readonly string[],
11
+ path: string,
12
+ ): boolean => patterns.some((p) => matchesProtectedPattern(p, path));
13
+
1
14
  // Removes the basePath from a pathname if present
2
15
  // Returns the pathname unchanged if it's not under the basePath
3
16
  export const stripBasePath = (pathname: string, basePath = ""): string => {
package/src/vite/build.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { build as esbuild } from "esbuild";
@@ -10,24 +11,34 @@ import invariant from "../lib/util/invariant.js";
10
11
  import { joinUrl } from "../lib/util/joinUrl.js";
11
12
  import { getViteConfig } from "./config.js";
12
13
  import { getBuildHtml } from "./html.js";
14
+ import { writeManifest } from "./manifest.js";
13
15
  import { writeOutput } from "./output.js";
14
16
  import { prerender } from "./prerender/prerender.js";
17
+ import {
18
+ assertCloudflareWranglerGatesProtected,
19
+ assertNoProtectedLeaks,
20
+ moveProtectedChunks,
21
+ assertProtectedPatternsCovered,
22
+ } from "./protected/build.js";
15
23
 
16
24
  const DIST_DIR = "dist";
17
25
 
26
+ export type SSRAdapter = "node" | "cloudflare" | "vercel" | "lambda";
27
+
18
28
  export type BuildOptions = {
19
29
  dir: string;
20
30
  ssr?: boolean;
21
- adapter?: "node" | "cloudflare" | "vercel";
31
+ adapter?: SSRAdapter;
22
32
  };
23
33
 
24
34
  export async function runBuild(options: BuildOptions) {
25
35
  const { dir, ssr, adapter = "node" } = options;
26
36
 
27
- const viteConfig = await getViteConfig(dir, {
28
- mode: "production",
29
- command: "build",
30
- });
37
+ const viteConfig = await getViteConfig(
38
+ dir,
39
+ { mode: "production", command: "build" },
40
+ { adapter, ssr },
41
+ );
31
42
 
32
43
  const builder = await createBuilder(viteConfig);
33
44
 
@@ -67,6 +78,7 @@ export async function runBuild(options: BuildOptions) {
67
78
  const jsEntry = clientResult.output.find(
68
79
  (o) => "isEntry" in o && o.isEntry,
69
80
  )?.fileName;
81
+
70
82
  const cssEntries = clientResult.output
71
83
  .filter((o) => o.fileName.endsWith(".css"))
72
84
  .map((o) => o.fileName);
@@ -88,8 +100,25 @@ export async function runBuild(options: BuildOptions) {
88
100
  adapter,
89
101
  serverOutDir,
90
102
  html,
91
- basePath: config.basePath,
92
103
  });
104
+ assertProtectedPatternsCovered(config);
105
+ assertNoProtectedLeaks(clientResult.output);
106
+ // On Cloudflare, protected chunks stay public (gate uses env.ASSETS.fetch).
107
+ // wrangler.toml must set run_worker_first for /_protected/*.
108
+ if (adapter !== "cloudflare") {
109
+ await moveProtectedChunks(clientOutDir, serverOutDir);
110
+ } else {
111
+ await assertCloudflareWranglerGatesProtected(dir, config);
112
+ }
113
+
114
+ // Mark the output as ESM so runtimes without a surrounding package.json
115
+ // (e.g. unzipped Lambda at /var/task) don't fall back to CommonJS.
116
+ await writeFile(
117
+ path.join(distDir, "package.json"),
118
+ `${JSON.stringify({ type: "module" }, null, 2)}\n`,
119
+ "utf-8",
120
+ );
121
+ await writeManifest(distDir, config);
93
122
  await rm(path.join(clientOutDir, "index.html"), { force: true });
94
123
  } else {
95
124
  // SSG: prerender and clean up server
@@ -190,45 +219,76 @@ const runPrerender = async (options: PrerenderOptions) => {
190
219
 
191
220
  type SSREntryOptions = {
192
221
  dir: string;
193
- adapter: "node" | "cloudflare" | "vercel";
222
+ adapter: SSRAdapter;
194
223
  serverOutDir: string;
195
224
  html: string;
196
- basePath?: string;
225
+ };
226
+
227
+ const findUserEntry = (dir: string) => {
228
+ for (const ext of ["ts", "tsx", "js", "mjs"]) {
229
+ const candidate = path.join(dir, `zudoku.server.${ext}`);
230
+ if (existsSync(candidate)) return candidate;
231
+ }
197
232
  };
198
233
 
199
234
  const bundleSSREntry = async (options: SSREntryOptions) => {
200
- const { dir, adapter, serverOutDir, html, basePath } = options;
201
- const tempEntryPath = path.join(dir, "__ssr-entry.ts");
235
+ const { dir, adapter, serverOutDir, html } = options;
202
236
 
203
237
  const packageRoot = getZudokuRootDir();
238
+ const userEntry = findUserEntry(dir);
204
239
 
205
- const templateContent = await readFile(
206
- path.join(packageRoot, "src/vite/ssr-templates", `${adapter}.ts`),
207
- "utf-8",
208
- );
240
+ let entryPoint = userEntry;
241
+ let tempEntryPath: string | undefined;
209
242
 
210
- const entryContent = templateContent
211
- .replace('"__TEMPLATE__"', JSON.stringify(html))
212
- .replace(
213
- '"__BASE_PATH__"',
214
- basePath ? JSON.stringify(basePath) : "undefined",
243
+ if (!entryPoint) {
244
+ tempEntryPath = path.join(dir, "__ssr-entry.ts");
245
+ const templateContent = await readFile(
246
+ path.join(packageRoot, "src/vite/ssr-templates", `${adapter}.ts`),
247
+ "utf-8",
215
248
  );
249
+ await writeFile(tempEntryPath, templateContent, "utf-8");
250
+ entryPoint = tempEntryPath;
251
+ }
216
252
 
217
- await writeFile(tempEntryPath, entryContent, "utf-8");
253
+ const frameworkPath = path.join(serverOutDir, "entry.server.js");
218
254
 
219
255
  try {
220
256
  await esbuild({
221
- entryPoints: [tempEntryPath],
257
+ entryPoints: [entryPoint],
222
258
  bundle: true,
223
- platform: adapter === "node" ? "node" : "neutral",
259
+ platform: ["node", "lambda"].includes(adapter) ? "node" : "neutral",
224
260
  target: "es2022",
225
261
  format: "esm",
226
262
  outfile: path.join(serverOutDir, "entry.js"),
227
- external: ["./entry.server.js", "./zudoku.config.js"],
263
+ external: ["./zudoku.config.js"],
228
264
  nodePaths: [path.join(packageRoot, "node_modules")],
229
265
  banner: { js: "// Bundled SSR entry" },
266
+ define: {
267
+ __ZUDOKU_TEMPLATE__: JSON.stringify(html),
268
+ },
269
+ plugins: [
270
+ {
271
+ name: "zudoku-ssr-entry",
272
+ setup(build) {
273
+ // Point at the Vite-pre-built framework (virtual modules
274
+ // already resolved) so esbuild's `define` can reach into it.
275
+ build.onResolve({ filter: /^zudoku\/server$/ }, () => ({
276
+ path: frameworkPath,
277
+ }));
278
+ },
279
+ },
280
+ ],
230
281
  });
282
+ // Framework is inlined into entry.js; drop the standalone Vite SSR output.
283
+ await Promise.all([
284
+ rm(frameworkPath, { force: true }),
285
+ rm(`${frameworkPath}.map`, { force: true }),
286
+ rm(path.join(serverOutDir, "assets"), {
287
+ recursive: true,
288
+ force: true,
289
+ }),
290
+ ]);
231
291
  } finally {
232
- await rm(tempEntryPath, { force: true });
292
+ if (tempEntryPath) await rm(tempEntryPath, { force: true });
233
293
  }
234
294
  };