@vercel/python 14.1.1 → 14.2.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
@@ -11970,7 +11970,7 @@ ${stderr}`);
11970
11970
  (0, import_build_utils20.debug)(
11971
11971
  `FastAPI: static discovery failed: ${err?.stderr ?? err?.message ?? err}`
11972
11972
  );
11973
- return { mounts: [], shadowRoutes: [] };
11973
+ return { mounts: [], shadowRoutes: [], excludedFiles: [] };
11974
11974
  } finally {
11975
11975
  await import_fs17.default.promises.rm(outputPath, { force: true });
11976
11976
  }
@@ -11978,6 +11978,9 @@ ${stderr}`);
11978
11978
  function trimTrailingSlashes(urlPath) {
11979
11979
  return urlPath.replace(/\/+$/, "");
11980
11980
  }
11981
+ function trimSlashes(urlPath) {
11982
+ return urlPath.replace(/^\/+|\/+$/g, "");
11983
+ }
11981
11984
  function mountCovers(base, urlPath) {
11982
11985
  return base === "" || urlPath === base || urlPath.startsWith(base + "/");
11983
11986
  }
@@ -11985,15 +11988,18 @@ function cdnUrlPath(outputStaticDir, destPath) {
11985
11988
  const rel = (0, import_path17.relative)(outputStaticDir, destPath);
11986
11989
  return rel === "" ? "/" : "/" + rel.split(import_path17.sep).join("/");
11987
11990
  }
11988
- async function copyFastAPIStaticMounts(mounts, outputStaticDir) {
11989
- const ordered = [
11991
+ function byCopyPriority(mounts) {
11992
+ return [
11990
11993
  ...mounts.filter((m) => !m.frontend),
11991
11994
  ...mounts.filter((m) => m.frontend).sort((a, b) => b.urlPath.length - a.urlPath.length)
11992
11995
  ];
11996
+ }
11997
+ async function copyFastAPIStaticMounts(mounts, outputStaticDir, excludedFiles = []) {
11998
+ const excluded = new Set(excludedFiles);
11993
11999
  const higherPriority = [];
11994
12000
  const copied = [];
11995
- for (const mount of ordered) {
11996
- const dest = (0, import_path17.join)(outputStaticDir, mount.urlPath.replace(/^\/+|\/+$/g, ""));
12001
+ for (const mount of byCopyPriority(mounts)) {
12002
+ const dest = (0, import_path17.join)(outputStaticDir, trimSlashes(mount.urlPath));
11997
12003
  try {
11998
12004
  await import_fs17.default.promises.mkdir(dest, { recursive: true });
11999
12005
  await import_fs17.default.promises.cp(mount.directory, dest, {
@@ -12001,7 +12007,7 @@ async function copyFastAPIStaticMounts(mounts, outputStaticDir) {
12001
12007
  force: false,
12002
12008
  filter: (_src, destPath) => {
12003
12009
  const urlPath = cdnUrlPath(outputStaticDir, destPath);
12004
- return !higherPriority.some((base) => mountCovers(base, urlPath));
12010
+ return !higherPriority.some((base) => mountCovers(base, urlPath)) && !excluded.has(urlPath);
12005
12011
  }
12006
12012
  });
12007
12013
  copied.push(mount);
@@ -12017,9 +12023,33 @@ var MAX_ROUTE_SRC_LENGTH = 4096;
12017
12023
  function shadowSrc(bodies) {
12018
12024
  return `^/((?:${bodies.join("|")})/?)$`;
12019
12025
  }
12026
+ function redirectSrc(bodies) {
12027
+ return `^/((?:${bodies.join("|")}))$`;
12028
+ }
12020
12029
  var SHADOW_SRC_WRAPPER_LENGTH = shadowSrc([]).length;
12030
+ var REDIRECT_SRC_WRAPPER_LENGTH = redirectSrc([]).length;
12031
+ function chunkBodies(bodies, wrapperLength) {
12032
+ const chunks = [];
12033
+ let srcLen = 0;
12034
+ let dropped = 0;
12035
+ for (const body of bodies) {
12036
+ if (wrapperLength + body.length > MAX_ROUTE_SRC_LENGTH) {
12037
+ dropped += 1;
12038
+ continue;
12039
+ }
12040
+ const current = chunks[chunks.length - 1];
12041
+ if (current && srcLen + 1 + body.length <= MAX_ROUTE_SRC_LENGTH) {
12042
+ current.push(body);
12043
+ srcLen += 1 + body.length;
12044
+ } else {
12045
+ chunks.push([body]);
12046
+ srcLen = wrapperLength + body.length;
12047
+ }
12048
+ }
12049
+ return { chunks, dropped };
12050
+ }
12021
12051
  async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir, entrypointAbs, variableName, suppressMiddlewareMounts = true) {
12022
- const { mounts, shadowRoutes } = await getFastAPIStaticDiscovery(
12052
+ const { mounts, shadowRoutes, excludedFiles } = await getFastAPIStaticDiscovery(
12023
12053
  venvPath,
12024
12054
  entrypointAbs,
12025
12055
  variableName,
@@ -12034,7 +12064,11 @@ async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir,
12034
12064
  (0, import_build_utils20.debug)(
12035
12065
  `Found ${mounts.length} FastAPI static mount(s): ${mounts.map((m) => m.urlPath).join(", ")}`
12036
12066
  );
12037
- const copiedMounts = await copyFastAPIStaticMounts(mounts, outputStaticDir);
12067
+ const copiedMounts = await copyFastAPIStaticMounts(
12068
+ mounts,
12069
+ outputStaticDir,
12070
+ excludedFiles
12071
+ );
12038
12072
  if (copiedMounts.length === 0) {
12039
12073
  return null;
12040
12074
  }
@@ -12042,6 +12076,7 @@ async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir,
12042
12076
  collectedMounts: mounts,
12043
12077
  cdnOutputDir: outputStaticDir,
12044
12078
  shadowRoutes,
12079
+ excludedFiles,
12045
12080
  // Only copied mounts get a fallback: a check:true dest that is missing makes
12046
12081
  // the proxy exit with the status instead of reaching the Lambda.
12047
12082
  fallbacks: copiedMounts.flatMap(
@@ -12064,22 +12099,11 @@ function fastapiShadowingRoutes(discovery, lambdaPath) {
12064
12099
  let dropped = 0;
12065
12100
  for (const [methodsKey, bodies] of byMethods) {
12066
12101
  const methods = methodsKey === "" ? void 0 : JSON.parse(methodsKey);
12067
- const chunks = [];
12068
- let srcLen = 0;
12069
- for (const body of bodies) {
12070
- if (SHADOW_SRC_WRAPPER_LENGTH + body.length > MAX_ROUTE_SRC_LENGTH) {
12071
- dropped += 1;
12072
- continue;
12073
- }
12074
- const current = chunks[chunks.length - 1];
12075
- if (current && srcLen + 1 + body.length <= MAX_ROUTE_SRC_LENGTH) {
12076
- current.push(body);
12077
- srcLen += 1 + body.length;
12078
- } else {
12079
- chunks.push([body]);
12080
- srcLen = SHADOW_SRC_WRAPPER_LENGTH + body.length;
12081
- }
12082
- }
12102
+ const { chunks, dropped: d } = chunkBodies(
12103
+ bodies,
12104
+ SHADOW_SRC_WRAPPER_LENGTH
12105
+ );
12106
+ dropped += d;
12083
12107
  for (const chunk of chunks) {
12084
12108
  routes.push({
12085
12109
  src: shadowSrc(chunk),
@@ -12141,46 +12165,79 @@ function fastapiFallbackRoutes(discovery) {
12141
12165
  });
12142
12166
  }
12143
12167
  function getFastAPICdnBundle(collectedMounts, workPath, fastapiConfig) {
12144
- if (fastapiConfig?.static?.exclude !== true)
12145
- return { excludePatterns: [], directoryStubs: {}, redirects: [] };
12168
+ const redirects = [];
12146
12169
  const excludePatterns = [];
12147
12170
  const directoryStubs = {};
12148
- const redirects = [];
12149
- for (const mount of collectedMounts) {
12150
- const relDir = (0, import_path17.relative)(workPath, mount.directory);
12151
- if (!relDir || relDir.startsWith("..") || (0, import_path17.isAbsolute)(relDir))
12171
+ let redirectDropped = 0;
12172
+ const higherPriority = [];
12173
+ for (const mount of byCopyPriority(collectedMounts)) {
12174
+ if (!mount.html && !mount.frontend) {
12175
+ higherPriority.push(trimTrailingSlashes(mount.urlPath));
12152
12176
  continue;
12153
- const relDirUnix = relDir.split(import_path17.sep).join("/");
12154
- excludePatterns.push(`${relDirUnix}/**`);
12155
- directoryStubs[`${relDirUnix}/.static-placeholder`] = new import_build_utils20.FileBlob({
12156
- data: ""
12157
- });
12158
- for (const fallbackFile of ["index.html", "404.html"]) {
12159
- const fsPath = (0, import_path17.join)(mount.directory, fallbackFile);
12160
- if (import_fs17.default.existsSync(fsPath)) {
12161
- directoryStubs[`${relDirUnix}/${fallbackFile}`] = new import_build_utils20.FileFsRef({
12162
- fsPath
12163
- });
12177
+ }
12178
+ const urlPrefix = trimSlashes(mount.urlPath);
12179
+ const bodies = [];
12180
+ const collectBodies = (absDir, urlPath) => {
12181
+ if (higherPriority.some((base) => mountCovers(base, `/${urlPath}`))) {
12182
+ return;
12164
12183
  }
12184
+ if (urlPath && import_fs17.default.existsSync((0, import_path17.join)(absDir, "index.html"))) {
12185
+ bodies.push(escapeRegex(urlPath));
12186
+ }
12187
+ for (const entry of import_fs17.default.readdirSync(absDir, { withFileTypes: true })) {
12188
+ if (!entry.isDirectory())
12189
+ continue;
12190
+ collectBodies(
12191
+ (0, import_path17.join)(absDir, entry.name),
12192
+ urlPath ? `${urlPath}/${entry.name}` : entry.name
12193
+ );
12194
+ }
12195
+ };
12196
+ try {
12197
+ collectBodies(mount.directory, urlPrefix);
12198
+ } catch (err) {
12199
+ (0, import_build_utils20.debug)(
12200
+ `FastAPI: skipping bare-dir redirects for ${mount.urlPath} (${err})`
12201
+ );
12165
12202
  }
12166
- if (mount.frontend) {
12167
- const urlPrefix = mount.urlPath.replace(/^\/+|\/+$/g, "");
12168
- const addBareRedirects = (absDir) => {
12169
- for (const entry of import_fs17.default.readdirSync(absDir, { withFileTypes: true })) {
12170
- if (!entry.isDirectory())
12171
- continue;
12172
- const subAbs = (0, import_path17.join)(absDir, entry.name);
12173
- const subPath = (0, import_path17.relative)(mount.directory, subAbs).split(import_path17.sep).join("/");
12174
- const urlPath = urlPrefix ? `${urlPrefix}/${subPath}` : subPath;
12175
- redirects.push({
12176
- src: `^/${escapeRegex(urlPath)}$`,
12177
- dest: `/${urlPath}/`,
12178
- status: 307
12203
+ higherPriority.push(trimTrailingSlashes(mount.urlPath));
12204
+ const { chunks, dropped } = chunkBodies(
12205
+ bodies,
12206
+ REDIRECT_SRC_WRAPPER_LENGTH
12207
+ );
12208
+ redirectDropped += dropped;
12209
+ for (const chunk of chunks) {
12210
+ redirects.push({
12211
+ src: redirectSrc(chunk),
12212
+ dest: "/$1/",
12213
+ status: 307,
12214
+ methods: ["GET", "HEAD"]
12215
+ });
12216
+ }
12217
+ }
12218
+ if (redirectDropped > 0) {
12219
+ (0, import_build_utils20.debug)(
12220
+ `FastAPI: ${redirectDropped} redirect(s) over the ${MAX_ROUTE_SRC_LENGTH} char cap left unredirected`
12221
+ );
12222
+ }
12223
+ if (fastapiConfig?.static?.exclude === true) {
12224
+ for (const mount of collectedMounts) {
12225
+ const relDir = (0, import_path17.relative)(workPath, mount.directory);
12226
+ if (!relDir || relDir.startsWith("..") || (0, import_path17.isAbsolute)(relDir))
12227
+ continue;
12228
+ const relDirUnix = relDir.split(import_path17.sep).join("/");
12229
+ excludePatterns.push(`${relDirUnix}/**`);
12230
+ directoryStubs[`${relDirUnix}/.static-placeholder`] = new import_build_utils20.FileBlob({
12231
+ data: ""
12232
+ });
12233
+ for (const fallbackFile of ["index.html", "404.html"]) {
12234
+ const fsPath = (0, import_path17.join)(mount.directory, fallbackFile);
12235
+ if (import_fs17.default.existsSync(fsPath)) {
12236
+ directoryStubs[`${relDirUnix}/${fallbackFile}`] = new import_build_utils20.FileFsRef({
12237
+ fsPath
12179
12238
  });
12180
- addBareRedirects(subAbs);
12181
12239
  }
12182
- };
12183
- addBareRedirects(mount.directory);
12240
+ }
12184
12241
  }
12185
12242
  }
12186
12243
  return { excludePatterns, directoryStubs, redirects };
@@ -14378,8 +14435,8 @@ var build = async ({
14378
14435
  const shadowingRoutes = fastapiStatic ? fastapiShadowingRoutes(fastapiStatic, lambdaPath) : [];
14379
14436
  const fallbackRoutes = fastapiStatic ? fastapiFallbackRoutes(fastapiStatic) : [];
14380
14437
  const routes = isNonWebService || !output ? queueRoutes.length > 0 ? queueRoutes : void 0 : [
14381
- ...cdnBundle.redirects,
14382
14438
  ...shadowingRoutes,
14439
+ ...cdnBundle.redirects,
14383
14440
  { handle: "filesystem" },
14384
14441
  ...queueRoutes,
14385
14442
  ...fallbackRoutes.length > 0 ? [{ handle: "miss" }, ...fallbackRoutes] : [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/python",
3
- "version": "14.1.1",
3
+ "version": "14.2.0",
4
4
  "main": "./dist/index.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://vercel.com/docs/runtimes#official-runtimes/python",
@@ -38,8 +38,8 @@
38
38
  "smol-toml": "1.5.2",
39
39
  "vitest": "4.1.10",
40
40
  "which": "3.0.0",
41
- "@vercel/build-utils": "14.10.1",
42
41
  "@vercel/error-utils": "2.2.1",
42
+ "@vercel/build-utils": "14.10.1",
43
43
  "@vercel/python-runtime": "0.23.0"
44
44
  },
45
45
  "scripts": {
@@ -6,24 +6,35 @@ Writes a JSON object to the output file:
6
6
 
7
7
  {
8
8
  "mounts": [
9
- {"urlPath": str, "directory": str,
10
- "fallback": {"file": str, "status": int} | null,
11
- "frontend": bool}
9
+ {
10
+ "urlPath": str,
11
+ "directory": str,
12
+ "fallback": {"file": str, "status": int} | null,
13
+ "frontend": bool,
14
+ "html": bool
15
+ },
16
+ ...
12
17
  ],
13
- "shadowRoutes": [str, ...]
18
+ "shadowRoutes": [
19
+ {"body": str, "methods": [str] | null},
20
+ ...
21
+ ],
22
+ "excludedFiles": [str, ...]
14
23
  }
15
24
 
16
- - "mounts" are StaticFiles / frontend directories to copy to the CDN;
25
+ - "mounts" are StaticFiles / frontend directories to copy to the CDN.
17
26
  "frontend" marks a low-priority app.frontend() build.
18
- - "mounts[].fallback" is the resolved frontend fallback file to serve for
27
+ "html" reflects the StaticFiles html mode (always True for frontends).
28
+ "mounts[].fallback" is the resolved frontend fallback file to serve for
19
29
  unmatched paths under that mount ("index.html"/"404.html"), or null for a
20
- plain `app.mount(StaticFiles(...))`.
21
- - "shadowRoutes" are routing patterns (regex bodies, matched against the request
22
- path minus its leading slash) for paths a higher-priority route owns and that
23
- must therefore be routed to the app (Lambda) BEFORE the CDN. This preserves
24
- FastAPI precedence:
25
- * app.mount(): routes declared BEFORE the mount win (evaluated in order).
26
- * app.frontend(): all normal routes win (the build is low-priority).
30
+ plain app.mount(StaticFiles(...)).
31
+ - "shadowRoutes" are pre-filesystem routing rules for paths that must reach
32
+ the Lambda before the CDN can act. Each entry carries a regex body (matched
33
+ against the request path minus its leading slash) and the HTTP methods it
34
+ applies to (null means all methods).
35
+ - "excludedFiles" are CDN URL paths (with leading slash) of files in plain
36
+ StaticFiles mounts that prior GET/HEAD app routes own. The builder withholds
37
+ these from CDN copying so the Lambda catch-all serves them directly.
27
38
 
28
39
  Mounts are suppressed (omitted from output) when the app or a mounted sub-app
29
40
  has user-defined middleware, because CDN-served files bypass the middleware
@@ -67,6 +78,7 @@ class StaticMount:
67
78
  directory: str
68
79
  fallback: Fallback | None = None
69
80
  frontend: bool = False
81
+ html: bool = False
70
82
 
71
83
  @classmethod
72
84
  def from_route(
@@ -86,6 +98,7 @@ class StaticMount:
86
98
  directory=os.path.abspath(str(directory)),
87
99
  fallback=_resolve_fallback(static_app),
88
100
  frontend=frontend,
101
+ html=static_app.html,
89
102
  )
90
103
 
91
104
 
@@ -196,7 +209,7 @@ def _index_subdir_shadows(url_prefix: str, directory: str) -> set[str]:
196
209
  return shadows
197
210
 
198
211
 
199
- def _divergent_url_shadows(mount: StaticMount, html: bool) -> list[ShadowRoute]:
212
+ def _divergent_url_shadows(mount: StaticMount) -> list[ShadowRoute]:
200
213
  """Shadow routes for directory URLs the CDN and the app serve differently.
201
214
 
202
215
  When a directory holds an index.html, the app and the CDN serve that
@@ -213,15 +226,18 @@ def _divergent_url_shadows(mount: StaticMount, html: bool) -> list[ShadowRoute]:
213
226
  html=False shadows both forms. The app 404s both while the CDN serves the
214
227
  index, so both must reach the Lambda.
215
228
 
216
- Directory redirects are GET-only: HEAD for bare dirs is handled by the
217
- builder's CDN redirect routes placed before shadow routes.
229
+ Shadows are all-methods: the Lambda 404s HEAD just as it does GET, which
230
+ matches the real app. No CDN redirect routes are emitted for html=False
231
+ mounts, so HEAD must be intercepted by the shadow.
218
232
  """
233
+ if mount.html:
234
+ return [] # bare-dir 307 redirects are emitted as static CDN rules by the builder
219
235
  bodies = _index_subdir_shadows(mount.urlPath, mount.directory)
220
- if not (html and mount.urlPath == "/"):
236
+ # Only needed when index.html is present at the root. Without it the
237
+ # CDN has no implicit directory index to serve and misses naturally.
238
+ if os.path.isfile(os.path.join(mount.directory, "index.html")):
221
239
  bodies.add(_escape_path(mount.urlPath))
222
- if html:
223
- bodies = {f"{body}(?!/)" for body in bodies}
224
- return [ShadowRoute(body=body, methods=("GET",)) for body in bodies]
240
+ return [ShadowRoute(body=body, methods=None) for body in bodies]
225
241
 
226
242
 
227
243
  def _frontend_groups(router: Router) -> list[_FrontendRouteGroup]:
@@ -338,6 +354,11 @@ class PriorRoute:
338
354
  parts.append(_escape(path[last:]))
339
355
  return "".join(parts)
340
356
 
357
+ @cached_property
358
+ def parametrized(self) -> bool:
359
+ """True when the path has `{param}` placeholders that expand to regex groups."""
360
+ return bool(_PARAM_RE.search(self.path_format))
361
+
341
362
 
342
363
  @dataclass
343
364
  class Precedence:
@@ -366,19 +387,18 @@ class Precedence:
366
387
  def shadow_bodies(self, mount: StaticMount) -> list[ShadowRoute]:
367
388
  """Shadow-route body+methods for every prior route that shadows `mount`.
368
389
 
369
- When a route declares GET, HEAD is added to the shadow methods. HTTP
370
- semantics tie HEAD to GET, so the Lambda (not the CDN) should handle
371
- HEAD on API-owned paths and return 405 when HEAD is not declared.
390
+ Used for frontend mounts: the frontend fallback in handle: 'miss'
391
+ intercepts extension-less CDN misses and would serve index.html before
392
+ the Lambda catch-all is reached. A pre-filesystem shadow ensures the
393
+ Lambda handles these paths first.
372
394
  """
373
395
  result = []
374
396
  for r in self.routes:
375
397
  if not r.shadows(mount):
376
398
  continue
377
399
  methods = r.methods
378
- # With plain StaticFiles, HEAD bypasses the GET route and the
379
- # mount serves it (200), so CDN can do the same without Lambda.
380
- # With app.frontend(), HEAD on a GET route path returns 405.
381
- # Shadow HEAD only for frontend mounts.
400
+ # HEAD on a GET-only route under a frontend returns 405 not 200,
401
+ # so the Lambda must handle it rather than the CDN.
382
402
  if methods is not None and "GET" in methods and mount.frontend:
383
403
  methods = methods | {"HEAD"}
384
404
  result.append(
@@ -389,6 +409,162 @@ class Precedence:
389
409
  )
390
410
  return result
391
411
 
412
+ def excluded_files(self, mount: StaticMount) -> set[str]:
413
+ """CDN URL paths of files in `mount` that prior routes own.
414
+
415
+ Returns URL paths to exclude from the CDN so the Lambda serves them.
416
+ """
417
+ relevant = [
418
+ r for r in self.routes
419
+ if r.shadows(mount)
420
+ # Routes with no GET/HEAD methods leave their files on the CDN.
421
+ and (r.methods is None or r.methods & {"GET", "HEAD"})
422
+ # A trailing-slash route owns only the slash form; the bare-form
423
+ # file stays on the CDN (see trailing_slash_shadows).
424
+ and not (r.path_format.endswith("/") and r.path_format != "/")
425
+ ]
426
+ if not relevant:
427
+ return set()
428
+
429
+ literal_routes: list[PriorRoute] = []
430
+ parametrized_bodies: list[str] = []
431
+ for r in relevant:
432
+ if r.parametrized:
433
+ parametrized_bodies.append(r.shadow_body)
434
+ else:
435
+ literal_routes.append(r)
436
+
437
+ excluded: set[str] = set()
438
+ mount_prefix = mount.urlPath.strip("/")
439
+
440
+ # Literal fast-path: check file existence directly from the route path.
441
+ for r in literal_routes:
442
+ rel = r.path_format[len(mount.urlPath.rstrip("/")) + 1:]
443
+ if os.path.isfile(os.path.join(mount.directory, *rel.split("/"))):
444
+ excluded.add(r.path_format)
445
+
446
+ # Parametrized: one combined-regex pass over the directory tree.
447
+ if parametrized_bodies:
448
+ combined = re.compile("^(?:" + "|".join(parametrized_bodies) + ")$")
449
+ for dirpath, _, filenames in os.walk(mount.directory):
450
+ for filename in filenames:
451
+ rel = os.path.relpath(
452
+ os.path.join(dirpath, filename), mount.directory
453
+ ).replace(os.sep, "/")
454
+ url_body = f"{mount_prefix}/{rel}".lstrip("/")
455
+ if combined.match(url_body):
456
+ excluded.add("/" + url_body)
457
+
458
+ return excluded
459
+
460
+ def trailing_slash_shadows(self, mount: StaticMount) -> list[ShadowRoute]:
461
+ """Slash-form shadows for trailing-slash routes whose bare path is a file.
462
+
463
+ A route declared as @app.get("/mount/file/") owns the slash form of a
464
+ path where a file sits at the bare form on the CDN. The file stays on
465
+ the CDN; a slash-form shadow routes /mount/file/ to the Lambda before
466
+ the CDN can serve it.
467
+
468
+ A parametrized trailing-slash route owns the slash form of every path
469
+ it matches, so its whole pattern is shadowed with no file check: slash
470
+ forms without an underlying CDN file miss to the Lambda anyway, so the
471
+ broad shadow only pre-empts paths the Lambda already owns.
472
+ """
473
+ result = []
474
+ prefix_len = len(mount.urlPath.rstrip("/")) + 1
475
+ for r in self.routes:
476
+ if not r.shadows(mount):
477
+ continue
478
+ if not r.path_format.endswith("/") or r.path_format == "/":
479
+ continue
480
+ if r.parametrized:
481
+ result.append(ShadowRoute(body=r.shadow_body + "/", methods=None))
482
+ continue
483
+ rel = r.path_format[prefix_len:].rstrip("/")
484
+ if not rel:
485
+ continue
486
+ if os.path.isfile(os.path.join(mount.directory, *rel.split("/"))):
487
+ result.append(ShadowRoute(body=r.shadow_body + "/", methods=None))
488
+ return result
489
+
490
+ def non_cdn_method_shadows(self, mount: StaticMount) -> list[ShadowRoute]:
491
+ """Method-scoped shadows for prior routes with no GET/HEAD methods.
492
+
493
+ Such a route's colliding file stays on the CDN (see `excluded_files`),
494
+ but the CDN matches an existing file for any method and terminates
495
+ with 405 rather than falling through to the Lambda. Shadowing the
496
+ route's declared methods sends them to the app, which serves the
497
+ route. GET/HEAD stays on the CDN.
498
+ """
499
+ return [
500
+ ShadowRoute(body=r.shadow_body, methods=tuple(sorted(r.methods)))
501
+ for r in self.routes
502
+ if r.shadows(mount)
503
+ and r.methods is not None
504
+ and not (r.methods & {"GET", "HEAD"})
505
+ ]
506
+
507
+ def owned_dir_url_paths(self, mount: StaticMount) -> tuple[set[str], set[str]]:
508
+ """Directory URL paths in `mount` that prior GET/HEAD routes own.
509
+
510
+ For html=True mounts the CDN emits bare-dir 307 redirects for every
511
+ directory that has an index.html and serves the index at the slash
512
+ form. A prior route that owns the bare directory URL must be shadowed
513
+ so the Lambda handles the request before the redirect fires. A route
514
+ declared with a trailing slash owns the slash form instead (the app
515
+ 307s the bare form to it), so that form must reach the Lambda before
516
+ the CDN serves the index.
517
+
518
+ Returns (bare_owned, slash_owned) directory URL paths.
519
+ """
520
+ relevant = [
521
+ r for r in self.routes
522
+ if r.shadows(mount)
523
+ and (r.methods is None or r.methods & {"GET", "HEAD"})
524
+ ]
525
+ if not relevant:
526
+ return set(), set()
527
+
528
+ bare_literal: set[str] = set()
529
+ slash_literal: set[str] = set()
530
+ bare_bodies: list[str] = []
531
+ slash_bodies: list[str] = []
532
+ for r in relevant:
533
+ slash_form = r.path_format.endswith("/") and r.path_format != "/"
534
+ if r.parametrized:
535
+ (slash_bodies if slash_form else bare_bodies).append(r.shadow_body)
536
+ else:
537
+ (slash_literal if slash_form else bare_literal).add(
538
+ r.path_format.rstrip("/") or "/"
539
+ )
540
+
541
+ def _combined(bodies: list[str]) -> re.Pattern[str] | None:
542
+ return re.compile("^(?:" + "|".join(bodies) + ")$") if bodies else None
543
+
544
+ bare_combined = _combined(bare_bodies)
545
+ slash_combined = _combined(slash_bodies)
546
+
547
+ mount_prefix = mount.urlPath.strip("/")
548
+ bare_owned: set[str] = set()
549
+ slash_owned: set[str] = set()
550
+
551
+ for dirpath, _, filenames in os.walk(mount.directory):
552
+ if "index.html" not in filenames:
553
+ continue
554
+ rel = os.path.relpath(dirpath, mount.directory)
555
+ dir_url = (
556
+ mount.urlPath.rstrip("/") or "/"
557
+ if rel == "."
558
+ else mount.urlPath.rstrip("/") + "/" + rel.replace(os.sep, "/")
559
+ )
560
+ url_body = (mount_prefix + "/" + rel.replace(os.sep, "/")).lstrip("/") if rel != "." else mount_prefix
561
+ if dir_url in bare_literal or (bare_combined and bare_combined.match(url_body)):
562
+ bare_owned.add(dir_url)
563
+ if dir_url in slash_literal or (slash_combined and slash_combined.match(url_body)):
564
+ slash_owned.add(dir_url)
565
+
566
+ return bare_owned, slash_owned
567
+
392
568
  def add_mount(self, url_prefix: str) -> None:
393
569
  self.mount_prefixes.append(url_prefix.rstrip("/"))
394
570
 
@@ -410,25 +586,40 @@ def _collect_mount(
410
586
  *,
411
587
  has_middleware: bool,
412
588
  suppress_middleware_mounts: bool,
413
- ) -> tuple[list[StaticMount], list[ShadowRoute]]:
589
+ ) -> tuple[list[StaticMount], list[ShadowRoute], list[str]]:
414
590
  """Discover one app.mount(): a StaticFiles mount serves from the CDN, a
415
591
  Router/sub-app recurses, and a raw ASGI app owns its subtree opaquely.
416
592
  """
417
593
  url_prefix = prefix + route.path
418
594
  if prior.eclipses(url_prefix):
419
- return [], []
595
+ return [], [], []
420
596
 
421
597
  if suppress_middleware_mounts:
422
598
  has_middleware = has_middleware or bool(getattr(route.app, "user_middleware", []))
423
599
 
424
600
  mounts: list[StaticMount] = []
425
601
  shadow_routes: list[ShadowRoute] = []
602
+ excluded_files: set[str] = set()
426
603
 
427
604
  static = StaticMount.from_route(route, prefix, frontend=False)
428
605
  if static and not has_middleware:
429
606
  mounts.append(static)
430
- shadow_routes += prior.shadow_bodies(static)
431
- shadow_routes += _divergent_url_shadows(static, html=route.app.html)
607
+ excluded_files |= prior.excluded_files(static)
608
+ shadow_routes += prior.non_cdn_method_shadows(static)
609
+ shadow_routes += prior.trailing_slash_shadows(static)
610
+ shadow_routes += _divergent_url_shadows(static)
611
+ if route.app.html:
612
+ bare_owned, slash_owned = prior.owned_dir_url_paths(static)
613
+ for dir_url in bare_owned:
614
+ shadow_routes.append(
615
+ ShadowRoute(body=_escape_path(dir_url) + "(?!/)", methods=None)
616
+ )
617
+ for dir_url in slash_owned:
618
+ # The trailing slash pins the builder's `^/((<body>)/?)$` wrap
619
+ # to the slash form only; the bare form stays on the CDN 307.
620
+ shadow_routes.append(
621
+ ShadowRoute(body=_escape_path(dir_url) + "/", methods=None)
622
+ )
432
623
 
433
624
  # A Starlette/FastAPI sub-app isn't a Router but exposes one as `.router`;
434
625
  # a StaticFiles or raw ASGI app exposes neither.
@@ -441,7 +632,7 @@ def _collect_mount(
441
632
  # Recurse for the sub-app's own StaticFiles mounts, then shadow the
442
633
  # rest of its subtree to the Lambda.
443
634
  sub_prefix = prefix + route.path.rstrip("/")
444
- sub_mounts, sub_shadow = collect(
635
+ sub_mounts, sub_shadow, sub_excluded = collect(
445
636
  sub_router,
446
637
  sub_prefix,
447
638
  prior,
@@ -450,6 +641,7 @@ def _collect_mount(
450
641
  )
451
642
  mounts.extend(sub_mounts)
452
643
  shadow_routes += sub_shadow
644
+ excluded_files.update(sub_excluded)
453
645
  shadow_routes.append(_subtree_shadow_route(sub_prefix, sub_mounts))
454
646
  elif static is None:
455
647
  # A raw ASGI app (e.g. WSGIMiddleware) owns its subtree with nothing on
@@ -458,7 +650,7 @@ def _collect_mount(
458
650
  shadow_routes.append(_subtree_shadow_route(url_prefix, []))
459
651
 
460
652
  prior.add_mount(url_prefix)
461
- return mounts, shadow_routes
653
+ return mounts, shadow_routes, sorted(excluded_files)
462
654
 
463
655
 
464
656
  def collect(
@@ -468,16 +660,21 @@ def collect(
468
660
  *,
469
661
  has_middleware: bool,
470
662
  suppress_middleware_mounts: bool,
471
- ) -> tuple[list[StaticMount], list[ShadowRoute]]:
472
- """Walk the route table for (static mounts to copy, shadow routes).
663
+ ) -> tuple[list[StaticMount], list[ShadowRoute], list[str]]:
664
+ """Walk the route table for (static mounts to copy, shadow routes, excluded files).
473
665
 
474
666
  Shadow routes are merged so that the same path covered by multiple API
475
667
  routes with different methods produces one entry with the union of methods.
668
+
669
+ Excluded files are CDN URL paths withheld from copying so the Lambda
670
+ catch-all serves them directly. Covers plain StaticFiles app-route-wins
671
+ only.
476
672
  """
477
673
  prior = prior.child() if prior else Precedence()
478
674
 
479
675
  mounts: list[StaticMount] = []
480
676
  shadow_routes: list[ShadowRoute] = []
677
+ excluded_files: set[str] = set()
481
678
  frontends: list[StaticMount] = []
482
679
 
483
680
  def _route_methods(route: Route) -> frozenset[str] | None:
@@ -486,13 +683,14 @@ def collect(
486
683
  return frozenset(m) if m else None
487
684
 
488
685
  def collect_mount(mount_route: Mount) -> None:
489
- sub_mounts, sub_shadow = _collect_mount(
686
+ sub_mounts, sub_shadow, sub_excluded = _collect_mount(
490
687
  mount_route, prefix, prior,
491
688
  has_middleware=has_middleware,
492
689
  suppress_middleware_mounts=suppress_middleware_mounts,
493
690
  )
494
691
  mounts.extend(sub_mounts)
495
692
  shadow_routes.extend(sub_shadow)
693
+ excluded_files.update(sub_excluded)
496
694
 
497
695
  for route in router.routes:
498
696
  if isinstance(route, Route):
@@ -563,10 +761,8 @@ def collect(
563
761
  continue
564
762
  mounts.append(m)
565
763
  shadow_routes += prior.shadow_bodies(m)
566
- # A frontend is html=True StaticFiles, so its URLs diverge the same way.
567
- shadow_routes += _divergent_url_shadows(m, html=True)
568
764
 
569
- return mounts, _merge_shadow_routes(shadow_routes)
765
+ return mounts, _merge_shadow_routes(shadow_routes), sorted(excluded_files)
570
766
 
571
767
 
572
768
  def _merge_shadow_routes(routes: list[ShadowRoute]) -> list[ShadowRoute]:
@@ -611,11 +807,13 @@ class Output:
611
807
 
612
808
  mounts: list[StaticMount] = field(default_factory=list)
613
809
  shadowRoutes: list[ShadowRoute] = field(default_factory=list)
810
+ excludedFiles: list[str] = field(default_factory=list)
614
811
 
615
812
  def to_dict(self) -> dict[str, object]:
616
813
  return {
617
814
  "mounts": [asdict(m) for m in self.mounts],
618
815
  "shadowRoutes": [sr.to_dict() for sr in self.shadowRoutes],
816
+ "excludedFiles": list(self.excludedFiles),
619
817
  }
620
818
 
621
819
 
@@ -651,10 +849,10 @@ def discover(variable_name: str, project_root: str, module_name: str, suppress_m
651
849
  if suppress_middleware_mounts and getattr(app, "user_middleware", []):
652
850
  return Output()
653
851
 
654
- mounts, shadow_routes = collect(
852
+ mounts, shadow_routes, excluded_files = collect(
655
853
  router, has_middleware=False, suppress_middleware_mounts=suppress_middleware_mounts
656
854
  )
657
- return Output(mounts=mounts, shadowRoutes=shadow_routes)
855
+ return Output(mounts=mounts, shadowRoutes=shadow_routes, excludedFiles=excluded_files)
658
856
 
659
857
 
660
858
  def main() -> None: