zuplo 6.70.69 → 6.70.71

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 (93) hide show
  1. package/docs/ai-gateway/getting-started.mdx +14 -9
  2. package/docs/ai-gateway/integrations/ai-sdk.mdx +17 -0
  3. package/docs/ai-gateway/introduction.mdx +12 -10
  4. package/docs/ai-gateway/providers.mdx +2 -0
  5. package/docs/analytics/access-and-entitlements.md +71 -0
  6. package/docs/analytics/overview.md +63 -0
  7. package/docs/analytics/reference/metrics-glossary.md +105 -0
  8. package/docs/analytics/reference/url-parameters.md +66 -0
  9. package/docs/analytics/shared-controls.md +121 -0
  10. package/docs/analytics/tabs/agents.md +88 -0
  11. package/docs/analytics/tabs/consumers.md +73 -0
  12. package/docs/analytics/tabs/graphql.md +77 -0
  13. package/docs/analytics/tabs/mcp.md +80 -0
  14. package/docs/analytics/tabs/origins.md +82 -0
  15. package/docs/analytics/tabs/requests.md +96 -0
  16. package/docs/articles/api-key-buckets.mdx +4 -2
  17. package/docs/articles/archiving-requests-to-storage.mdx +4 -4
  18. package/docs/articles/branch-based-deployments.mdx +10 -8
  19. package/docs/articles/ci-cd-github/basic-deployment.mdx +10 -1
  20. package/docs/articles/ci-cd-github/cleanup-on-branch-delete.mdx +52 -31
  21. package/docs/articles/ci-cd-github/deploy-and-test.mdx +14 -1
  22. package/docs/articles/ci-cd-github/local-testing.mdx +3 -1
  23. package/docs/articles/ci-cd-github/pr-preview-environments.mdx +53 -10
  24. package/docs/articles/custom-ci-cd-azure.mdx +1 -1
  25. package/docs/articles/custom-ci-cd-bitbucket.mdx +1 -1
  26. package/docs/articles/custom-ci-cd-circleci.mdx +1 -1
  27. package/docs/articles/custom-ci-cd-github.mdx +12 -3
  28. package/docs/articles/custom-ci-cd-gitlab.mdx +1 -1
  29. package/docs/articles/graphql.mdx +276 -0
  30. package/docs/articles/monetization/api-access.mdx +184 -0
  31. package/docs/articles/monetization/meters.mdx +4 -4
  32. package/docs/articles/monetization/monetization-policy.md +4 -1
  33. package/docs/articles/monetization/private-plans.md +3 -4
  34. package/docs/articles/monetization/stripe-integration.md +9 -0
  35. package/docs/articles/monetization/subscription-lifecycle.md +12 -11
  36. package/docs/articles/monorepo-deployment.mdx +37 -5
  37. package/docs/articles/opentelemetry.mdx +5 -2
  38. package/docs/articles/securing-the-gateway-with-client-mtls.mdx +68 -43
  39. package/docs/articles/step-1-setup-basic-gateway.mdx +1 -3
  40. package/docs/articles/step-2-add-rate-limiting.mdx +1 -1
  41. package/docs/articles/testing.mdx +1 -1
  42. package/docs/articles/troubleshooting.md +7 -3
  43. package/docs/articles/waf-ddos-akamai.md +35 -16
  44. package/docs/articles/waf-ddos-aws-waf-shield.mdx +35 -16
  45. package/docs/articles/waf-ddos-fastly.mdx +10 -7
  46. package/docs/cli/deploy.mdx +44 -9
  47. package/docs/cli/deploy.partial.mdx +44 -9
  48. package/docs/concepts/api-keys.md +2 -2
  49. package/docs/dev-portal/zudoku/components/callout.mdx +11 -18
  50. package/docs/dev-portal/zudoku/components/sidecar-box.mdx +131 -0
  51. package/docs/dev-portal/zudoku/configuration/api-catalog.md +62 -42
  52. package/docs/dev-portal/zudoku/configuration/api-reference.md +5 -4
  53. package/docs/dev-portal/zudoku/configuration/navigation.mdx +70 -7
  54. package/docs/dev-portal/zudoku/configuration/search.md +36 -0
  55. package/docs/dev-portal/zudoku/configuration/site.md +38 -0
  56. package/docs/dev-portal/zudoku/customization/colors-theme.mdx +51 -40
  57. package/docs/errors/rate-limit-exceeded.mdx +30 -3
  58. package/docs/guides/canary-routing-for-employees.mdx +103 -39
  59. package/docs/guides/modify-openapi-paths.mdx +3 -3
  60. package/docs/handlers/legacy-dev-portal-handler.mdx +1 -1
  61. package/docs/handlers/mcp-server.mdx +13 -11
  62. package/docs/handlers/url-forward.mdx +5 -1
  63. package/docs/handlers/url-rewrite.mdx +7 -2
  64. package/docs/handlers/websocket-handler.mdx +5 -1
  65. package/docs/mcp-gateway/observability/logging.mdx +19 -12
  66. package/docs/mcp-server/resources.mdx +27 -15
  67. package/docs/mcp-server/testing.mdx +0 -2
  68. package/docs/policies/_index.md +2 -0
  69. package/docs/policies/archive-request-azure-storage-inbound/doc.md +1 -1
  70. package/docs/policies/archive-response-azure-storage-outbound/doc.md +1 -1
  71. package/docs/policies/data-loss-prevention-inbound/doc.md +116 -0
  72. package/docs/policies/data-loss-prevention-inbound/intro.md +15 -0
  73. package/docs/policies/data-loss-prevention-inbound/schema.json +220 -0
  74. package/docs/policies/data-loss-prevention-outbound/doc.md +116 -0
  75. package/docs/policies/data-loss-prevention-outbound/intro.md +18 -0
  76. package/docs/policies/data-loss-prevention-outbound/schema.json +220 -0
  77. package/docs/policies/ip-restriction-inbound/policy.ts +1 -1
  78. package/docs/programmable-api/background-dispatcher.mdx +6 -8
  79. package/docs/programmable-api/http-problems.mdx +0 -18
  80. package/docs/programmable-api/jwt-service-plugin.mdx +131 -109
  81. package/docs/programmable-api/runtime-behaviors.mdx +4 -2
  82. package/docs/programmable-api/streaming-zone-cache.mdx +4 -6
  83. package/docs/programmable-api/web-crypto-apis.mdx +10 -6
  84. package/docs/programmable-api/zone-cache.mdx +1 -1
  85. package/docs/rate-limiting/combining-policies.mdx +293 -0
  86. package/docs/rate-limiting/dynamic-rate-limiting.mdx +240 -0
  87. package/docs/rate-limiting/getting-started.mdx +339 -0
  88. package/docs/rate-limiting/how-it-works.md +225 -0
  89. package/docs/rate-limiting/monitoring-and-troubleshooting.mdx +243 -0
  90. package/docs/{articles → rate-limiting}/per-user-rate-limits-using-db.mdx +39 -28
  91. package/package.json +4 -4
  92. package/docs/concepts/rate-limiting.md +0 -246
  93. package/docs/errors/get-head-body-error.mdx +0 -41
@@ -127,6 +127,44 @@ export const NotFound = () => (
127
127
 
128
128
  ## Layout
129
129
 
130
+ ### Collapsible Sidebar
131
+
132
+ The navigation sidebar is collapsible by default. A small toggle button on the sidebar's right
133
+ border lets users hide and reveal it. Configure the behavior under `site.sidebar`:
134
+
135
+ ```tsx title=zudoku.config.tsx
136
+ {
137
+ site: {
138
+ sidebar: {
139
+ collapsible: true, // default: true. Set to false to disable the toggle entirely.
140
+ toggleVisibility: "always", // "always" (default) or "hover" — show button only when hovering the sidebar's right edge
141
+ togglePosition: "bottom", // "top", "center", or "bottom" (default)
142
+ },
143
+ }
144
+ }
145
+ ```
146
+
147
+ For finer vertical placement, override the `--sidebar-toggle-y` CSS variable in your stylesheet:
148
+
149
+ ```css
150
+ :root {
151
+ --sidebar-toggle-y: 30%;
152
+ }
153
+ ```
154
+
155
+ The toggle button carries `aria-expanded="true"` when the sidebar is open and `"false"` when
156
+ collapsed. Combine it with the `[data-sidebar-toggle]` selector to position the button differently
157
+ per state:
158
+
159
+ ```css
160
+ [data-sidebar-toggle][aria-expanded="true"] {
161
+ --sidebar-toggle-y: 20%;
162
+ }
163
+ [data-sidebar-toggle][aria-expanded="false"] {
164
+ --sidebar-toggle-y: 80%;
165
+ }
166
+ ```
167
+
130
168
  ### Banner
131
169
 
132
170
  Add a banner message to the top of the page:
@@ -164,29 +164,55 @@ const config = {
164
164
  };
165
165
  ```
166
166
 
167
- Alternatively, you can copy the CSS code and paste it into your `customCss` configuration:
167
+ Alternatively, paste the copied CSS into a stylesheet and import it from your config:
168
+
169
+ ```css title=styles.css
170
+ /* Copied CSS code */
171
+ ```
168
172
 
169
173
  ```ts title=zudoku.config.ts
170
- const config = {
171
- theme: {
172
- customCss: `
173
- /* Copied CSS code */
174
- `,
175
- },
176
- };
174
+ import "./styles.css";
177
175
  ```
178
176
 
179
177
  ## Custom CSS
180
178
 
181
- For advanced styling, you can add custom CSS either as a string or structured object:
179
+ The recommended way to add custom styles is to write a `.css` file alongside your config and import
180
+ it. This gives you HMR during development, syntax highlighting, autocompletion, and lets you split
181
+ styles across files as your site grows.
182
182
 
183
- :::note
183
+ ```css title=styles.css
184
+ .custom {
185
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
186
+ }
187
+ ```
184
188
 
185
- Changes to `customCss` require restarting the development server to take effect.
189
+ ```ts title=zudoku.config.ts
190
+ import "./styles.css";
186
191
 
187
- :::
192
+ const config = {
193
+ // ...
194
+ };
195
+ ```
196
+
197
+ If TypeScript reports `Cannot find module './styles.css'`, add `zudoku/client` to your tsconfig
198
+ types so CSS side-effect imports are recognized:
199
+
200
+ ```json title=tsconfig.json
201
+ {
202
+ "compilerOptions": {
203
+ "types": ["zudoku/client"]
204
+ }
205
+ }
206
+ ```
207
+
208
+ Projects created with `create-zudoku` include this by default.
209
+
210
+ ### Inline alternatives (deprecated)
188
211
 
189
- ### CSS String
212
+ The `theme.customCss` option is deprecated and will be removed in a future release. It still accepts
213
+ a CSS string or object for backwards compatibility, but every change requires restarting the dev
214
+ server and you lose syntax highlighting, autocompletion, and HMR. Migrate to an imported `.css`
215
+ file.
190
216
 
191
217
  ```ts title=zudoku.config.ts
192
218
  const config = {
@@ -200,37 +226,22 @@ const config = {
200
226
  };
201
227
  ```
202
228
 
203
- ### CSS Object
204
-
205
- ```ts title=zudoku.config.ts
206
- const config = {
207
- theme: {
208
- customCss: {
209
- ".custom": {
210
- background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
211
- },
212
- },
213
- },
214
- };
215
- ```
216
-
217
229
  ### Enabling Code Ligatures
218
230
 
219
231
  Dev Portal disables ligatures in code blocks by default to avoid unwanted glyph joining in fonts like
220
232
  Geist Mono (e.g. `---`, `###`, `|--|`). If you're using a coding font designed around ligatures
221
- (Fira Code, JetBrains Mono, etc.), re-enable them via `customCss`:
222
-
223
- ```ts title=zudoku.config.ts
224
- const config = {
225
- theme: {
226
- customCss: `
227
- code, pre, kbd, samp, .shiki, .shiki span {
228
- font-variant-ligatures: normal;
229
- font-feature-settings: normal;
230
- }
231
- `,
232
- },
233
- };
233
+ (Fira Code, JetBrains Mono, etc.), re-enable them in your CSS file:
234
+
235
+ ```css title=styles.css
236
+ code,
237
+ pre,
238
+ kbd,
239
+ samp,
240
+ .shiki,
241
+ .shiki span {
242
+ font-variant-ligatures: normal;
243
+ font-feature-settings: normal;
244
+ }
234
245
  ```
235
246
 
236
247
  ## Default Theme
@@ -4,6 +4,29 @@ title: Rate Limit Exceeded (RATE_LIMIT_EXCEEDED)
4
4
 
5
5
  The request was rejected because the client exceeded the configured rate limit.
6
6
 
7
+ ## Response format
8
+
9
+ The 429 response uses the
10
+ [Problem Details](../programmable-api/http-problems.mdx) format:
11
+
12
+ ```json
13
+ {
14
+ "type": "https://httpproblems.com/http-status/429",
15
+ "title": "Too Many Requests",
16
+ "status": 429,
17
+ "detail": "Rate limit exceeded",
18
+ "instance": "/your-route",
19
+ "trace": {
20
+ "requestId": "4d54e4ee-c003-4d75-aba9-e09a6d707b08",
21
+ "timestamp": "2026-04-14T12:00:00.000Z",
22
+ "buildId": "ec44e831-3a02-467e-a26c-7e401e4473bf"
23
+ }
24
+ }
25
+ ```
26
+
27
+ If `headerMode` is set to `"retry-after"` (the default), the response includes a
28
+ `Retry-After` header with the number of seconds to wait before retrying.
29
+
7
30
  ## Common Causes
8
31
 
9
32
  - **Too many requests** — The client sent more requests than the rate limit
@@ -24,8 +47,12 @@ The request was rejected because the client exceeded the configured rate limit.
24
47
 
25
48
  ## For API Operators
26
49
 
27
- - Review the rate limiting policy configuration in the route settings.
50
+ - Review the rate limiting policy configuration in the route settings. Check the
51
+ `requestsAllowed` and `timeWindowMinutes` values and verify that the
52
+ `rateLimitBy` identifier is resolving correctly.
28
53
  - Consider using
29
- [dynamic rate limiting](../articles/step-5-dynamic-rate-limiting.mdx) to set
54
+ [dynamic rate limiting](../rate-limiting/dynamic-rate-limiting.mdx) to set
30
55
  different limits per customer tier.
31
- - Check the rate limit metrics to determine if limits need adjustment.
56
+ - Use your [logging integration](../articles/logging.mdx) to filter for 429
57
+ responses and identify which consumers are being throttled. Break down by user
58
+ or IP to spot noisy neighbors.
@@ -32,6 +32,10 @@ If any condition is met, the request routes to canary backends.
32
32
 
33
33
  ## Creating a Canary Routing Policy
34
34
 
35
+ The policy doesn't set the backend URL directly. Instead, it stores the chosen
36
+ backend on `context.custom`, and the route's handler reads that value to forward
37
+ the request (see "Adding the Policy to Routes" below).
38
+
35
39
  Create a new policy file in your project:
36
40
 
37
41
  ```typescript
@@ -62,9 +66,9 @@ export const canaryRoutingPolicy: InboundPolicyHandler = async (
62
66
  const isCanary =
63
67
  stageParam === "canary" || stageHeader === "canary" || canaryUser;
64
68
 
65
- // Route based on stage
69
+ // Store the backend URL for the route's handler to use
66
70
  if (isCanary) {
67
- context.route.url = environment.API_URL_CANARY;
71
+ context.custom.backendUrl = environment.API_URL_CANARY;
68
72
 
69
73
  // Log canary routing for debugging
70
74
  context.log.info("Routing to canary backend", {
@@ -73,13 +77,19 @@ export const canaryRoutingPolicy: InboundPolicyHandler = async (
73
77
  stage: stageParam || stageHeader || "canary",
74
78
  });
75
79
  } else {
76
- context.route.url = environment.API_URL_PRODUCTION;
80
+ context.custom.backendUrl = environment.API_URL_PRODUCTION;
77
81
  }
78
82
 
79
83
  // Remove stage query parameter to avoid passing it to backend
80
84
  if (stageParam) {
81
85
  url.searchParams.delete("stage");
82
- return new ZuploRequest(url.toString(), request);
86
+ return new ZuploRequest(url.toString(), {
87
+ method: request.method,
88
+ headers: request.headers,
89
+ body: request.body,
90
+ user: request.user,
91
+ params: request.params,
92
+ });
83
93
  }
84
94
 
85
95
  return request;
@@ -139,24 +149,30 @@ export const multiServiceCanaryRoutingPolicy: InboundPolicyHandler = async (
139
149
  // Clean up query parameter
140
150
  if (stageParam) {
141
151
  url.searchParams.delete("stage");
142
- return new ZuploRequest(url.toString(), request);
152
+ return new ZuploRequest(url.toString(), {
153
+ method: request.method,
154
+ headers: request.headers,
155
+ body: request.body,
156
+ user: request.user,
157
+ params: request.params,
158
+ });
143
159
  }
144
160
 
145
161
  return request;
146
162
  };
147
163
  ```
148
164
 
165
+ Each service's route then reads the matching value in its handler options. For
166
+ example, the user API route would use a URL Forward handler with
167
+ `"baseUrl": "${context.custom.userApiUrl}"`.
168
+
149
169
  ## Percentage-Based Canary Routing
150
170
 
151
171
  Roll out canary deployments gradually with percentage-based routing:
152
172
 
153
173
  ```typescript
154
174
  // policies/percentage-canary-routing.ts
155
- import {
156
- InboundPolicyHandler,
157
- ZuploRequest,
158
- environment,
159
- } from "@zuplo/runtime";
175
+ import { InboundPolicyHandler, environment } from "@zuplo/runtime";
160
176
 
161
177
  export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async (
162
178
  request,
@@ -168,7 +184,7 @@ export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async (
168
184
  : [];
169
185
 
170
186
  if (request.user?.sub && CANARY_USERS.includes(request.user.sub)) {
171
- context.route.url = environment.API_URL_CANARY;
187
+ context.custom.backendUrl = environment.API_URL_CANARY;
172
188
  return request;
173
189
  }
174
190
 
@@ -176,8 +192,12 @@ export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async (
176
192
  const CANARY_PERCENTAGE = parseInt(environment.CANARY_PERCENTAGE || "0", 10);
177
193
 
178
194
  if (CANARY_PERCENTAGE > 0) {
179
- // Use a consistent hash for sticky sessions
180
- const sessionId = request.headers.get("x-session-id") || request.ip;
195
+ // Use a consistent hash for sticky sessions. The client IP is
196
+ // available on the true-client-ip header.
197
+ const sessionId =
198
+ request.headers.get("x-session-id") ??
199
+ request.headers.get("true-client-ip") ??
200
+ "unknown";
181
201
  const hash = await crypto.subtle.digest(
182
202
  "SHA-256",
183
203
  new TextEncoder().encode(sessionId),
@@ -186,13 +206,13 @@ export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async (
186
206
  const hashValue = hashArray[0] / 255; // Value between 0 and 1
187
207
 
188
208
  if (hashValue * 100 < CANARY_PERCENTAGE) {
189
- context.route.url = environment.API_URL_CANARY;
209
+ context.custom.backendUrl = environment.API_URL_CANARY;
190
210
  request.headers.set("X-Canary-Route", "percentage");
191
211
  } else {
192
- context.route.url = environment.API_URL_PRODUCTION;
212
+ context.custom.backendUrl = environment.API_URL_PRODUCTION;
193
213
  }
194
214
  } else {
195
- context.route.url = environment.API_URL_PRODUCTION;
215
+ context.custom.backendUrl = environment.API_URL_PRODUCTION;
196
216
  }
197
217
 
198
218
  return request;
@@ -221,7 +241,9 @@ CANARY_PERCENTAGE=10 # Route 10% of traffic to canary
221
241
 
222
242
  ### Adding the Policy to Routes
223
243
 
224
- Add the policy to your route configuration:
244
+ Add the policy to your route configuration. Use the built-in
245
+ [URL Forward handler](../handlers/url-forward.mdx) and reference the value the
246
+ policy stored on `context.custom` in the `baseUrl` option:
225
247
 
226
248
  ```json
227
249
  {
@@ -231,14 +253,14 @@ Add the policy to your route configuration:
231
253
  "x-zuplo-route": {
232
254
  "corsPolicy": "anything-goes",
233
255
  "handler": {
234
- "export": "urlRewriteHandler",
256
+ "export": "urlForwardHandler",
235
257
  "module": "$import(@zuplo/runtime)",
236
258
  "options": {
237
- "rewritePattern": "https://api.company.com$1"
259
+ "baseUrl": "${context.custom.backendUrl}"
238
260
  }
239
261
  },
240
262
  "policies": {
241
- "inbound": ["canary-routing", "auth-policy"]
263
+ "inbound": ["auth-policy", "canary-routing"]
242
264
  }
243
265
  }
244
266
  }
@@ -247,6 +269,9 @@ Add the policy to your route configuration:
247
269
  }
248
270
  ```
249
271
 
272
+ The authentication policy runs first so that `request.user` is populated when
273
+ the canary routing policy checks the allow list.
274
+
250
275
  ## Testing Your Policy
251
276
 
252
277
  ### 1. Using Query Parameters
@@ -289,7 +314,9 @@ curl https://your-api.zuplo.app/api/v1/users \
289
314
 
290
315
  ## Monitoring and Observability
291
316
 
292
- Add comprehensive logging to track canary routing:
317
+ Add comprehensive logging to track canary routing. Inbound policies run before a
318
+ response exists, so set debug response headers from a
319
+ [response sending hook](../programmable-api/hooks.mdx):
293
320
 
294
321
  ```typescript
295
322
  export const canaryRoutingPolicy: InboundPolicyHandler = async (
@@ -298,20 +325,26 @@ export const canaryRoutingPolicy: InboundPolicyHandler = async (
298
325
  ) => {
299
326
  const startTime = Date.now();
300
327
 
328
+ const CANARY_USERS = environment.CANARY_USERS
329
+ ? environment.CANARY_USERS.split(",").map((user) => user.trim())
330
+ : [];
331
+
301
332
  // Check stage conditions
302
333
  const url = new URL(request.url);
303
334
  const stageParam = url.searchParams.get("stage");
304
335
  const stageHeader = request.headers.get("x-stage");
305
- const canaryUser = /* ... check if user is in canary list ... */;
336
+ const canaryUser =
337
+ request.user?.sub && CANARY_USERS.includes(request.user.sub);
306
338
 
307
339
  const isCanary =
308
- stageParam === "canary" ||
309
- stageHeader === "canary" ||
310
- canaryUser;
340
+ stageParam === "canary" || stageHeader === "canary" || canaryUser;
311
341
 
312
- if (isCanary) {
313
- context.route.url = environment.API_URL_CANARY;
342
+ const backendType = isCanary ? "canary" : "release";
343
+ context.custom.backendUrl = isCanary
344
+ ? environment.API_URL_CANARY
345
+ : environment.API_URL_PRODUCTION;
314
346
 
347
+ if (isCanary) {
315
348
  // Log canary routing metrics
316
349
  context.log.info("Canary route selected", {
317
350
  userId: request.user?.sub,
@@ -320,14 +353,14 @@ export const canaryRoutingPolicy: InboundPolicyHandler = async (
320
353
  stage: "canary",
321
354
  duration: Date.now() - startTime,
322
355
  });
323
-
324
- // Add response header for debugging
325
- context.response.headers.set("X-Backend-Type", "canary");
326
- } else {
327
- context.route.url = environment.API_URL_PRODUCTION;
328
- context.response.headers.set("X-Backend-Type", "release");
329
356
  }
330
357
 
358
+ // Add a response header for debugging
359
+ context.addResponseSendingHook((response) => {
360
+ response.headers.set("X-Backend-Type", backendType);
361
+ return response;
362
+ });
363
+
331
364
  return request;
332
365
  };
333
366
  ```
@@ -359,14 +392,45 @@ if (isCanaryUser) {
359
392
 
360
393
  ### 3. Fallback Handling
361
394
 
362
- Implement fallback logic for canary backend failures:
395
+ To fall back to production when the canary backend fails, replace the URL
396
+ Forward handler with a [custom handler](../handlers/custom-handler.mdx) that
397
+ retries against production on server errors:
363
398
 
364
399
  ```typescript
365
- // In your error handling policy
366
- if (context.response.status >= 500 && context.custom.isCanary) {
367
- // Retry with production backend
368
- context.route.url = environment.API_URL_PRODUCTION;
369
- return context.retry();
400
+ // modules/canary-fallback-handler.ts
401
+ import { ZuploContext, ZuploRequest, environment } from "@zuplo/runtime";
402
+
403
+ export default async function handler(
404
+ request: ZuploRequest,
405
+ context: ZuploContext,
406
+ ) {
407
+ const url = new URL(request.url);
408
+ const backendUrl = context.custom.backendUrl;
409
+ const target = `${backendUrl}${url.pathname}${url.search}`;
410
+
411
+ // Buffer the body so the request can be retried
412
+ const body = request.body ? await request.arrayBuffer() : undefined;
413
+
414
+ const response = await fetch(target, {
415
+ method: request.method,
416
+ headers: request.headers,
417
+ body,
418
+ });
419
+
420
+ // Retry against production if the canary backend returns a server error
421
+ if (backendUrl === environment.API_URL_CANARY && response.status >= 500) {
422
+ context.log.warn("Canary backend failed, falling back to production", {
423
+ status: response.status,
424
+ });
425
+ const fallbackBase = environment.API_URL_PRODUCTION;
426
+ return fetch(`${fallbackBase}${url.pathname}${url.search}`, {
427
+ method: request.method,
428
+ headers: request.headers,
429
+ body,
430
+ });
431
+ }
432
+
433
+ return response;
370
434
  }
371
435
  ```
372
436
 
@@ -106,8 +106,8 @@ You can generate multiple versions as part of your build process:
106
106
  ```json title="package.json"
107
107
  {
108
108
  "scripts": {
109
- "build:api:v1": "npx tsx add-path-prefix.ts /v1 openapi.json dist/openapi-v1.json",
110
- "build:api:v2": "npx tsx add-path-prefix.ts /v2 openapi.json dist/openapi-v2.json",
109
+ "build:api:v1": "node add-path-prefix.mjs /v1 openapi.json dist/openapi-v1.json",
110
+ "build:api:v2": "node add-path-prefix.mjs /v2 openapi.json dist/openapi-v2.json",
111
111
  "build:api:all": "npm run build:api:v1 && npm run build:api:v2"
112
112
  }
113
113
  }
@@ -128,7 +128,7 @@ mkdir -p $OUTPUT_DIR
128
128
  for prefix in "${PREFIXES[@]}"; do
129
129
  echo "Building with prefix: /$prefix"
130
130
 
131
- npx tsx add-path-prefix.ts \
131
+ node add-path-prefix.mjs \
132
132
  "/$prefix" \
133
133
  "$BASE_FILE" \
134
134
  "$OUTPUT_DIR/openapi-$prefix.json"
@@ -35,7 +35,7 @@ as it provides better performance and usability.
35
35
  "title": "Dev Portal Redirect"
36
36
  },
37
37
  "paths": {
38
- "/docs{(.*)}": {
38
+ "/docs(.*)": {
39
39
  "x-zuplo-path": {
40
40
  "pathMode": "url-pattern"
41
41
  },
@@ -35,14 +35,15 @@ Open the **Route Designer** by navigating to the **Code** tab, then click
35
35
  **routes.oas.json**. For any route definition, select **MCP Server** from the
36
36
  **Request Handlers** drop-down. Set the method to **POST**.
37
37
 
38
- Configure the handler with the following required options:
38
+ Configure the handler with the following options:
39
39
 
40
- - **Server Name** - The name of the MCP server. AI MCP clients will read this
41
- name when they initialize with the server.
42
- - **Server Version** - The version of your MCP server. AI MCP clients read this
43
- version when they initialize with the server and may make autonomous decisions
44
- based on the versioning of your MCP server and the instructions they've been
45
- given.
40
+ - **Server Name** (optional) - The name of the MCP server. AI MCP clients will
41
+ read this name when they initialize with the server. If not set, the server
42
+ advertises the default name `Zuplo MCP Server`.
43
+ - **Server Version** (optional) - The version of your MCP server. AI MCP clients
44
+ read this version when they initialize with the server and may make autonomous
45
+ decisions based on the versioning of your MCP server and the instructions
46
+ they've been given. If not set, the version defaults to `0.0.0`.
46
47
 
47
48
  Next, configure your routes to be transformed into MCP tools or prompts (see
48
49
  Configuration section below).
@@ -65,7 +66,7 @@ with the following route configuration:
65
66
  "handler": {
66
67
 
67
68
  // The MCP Server Handler handler
68
- // and required options
69
+ // and example options
69
70
 
70
71
  "export": "mcpServerHandler",
71
72
  "module": "$import(@zuplo/runtime)",
@@ -86,10 +87,11 @@ with the following route configuration:
86
87
 
87
88
  ## Configuration
88
89
 
89
- The MCP Server handler requires the following configurations:
90
+ The MCP Server handler supports the following configuration options:
90
91
 
91
- - `name` (optional) - The name identifier of the MCP server.
92
- - `version` (optional) - The version of the MCP server.
92
+ - `name` (optional, default `Zuplo MCP Server`) - The name identifier of the MCP
93
+ server.
94
+ - `version` (optional, default `0.0.0`) - The version of the MCP server.
93
95
  - `debugMode` (optional, default `false`) - Verbose logs on server startup,
94
96
  initialization, tool listing, and tool calls. NOT recommended for production
95
97
  environments.
@@ -53,6 +53,8 @@ The following objects are available for substitution:
53
53
  `params.productId` would be the value of `:productId` in a route.
54
54
  - `query: Record<string, string>` - The query parameters of the route. For
55
55
  example, `query.filterBy` would be the value of `?filterBy=VALUE`.
56
+ - `method: string` - The HTTP method of the incoming request. For example, `GET`
57
+ or `POST`.
56
58
  - `headers: Headers` - the incoming request's
57
59
  [headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
58
60
  - `url: string` - The full incoming request as a string
@@ -62,6 +64,9 @@ The following objects are available for substitution:
62
64
  - `hostname: string` - The
63
65
  [`hostname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/hostname)
64
66
  portion of the incoming URL
67
+ - `origin: string` - The
68
+ [`origin`](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin)
69
+ portion of the incoming URL
65
70
  - `pathname: string` - The
66
71
  [`pathname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname)
67
72
  portion of the incoming URL
@@ -93,7 +98,6 @@ A few examples of the values of various substitutions.
93
98
  - `${params.productId}` - `":productId"`
94
99
  - `${pathname}` - `"/v1/products/:productId"`
95
100
  - `${port}` - `"8080"`
96
- - `${protocol}` - `"https:"`
97
101
  - `${query.category}` - `"cars"`
98
102
  - `${search}` - `"?category=cars"`
99
103
  - `${url}` - `"https://example.com:8080/v1/products/:productId?category=cars"`
@@ -43,6 +43,8 @@ The following objects are available for substitution:
43
43
  `params.productId` would be the value of `:productId` in a route.
44
44
  - `query: Record<string, string>` - The query parameters of the route. For
45
45
  example, `query.filterBy` would be the value of `?filterBy=VALUE`.
46
+ - `method: string` - The HTTP method of the incoming request. For example, `GET`
47
+ or `POST`.
46
48
  - `headers: Headers` - the incoming request's
47
49
  [headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
48
50
  - `url: string` - The full incoming request as a string
@@ -52,6 +54,9 @@ The following objects are available for substitution:
52
54
  - `hostname: string` - The
53
55
  [`hostname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/hostname)
54
56
  portion of the incoming URL
57
+ - `origin: string` - The
58
+ [`origin`](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin)
59
+ portion of the incoming URL
55
60
  - `pathname: string` - The
56
61
  [`pathname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname)
57
62
  portion of the incoming URL
@@ -83,7 +88,6 @@ A few examples of the values of various substitutions.
83
88
  - `${params.productId}` - `":productId"`
84
89
  - `${pathname}` - `"/v1/products/:productId"`
85
90
  - `${port}` - `"8080"`
86
- - `${protocol}` - `"https:"`
87
91
  - `${query.category}` - `"cars"`
88
92
  - `${search}` - `"?category=cars"`
89
93
  - `${url}` - `"https://example.com:8080/v1/products/:productId?category=cars"`
@@ -132,7 +136,8 @@ use-cases.
132
136
  - Type: `string`
133
137
  - Supports JavaScript template interpolation with request context
134
138
  - Available variables: `env`, `request`, `context`, `params`, `query`,
135
- `headers`, `url`, `host`, `hostname`, `pathname`, `port`, `search`
139
+ `method`, `headers`, `url`, `host`, `hostname`, `origin`, `pathname`,
140
+ `port`, `search`
136
141
  - Example: `"https://api-${params.version}.example.com/users/${params.id}"`
137
142
 
138
143
  - **`forwardSearch`** (optional): Controls whether query parameters are
@@ -84,6 +84,8 @@ The following objects are available for substitution:
84
84
  `params.productId` would be the value of `:productId` in a route.
85
85
  - `query: Record<string, string>` - The query parameters of the route. For
86
86
  example, `query.filterBy` would be the value of `?filterBy=VALUE`.
87
+ - `method: string` - The HTTP method of the incoming request. For example, `GET`
88
+ or `POST`.
87
89
  - `headers: Headers` - the incoming request's
88
90
  [headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
89
91
  - `url: string` - The full incoming request as a string
@@ -93,6 +95,9 @@ The following objects are available for substitution:
93
95
  - `hostname: string` - The
94
96
  [`hostname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/hostname)
95
97
  portion of the incoming URL
98
+ - `origin: string` - The
99
+ [`origin`](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin)
100
+ portion of the incoming URL
96
101
  - `pathname: string` - The
97
102
  [`pathname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname)
98
103
  portion of the incoming URL
@@ -124,7 +129,6 @@ A few examples of the values of various substitutions.
124
129
  - `${params.productId}` - `":productId"`
125
130
  - `${pathname}` - `"/v1/products/:productId"`
126
131
  - `${port}` - `"8080"`
127
- - `${protocol}` - `"https:"`
128
132
  - `${query.category}` - `"cars"`
129
133
  - `${search}` - `"?category=cars"`
130
134
  - `${url}` - `"https://example.com:8080/v1/products/:productId?category=cars"`