zudoku 0.92.0 → 0.93.0
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 +116 -3
- package/dist/declarations/lib/plugins/api-keys/fetchSigned.d.ts +2 -0
- package/dist/declarations/lib/util/redirectPattern.d.ts +11 -0
- package/docs/configuration/authentication-auth0.md +34 -19
- package/docs/configuration/overview.md +5 -1
- package/docs/guides/managing-api-keys-and-identities.md +8 -0
- package/docs/guides/redirects.md +37 -0
- package/package.json +1 -1
- package/src/app/utils/createRedirectRoutes.ts +2 -1
- package/src/lib/authentication/providers/openid.tsx +1 -14
- package/src/lib/plugins/api-keys/fetchSigned.ts +35 -0
- package/src/lib/plugins/api-keys/index.tsx +31 -34
- package/src/lib/util/redirectPattern.ts +172 -0
- package/src/vite/output.ts +20 -1
- package/src/vite/prerender/prerender.ts +5 -1
- package/src/vite/prerender/utils.ts +18 -1
package/dist/cli/cli.js
CHANGED
|
@@ -9107,6 +9107,98 @@ import { cp, mkdir as mkdir3, readdir, rm, writeFile as writeFile4 } from "node:
|
|
|
9107
9107
|
import path20 from "node:path";
|
|
9108
9108
|
init_joinUrl();
|
|
9109
9109
|
|
|
9110
|
+
// src/lib/util/redirectPattern.ts
|
|
9111
|
+
init_joinUrl();
|
|
9112
|
+
var SPLAT = "*";
|
|
9113
|
+
var ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i;
|
|
9114
|
+
var TOKEN_REGEX = /\/(?::([\w-]+)|\*)(?=[/?#]|$)/g;
|
|
9115
|
+
var parseSegments = (path32) => {
|
|
9116
|
+
const parts = path32.split("/").filter(Boolean);
|
|
9117
|
+
return parts.map((part, index) => {
|
|
9118
|
+
if (part === SPLAT && index === parts.length - 1) return { type: "splat" };
|
|
9119
|
+
const match = part.match(/^:([\w-]+)(\?)?$/);
|
|
9120
|
+
if (!match?.[1]) return { type: "static", value: part };
|
|
9121
|
+
return { type: "param", name: match[1], optional: match[2] === "?" };
|
|
9122
|
+
});
|
|
9123
|
+
};
|
|
9124
|
+
var getParamNames = (from) => parseSegments(from).flatMap((segment) => {
|
|
9125
|
+
if (segment.type === "param") return [segment.name];
|
|
9126
|
+
if (segment.type === "splat") return [SPLAT];
|
|
9127
|
+
return [];
|
|
9128
|
+
});
|
|
9129
|
+
var isDynamicRedirect = (redirect) => getParamNames(redirect.from).length > 0;
|
|
9130
|
+
var replaceTokens = (to, names, replace) => {
|
|
9131
|
+
const target = to.replace(TOKEN_REGEX, (token, name) => {
|
|
9132
|
+
const key = name ?? SPLAT;
|
|
9133
|
+
return names.includes(key) ? replace(key) : token;
|
|
9134
|
+
});
|
|
9135
|
+
const lostPath = target === "" || /^[?#]/.test(target);
|
|
9136
|
+
return target !== to && lostPath ? `/${target}` : target;
|
|
9137
|
+
};
|
|
9138
|
+
var rankPath = (path32) => {
|
|
9139
|
+
const segments = joinUrl(path32).split("/");
|
|
9140
|
+
return segments.reduce(
|
|
9141
|
+
(score, segment) => {
|
|
9142
|
+
if (segment === SPLAT) return score;
|
|
9143
|
+
if (segment.startsWith(":")) return score + 3;
|
|
9144
|
+
return score + (segment === "" ? 1 : 10);
|
|
9145
|
+
},
|
|
9146
|
+
segments.length - (segments.includes(SPLAT) ? 2 : 0)
|
|
9147
|
+
);
|
|
9148
|
+
};
|
|
9149
|
+
var segmentToPath = (segment) => {
|
|
9150
|
+
if (segment.type === "static") return segment.value;
|
|
9151
|
+
if (segment.type === "splat") return SPLAT;
|
|
9152
|
+
return `:${segment.name}`;
|
|
9153
|
+
};
|
|
9154
|
+
var explodeOptional = (segments) => {
|
|
9155
|
+
const [first, ...rest] = segments;
|
|
9156
|
+
if (!first) return [[]];
|
|
9157
|
+
const variants = explodeOptional(rest);
|
|
9158
|
+
const withFirst = variants.map((variant) => [first, ...variant]);
|
|
9159
|
+
return first.type === "param" && first.optional ? [...withFirst, ...variants] : withFirst;
|
|
9160
|
+
};
|
|
9161
|
+
var escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9162
|
+
var toBuildOutputRedirects = (redirects, basePath) => {
|
|
9163
|
+
const base = joinUrl(basePath);
|
|
9164
|
+
const prefix = base === "/" ? "" : escapeRegex(base);
|
|
9165
|
+
return redirects.flatMap((redirect) => {
|
|
9166
|
+
const names = getParamNames(redirect.from);
|
|
9167
|
+
const to = ABSOLUTE_URL_REGEX.test(redirect.to) ? redirect.to : joinUrl(basePath, redirect.to);
|
|
9168
|
+
const toRoute = (segments) => {
|
|
9169
|
+
const captured = segments.flatMap((segment) => {
|
|
9170
|
+
if (segment.type === "param") return [segment.name];
|
|
9171
|
+
if (segment.type === "splat") return [SPLAT];
|
|
9172
|
+
return [];
|
|
9173
|
+
});
|
|
9174
|
+
const pattern = segments.map((segment) => {
|
|
9175
|
+
if (segment.type === "static") {
|
|
9176
|
+
return `/${escapeRegex(segment.value)}`;
|
|
9177
|
+
}
|
|
9178
|
+
return segment.type === "splat" ? "(/.+)" : "(/[^/]+)";
|
|
9179
|
+
}).join("");
|
|
9180
|
+
const endsWithSplat = segments.at(-1)?.type === "splat";
|
|
9181
|
+
return {
|
|
9182
|
+
// React Router accepts any number of trailing slashes
|
|
9183
|
+
src: `^${prefix}${pattern}${endsWithSplat ? "" : "/*"}$`,
|
|
9184
|
+
// Like React Router, a repeated param name takes the last value
|
|
9185
|
+
location: replaceTokens(
|
|
9186
|
+
to,
|
|
9187
|
+
names,
|
|
9188
|
+
(name) => captured.includes(name) ? `$${captured.lastIndexOf(name) + 1}` : ""
|
|
9189
|
+
)
|
|
9190
|
+
};
|
|
9191
|
+
};
|
|
9192
|
+
return explodeOptional(parseSegments(redirect.from)).flatMap(
|
|
9193
|
+
(segments) => {
|
|
9194
|
+
const rank = rankPath(`/${segments.map(segmentToPath).join("/")}`);
|
|
9195
|
+
const variants = segments.at(-1)?.type === "splat" ? [segments, segments.slice(0, -1)] : [segments];
|
|
9196
|
+
return variants.map((variant) => ({ rank, ...toRoute(variant) }));
|
|
9197
|
+
}
|
|
9198
|
+
);
|
|
9199
|
+
}).toSorted((a, b) => b.rank - a.rank).map(({ src, location }) => ({ src, location }));
|
|
9200
|
+
};
|
|
9201
|
+
|
|
9110
9202
|
// src/vite/vercel-markdown-middleware.ts
|
|
9111
9203
|
var normalizePath2 = (value) => {
|
|
9112
9204
|
const pathname = value.split("/").filter(Boolean).map((segment) => {
|
|
@@ -9575,8 +9667,20 @@ function generateOutput({
|
|
|
9575
9667
|
continue: true
|
|
9576
9668
|
});
|
|
9577
9669
|
}
|
|
9578
|
-
|
|
9670
|
+
const dynamicRedirects = toBuildOutputRedirects(
|
|
9671
|
+
(config.redirects ?? []).filter(isDynamicRedirect),
|
|
9672
|
+
config.basePath
|
|
9673
|
+
);
|
|
9674
|
+
if (rewrites.length > 0 || dynamicRedirects.length > 0) {
|
|
9579
9675
|
routes.push({ handle: "filesystem" });
|
|
9676
|
+
for (const { src, location } of dynamicRedirects) {
|
|
9677
|
+
routes.push({
|
|
9678
|
+
src,
|
|
9679
|
+
dest: location,
|
|
9680
|
+
status: 301,
|
|
9681
|
+
headers: { Location: location }
|
|
9682
|
+
});
|
|
9683
|
+
}
|
|
9580
9684
|
for (const rewrite of rewrites) {
|
|
9581
9685
|
routes.push({
|
|
9582
9686
|
src: joinUrl(config.basePath, rewrite.source),
|
|
@@ -9778,10 +9882,16 @@ var collectRewrites = (resolved) => resolved.flatMap((r) => [
|
|
|
9778
9882
|
...collectRewrites(r.children)
|
|
9779
9883
|
]);
|
|
9780
9884
|
var routesToPaths = (routes) => collectPaths(resolveRoutes(routes), "");
|
|
9885
|
+
var withoutDynamicRedirectRoutes = (routes, redirects = []) => {
|
|
9886
|
+
const paths = new Set(
|
|
9887
|
+
redirects.filter(isDynamicRedirect).map(({ from }) => joinUrl(from))
|
|
9888
|
+
);
|
|
9889
|
+
return routes.filter((route) => !route.path || !paths.has(route.path));
|
|
9890
|
+
};
|
|
9781
9891
|
var routesToPrerenderPaths = (routes, redirects = []) => Array.from(
|
|
9782
9892
|
/* @__PURE__ */ new Set([
|
|
9783
9893
|
...routesToPaths(routes),
|
|
9784
|
-
...redirects.map(({ from }) => joinUrl(from))
|
|
9894
|
+
...redirects.filter((redirect) => !isDynamicRedirect(redirect)).map(({ from }) => joinUrl(from))
|
|
9785
9895
|
])
|
|
9786
9896
|
);
|
|
9787
9897
|
var routesToRewrites = (routes) => collectRewrites(resolveRoutes(routes));
|
|
@@ -9826,7 +9936,10 @@ var prerender = async ({
|
|
|
9826
9936
|
const buildConfig = await getBuildConfig();
|
|
9827
9937
|
const module = await import(entryServerPath);
|
|
9828
9938
|
const getRoutes = module.getRoutesByConfig;
|
|
9829
|
-
const routes =
|
|
9939
|
+
const routes = withoutDynamicRedirectRoutes(
|
|
9940
|
+
getRoutes(config),
|
|
9941
|
+
config.redirects
|
|
9942
|
+
);
|
|
9830
9943
|
const paths = routesToPrerenderPaths(routes, config.redirects);
|
|
9831
9944
|
const rewrites = routesToRewrites(routes);
|
|
9832
9945
|
const { maxThreads, maxOldGenerationSizeMb } = getWorkerScaling(
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type Redirect = {
|
|
2
|
+
from: string;
|
|
3
|
+
to: string;
|
|
4
|
+
};
|
|
5
|
+
export declare const isDynamicRedirect: (redirect: Pick<Redirect, "from">) => boolean;
|
|
6
|
+
export declare const resolveRedirectTarget: ({ from, to }: Redirect, params: Record<string, string | undefined>) => string;
|
|
7
|
+
export declare const toBuildOutputRedirects: (redirects: readonly Redirect[], basePath?: string) => {
|
|
8
|
+
src: string;
|
|
9
|
+
location: string;
|
|
10
|
+
}[];
|
|
11
|
+
export {};
|
|
@@ -50,21 +50,7 @@ If you don't have an Auth0 account, you can sign up for a
|
|
|
50
50
|
|
|
51
51
|
Keep the default **Refresh Token Expiration** settings unless you have specific requirements.
|
|
52
52
|
|
|
53
|
-
3.
|
|
54
|
-
- Navigate to the [APIs section](https://manage.auth0.com/#/apis) in the Auth0 dashboard
|
|
55
|
-
- Click **Create API**
|
|
56
|
-
- Set a name (e.g., "Zudoku API") and an identifier (e.g., `https://your-domain.com/api`)
|
|
57
|
-
- Choose **RS256** as the signing algorithm
|
|
58
|
-
- Save the API
|
|
59
|
-
|
|
60
|
-
:::warning
|
|
61
|
-
|
|
62
|
-
This step is important. If you skip creating an API, Zudoku will not be able to validate the
|
|
63
|
-
tokens issued by Auth0, leading to authentication failures.
|
|
64
|
-
|
|
65
|
-
:::
|
|
66
|
-
|
|
67
|
-
4. **Configure Zudoku**
|
|
53
|
+
3. **Configure Zudoku**
|
|
68
54
|
|
|
69
55
|
Add the Auth0 configuration to your [Zudoku configuration file](./overview.md):
|
|
70
56
|
|
|
@@ -76,7 +62,6 @@ If you don't have an Auth0 account, you can sign up for a
|
|
|
76
62
|
type: "auth0",
|
|
77
63
|
domain: "your-domain.us.auth0.com",
|
|
78
64
|
clientId: "<your-auth0-client-id>",
|
|
79
|
-
audience: "https://your-domain.com/api", // Your Auth0 API identifier
|
|
80
65
|
},
|
|
81
66
|
// ... other configuration
|
|
82
67
|
};
|
|
@@ -85,7 +70,36 @@ If you don't have an Auth0 account, you can sign up for a
|
|
|
85
70
|
Where:
|
|
86
71
|
- **domain**: Your Auth0 domain (found in your application's Basic Information)
|
|
87
72
|
- **clientId**: The Client ID from your Auth0 application settings
|
|
88
|
-
|
|
73
|
+
|
|
74
|
+
4. **(Optional) Configure an audience for API access**
|
|
75
|
+
|
|
76
|
+
Only configure an `audience` if you need the access token to call a specific API (for example,
|
|
77
|
+
when using the API playground to send authenticated requests to your backend). Without an
|
|
78
|
+
audience, Auth0 issues an opaque token that can be used to identify the user via `/userinfo` —
|
|
79
|
+
which is all Zudoku needs for sign-in and protected routes.
|
|
80
|
+
|
|
81
|
+
:::tip
|
|
82
|
+
|
|
83
|
+
Avoid setting an audience when you don't need it. Issuing an access token scoped to an API when
|
|
84
|
+
it isn't going to call that API gives the token broader privileges than necessary.
|
|
85
|
+
|
|
86
|
+
:::
|
|
87
|
+
|
|
88
|
+
If you do need API access:
|
|
89
|
+
- Navigate to the [APIs section](https://manage.auth0.com/#/apis) in the Auth0 dashboard
|
|
90
|
+
- Click **Create API**, set a name (e.g., "Zudoku API") and an identifier (e.g.,
|
|
91
|
+
`https://your-domain.com/api`)
|
|
92
|
+
- Choose **RS256** as the signing algorithm and save the API
|
|
93
|
+
- Add the identifier to your Zudoku configuration:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
authentication: {
|
|
97
|
+
type: "auth0",
|
|
98
|
+
domain: "your-domain.us.auth0.com",
|
|
99
|
+
clientId: "<your-auth0-client-id>",
|
|
100
|
+
audience: "https://your-domain.com/api", // Your Auth0 API identifier
|
|
101
|
+
}
|
|
102
|
+
```
|
|
89
103
|
|
|
90
104
|
</Stepper>
|
|
91
105
|
|
|
@@ -188,8 +202,9 @@ authentication: {
|
|
|
188
202
|
3. **Authentication Loop**: Check that your Auth0 domain is a plain hostname only (e.g.,
|
|
189
203
|
`your-domain.us.auth0.com`) without a protocol prefix (`https://`) or trailing slash.
|
|
190
204
|
|
|
191
|
-
4. **Token Validation Errors**:
|
|
192
|
-
identifier of the Auth0 API you created.
|
|
205
|
+
4. **Token Validation Errors**: If you've configured an `audience`, make sure it matches the
|
|
206
|
+
identifier of the Auth0 API you created. If you don't need to call an API, remove `audience` from
|
|
207
|
+
your configuration — Auth0 will issue an opaque token suitable for `/userinfo`.
|
|
193
208
|
|
|
194
209
|
## Next Steps
|
|
195
210
|
|
|
@@ -284,12 +284,16 @@ URL.
|
|
|
284
284
|
// ...
|
|
285
285
|
"redirects": [
|
|
286
286
|
{ "from": "/", "to": "/documentation/introduction" },
|
|
287
|
-
{ "from": "/documentation", "to": "/documentation/introduction" }
|
|
287
|
+
{ "from": "/documentation", "to": "/documentation/introduction" },
|
|
288
|
+
{ "from": "/blog/:slug", "to": "/posts/:slug" }
|
|
288
289
|
]
|
|
289
290
|
// ...
|
|
290
291
|
}
|
|
291
292
|
```
|
|
292
293
|
|
|
294
|
+
`from` paths can contain `:param` segments and a trailing `*` wildcard. See
|
|
295
|
+
[Redirects](/docs/guides/redirects#path-parameters-and-wildcards) for details.
|
|
296
|
+
|
|
293
297
|
### `port`
|
|
294
298
|
|
|
295
299
|
The port on which the development server will run. Defaults to `3000`. This option can also be
|
|
@@ -35,6 +35,14 @@ this is already enabled.)
|
|
|
35
35
|
|
|
36
36
|
This will make the API Key UI available at `/settings/api-keys` in your Zudoku.
|
|
37
37
|
|
|
38
|
+
:::note
|
|
39
|
+
|
|
40
|
+
Zuplo's API key service identifies users by their JWT access token. If you use
|
|
41
|
+
[Auth0](../configuration/authentication-auth0.md), configure an `audience` in your authentication
|
|
42
|
+
settings. Without one, Auth0 issues an opaque access token that can't be used to manage API keys.
|
|
43
|
+
|
|
44
|
+
:::
|
|
45
|
+
|
|
38
46
|
### Creating API Keys
|
|
39
47
|
|
|
40
48
|
By default, there is no **Create API Key** Button. To get the button working you have to implement
|
package/docs/guides/redirects.md
CHANGED
|
@@ -52,6 +52,43 @@ redirects: [{ from: "/old-page", to: "/new-page" }];
|
|
|
52
52
|
Trailing slashes in the `from` path are normalized automatically. Both `/old-page` and `/old-page/`
|
|
53
53
|
will match the same redirect rule.
|
|
54
54
|
|
|
55
|
+
## Path parameters and wildcards
|
|
56
|
+
|
|
57
|
+
A `from` path can match more than one URL. Use `:name` for a single path segment and a trailing `*`
|
|
58
|
+
for everything after a prefix. Reference the same tokens in `to` to carry the matched values over:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
redirects: [
|
|
62
|
+
// /blog/hello-world → /posts/hello-world
|
|
63
|
+
{ from: "/blog/:slug", to: "/posts/:slug" },
|
|
64
|
+
// /v1/guides/auth/tokens → /guides/auth/tokens
|
|
65
|
+
{ from: "/v1/*", to: "/*" },
|
|
66
|
+
// /dashboard/apps/42 → https://dashboard.example.com/apps/42
|
|
67
|
+
{ from: "/dashboard/*", to: "https://dashboard.example.com/*" },
|
|
68
|
+
];
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- **`:name`** matches exactly one segment. Add `?` (`:name?`) to make it optional.
|
|
72
|
+
- **`*`** must be the last segment of `from`. It also matches the prefix itself, so `/dashboard/*`
|
|
73
|
+
redirects `/dashboard` too. In that case the `/*` in `to` is dropped, leaving `/` if nothing else
|
|
74
|
+
remains.
|
|
75
|
+
- In `to`, `/:name` and `/*` must each fill a whole path segment, so `/posts/:slug` works and
|
|
76
|
+
`/posts-:slug` doesn't. Tokens that don't appear in `from` are left as they are.
|
|
77
|
+
- Zudoku doesn't copy the incoming query string to the target.
|
|
78
|
+
|
|
79
|
+
Pages in your portal take precedence over a wildcard redirect, so `{ from: "/docs/*", to: "/" }`
|
|
80
|
+
still lets visitors reach `/docs/introduction` if that page exists.
|
|
81
|
+
|
|
82
|
+
Static hosts can't prerender a file for every URL a pattern matches, so pattern redirects behave
|
|
83
|
+
differently from exact ones:
|
|
84
|
+
|
|
85
|
+
- **Zuplo** and **Vercel**: Zudoku turns the pattern into a regular-expression route in the build
|
|
86
|
+
output, and the platform returns an HTTP **301**.
|
|
87
|
+
- **SSR deployments**: the server returns an HTTP 301, the same as exact redirects.
|
|
88
|
+
- **Other static hosts**: no redirect file is written. If your host serves `404.html` (or your app's
|
|
89
|
+
`index.html`) for unknown paths, the page loads, Zudoku matches the pattern, and the browser is
|
|
90
|
+
redirected. Otherwise, set up the redirect in your host's own configuration.
|
|
91
|
+
|
|
55
92
|
## How redirects work
|
|
56
93
|
|
|
57
94
|
Zudoku redirects operate at two levels depending on how the visitor reaches the page:
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { redirect, type RouteObject } from "react-router";
|
|
2
2
|
import type { ZudokuRedirect } from "../../config/validators/ZudokuConfig.js";
|
|
3
3
|
import { joinUrl } from "../../lib/util/joinUrl.js";
|
|
4
|
+
import { resolveRedirectTarget } from "../../lib/util/redirectPattern.js";
|
|
4
5
|
|
|
5
6
|
export const createRedirectRoutes = (
|
|
6
7
|
redirects?: ZudokuRedirect[],
|
|
7
8
|
): RouteObject[] =>
|
|
8
9
|
(redirects ?? []).map((r) => ({
|
|
9
10
|
path: joinUrl(r.from),
|
|
10
|
-
loader: () => redirect(r
|
|
11
|
+
loader: ({ params }) => redirect(resolveRedirectTarget(r, params), 301),
|
|
11
12
|
}));
|
|
@@ -17,7 +17,7 @@ import { LogoutCallbackHandler } from "../components/LogoutCallbackHandler.js";
|
|
|
17
17
|
import { OAuthErrorPage } from "../components/OAuthErrorPage.js";
|
|
18
18
|
import { fetchServerSession } from "../cookie-sync.js";
|
|
19
19
|
import { DEFAULT_SESSION_MAX_AGE, SESSION_ENDPOINT_PATH } from "../cookies.js";
|
|
20
|
-
import { AuthorizationError
|
|
20
|
+
import { AuthorizationError } from "../errors.js";
|
|
21
21
|
import { type UserProfile, useAuthState } from "../state.js";
|
|
22
22
|
import { redirectToSignUpUrl } from "./util.js";
|
|
23
23
|
|
|
@@ -178,19 +178,6 @@ export class OpenIDAuthenticationProvider
|
|
|
178
178
|
throw new AuthorizationError("No expires_in in response");
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
const accessToken = response.access_token;
|
|
182
|
-
if (accessToken.split(".").length !== 3) {
|
|
183
|
-
throw new OAuthAuthorizationError(
|
|
184
|
-
"The access token received is not a valid JWT.",
|
|
185
|
-
{
|
|
186
|
-
error: "configuration_error",
|
|
187
|
-
error_description:
|
|
188
|
-
"The authentication provider is issuing opaque tokens instead of JWTs. " +
|
|
189
|
-
"Ensure you have configured the correct `audience` in your authentication settings.",
|
|
190
|
-
},
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
181
|
const claims = response.id_token
|
|
195
182
|
? oauth.getValidatedIdTokenClaims(response)
|
|
196
183
|
: undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ZudokuContext } from "../../core/ZudokuContext.js";
|
|
2
|
+
import { ZudokuError } from "../../util/invariant.js";
|
|
3
|
+
|
|
4
|
+
const isJwt = (token: string) => token.split(".").length === 3;
|
|
5
|
+
|
|
6
|
+
const isAuthFailure = (response: Response) =>
|
|
7
|
+
response.status === 401 || response.status === 403;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Signs and sends a request to Zuplo's API key service. The service identifies
|
|
11
|
+
* the user from a JWT access token, so an opaque token (e.g. Auth0 without an
|
|
12
|
+
* `audience`) rejected with 401/403 is reported as a configuration error
|
|
13
|
+
* instead of a generic request failure.
|
|
14
|
+
*/
|
|
15
|
+
export const fetchSigned = async (context: ZudokuContext, request: Request) => {
|
|
16
|
+
const signedRequest = await context.signRequest(request);
|
|
17
|
+
const response = await fetch(signedRequest);
|
|
18
|
+
|
|
19
|
+
const accessToken = signedRequest.headers
|
|
20
|
+
.get("Authorization")
|
|
21
|
+
?.replace(/^Bearer /i, "");
|
|
22
|
+
|
|
23
|
+
if (isAuthFailure(response) && accessToken && !isJwt(accessToken)) {
|
|
24
|
+
throw new ZudokuError(
|
|
25
|
+
"The access token issued by the authentication provider can't be used to manage API keys.",
|
|
26
|
+
{
|
|
27
|
+
title: "Configuration Error",
|
|
28
|
+
developerHint:
|
|
29
|
+
"Zuplo API key management requires a JWT access token, but the authentication provider issued an opaque token. If you use Auth0, set `audience` in your authentication configuration to the identifier of your Auth0 API.",
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return response;
|
|
35
|
+
};
|
|
@@ -11,6 +11,7 @@ import type { ZudokuContext } from "../../core/ZudokuContext.js";
|
|
|
11
11
|
import invariant from "../../util/invariant.js";
|
|
12
12
|
import { joinUrl } from "../../util/joinUrl.js";
|
|
13
13
|
import { throwIfProblemJson } from "../../util/problemJson.js";
|
|
14
|
+
import { fetchSigned } from "./fetchSigned.js";
|
|
14
15
|
import { SettingsApiKeys } from "./SettingsApiKeys.js";
|
|
15
16
|
|
|
16
17
|
const DEFAULT_GATEWAY_URL = "https://api.zuploedge.com";
|
|
@@ -93,7 +94,7 @@ const createZuploService = ({
|
|
|
93
94
|
method: "DELETE",
|
|
94
95
|
},
|
|
95
96
|
);
|
|
96
|
-
const response = await
|
|
97
|
+
const response = await fetchSigned(context, request);
|
|
97
98
|
await throwIfProblemJson(response);
|
|
98
99
|
invariant(response.ok, "Failed to delete API key");
|
|
99
100
|
},
|
|
@@ -103,23 +104,22 @@ const createZuploService = ({
|
|
|
103
104
|
"Cannot update API key description.",
|
|
104
105
|
developerHintOptions,
|
|
105
106
|
);
|
|
106
|
-
const response = await
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
),
|
|
113
|
-
{
|
|
114
|
-
method: "PATCH",
|
|
115
|
-
headers: {
|
|
116
|
-
"Content-Type": "application/json",
|
|
117
|
-
},
|
|
118
|
-
body: JSON.stringify({
|
|
119
|
-
label: consumer.label,
|
|
120
|
-
}),
|
|
121
|
-
},
|
|
107
|
+
const response = await fetchSigned(
|
|
108
|
+
context,
|
|
109
|
+
new Request(
|
|
110
|
+
joinUrl(
|
|
111
|
+
getApiKeyEndpoint(context),
|
|
112
|
+
`${deploymentName}/consumers/${consumer.id}`,
|
|
122
113
|
),
|
|
114
|
+
{
|
|
115
|
+
method: "PATCH",
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
},
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
label: consumer.label,
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
123
|
),
|
|
124
124
|
);
|
|
125
125
|
await throwIfProblemJson(response);
|
|
@@ -127,21 +127,20 @@ const createZuploService = ({
|
|
|
127
127
|
},
|
|
128
128
|
rollKey: async (consumerId, context) => {
|
|
129
129
|
invariant(deploymentName, "Cannot roll API key.", developerHintOptions);
|
|
130
|
-
const response = await
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
),
|
|
137
|
-
{
|
|
138
|
-
method: "POST",
|
|
139
|
-
headers: {
|
|
140
|
-
"Content-Type": "application/json",
|
|
141
|
-
},
|
|
142
|
-
body: JSON.stringify({}),
|
|
143
|
-
},
|
|
130
|
+
const response = await fetchSigned(
|
|
131
|
+
context,
|
|
132
|
+
new Request(
|
|
133
|
+
joinUrl(
|
|
134
|
+
getApiKeyEndpoint(context),
|
|
135
|
+
`${deploymentName}/consumers/${consumerId}/roll-key`,
|
|
144
136
|
),
|
|
137
|
+
{
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: {
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
},
|
|
142
|
+
body: JSON.stringify({}),
|
|
143
|
+
},
|
|
145
144
|
),
|
|
146
145
|
);
|
|
147
146
|
await throwIfProblemJson(response);
|
|
@@ -152,9 +151,7 @@ const createZuploService = ({
|
|
|
152
151
|
const request = new Request(
|
|
153
152
|
joinUrl(getApiKeyEndpoint(context), `${deploymentName}/consumers`),
|
|
154
153
|
);
|
|
155
|
-
await context
|
|
156
|
-
|
|
157
|
-
const keys = await fetch(request);
|
|
154
|
+
const keys = await fetchSigned(context, request);
|
|
158
155
|
await throwIfProblemJson(keys);
|
|
159
156
|
invariant(keys.ok, "Failed to fetch API keys");
|
|
160
157
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { joinUrl } from "./joinUrl.js";
|
|
2
|
+
|
|
3
|
+
type Redirect = { from: string; to: string };
|
|
4
|
+
|
|
5
|
+
type Segment =
|
|
6
|
+
| { type: "static"; value: string }
|
|
7
|
+
| { type: "param"; name: string; optional: boolean }
|
|
8
|
+
| { type: "splat" };
|
|
9
|
+
|
|
10
|
+
const SPLAT = "*";
|
|
11
|
+
// React Router's own check for URLs that leave the app
|
|
12
|
+
const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i;
|
|
13
|
+
// A `/:param` or `/*` token that spans a whole path segment
|
|
14
|
+
const TOKEN_REGEX = /\/(?::([\w-]+)|\*)(?=[/?#]|$)/g;
|
|
15
|
+
|
|
16
|
+
// Parses a `from` path using React Router's syntax: `:param`, `:param?` and a
|
|
17
|
+
// trailing `*` splat.
|
|
18
|
+
const parseSegments = (path: string): Segment[] => {
|
|
19
|
+
const parts = path.split("/").filter(Boolean);
|
|
20
|
+
return parts.map((part, index): Segment => {
|
|
21
|
+
if (part === SPLAT && index === parts.length - 1) return { type: "splat" };
|
|
22
|
+
const match = part.match(/^:([\w-]+)(\?)?$/);
|
|
23
|
+
if (!match?.[1]) return { type: "static", value: part };
|
|
24
|
+
return { type: "param", name: match[1], optional: match[2] === "?" };
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const getParamNames = (from: string) =>
|
|
29
|
+
parseSegments(from).flatMap((segment) => {
|
|
30
|
+
if (segment.type === "param") return [segment.name];
|
|
31
|
+
if (segment.type === "splat") return [SPLAT];
|
|
32
|
+
return [];
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const isDynamicRedirect = (redirect: Pick<Redirect, "from">) =>
|
|
36
|
+
getParamNames(redirect.from).length > 0;
|
|
37
|
+
|
|
38
|
+
// Replaces the `/:param` and `/*` tokens in `to` that `from` defines. Tokens
|
|
39
|
+
// unknown to `from` are left untouched. Removing empty params can leave no path
|
|
40
|
+
// at all (`/*` for `/v1/*` at `/v1`), which would redirect back to the same
|
|
41
|
+
// URL, so that falls back to the root. A `to` without replaced tokens is kept
|
|
42
|
+
// as written, so relative targets like `?tab=2` still work.
|
|
43
|
+
const replaceTokens = (
|
|
44
|
+
to: string,
|
|
45
|
+
names: string[],
|
|
46
|
+
replace: (name: string) => string,
|
|
47
|
+
) => {
|
|
48
|
+
const target = to.replace(TOKEN_REGEX, (token, name: string | undefined) => {
|
|
49
|
+
const key = name ?? SPLAT;
|
|
50
|
+
return names.includes(key) ? replace(key) : token;
|
|
51
|
+
});
|
|
52
|
+
const lostPath = target === "" || /^[?#]/.test(target);
|
|
53
|
+
return target !== to && lostPath ? `/${target}` : target;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const encodeParam = (name: string, value: string) =>
|
|
57
|
+
name === SPLAT
|
|
58
|
+
? value.split("/").map(encodeURIComponent).join("/")
|
|
59
|
+
: encodeURIComponent(value);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Builds the redirect target for a matched `from` path by substituting the
|
|
63
|
+
* matched params into `to`, e.g. `{ from: "/blog/:slug", to: "/posts/:slug" }`
|
|
64
|
+
* sends `/blog/hello` to `/posts/hello`. Empty params drop their segment.
|
|
65
|
+
*/
|
|
66
|
+
export const resolveRedirectTarget = (
|
|
67
|
+
{ from, to }: Redirect,
|
|
68
|
+
params: Record<string, string | undefined>,
|
|
69
|
+
) =>
|
|
70
|
+
replaceTokens(to, getParamNames(from), (name) => {
|
|
71
|
+
const value = params[name];
|
|
72
|
+
return value ? `/${encodeParam(name, value)}` : "";
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Mirrors React Router's `computeScore` for a concrete (non-optional) path.
|
|
76
|
+
const rankPath = (path: string) => {
|
|
77
|
+
const segments = joinUrl(path).split("/");
|
|
78
|
+
return segments.reduce(
|
|
79
|
+
(score, segment) => {
|
|
80
|
+
if (segment === SPLAT) return score;
|
|
81
|
+
if (segment.startsWith(":")) return score + 3;
|
|
82
|
+
return score + (segment === "" ? 1 : 10);
|
|
83
|
+
},
|
|
84
|
+
segments.length - (segments.includes(SPLAT) ? 2 : 0),
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const segmentToPath = (segment: Segment) => {
|
|
89
|
+
if (segment.type === "static") return segment.value;
|
|
90
|
+
if (segment.type === "splat") return SPLAT;
|
|
91
|
+
return `:${segment.name}`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// React Router ranks each concrete variant of a path with optional segments,
|
|
95
|
+
// in this order: `/:a?/:b?` becomes `/:a/:b`, `/:a`, `/:b` and `/`.
|
|
96
|
+
const explodeOptional = (segments: Segment[]): Segment[][] => {
|
|
97
|
+
const [first, ...rest] = segments;
|
|
98
|
+
if (!first) return [[]];
|
|
99
|
+
const variants = explodeOptional(rest);
|
|
100
|
+
const withFirst = variants.map((variant) => [first, ...variant]);
|
|
101
|
+
return first.type === "param" && first.optional
|
|
102
|
+
? [...withFirst, ...variants]
|
|
103
|
+
: withFirst;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const escapeRegex = (value: string) =>
|
|
107
|
+
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Converts dynamic redirects into Build Output API routes. Each concrete
|
|
111
|
+
* variant of a `from` path becomes a regex with one capture group per param
|
|
112
|
+
* (including its leading slash), and the tokens in `to` become the matching
|
|
113
|
+
* `$n` references. Build Output routes match top to bottom, so they are sorted
|
|
114
|
+
* the way React Router ranks the same paths, keeping configuration order for
|
|
115
|
+
* ties.
|
|
116
|
+
*/
|
|
117
|
+
export const toBuildOutputRedirects = (
|
|
118
|
+
redirects: readonly Redirect[],
|
|
119
|
+
basePath?: string,
|
|
120
|
+
) => {
|
|
121
|
+
const base = joinUrl(basePath);
|
|
122
|
+
const prefix = base === "/" ? "" : escapeRegex(base);
|
|
123
|
+
|
|
124
|
+
return redirects
|
|
125
|
+
.flatMap((redirect) => {
|
|
126
|
+
const names = getParamNames(redirect.from);
|
|
127
|
+
const to = ABSOLUTE_URL_REGEX.test(redirect.to)
|
|
128
|
+
? redirect.to
|
|
129
|
+
: joinUrl(basePath, redirect.to);
|
|
130
|
+
|
|
131
|
+
const toRoute = (segments: Segment[]) => {
|
|
132
|
+
const captured = segments.flatMap((segment) => {
|
|
133
|
+
if (segment.type === "param") return [segment.name];
|
|
134
|
+
if (segment.type === "splat") return [SPLAT];
|
|
135
|
+
return [];
|
|
136
|
+
});
|
|
137
|
+
const pattern = segments
|
|
138
|
+
.map((segment) => {
|
|
139
|
+
if (segment.type === "static") {
|
|
140
|
+
return `/${escapeRegex(segment.value)}`;
|
|
141
|
+
}
|
|
142
|
+
return segment.type === "splat" ? "(/.+)" : "(/[^/]+)";
|
|
143
|
+
})
|
|
144
|
+
.join("");
|
|
145
|
+
const endsWithSplat = segments.at(-1)?.type === "splat";
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
// React Router accepts any number of trailing slashes
|
|
149
|
+
src: `^${prefix}${pattern}${endsWithSplat ? "" : "/*"}$`,
|
|
150
|
+
// Like React Router, a repeated param name takes the last value
|
|
151
|
+
location: replaceTokens(to, names, (name) =>
|
|
152
|
+
captured.includes(name) ? `$${captured.lastIndexOf(name) + 1}` : "",
|
|
153
|
+
),
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return explodeOptional(parseSegments(redirect.from)).flatMap(
|
|
158
|
+
(segments) => {
|
|
159
|
+
const rank = rankPath(`/${segments.map(segmentToPath).join("/")}`);
|
|
160
|
+
// An empty splat gets its own route so its target is resolved here
|
|
161
|
+
// rather than from an empty `$n` at request time
|
|
162
|
+
const variants =
|
|
163
|
+
segments.at(-1)?.type === "splat"
|
|
164
|
+
? [segments, segments.slice(0, -1)]
|
|
165
|
+
: [segments];
|
|
166
|
+
return variants.map((variant) => ({ rank, ...toRoute(variant) }));
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
})
|
|
170
|
+
.toSorted((a, b) => b.rank - a.rank)
|
|
171
|
+
.map(({ src, location }) => ({ src, location }));
|
|
172
|
+
};
|
package/src/vite/output.ts
CHANGED
|
@@ -6,6 +6,10 @@ import { getZudokuPackageJson } from "../cli/common/package-json.js";
|
|
|
6
6
|
import type { LoadedConfig } from "../config/config.js";
|
|
7
7
|
import invariant from "../lib/util/invariant.js";
|
|
8
8
|
import { joinUrl } from "../lib/util/joinUrl.js";
|
|
9
|
+
import {
|
|
10
|
+
isDynamicRedirect,
|
|
11
|
+
toBuildOutputRedirects,
|
|
12
|
+
} from "../lib/util/redirectPattern.js";
|
|
9
13
|
import type { RouteRewrite } from "./prerender/utils.js";
|
|
10
14
|
import {
|
|
11
15
|
generateVercelMarkdownMiddleware,
|
|
@@ -200,8 +204,23 @@ export function generateOutput({
|
|
|
200
204
|
});
|
|
201
205
|
}
|
|
202
206
|
|
|
203
|
-
|
|
207
|
+
// Dynamic redirects can't be prerendered, so match them with a regex. They
|
|
208
|
+
// run after the filesystem so real pages win, like React Router's ranking.
|
|
209
|
+
const dynamicRedirects = toBuildOutputRedirects(
|
|
210
|
+
(config.redirects ?? []).filter(isDynamicRedirect),
|
|
211
|
+
config.basePath,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (rewrites.length > 0 || dynamicRedirects.length > 0) {
|
|
204
215
|
routes.push({ handle: "filesystem" });
|
|
216
|
+
for (const { src, location } of dynamicRedirects) {
|
|
217
|
+
routes.push({
|
|
218
|
+
src,
|
|
219
|
+
dest: location,
|
|
220
|
+
status: 301,
|
|
221
|
+
headers: { Location: location },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
205
224
|
for (const rewrite of rewrites) {
|
|
206
225
|
routes.push({
|
|
207
226
|
src: joinUrl(config.basePath, rewrite.source),
|
|
@@ -23,6 +23,7 @@ import { generateSitemap } from "../sitemap.js";
|
|
|
23
23
|
import {
|
|
24
24
|
routesToPrerenderPaths,
|
|
25
25
|
routesToRewrites,
|
|
26
|
+
withoutDynamicRedirectRoutes,
|
|
26
27
|
selectPagesToIndex,
|
|
27
28
|
} from "./utils.js";
|
|
28
29
|
import type { StaticWorkerData, WorkerData } from "./worker.js";
|
|
@@ -87,7 +88,10 @@ export const prerender = async ({
|
|
|
87
88
|
const module = await import(entryServerPath);
|
|
88
89
|
const getRoutes = module.getRoutesByConfig as typeof getRoutesByConfig;
|
|
89
90
|
|
|
90
|
-
const routes =
|
|
91
|
+
const routes = withoutDynamicRedirectRoutes(
|
|
92
|
+
getRoutes(config),
|
|
93
|
+
config.redirects,
|
|
94
|
+
);
|
|
91
95
|
const paths = routesToPrerenderPaths(routes, config.redirects);
|
|
92
96
|
const rewrites = routesToRewrites(routes);
|
|
93
97
|
const { maxThreads, maxOldGenerationSizeMb } = getWorkerScaling(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RouteObject } from "react-router";
|
|
2
2
|
import { joinUrl } from "../../lib/util/joinUrl.js";
|
|
3
|
+
import { isDynamicRedirect } from "../../lib/util/redirectPattern.js";
|
|
3
4
|
|
|
4
5
|
const resolveRoutePath = (path: string): string | undefined => {
|
|
5
6
|
const segments = path.split("/");
|
|
@@ -72,6 +73,20 @@ const collectRewrites = (resolved: ResolvedRoute[]): RouteRewrite[] =>
|
|
|
72
73
|
export const routesToPaths = (routes: RouteObject[]): string[] =>
|
|
73
74
|
collectPaths(resolveRoutes(routes), "");
|
|
74
75
|
|
|
76
|
+
// Dynamic redirects (`/:param`, `/*`) can't be prerendered to a file; they are
|
|
77
|
+
// emitted as Build Output API routes instead (see `generateOutput`). Their
|
|
78
|
+
// top-level route objects are dropped so optional params don't get stripped
|
|
79
|
+
// into a prerendered path and rewrite.
|
|
80
|
+
export const withoutDynamicRedirectRoutes = (
|
|
81
|
+
routes: RouteObject[],
|
|
82
|
+
redirects: readonly { from: string }[] = [],
|
|
83
|
+
): RouteObject[] => {
|
|
84
|
+
const paths = new Set(
|
|
85
|
+
redirects.filter(isDynamicRedirect).map(({ from }) => joinUrl(from)),
|
|
86
|
+
);
|
|
87
|
+
return routes.filter((route) => !route.path || !paths.has(route.path));
|
|
88
|
+
};
|
|
89
|
+
|
|
75
90
|
export const routesToPrerenderPaths = (
|
|
76
91
|
routes: RouteObject[],
|
|
77
92
|
redirects: readonly { from: string }[] = [],
|
|
@@ -79,7 +94,9 @@ export const routesToPrerenderPaths = (
|
|
|
79
94
|
Array.from(
|
|
80
95
|
new Set([
|
|
81
96
|
...routesToPaths(routes),
|
|
82
|
-
...redirects
|
|
97
|
+
...redirects
|
|
98
|
+
.filter((redirect) => !isDynamicRedirect(redirect))
|
|
99
|
+
.map(({ from }) => joinUrl(from)),
|
|
83
100
|
]),
|
|
84
101
|
);
|
|
85
102
|
|