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
|
@@ -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 (
|
|
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
|
-
|
|
126
|
-
//
|
|
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
|
-
|
|
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
|
|
180
|
-
|
|
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
|
-
|
|
216
|
+
<Outlet />
|
|
196
217
|
<LoginDialog
|
|
197
|
-
open={
|
|
198
|
-
onCancel={
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
17
|
-
<PagefindSearch isOpen={isOpen} onClose={onClose} options={options} />
|
|
18
|
-
</ClientOnly>
|
|
15
|
+
<PagefindSearch isOpen={isOpen} onClose={onClose} options={options} />
|
|
19
16
|
),
|
|
20
17
|
};
|
|
21
18
|
};
|
package/src/lib/util/os.ts
CHANGED
package/src/lib/util/url.ts
CHANGED
|
@@ -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?:
|
|
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(
|
|
28
|
-
|
|
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:
|
|
222
|
+
adapter: SSRAdapter;
|
|
194
223
|
serverOutDir: string;
|
|
195
224
|
html: string;
|
|
196
|
-
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
"utf-8",
|
|
208
|
-
);
|
|
240
|
+
let entryPoint = userEntry;
|
|
241
|
+
let tempEntryPath: string | undefined;
|
|
209
242
|
|
|
210
|
-
|
|
211
|
-
.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
253
|
+
const frameworkPath = path.join(serverOutDir, "entry.server.js");
|
|
218
254
|
|
|
219
255
|
try {
|
|
220
256
|
await esbuild({
|
|
221
|
-
entryPoints: [
|
|
257
|
+
entryPoints: [entryPoint],
|
|
222
258
|
bundle: true,
|
|
223
|
-
platform:
|
|
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: ["./
|
|
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
|
};
|