@vercel/python 6.57.1 → 6.58.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/index.js CHANGED
@@ -10403,8 +10403,7 @@ async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir,
10403
10403
  return null;
10404
10404
  }
10405
10405
  return {
10406
- // All mounts (not just copied) so the guard carves out every subtree.
10407
- mountPrefixes: mounts.map((m) => m.urlPath),
10406
+ collectedMounts: mounts,
10408
10407
  cdnOutputDir: outputStaticDir,
10409
10408
  shadowRoutes,
10410
10409
  // Only copied mounts get a fallback: a check:true dest that is missing makes
@@ -10415,21 +10414,45 @@ async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir,
10415
10414
  };
10416
10415
  }
10417
10416
  function fastapiShadowingRoutes(discovery, lambdaPath) {
10418
- const chunks = [];
10419
- let srcLen = 0;
10420
- let dropped = 0;
10421
- for (const body of discovery.shadowRoutes) {
10422
- if (SHADOW_SRC_WRAPPER_LENGTH + body.length > MAX_ROUTE_SRC_LENGTH) {
10423
- dropped += 1;
10424
- continue;
10425
- }
10426
- const current = chunks[chunks.length - 1];
10427
- if (current && srcLen + 1 + body.length <= MAX_ROUTE_SRC_LENGTH) {
10428
- current.push(body);
10429
- srcLen += 1 + body.length;
10417
+ const byMethods = /* @__PURE__ */ new Map();
10418
+ for (const sr of discovery.shadowRoutes) {
10419
+ const key = sr.methods === null ? "" : JSON.stringify([...sr.methods].sort());
10420
+ const group = byMethods.get(key);
10421
+ if (group) {
10422
+ group.push(sr.body);
10430
10423
  } else {
10431
- chunks.push([body]);
10432
- srcLen = SHADOW_SRC_WRAPPER_LENGTH + body.length;
10424
+ byMethods.set(key, [sr.body]);
10425
+ }
10426
+ }
10427
+ const routes = [];
10428
+ let dropped = 0;
10429
+ for (const [methodsKey, bodies] of byMethods) {
10430
+ const methods = methodsKey === "" ? void 0 : JSON.parse(methodsKey);
10431
+ const chunks = [];
10432
+ let srcLen = 0;
10433
+ for (const body of bodies) {
10434
+ if (SHADOW_SRC_WRAPPER_LENGTH + body.length > MAX_ROUTE_SRC_LENGTH) {
10435
+ dropped += 1;
10436
+ continue;
10437
+ }
10438
+ const current = chunks[chunks.length - 1];
10439
+ if (current && srcLen + 1 + body.length <= MAX_ROUTE_SRC_LENGTH) {
10440
+ current.push(body);
10441
+ srcLen += 1 + body.length;
10442
+ } else {
10443
+ chunks.push([body]);
10444
+ srcLen = SHADOW_SRC_WRAPPER_LENGTH + body.length;
10445
+ }
10446
+ }
10447
+ for (const chunk of chunks) {
10448
+ routes.push({
10449
+ src: shadowSrc(chunk),
10450
+ dest: `/${lambdaPath}`,
10451
+ ...methods ? { methods } : {},
10452
+ transforms: [
10453
+ { type: "request.path", op: "set", args: "/$1" }
10454
+ ]
10455
+ });
10433
10456
  }
10434
10457
  }
10435
10458
  if (dropped > 0) {
@@ -10437,20 +10460,16 @@ function fastapiShadowingRoutes(discovery, lambdaPath) {
10437
10460
  `FastAPI: ${dropped} shadow route(s) over the ${MAX_ROUTE_SRC_LENGTH} char cap left unshadowed`
10438
10461
  );
10439
10462
  }
10440
- return chunks.map((bodies) => ({
10441
- src: shadowSrc(bodies),
10442
- dest: `/${lambdaPath}`,
10443
- transforms: [
10444
- { type: "request.path", op: "set", args: "/$1" }
10445
- ]
10446
- }));
10463
+ return routes;
10447
10464
  }
10448
10465
  function escapeRegex(text) {
10449
10466
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10450
10467
  }
10451
10468
  var NAVIGATION_ACCEPT_RE = ".*(?:^|[,\\s])(?:text/html|application/xhtml\\+xml)(?=[,;\\s]|$)(?![^,]*;\\s*q=0(?:\\.0+)?(?:[,;\\s]|$)).*";
10452
10469
  function fastapiFallbackRoutes(discovery) {
10453
- const mountPrefixes = discovery.mountPrefixes.map(trimTrailingSlashes);
10470
+ const mountPrefixes = discovery.collectedMounts.map(
10471
+ (m) => trimTrailingSlashes(m.urlPath)
10472
+ );
10454
10473
  return discovery.fallbacks.flatMap((fb) => {
10455
10474
  const prefix = trimTrailingSlashes(fb.urlPath);
10456
10475
  const nested = mountPrefixes.filter((urlPath) => urlPath.startsWith(`${prefix}/`)).map((urlPath) => urlPath.slice(prefix.length + 1));
@@ -10485,6 +10504,51 @@ function fastapiFallbackRoutes(discovery) {
10485
10504
  ];
10486
10505
  });
10487
10506
  }
10507
+ function getFastAPICdnBundle(collectedMounts, workPath, fastapiConfig) {
10508
+ if (fastapiConfig?.static?.exclude === false)
10509
+ return { excludePatterns: [], directoryStubs: {}, redirects: [] };
10510
+ const excludePatterns = [];
10511
+ const directoryStubs = {};
10512
+ const redirects = [];
10513
+ for (const mount of collectedMounts) {
10514
+ const relDir = (0, import_path17.relative)(workPath, mount.directory);
10515
+ if (!relDir || relDir.startsWith("..") || (0, import_path17.isAbsolute)(relDir))
10516
+ continue;
10517
+ const relDirUnix = relDir.split(import_path17.sep).join("/");
10518
+ excludePatterns.push(`${relDirUnix}/**`);
10519
+ directoryStubs[`${relDirUnix}/.static-placeholder`] = new import_build_utils20.FileBlob({
10520
+ data: ""
10521
+ });
10522
+ for (const fallbackFile of ["index.html", "404.html"]) {
10523
+ const fsPath = (0, import_path17.join)(mount.directory, fallbackFile);
10524
+ if (import_fs17.default.existsSync(fsPath)) {
10525
+ directoryStubs[`${relDirUnix}/${fallbackFile}`] = new import_build_utils20.FileFsRef({
10526
+ fsPath
10527
+ });
10528
+ }
10529
+ }
10530
+ if (mount.frontend) {
10531
+ const urlPrefix = mount.urlPath.replace(/^\/+|\/+$/g, "");
10532
+ const addBareRedirects = (absDir) => {
10533
+ for (const entry of import_fs17.default.readdirSync(absDir, { withFileTypes: true })) {
10534
+ if (!entry.isDirectory())
10535
+ continue;
10536
+ const subAbs = (0, import_path17.join)(absDir, entry.name);
10537
+ const subPath = (0, import_path17.relative)(mount.directory, subAbs).split(import_path17.sep).join("/");
10538
+ const urlPath = urlPrefix ? `${urlPrefix}/${subPath}` : subPath;
10539
+ redirects.push({
10540
+ src: `^/${escapeRegex(urlPath)}$`,
10541
+ dest: `/${urlPath}/`,
10542
+ status: 307
10543
+ });
10544
+ addBareRedirects(subAbs);
10545
+ }
10546
+ };
10547
+ addBareRedirects(mount.directory);
10548
+ }
10549
+ }
10550
+ return { excludePatterns, directoryStubs, redirects };
10551
+ }
10488
10552
 
10489
10553
  // src/index.ts
10490
10554
  var import_python_analysis12 = require("@vercel/python-analysis");
@@ -12109,11 +12173,22 @@ var build = async ({
12109
12173
  );
12110
12174
  }
12111
12175
  }
12176
+ const excludeFilesPatterns = typeof config?.excludeFiles === "string" ? [config.excludeFiles] : Array.isArray(config?.excludeFiles) ? config.excludeFiles : [];
12177
+ const cdnBundle = getFastAPICdnBundle(
12178
+ fastapiStatic?.collectedMounts ?? [],
12179
+ workPath,
12180
+ pythonPackage.manifest?.data?.tool?.vercel?.fastapi
12181
+ );
12112
12182
  const globOptions = {
12113
12183
  cwd: workPath,
12114
- ignore: config && typeof config.excludeFiles === "string" ? [...predefinedExcludes, config.excludeFiles] : predefinedExcludes
12184
+ ignore: [
12185
+ ...predefinedExcludes,
12186
+ ...excludeFilesPatterns,
12187
+ ...cdnBundle.excludePatterns
12188
+ ]
12115
12189
  };
12116
12190
  const files = await (0, import_build_utils24.glob)("**", globOptions);
12191
+ Object.assign(files, cdnBundle.directoryStubs);
12117
12192
  const appPythonSourceFiles = Object.keys(files).filter((file) => file.endsWith(".py")).map((file) => (0, import_path21.join)(workPath, file)).sort();
12118
12193
  if (djangoStatic?.manifestRelPath) {
12119
12194
  files[djangoStatic.manifestRelPath] = new import_build_utils24.FileFsRef({
@@ -12634,6 +12709,7 @@ var build = async ({
12634
12709
  const shadowingRoutes = fastapiStatic ? fastapiShadowingRoutes(fastapiStatic, lambdaPath) : [];
12635
12710
  const fallbackRoutes = fastapiStatic ? fastapiFallbackRoutes(fastapiStatic) : [];
12636
12711
  const routes = isNonWebService || !output ? queueRoutes.length > 0 ? queueRoutes : void 0 : [
12712
+ ...cdnBundle.redirects,
12637
12713
  ...shadowingRoutes,
12638
12714
  { handle: "filesystem" },
12639
12715
  ...queueRoutes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/python",
3
- "version": "6.57.1",
3
+ "version": "6.58.0",
4
4
  "main": "./dist/index.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://vercel.com/docs/runtimes#official-runtimes/python",
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/python"
15
15
  },
16
16
  "dependencies": {
17
- "@vercel/python-analysis": "0.13.2"
17
+ "@vercel/python-analysis": "0.14.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@renovatebot/pep440": "4.2.1",
@@ -35,8 +35,8 @@
35
35
  "smol-toml": "1.5.2",
36
36
  "vitest": "4.1.10",
37
37
  "which": "3.0.0",
38
+ "@vercel/build-utils": "14.2.0",
38
39
  "@vercel/error-utils": "2.2.1",
39
- "@vercel/build-utils": "14.1.1",
40
40
  "@vercel/python-runtime": "0.21.0"
41
41
  },
42
42
  "scripts": {
@@ -44,6 +44,7 @@
44
44
  "type-check": "tsc --noEmit",
45
45
  "test": "cross-env VERCEL_FORCE_PYTHON_STREAMING=1 NODE_OPTIONS=--experimental-vm-modules vitest run --config ../../vitest.config.mts",
46
46
  "test-unit": "cross-env VERCEL_FORCE_PYTHON_STREAMING=1 NODE_OPTIONS=--experimental-vm-modules vitest run --config ../../vitest.config.mts test/unit",
47
- "test-e2e": "cross-env VERCEL_FORCE_PYTHON_STREAMING=1 NODE_OPTIONS=--experimental-vm-modules vitest run --config ../../vitest.config.mts test/integration-"
47
+ "test-e2e": "cross-env VERCEL_FORCE_PYTHON_STREAMING=1 NODE_OPTIONS=--experimental-vm-modules vitest run --config ../../vitest.config.mts test/integration-",
48
+ "test-e2e-builder": "pnpm run test-e2e"
48
49
  }
49
50
  }
@@ -170,7 +170,7 @@ def _subtree_shadow(prefix: str, mounts: list[StaticMount]) -> str:
170
170
 
171
171
 
172
172
  def _index_subdir_shadows(url_prefix: str, directory: str) -> set[str]:
173
- """Shadow bodies for subdirectories that hold an index.html.
173
+ """Regex bodies for subdirectories that hold an index.html.
174
174
 
175
175
  A directory with an index.html diverges on the CDN. StaticFiles(html=True)
176
176
  and frontends 307 the bare directory URL to its trailing-slash form and serve
@@ -190,8 +190,8 @@ def _index_subdir_shadows(url_prefix: str, directory: str) -> set[str]:
190
190
  return shadows
191
191
 
192
192
 
193
- def _divergent_url_shadows(mount: StaticMount, html: bool) -> set[str]:
194
- """Shadow bodies for directory URLs the CDN and the app serve differently.
193
+ def _divergent_url_shadows(mount: StaticMount, html: bool) -> list[ShadowRoute]:
194
+ """Shadow routes for directory URLs the CDN and the app serve differently.
195
195
 
196
196
  When a directory holds an index.html, the app and the CDN serve that
197
197
  directory's URL differently. This affects every such subdirectory and the
@@ -206,13 +206,16 @@ def _divergent_url_shadows(mount: StaticMount, html: bool) -> set[str]:
206
206
 
207
207
  html=False shadows both forms. The app 404s both while the CDN serves the
208
208
  index, so both must reach the Lambda.
209
+
210
+ Directory redirects are GET-only: HEAD for bare dirs is handled by the
211
+ builder's CDN redirect routes placed before shadow routes.
209
212
  """
210
- shadows = _index_subdir_shadows(mount.urlPath, mount.directory)
213
+ bodies = _index_subdir_shadows(mount.urlPath, mount.directory)
211
214
  if not (html and mount.urlPath == "/"):
212
- shadows.add(_escape_path(mount.urlPath))
215
+ bodies.add(_escape_path(mount.urlPath))
213
216
  if html:
214
- return {f"{body}(?!/)" for body in shadows}
215
- return shadows
217
+ bodies = {f"{body}(?!/)" for body in bodies}
218
+ return [ShadowRoute(body=body, methods=("GET",)) for body in bodies]
216
219
 
217
220
 
218
221
  def _frontend_groups(router: Router) -> list[_FrontendRouteGroup]:
@@ -247,6 +250,22 @@ def _effective_route_contexts(route: BaseRoute) -> list[_EffectiveRouteContext]:
247
250
  return []
248
251
 
249
252
 
253
+ @dataclass(frozen=True)
254
+ class ShadowRoute:
255
+ """One shadow-route entry in the builder output.
256
+
257
+ `body` is the regex body for the route src; `methods` is the list of HTTP
258
+ methods the shadow applies to (from the API route that declared it), or
259
+ None when the shadow must match all methods.
260
+ """
261
+
262
+ body: str
263
+ methods: tuple[str, ...] | None # sorted tuple, or None for all
264
+
265
+ def to_dict(self) -> dict[str, object]:
266
+ return {"body": self.body, "methods": list(self.methods) if self.methods is not None else None}
267
+
268
+
250
269
  @dataclass(frozen=True)
251
270
  class PriorRoute:
252
271
  """An HTTP route that outranks static files.
@@ -255,10 +274,12 @@ class PriorRoute:
255
274
  (Lambda), not the CDN, so FastAPI's declaration-order precedence is
256
275
  preserved. `path_format` is the full route path (e.g. "/items/{id}") and
257
276
  `convertors` maps each `{name}` placeholder to its Starlette convertor.
277
+ `methods` is the set of HTTP methods the route handles, or None for all.
258
278
  """
259
279
 
260
280
  path_format: str
261
281
  convertors: dict[str, Convertor]
282
+ methods: frozenset[str] | None = None
262
283
 
263
284
  def shadows(self, mount: StaticMount) -> bool:
264
285
  """True if this route can match paths the mount serves.
@@ -336,9 +357,31 @@ class Precedence:
336
357
  def add_route(self, route: PriorRoute) -> None:
337
358
  self.routes.append(route)
338
359
 
339
- def shadow_bodies(self, mount: StaticMount) -> set[str]:
340
- """Shadow-route bodies for every prior route that shadows `mount`."""
341
- return {r.shadow_body for r in self.routes if r.shadows(mount)}
360
+ def shadow_bodies(self, mount: StaticMount) -> list[ShadowRoute]:
361
+ """Shadow-route body+methods for every prior route that shadows `mount`.
362
+
363
+ When a route declares GET, HEAD is added to the shadow methods. HTTP
364
+ semantics tie HEAD to GET, so the Lambda (not the CDN) should handle
365
+ HEAD on API-owned paths and return 405 when HEAD is not declared.
366
+ """
367
+ result = []
368
+ for r in self.routes:
369
+ if not r.shadows(mount):
370
+ continue
371
+ methods = r.methods
372
+ # With plain StaticFiles, HEAD bypasses the GET route and the
373
+ # mount serves it (200), so CDN can do the same without Lambda.
374
+ # With app.frontend(), HEAD on a GET route path returns 405.
375
+ # Shadow HEAD only for frontend mounts.
376
+ if methods is not None and "GET" in methods and mount.frontend:
377
+ methods = methods | {"HEAD"}
378
+ result.append(
379
+ ShadowRoute(
380
+ body=r.shadow_body,
381
+ methods=tuple(sorted(methods)) if methods is not None else None,
382
+ )
383
+ )
384
+ return result
342
385
 
343
386
  def add_mount(self, url_prefix: str) -> None:
344
387
  self.mount_prefixes.append(url_prefix.rstrip("/"))
@@ -349,24 +392,29 @@ class Precedence:
349
392
  return any(p == q or p.startswith(q + "/") for q in self.mount_prefixes)
350
393
 
351
394
 
395
+ def _subtree_shadow_route(prefix: str, mounts: list[StaticMount]) -> ShadowRoute:
396
+ """A ShadowRoute routing a mounted sub-app's whole subtree to the Lambda."""
397
+ return ShadowRoute(body=_subtree_shadow(prefix, mounts), methods=None)
398
+
399
+
352
400
  def _collect_mount(
353
401
  route: Mount, prefix: str, prior: Precedence
354
- ) -> tuple[list[StaticMount], set[str]]:
402
+ ) -> tuple[list[StaticMount], list[ShadowRoute]]:
355
403
  """Discover one app.mount(): a StaticFiles mount serves from the CDN, a
356
404
  Router/sub-app recurses, and a raw ASGI app owns its subtree opaquely.
357
405
  """
358
406
  url_prefix = prefix + route.path
359
407
  if prior.eclipses(url_prefix):
360
- return [], set()
408
+ return [], []
361
409
 
362
410
  mounts: list[StaticMount] = []
363
- shadow_routes: set[str] = set()
411
+ shadow_routes: list[ShadowRoute] = []
364
412
 
365
413
  static = StaticMount.from_route(route, prefix, frontend=False)
366
414
  if static:
367
415
  mounts.append(static)
368
- shadow_routes |= prior.shadow_bodies(static)
369
- shadow_routes |= _divergent_url_shadows(static, html=route.app.html)
416
+ shadow_routes += prior.shadow_bodies(static)
417
+ shadow_routes += _divergent_url_shadows(static, html=route.app.html)
370
418
 
371
419
  # A Starlette/FastAPI sub-app isn't a Router but exposes one as `.router`;
372
420
  # a StaticFiles or raw ASGI app exposes neither.
@@ -381,13 +429,13 @@ def _collect_mount(
381
429
  sub_prefix = prefix + route.path.rstrip("/")
382
430
  sub_mounts, sub_shadow = collect(sub_router, sub_prefix, prior)
383
431
  mounts.extend(sub_mounts)
384
- shadow_routes |= sub_shadow
385
- shadow_routes.add(_subtree_shadow(sub_prefix, sub_mounts))
432
+ shadow_routes += sub_shadow
433
+ shadow_routes.append(_subtree_shadow_route(sub_prefix, sub_mounts))
386
434
  elif static is None:
387
435
  # A raw ASGI app (e.g. WSGIMiddleware) owns its subtree with nothing on
388
436
  # the CDN. Shadow it so a lower-priority source's leaked copy there
389
437
  # routes to the Lambda.
390
- shadow_routes.add(_subtree_shadow(url_prefix, []))
438
+ shadow_routes.append(_subtree_shadow_route(url_prefix, []))
391
439
 
392
440
  prior.add_mount(url_prefix)
393
441
  return mounts, shadow_routes
@@ -397,23 +445,32 @@ def collect(
397
445
  router: Router,
398
446
  prefix: str = "",
399
447
  prior: Precedence | None = None,
400
- ) -> tuple[list[StaticMount], set[str]]:
401
- """Walk the route table for (static mounts to copy, shadow-route bodies)."""
448
+ ) -> tuple[list[StaticMount], list[ShadowRoute]]:
449
+ """Walk the route table for (static mounts to copy, shadow routes).
450
+
451
+ Shadow routes are merged so that the same path covered by multiple API
452
+ routes with different methods produces one entry with the union of methods.
453
+ """
402
454
  prior = prior.child() if prior else Precedence()
403
455
 
404
456
  mounts: list[StaticMount] = []
405
- shadow_routes: set[str] = set()
457
+ shadow_routes: list[ShadowRoute] = []
406
458
  frontends: list[StaticMount] = []
407
459
 
460
+ def _route_methods(route: Route) -> frozenset[str] | None:
461
+ """HTTP methods for a Starlette Route, or None for no restriction."""
462
+ m = getattr(route, "methods", None)
463
+ return frozenset(m) if m else None
464
+
408
465
  def collect_mount(mount_route: Mount) -> None:
409
466
  sub_mounts, sub_shadow = _collect_mount(mount_route, prefix, prior)
410
467
  mounts.extend(sub_mounts)
411
- shadow_routes.update(sub_shadow)
468
+ shadow_routes.extend(sub_shadow)
412
469
 
413
470
  for route in router.routes:
414
471
  if isinstance(route, Route):
415
472
  prior.add_route(
416
- PriorRoute(prefix + route.path_format, route.param_convertors)
473
+ PriorRoute(prefix + route.path_format, route.param_convertors, _route_methods(route))
417
474
  )
418
475
  elif isinstance(route, Mount):
419
476
  collect_mount(route)
@@ -433,10 +490,12 @@ def collect(
433
490
  if ctx.starlette_route is not None:
434
491
  path = ctx.starlette_route.path_format
435
492
  convertors = ctx.starlette_route.param_convertors
493
+ methods = _route_methods(ctx.starlette_route)
436
494
  else:
437
495
  path = ctx.path
438
496
  convertors = ctx.param_convertors
439
- prior.add_route(PriorRoute(prefix + path, convertors))
497
+ methods = _route_methods(ctx.original_route)
498
+ prior.add_route(PriorRoute(prefix + path, convertors, methods))
440
499
 
441
500
  # app.include_router() with a frontend build.
442
501
  for ctx in _effective_low_priority_routes(route):
@@ -464,24 +523,66 @@ def collect(
464
523
  if prior.eclipses(m.urlPath):
465
524
  continue
466
525
  mounts.append(m)
467
- shadow_routes |= prior.shadow_bodies(m)
526
+ shadow_routes += prior.shadow_bodies(m)
468
527
  # A frontend is html=True StaticFiles, so its URLs diverge the same way.
469
- shadow_routes |= _divergent_url_shadows(m, html=True)
528
+ shadow_routes += _divergent_url_shadows(m, html=True)
470
529
 
471
- return mounts, shadow_routes
530
+ return mounts, _merge_shadow_routes(shadow_routes)
531
+
532
+
533
+ def _merge_shadow_routes(routes: list[ShadowRoute]) -> list[ShadowRoute]:
534
+ """Combine shadow routes that share a body by unioning their methods.
535
+
536
+ When the same path is covered by multiple API routes with different methods
537
+ (e.g. @app.get and @app.post on the same path), each produces a separate
538
+ ShadowRoute. Merging them emits one CDN route whose `methods` covers all
539
+ the declared methods. A None methods entry (match-all) subsumes any
540
+ specific set.
541
+ """
542
+ merged: dict[str, frozenset[str] | None] = {}
543
+ for route in routes:
544
+ methods = frozenset(route.methods) if route.methods is not None else None
545
+ if route.body not in merged:
546
+ merged[route.body] = methods
547
+ else:
548
+ existing = merged[route.body]
549
+ if existing is None or methods is None:
550
+ merged[route.body] = None # match-all subsumes everything
551
+ else:
552
+ merged[route.body] = existing | methods
553
+ return sorted(
554
+ [
555
+ ShadowRoute(
556
+ body=body,
557
+ methods=tuple(sorted(m)) if m is not None else None,
558
+ )
559
+ for body, m in merged.items()
560
+ ],
561
+ key=lambda sr: sr.body,
562
+ )
472
563
 
473
564
 
474
565
  @dataclass(frozen=True)
475
566
  class Output:
476
- """The JSON document written for the builder; field names are the JSON keys."""
567
+ """The JSON document written for the builder; field names are the JSON keys.
568
+
569
+ Each shadowRoutes entry is {"body": str, "methods": list[str] | None}.
570
+ `methods=None` means the shadow applies to all HTTP methods.
571
+ """
477
572
 
478
573
  mounts: list[StaticMount] = field(default_factory=list)
479
- shadowRoutes: list[str] = field(default_factory=list)
574
+ shadowRoutes: list[ShadowRoute] = field(default_factory=list)
575
+
576
+ def to_dict(self) -> dict[str, object]:
577
+ return {
578
+ "mounts": [asdict(m) for m in self.mounts],
579
+ "shadowRoutes": [sr.to_dict() for sr in self.shadowRoutes],
580
+ }
480
581
 
481
582
 
482
583
  def write_output(output_path: str, discovery: Output) -> None:
483
584
  with open(output_path, "w") as f:
484
- json.dump(asdict(discovery), f)
585
+ json.dump(discovery.to_dict(), f)
485
586
 
486
587
 
487
588
  def discover(entrypoint_abs: str, variable_name: str) -> Output:
@@ -503,7 +604,7 @@ def discover(entrypoint_abs: str, variable_name: str) -> Output:
503
604
  return Output()
504
605
 
505
606
  mounts, shadow_routes = collect(router)
506
- return Output(mounts=mounts, shadowRoutes=sorted(shadow_routes))
607
+ return Output(mounts=mounts, shadowRoutes=shadow_routes)
507
608
 
508
609
 
509
610
  def main() -> None: