@vercel/python 14.1.0 → 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
@@ -3328,9 +3328,9 @@ var require_pep440 = __commonJS({
3328
3328
  var require_lib = __commonJS({
3329
3329
  "../../node_modules/.pnpm/which@3.0.0/node_modules/which/lib/index.js"(exports, module2) {
3330
3330
  var isexe = require_isexe();
3331
- var { join: join21, delimiter: delimiter2, sep: sep6, posix } = require("path");
3331
+ var { join: join21, delimiter: delimiter2, sep: sep6, posix: posix4 } = require("path");
3332
3332
  var isWindows = process.platform === "win32";
3333
- var rSlash = new RegExp(`[${posix.sep}${sep6 === posix.sep ? "" : sep6}]`.replace(/(\\)/g, "\\$1"));
3333
+ var rSlash = new RegExp(`[${posix4.sep}${sep6 === posix4.sep ? "" : sep6}]`.replace(/(\\)/g, "\\$1"));
3334
3334
  var rRel = new RegExp(`^\\.${rSlash.source}`);
3335
3335
  var getNotFoundError = (cmd) => Object.assign(new Error(`not found: ${cmd}`), { code: "ENOENT" });
3336
3336
  var getPathInfo = (cmd, {
@@ -10864,6 +10864,9 @@ function installGlobalCleanupHandlers() {
10864
10864
  killAll();
10865
10865
  });
10866
10866
  }
10867
+ function escapePythonStringContents(value) {
10868
+ return JSON.stringify(value).slice(1, -1);
10869
+ }
10867
10870
  function createDevShim(workPath, entry, modulePath, serviceName, framework, variableName) {
10868
10871
  try {
10869
10872
  const vercelPythonDir = serviceName ? (0, import_path13.join)(workPath, ".vercel", "python", "services", serviceName) : (0, import_path13.join)(workPath, ".vercel", "python");
@@ -10875,7 +10878,7 @@ function createDevShim(workPath, entry, modulePath, serviceName, framework, vari
10875
10878
  qualifiedModule = `${pkgName}.${modulePath}`;
10876
10879
  extraPythonPath = (0, import_path13.dirname)(workPath);
10877
10880
  }
10878
- const entryAbs = (0, import_path13.join)(workPath, entry);
10881
+ const entryAbs = import_path13.posix.join(workPath.replace(/\\/g, "/"), entry);
10879
10882
  const shimPath = (0, import_path13.join)(vercelPythonDir, `${DEV_SHIM_MODULE}.py`);
10880
10883
  const templatePath = (0, import_path13.join)(
10881
10884
  __dirname,
@@ -10884,7 +10887,13 @@ function createDevShim(workPath, entry, modulePath, serviceName, framework, vari
10884
10887
  `${DEV_SHIM_MODULE}.py`
10885
10888
  );
10886
10889
  const template = (0, import_fs13.readFileSync)(templatePath, "utf8");
10887
- const shimSource = template.replace(/__VC_DEV_MODULE_NAME__/g, qualifiedModule).replace(/__VC_DEV_ENTRY_ABS__/g, entryAbs).replace(/__VC_DEV_FRAMEWORK__/g, framework).replace(/__VC_DEV_VARIABLE_NAME__/g, variableName);
10890
+ const shimSource = template.replace(
10891
+ /__VC_DEV_MODULE_NAME__/g,
10892
+ escapePythonStringContents(qualifiedModule)
10893
+ ).replace(/__VC_DEV_ENTRY_ABS__/g, escapePythonStringContents(entryAbs)).replace(/__VC_DEV_FRAMEWORK__/g, escapePythonStringContents(framework)).replace(
10894
+ /__VC_DEV_VARIABLE_NAME__/g,
10895
+ escapePythonStringContents(variableName)
10896
+ );
10888
10897
  (0, import_fs13.writeFileSync)(shimPath, shimSource, "utf8");
10889
10898
  (0, import_build_utils15.debug)(`Prepared Python dev shim at ${shimPath}`);
10890
10899
  return {
@@ -11418,7 +11427,7 @@ var litellmQuirk = {
11418
11427
  if (configName) {
11419
11428
  (0, import_build_utils16.debug)(`LiteLLM quirk: found config at ${configName}`);
11420
11429
  buildEnv.CONFIG_FILE_PATH = (0, import_path14.join)(ctx.workPath, configName);
11421
- env.CONFIG_FILE_PATH = (0, import_path14.join)(LAMBDA_ROOT, configName);
11430
+ env.CONFIG_FILE_PATH = import_path14.posix.join(LAMBDA_ROOT, configName);
11422
11431
  }
11423
11432
  } else {
11424
11433
  (0, import_build_utils16.debug)(
@@ -11546,7 +11555,7 @@ var prismaQuirk = {
11546
11555
  async run(ctx) {
11547
11556
  const { venvPath, pythonEnv, workPath } = ctx;
11548
11557
  const pythonPath = getVenvPythonBin(venvPath);
11549
- const runtimeCacheDir = (0, import_path15.join)(
11558
+ const runtimeCacheDir = import_path15.posix.join(
11550
11559
  LAMBDA_ROOT2,
11551
11560
  resolveVendorDir(),
11552
11561
  "prisma",
@@ -11961,7 +11970,7 @@ ${stderr}`);
11961
11970
  (0, import_build_utils20.debug)(
11962
11971
  `FastAPI: static discovery failed: ${err?.stderr ?? err?.message ?? err}`
11963
11972
  );
11964
- return { mounts: [], shadowRoutes: [] };
11973
+ return { mounts: [], shadowRoutes: [], excludedFiles: [] };
11965
11974
  } finally {
11966
11975
  await import_fs17.default.promises.rm(outputPath, { force: true });
11967
11976
  }
@@ -11969,6 +11978,9 @@ ${stderr}`);
11969
11978
  function trimTrailingSlashes(urlPath) {
11970
11979
  return urlPath.replace(/\/+$/, "");
11971
11980
  }
11981
+ function trimSlashes(urlPath) {
11982
+ return urlPath.replace(/^\/+|\/+$/g, "");
11983
+ }
11972
11984
  function mountCovers(base, urlPath) {
11973
11985
  return base === "" || urlPath === base || urlPath.startsWith(base + "/");
11974
11986
  }
@@ -11976,15 +11988,18 @@ function cdnUrlPath(outputStaticDir, destPath) {
11976
11988
  const rel = (0, import_path17.relative)(outputStaticDir, destPath);
11977
11989
  return rel === "" ? "/" : "/" + rel.split(import_path17.sep).join("/");
11978
11990
  }
11979
- async function copyFastAPIStaticMounts(mounts, outputStaticDir) {
11980
- const ordered = [
11991
+ function byCopyPriority(mounts) {
11992
+ return [
11981
11993
  ...mounts.filter((m) => !m.frontend),
11982
11994
  ...mounts.filter((m) => m.frontend).sort((a, b) => b.urlPath.length - a.urlPath.length)
11983
11995
  ];
11996
+ }
11997
+ async function copyFastAPIStaticMounts(mounts, outputStaticDir, excludedFiles = []) {
11998
+ const excluded = new Set(excludedFiles);
11984
11999
  const higherPriority = [];
11985
12000
  const copied = [];
11986
- for (const mount of ordered) {
11987
- 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));
11988
12003
  try {
11989
12004
  await import_fs17.default.promises.mkdir(dest, { recursive: true });
11990
12005
  await import_fs17.default.promises.cp(mount.directory, dest, {
@@ -11992,7 +12007,7 @@ async function copyFastAPIStaticMounts(mounts, outputStaticDir) {
11992
12007
  force: false,
11993
12008
  filter: (_src, destPath) => {
11994
12009
  const urlPath = cdnUrlPath(outputStaticDir, destPath);
11995
- return !higherPriority.some((base) => mountCovers(base, urlPath));
12010
+ return !higherPriority.some((base) => mountCovers(base, urlPath)) && !excluded.has(urlPath);
11996
12011
  }
11997
12012
  });
11998
12013
  copied.push(mount);
@@ -12008,9 +12023,33 @@ var MAX_ROUTE_SRC_LENGTH = 4096;
12008
12023
  function shadowSrc(bodies) {
12009
12024
  return `^/((?:${bodies.join("|")})/?)$`;
12010
12025
  }
12026
+ function redirectSrc(bodies) {
12027
+ return `^/((?:${bodies.join("|")}))$`;
12028
+ }
12011
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
+ }
12012
12051
  async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir, entrypointAbs, variableName, suppressMiddlewareMounts = true) {
12013
- const { mounts, shadowRoutes } = await getFastAPIStaticDiscovery(
12052
+ const { mounts, shadowRoutes, excludedFiles } = await getFastAPIStaticDiscovery(
12014
12053
  venvPath,
12015
12054
  entrypointAbs,
12016
12055
  variableName,
@@ -12025,7 +12064,11 @@ async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir,
12025
12064
  (0, import_build_utils20.debug)(
12026
12065
  `Found ${mounts.length} FastAPI static mount(s): ${mounts.map((m) => m.urlPath).join(", ")}`
12027
12066
  );
12028
- const copiedMounts = await copyFastAPIStaticMounts(mounts, outputStaticDir);
12067
+ const copiedMounts = await copyFastAPIStaticMounts(
12068
+ mounts,
12069
+ outputStaticDir,
12070
+ excludedFiles
12071
+ );
12029
12072
  if (copiedMounts.length === 0) {
12030
12073
  return null;
12031
12074
  }
@@ -12033,6 +12076,7 @@ async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir,
12033
12076
  collectedMounts: mounts,
12034
12077
  cdnOutputDir: outputStaticDir,
12035
12078
  shadowRoutes,
12079
+ excludedFiles,
12036
12080
  // Only copied mounts get a fallback: a check:true dest that is missing makes
12037
12081
  // the proxy exit with the status instead of reaching the Lambda.
12038
12082
  fallbacks: copiedMounts.flatMap(
@@ -12055,22 +12099,11 @@ function fastapiShadowingRoutes(discovery, lambdaPath) {
12055
12099
  let dropped = 0;
12056
12100
  for (const [methodsKey, bodies] of byMethods) {
12057
12101
  const methods = methodsKey === "" ? void 0 : JSON.parse(methodsKey);
12058
- const chunks = [];
12059
- let srcLen = 0;
12060
- for (const body of bodies) {
12061
- if (SHADOW_SRC_WRAPPER_LENGTH + body.length > MAX_ROUTE_SRC_LENGTH) {
12062
- dropped += 1;
12063
- continue;
12064
- }
12065
- const current = chunks[chunks.length - 1];
12066
- if (current && srcLen + 1 + body.length <= MAX_ROUTE_SRC_LENGTH) {
12067
- current.push(body);
12068
- srcLen += 1 + body.length;
12069
- } else {
12070
- chunks.push([body]);
12071
- srcLen = SHADOW_SRC_WRAPPER_LENGTH + body.length;
12072
- }
12073
- }
12102
+ const { chunks, dropped: d } = chunkBodies(
12103
+ bodies,
12104
+ SHADOW_SRC_WRAPPER_LENGTH
12105
+ );
12106
+ dropped += d;
12074
12107
  for (const chunk of chunks) {
12075
12108
  routes.push({
12076
12109
  src: shadowSrc(chunk),
@@ -12132,46 +12165,79 @@ function fastapiFallbackRoutes(discovery) {
12132
12165
  });
12133
12166
  }
12134
12167
  function getFastAPICdnBundle(collectedMounts, workPath, fastapiConfig) {
12135
- if (fastapiConfig?.static?.exclude !== true)
12136
- return { excludePatterns: [], directoryStubs: {}, redirects: [] };
12168
+ const redirects = [];
12137
12169
  const excludePatterns = [];
12138
12170
  const directoryStubs = {};
12139
- const redirects = [];
12140
- for (const mount of collectedMounts) {
12141
- const relDir = (0, import_path17.relative)(workPath, mount.directory);
12142
- 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));
12143
12176
  continue;
12144
- const relDirUnix = relDir.split(import_path17.sep).join("/");
12145
- excludePatterns.push(`${relDirUnix}/**`);
12146
- directoryStubs[`${relDirUnix}/.static-placeholder`] = new import_build_utils20.FileBlob({
12147
- data: ""
12148
- });
12149
- for (const fallbackFile of ["index.html", "404.html"]) {
12150
- const fsPath = (0, import_path17.join)(mount.directory, fallbackFile);
12151
- if (import_fs17.default.existsSync(fsPath)) {
12152
- directoryStubs[`${relDirUnix}/${fallbackFile}`] = new import_build_utils20.FileFsRef({
12153
- fsPath
12154
- });
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;
12183
+ }
12184
+ if (urlPath && import_fs17.default.existsSync((0, import_path17.join)(absDir, "index.html"))) {
12185
+ bodies.push(escapeRegex(urlPath));
12155
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
+ );
12156
12202
  }
12157
- if (mount.frontend) {
12158
- const urlPrefix = mount.urlPath.replace(/^\/+|\/+$/g, "");
12159
- const addBareRedirects = (absDir) => {
12160
- for (const entry of import_fs17.default.readdirSync(absDir, { withFileTypes: true })) {
12161
- if (!entry.isDirectory())
12162
- continue;
12163
- const subAbs = (0, import_path17.join)(absDir, entry.name);
12164
- const subPath = (0, import_path17.relative)(mount.directory, subAbs).split(import_path17.sep).join("/");
12165
- const urlPath = urlPrefix ? `${urlPrefix}/${subPath}` : subPath;
12166
- redirects.push({
12167
- src: `^/${escapeRegex(urlPath)}$`,
12168
- dest: `/${urlPath}/`,
12169
- 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
12170
12238
  });
12171
- addBareRedirects(subAbs);
12172
12239
  }
12173
- };
12174
- addBareRedirects(mount.directory);
12240
+ }
12175
12241
  }
12176
12242
  }
12177
12243
  return { excludePatterns, directoryStubs, redirects };
@@ -14369,8 +14435,8 @@ var build = async ({
14369
14435
  const shadowingRoutes = fastapiStatic ? fastapiShadowingRoutes(fastapiStatic, lambdaPath) : [];
14370
14436
  const fallbackRoutes = fastapiStatic ? fastapiFallbackRoutes(fastapiStatic) : [];
14371
14437
  const routes = isNonWebService || !output ? queueRoutes.length > 0 ? queueRoutes : void 0 : [
14372
- ...cdnBundle.redirects,
14373
14438
  ...shadowingRoutes,
14439
+ ...cdnBundle.redirects,
14374
14440
  { handle: "filesystem" },
14375
14441
  ...queueRoutes,
14376
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.0",
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",
@@ -39,8 +39,8 @@
39
39
  "vitest": "4.1.10",
40
40
  "which": "3.0.0",
41
41
  "@vercel/error-utils": "2.2.1",
42
- "@vercel/python-runtime": "0.23.0",
43
- "@vercel/build-utils": "14.10.1"
42
+ "@vercel/build-utils": "14.10.1",
43
+ "@vercel/python-runtime": "0.23.0"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "node ../../utils/build-builder.mjs",
@@ -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: