@vercel/python 6.56.2 → 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
@@ -3301,9 +3301,9 @@ var require_pep440 = __commonJS({
3301
3301
  var require_lib = __commonJS({
3302
3302
  "../../node_modules/.pnpm/which@3.0.0/node_modules/which/lib/index.js"(exports, module2) {
3303
3303
  var isexe = require_isexe();
3304
- var { join: join21, delimiter: delimiter2, sep: sep4, posix } = require("path");
3304
+ var { join: join21, delimiter: delimiter2, sep: sep5, posix } = require("path");
3305
3305
  var isWindows = process.platform === "win32";
3306
- var rSlash = new RegExp(`[${posix.sep}${sep4 === posix.sep ? "" : sep4}]`.replace(/(\\)/g, "\\$1"));
3306
+ var rSlash = new RegExp(`[${posix.sep}${sep5 === posix.sep ? "" : sep5}]`.replace(/(\\)/g, "\\$1"));
3307
3307
  var rRel = new RegExp(`^\\.${rSlash.source}`);
3308
3308
  var getNotFoundError = (cmd) => Object.assign(new Error(`not found: ${cmd}`), { code: "ENOENT" });
3309
3309
  var getPathInfo = (cmd, {
@@ -5032,7 +5032,7 @@ var import_fs20 = __toESM(require("fs"));
5032
5032
  var import_path21 = require("path");
5033
5033
 
5034
5034
  // src/package-versions.ts
5035
- var VERCEL_RUNTIME_VERSION = "0.20.0";
5035
+ var VERCEL_RUNTIME_VERSION = "0.21.0";
5036
5036
  var VERCEL_WORKERS_VERSION = "0.0.25";
5037
5037
 
5038
5038
  // src/conditional-vendoring.ts
@@ -5140,8 +5140,48 @@ var import_execa = __toESM(require_execa());
5140
5140
  var import_pep440 = __toESM(require_pep440());
5141
5141
  var import_build_utils = require("@vercel/build-utils");
5142
5142
  var import_python_analysis2 = require("@vercel/python-analysis");
5143
- var MIN_QUEUE_WORKFLOW_SDK_VERSION = "0.8.0";
5144
- var VERSION_QUERY_SCRIPT = 'import importlib.metadata, sys; sys.stdout.write(importlib.metadata.version("vercel"))';
5143
+ var WORKFLOW_SDK_DISTRIBUTIONS = [
5144
+ "vercel-workflow",
5145
+ "vercel"
5146
+ ];
5147
+ var MIN_QUEUE_WORKFLOW_SDK_VERSION = {
5148
+ "vercel-workflow": "0.9.0",
5149
+ vercel: "0.8.0"
5150
+ };
5151
+ var QUEUE_WORKFLOW_SDK_REQUIREMENT = WORKFLOW_SDK_DISTRIBUTIONS.map(
5152
+ (distribution) => `${distribution}>=${MIN_QUEUE_WORKFLOW_SDK_VERSION[distribution]}`
5153
+ ).join(" or ");
5154
+ function versionQueryScript(distributions) {
5155
+ return [
5156
+ "import importlib.metadata, sys",
5157
+ `for name in (${distributions.map((name) => `"${name}"`).join(", ")},):`,
5158
+ " try:",
5159
+ " version = importlib.metadata.version(name)",
5160
+ " except importlib.metadata.PackageNotFoundError:",
5161
+ " continue",
5162
+ ' sys.stdout.write(name + " " + version + "\\n")'
5163
+ ].join("\n");
5164
+ }
5165
+ function parseVersionQueryOutput(stdout, distributions) {
5166
+ const installed = /* @__PURE__ */ new Map();
5167
+ for (const line of stdout.split("\n")) {
5168
+ const [name, version2] = line.trim().split(/\s+/);
5169
+ const distribution = distributions.find((candidate) => candidate === name);
5170
+ if (distribution && version2 && (0, import_pep440.valid)(version2)) {
5171
+ installed.set(distribution, version2);
5172
+ }
5173
+ }
5174
+ return installed;
5175
+ }
5176
+ function pickInstalledDistribution(installed, distributions) {
5177
+ for (const distribution of distributions) {
5178
+ const version2 = installed.get(distribution);
5179
+ if (version2) {
5180
+ return { distribution, version: version2 };
5181
+ }
5182
+ }
5183
+ return void 0;
5184
+ }
5145
5185
  async function getDeclaredDependencyNames(dependencies) {
5146
5186
  if (!Array.isArray(dependencies)) {
5147
5187
  return void 0;
@@ -5164,11 +5204,13 @@ async function isLegacyWorkersProject(workPath) {
5164
5204
  );
5165
5205
  return names?.has("vercel-workers") ?? false;
5166
5206
  }
5167
- async function hasExplicitVercelSdkDependency(pythonPackage) {
5207
+ async function hasExplicitWorkflowSdkDependency(pythonPackage) {
5168
5208
  const names = await getDeclaredDependencyNames(
5169
5209
  pythonPackage?.manifest?.data?.project?.dependencies
5170
5210
  );
5171
- return names?.has("vercel") ?? false;
5211
+ return WORKFLOW_SDK_DISTRIBUTIONS.some(
5212
+ (distribution) => names?.has(distribution) ?? false
5213
+ );
5172
5214
  }
5173
5215
  async function getLocalSdkSourcePaths({
5174
5216
  pythonPackage,
@@ -5194,44 +5236,58 @@ async function getLocalSdkSourcePaths({
5194
5236
  (entry) => entry.isDirectory() && (entry.name === "vercel" || entry.name.startsWith("vercel-")) && (names?.has("vercel") || names?.has(entry.name))
5195
5237
  ).map((entry) => entry.name).sort().map((name) => (0, import_path.join)(sdkRoot, "src", name));
5196
5238
  }
5197
- async function getInstalledVercelSdkVersion({
5239
+ async function getInstalledDistributionVersions({
5198
5240
  uv,
5199
5241
  venvPath,
5200
5242
  projectDir,
5201
- uvLockPath
5243
+ uvLockPath,
5244
+ distributions
5202
5245
  }) {
5246
+ const label = distributions.join("/");
5247
+ let installed = /* @__PURE__ */ new Map();
5203
5248
  try {
5204
5249
  const result = await uv.run({
5205
5250
  venvPath,
5206
5251
  projectDir,
5207
- args: ["python", "-c", VERSION_QUERY_SCRIPT]
5252
+ args: ["python", "-c", versionQueryScript(distributions)]
5208
5253
  });
5209
- const version2 = result.stdout.trim();
5210
- if ((0, import_pep440.valid)(version2)) {
5211
- return version2;
5212
- }
5254
+ installed = parseVersionQueryOutput(result.stdout, distributions);
5213
5255
  } catch (err) {
5214
5256
  (0, import_build_utils.debug)(
5215
- `Failed to query installed vercel SDK version: ${err instanceof Error ? err.message : String(err)}`
5257
+ `Failed to query installed ${label} version: ${err instanceof Error ? err.message : String(err)}`
5216
5258
  );
5217
5259
  }
5218
- if (uvLockPath) {
5260
+ const missing = distributions.filter(
5261
+ (distribution) => !installed.has(distribution)
5262
+ );
5263
+ if (missing.length > 0 && uvLockPath) {
5219
5264
  try {
5220
5265
  const lockContent = await import_fs.default.promises.readFile(uvLockPath, "utf8");
5221
5266
  const lockFile = (0, import_python_analysis2.parseUvLock)(lockContent, uvLockPath);
5222
- const locked = lockFile.packages.find(
5223
- (pkg) => (0, import_python_analysis2.normalizePackageName)(pkg.name) === "vercel"
5224
- )?.version;
5225
- if (locked && (0, import_pep440.valid)(locked)) {
5226
- return locked;
5267
+ for (const distribution of missing) {
5268
+ const locked = lockFile.packages.find(
5269
+ (pkg) => (0, import_python_analysis2.normalizePackageName)(pkg.name) === distribution
5270
+ )?.version;
5271
+ if (locked && (0, import_pep440.valid)(locked)) {
5272
+ installed.set(distribution, locked);
5273
+ }
5227
5274
  }
5228
5275
  } catch (err) {
5229
5276
  (0, import_build_utils.debug)(
5230
- `Failed to resolve vercel SDK version from uv.lock: ${err instanceof Error ? err.message : String(err)}`
5277
+ `Failed to resolve ${label} version from uv.lock: ${err instanceof Error ? err.message : String(err)}`
5231
5278
  );
5232
5279
  }
5233
5280
  }
5234
- return void 0;
5281
+ return installed;
5282
+ }
5283
+ async function getInstalledWorkflowSdkVersion(opts) {
5284
+ return pickInstalledDistribution(
5285
+ await getInstalledDistributionVersions({
5286
+ ...opts,
5287
+ distributions: WORKFLOW_SDK_DISTRIBUTIONS
5288
+ }),
5289
+ WORKFLOW_SDK_DISTRIBUTIONS
5290
+ );
5235
5291
  }
5236
5292
  async function resolveWorkflowServingMode({
5237
5293
  pythonPackage,
@@ -5240,51 +5296,64 @@ async function resolveWorkflowServingMode({
5240
5296
  projectDir,
5241
5297
  uvLockPath
5242
5298
  }) {
5243
- if (!await hasExplicitVercelSdkDependency(pythonPackage)) {
5299
+ if (!await hasExplicitWorkflowSdkDependency(pythonPackage)) {
5244
5300
  return "workers";
5245
5301
  }
5246
- const version2 = await getInstalledVercelSdkVersion({
5302
+ const installed = await getInstalledWorkflowSdkVersion({
5247
5303
  uv,
5248
5304
  venvPath,
5249
5305
  projectDir,
5250
5306
  uvLockPath
5251
5307
  });
5252
- if (version2 && isQueueWorkflowSdkVersion(version2)) {
5253
- (0, import_build_utils.debug)(`Detected vercel SDK ${version2}: serving workflows via vercel-queue`);
5308
+ const detected = installed ? `${installed.distribution} ${installed.version}` : "workflow SDK (unknown version)";
5309
+ if (isQueueWorkflowSdk(installed)) {
5310
+ (0, import_build_utils.debug)(`Detected ${detected}: serving workflows via vercel-queue`);
5254
5311
  return "queue";
5255
5312
  }
5256
- (0, import_build_utils.debug)(
5257
- `Detected vercel SDK ${version2 ?? "(unknown version)"}: serving workflows via vercel-workers`
5258
- );
5313
+ (0, import_build_utils.debug)(`Detected ${detected}: serving workflows via vercel-workers`);
5259
5314
  return "workers";
5260
5315
  }
5261
- function isQueueWorkflowSdkVersion(version2) {
5316
+ function isQueueWorkflowSdk(installed) {
5317
+ if (!installed) {
5318
+ return false;
5319
+ }
5262
5320
  try {
5263
- return (0, import_pep440.ge)(version2, MIN_QUEUE_WORKFLOW_SDK_VERSION);
5321
+ return (0, import_pep440.ge)(
5322
+ installed.version,
5323
+ MIN_QUEUE_WORKFLOW_SDK_VERSION[installed.distribution]
5324
+ );
5264
5325
  } catch {
5265
5326
  return false;
5266
5327
  }
5267
5328
  }
5268
- async function queryPythonVercelSdkVersion({
5329
+ async function queryPythonDistributionVersions({
5269
5330
  pythonBin,
5270
5331
  cwd,
5271
- env
5332
+ env,
5333
+ distributions
5272
5334
  }) {
5273
5335
  try {
5274
- const result = await (0, import_execa.default)(pythonBin, ["-c", VERSION_QUERY_SCRIPT], {
5275
- cwd,
5276
- env
5277
- });
5278
- const version2 = result.stdout.trim();
5279
- if ((0, import_pep440.valid)(version2)) {
5280
- return version2;
5281
- }
5336
+ const result = await (0, import_execa.default)(
5337
+ pythonBin,
5338
+ ["-c", versionQueryScript(distributions)],
5339
+ { cwd, env }
5340
+ );
5341
+ return parseVersionQueryOutput(result.stdout, distributions);
5282
5342
  } catch (err) {
5283
5343
  (0, import_build_utils.debug)(
5284
- `Failed to query installed vercel SDK version: ${err instanceof Error ? err.message : String(err)}`
5344
+ `Failed to query installed ${distributions.join("/")} version: ${err instanceof Error ? err.message : String(err)}`
5285
5345
  );
5286
5346
  }
5287
- return void 0;
5347
+ return /* @__PURE__ */ new Map();
5348
+ }
5349
+ async function queryPythonWorkflowSdkVersion(opts) {
5350
+ return pickInstalledDistribution(
5351
+ await queryPythonDistributionVersions({
5352
+ ...opts,
5353
+ distributions: WORKFLOW_SDK_DISTRIBUTIONS
5354
+ }),
5355
+ WORKFLOW_SDK_DISTRIBUTIONS
5356
+ );
5288
5357
  }
5289
5358
 
5290
5359
  // src/index.ts
@@ -9525,12 +9594,13 @@ If you are using a virtual environment, activate it before running "vercel dev",
9525
9594
  if (queueSidecarKind) {
9526
9595
  let useQueueServing = !legacyProject;
9527
9596
  if (useQueueServing && queueSidecarKind === "workflow") {
9528
- const sdkVersion = await queryPythonVercelSdkVersion({
9529
- pythonBin: spawnCommand,
9530
- cwd: workPath,
9531
- env
9532
- });
9533
- useQueueServing = sdkVersion !== void 0 && isQueueWorkflowSdkVersion(sdkVersion);
9597
+ useQueueServing = isQueueWorkflowSdk(
9598
+ await queryPythonWorkflowSdkVersion({
9599
+ pythonBin: spawnCommand,
9600
+ cwd: workPath,
9601
+ env
9602
+ })
9603
+ );
9534
9604
  }
9535
9605
  if (useQueueServing) {
9536
9606
  env.VERCEL_DEV_QUEUE_SERVING = "1";
@@ -10240,18 +10310,12 @@ var import_path17 = require("path");
10240
10310
  var import_execa9 = __toESM(require_execa());
10241
10311
  var import_build_utils20 = require("@vercel/build-utils");
10242
10312
  var scriptPath3 = (0, import_path17.join)(__dirname, "..", "templates", "vc_fastapi_static.py");
10243
- var _STATIC_FILE_COLLECTION_ERROR_MESSAGE = "Warning: FastAPI static file collection failed. Static files will not be served from the CDN.";
10244
- async function getFastAPIStaticMounts(venvPath, entrypointAbs, variableName, env, workPath) {
10313
+ var STATIC_COLLECTION_WARNING = "Warning: FastAPI static file collection failed. Static files will not be served from the CDN.";
10314
+ async function getFastAPIStaticDiscovery(venvPath, entrypointAbs, variableName, env, workPath) {
10245
10315
  const pythonPath = getVenvPythonBin(venvPath);
10246
- const outputPath = (0, import_path17.join)(
10247
- workPath,
10248
- ".vercel",
10249
- "python",
10250
- "vc_fastapi_static_output.json"
10251
- );
10252
- await import_fs17.default.promises.mkdir((0, import_path17.join)(workPath, ".vercel", "python"), {
10253
- recursive: true
10254
- });
10316
+ const outputDir = (0, import_path17.join)(workPath, ".vercel", "python");
10317
+ const outputPath = (0, import_path17.join)(outputDir, "vc_fastapi_static_output.json");
10318
+ await import_fs17.default.promises.mkdir(outputDir, { recursive: true });
10255
10319
  try {
10256
10320
  const { stderr } = await (0, import_execa9.default)(
10257
10321
  pythonPath,
@@ -10262,28 +10326,65 @@ async function getFastAPIStaticMounts(venvPath, entrypointAbs, variableName, env
10262
10326
  (0, import_build_utils20.debug)(`FastAPI shim stderr:
10263
10327
  ${stderr}`);
10264
10328
  }
10265
- } catch (err) {
10266
- console.error(_STATIC_FILE_COLLECTION_ERROR_MESSAGE);
10267
- (0, import_build_utils20.debug)(
10268
- `FastAPI: could not discover static mounts: ${err?.stderr ?? err?.message ?? err}`
10269
- );
10270
- return [];
10271
- }
10272
- try {
10273
10329
  const raw = await import_fs17.default.promises.readFile(outputPath, "utf8");
10274
10330
  const parsed = JSON.parse(raw);
10275
- (0, import_build_utils20.debug)(`FastAPI: discovered mounts: ${JSON.stringify(parsed)}`);
10331
+ (0, import_build_utils20.debug)(`FastAPI: discovered: ${JSON.stringify(parsed)}`);
10276
10332
  return parsed;
10277
- } catch {
10278
- console.error(_STATIC_FILE_COLLECTION_ERROR_MESSAGE);
10279
- (0, import_build_utils20.debug)(`FastAPI: could not read shim output file: ${outputPath}`);
10280
- return [];
10333
+ } catch (err) {
10334
+ console.error(STATIC_COLLECTION_WARNING);
10335
+ (0, import_build_utils20.debug)(
10336
+ `FastAPI: static discovery failed: ${err?.stderr ?? err?.message ?? err}`
10337
+ );
10338
+ return { mounts: [], shadowRoutes: [] };
10281
10339
  } finally {
10282
10340
  await import_fs17.default.promises.rm(outputPath, { force: true });
10283
10341
  }
10284
10342
  }
10343
+ function trimTrailingSlashes(urlPath) {
10344
+ return urlPath.replace(/\/+$/, "");
10345
+ }
10346
+ function mountCovers(base, urlPath) {
10347
+ return base === "" || urlPath === base || urlPath.startsWith(base + "/");
10348
+ }
10349
+ function cdnUrlPath(outputStaticDir, destPath) {
10350
+ const rel = (0, import_path17.relative)(outputStaticDir, destPath);
10351
+ return rel === "" ? "/" : "/" + rel.split(import_path17.sep).join("/");
10352
+ }
10353
+ async function copyFastAPIStaticMounts(mounts, outputStaticDir) {
10354
+ const ordered = [
10355
+ ...mounts.filter((m) => !m.frontend),
10356
+ ...mounts.filter((m) => m.frontend).sort((a, b) => b.urlPath.length - a.urlPath.length)
10357
+ ];
10358
+ const higherPriority = [];
10359
+ const copied = [];
10360
+ for (const mount of ordered) {
10361
+ const dest = (0, import_path17.join)(outputStaticDir, mount.urlPath.replace(/^\/+|\/+$/g, ""));
10362
+ try {
10363
+ await import_fs17.default.promises.mkdir(dest, { recursive: true });
10364
+ await import_fs17.default.promises.cp(mount.directory, dest, {
10365
+ recursive: true,
10366
+ force: false,
10367
+ filter: (_src, destPath) => {
10368
+ const urlPath = cdnUrlPath(outputStaticDir, destPath);
10369
+ return !higherPriority.some((base) => mountCovers(base, urlPath));
10370
+ }
10371
+ });
10372
+ copied.push(mount);
10373
+ (0, import_build_utils20.debug)(`copied ${mount.directory} -> ${dest}`);
10374
+ } catch (err) {
10375
+ (0, import_build_utils20.debug)(`FastAPI: skipping ${mount.urlPath}: copy failed (${err})`);
10376
+ }
10377
+ higherPriority.push(trimTrailingSlashes(mount.urlPath));
10378
+ }
10379
+ return copied;
10380
+ }
10381
+ var MAX_ROUTE_SRC_LENGTH = 4096;
10382
+ function shadowSrc(bodies) {
10383
+ return `^/((?:${bodies.join("|")})/?)$`;
10384
+ }
10385
+ var SHADOW_SRC_WRAPPER_LENGTH = shadowSrc([]).length;
10285
10386
  async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir, entrypointAbs, variableName) {
10286
- const mounts = await getFastAPIStaticMounts(
10387
+ const { mounts, shadowRoutes } = await getFastAPIStaticDiscovery(
10287
10388
  venvPath,
10288
10389
  entrypointAbs,
10289
10390
  variableName,
@@ -10297,18 +10398,157 @@ async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir,
10297
10398
  (0, import_build_utils20.debug)(
10298
10399
  `Found ${mounts.length} FastAPI static mount(s): ${mounts.map((m) => m.urlPath).join(", ")}`
10299
10400
  );
10300
- for (const mount of mounts) {
10301
- const urlSubPath = mount.urlPath.replace(/^\/|\/$/g, "");
10302
- const dest = (0, import_path17.join)(outputStaticDir, urlSubPath);
10303
- await import_fs17.default.promises.mkdir(dest, { recursive: true });
10304
- await import_fs17.default.promises.cp(mount.directory, dest, { recursive: true });
10305
- (0, import_build_utils20.debug)(`copied ${mount.directory} -> ${dest}`);
10401
+ const copiedMounts = await copyFastAPIStaticMounts(mounts, outputStaticDir);
10402
+ if (copiedMounts.length === 0) {
10403
+ return null;
10306
10404
  }
10307
10405
  return {
10308
- collectedMounts: mounts.map((m) => m.urlPath),
10309
- cdnOutputDir: outputStaticDir
10406
+ collectedMounts: mounts,
10407
+ cdnOutputDir: outputStaticDir,
10408
+ shadowRoutes,
10409
+ // Only copied mounts get a fallback: a check:true dest that is missing makes
10410
+ // the proxy exit with the status instead of reaching the Lambda.
10411
+ fallbacks: copiedMounts.flatMap(
10412
+ (m) => m.fallback ? [{ urlPath: m.urlPath, ...m.fallback }] : []
10413
+ )
10310
10414
  };
10311
10415
  }
10416
+ function fastapiShadowingRoutes(discovery, lambdaPath) {
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);
10423
+ } else {
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
+ });
10456
+ }
10457
+ }
10458
+ if (dropped > 0) {
10459
+ (0, import_build_utils20.debug)(
10460
+ `FastAPI: ${dropped} shadow route(s) over the ${MAX_ROUTE_SRC_LENGTH} char cap left unshadowed`
10461
+ );
10462
+ }
10463
+ return routes;
10464
+ }
10465
+ function escapeRegex(text) {
10466
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10467
+ }
10468
+ var NAVIGATION_ACCEPT_RE = ".*(?:^|[,\\s])(?:text/html|application/xhtml\\+xml)(?=[,;\\s]|$)(?![^,]*;\\s*q=0(?:\\.0+)?(?:[,;\\s]|$)).*";
10469
+ function fastapiFallbackRoutes(discovery) {
10470
+ const mountPrefixes = discovery.collectedMounts.map(
10471
+ (m) => trimTrailingSlashes(m.urlPath)
10472
+ );
10473
+ return discovery.fallbacks.flatMap((fb) => {
10474
+ const prefix = trimTrailingSlashes(fb.urlPath);
10475
+ const nested = mountPrefixes.filter((urlPath) => urlPath.startsWith(`${prefix}/`)).map((urlPath) => urlPath.slice(prefix.length + 1));
10476
+ const guard = nested.length ? `(?!(?:${nested.map(escapeRegex).join("|")})(?:/|$))` : "";
10477
+ const isNavigation = fb.status === 200;
10478
+ const navigationGuard = isNavigation ? "(?!.*[^/.]\\.[^/]*$)" : "";
10479
+ const src = `^${escapeRegex(prefix)}/${guard}${navigationGuard}.*$`;
10480
+ if (src.length > MAX_ROUTE_SRC_LENGTH) {
10481
+ (0, import_build_utils20.debug)(
10482
+ `FastAPI: fallback route for ${fb.urlPath} over the ${MAX_ROUTE_SRC_LENGTH} char cap, served by the Lambda`
10483
+ );
10484
+ return [];
10485
+ }
10486
+ const navigationOnly = isNavigation ? {
10487
+ has: [
10488
+ {
10489
+ type: "header",
10490
+ key: "accept",
10491
+ value: NAVIGATION_ACCEPT_RE
10492
+ }
10493
+ ]
10494
+ } : {};
10495
+ return [
10496
+ {
10497
+ src,
10498
+ dest: `${prefix}/${fb.file}`,
10499
+ status: fb.status,
10500
+ check: true,
10501
+ methods: ["GET", "HEAD"],
10502
+ ...navigationOnly
10503
+ }
10504
+ ];
10505
+ });
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
+ }
10312
10552
 
10313
10553
  // src/index.ts
10314
10554
  var import_python_analysis12 = require("@vercel/python-analysis");
@@ -11730,7 +11970,7 @@ var build = async ({
11730
11970
  if (workflowMode === "workers" && workflows.length > 1) {
11731
11971
  throw new import_build_utils24.NowBuildError({
11732
11972
  code: "PYTHON_INVALID_WORKFLOW_CONFIG",
11733
- message: `"tool.vercel.workflows" declares multiple entrypoints, which requires vercel>=${MIN_QUEUE_WORKFLOW_SDK_VERSION} with a distinct namespace per Workflows registry; the installed SDK serves every workflow on the shared __wkf_* topic`
11973
+ message: `"tool.vercel.workflows" declares multiple entrypoints, which requires ${QUEUE_WORKFLOW_SDK_REQUIREMENT} with a distinct namespace per Workflows registry; the installed SDK serves every workflow on the shared __wkf_* topic`
11734
11974
  });
11735
11975
  }
11736
11976
  }
@@ -11933,11 +12173,22 @@ var build = async ({
11933
12173
  );
11934
12174
  }
11935
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
+ );
11936
12182
  const globOptions = {
11937
12183
  cwd: workPath,
11938
- ignore: config && typeof config.excludeFiles === "string" ? [...predefinedExcludes, config.excludeFiles] : predefinedExcludes
12184
+ ignore: [
12185
+ ...predefinedExcludes,
12186
+ ...excludeFilesPatterns,
12187
+ ...cdnBundle.excludePatterns
12188
+ ]
11939
12189
  };
11940
12190
  const files = await (0, import_build_utils24.glob)("**", globOptions);
12191
+ Object.assign(files, cdnBundle.directoryStubs);
11941
12192
  const appPythonSourceFiles = Object.keys(files).filter((file) => file.endsWith(".py")).map((file) => (0, import_path21.join)(workPath, file)).sort();
11942
12193
  if (djangoStatic?.manifestRelPath) {
11943
12194
  files[djangoStatic.manifestRelPath] = new import_build_utils24.FileFsRef({
@@ -12455,9 +12706,14 @@ var build = async ({
12455
12706
  src: `/${outputPath}`,
12456
12707
  dest: `/${outputPath}`
12457
12708
  }));
12709
+ const shadowingRoutes = fastapiStatic ? fastapiShadowingRoutes(fastapiStatic, lambdaPath) : [];
12710
+ const fallbackRoutes = fastapiStatic ? fastapiFallbackRoutes(fastapiStatic) : [];
12458
12711
  const routes = isNonWebService || !output ? queueRoutes.length > 0 ? queueRoutes : void 0 : [
12712
+ ...cdnBundle.redirects,
12713
+ ...shadowingRoutes,
12459
12714
  { handle: "filesystem" },
12460
12715
  ...queueRoutes,
12716
+ ...fallbackRoutes,
12461
12717
  // This route matches the resolved destination after rewrites. Copy
12462
12718
  // that path into the runtime request before dispatching the shared
12463
12719
  // framework Lambda so application routing observes the rewrite.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/python",
3
- "version": "6.56.2",
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,15 +35,16 @@
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/python-runtime": "0.20.0",
40
- "@vercel/build-utils": "14.0.5"
40
+ "@vercel/python-runtime": "0.21.0"
41
41
  },
42
42
  "scripts": {
43
43
  "build": "node ../../utils/build-builder.mjs",
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
  }
@@ -1,7 +1,29 @@
1
1
  """
2
2
  Discover StaticFiles mounts in a FastAPI/Starlette app by importing the user's
3
- app object and walking its route table. Writes a JSON array of
4
- {"urlPath": str, "directory": str} objects to the output file.
3
+ app object and walking its route table.
4
+
5
+ Writes a JSON object to the output file:
6
+
7
+ {
8
+ "mounts": [
9
+ {"urlPath": str, "directory": str,
10
+ "fallback": {"file": str, "status": int} | null,
11
+ "frontend": bool}
12
+ ],
13
+ "shadowRoutes": [str, ...]
14
+ }
15
+
16
+ - "mounts" are StaticFiles / frontend directories to copy to the CDN;
17
+ "frontend" marks a low-priority app.frontend() build.
18
+ - "mounts[].fallback" is the resolved frontend fallback file to serve for
19
+ 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).
5
27
 
6
28
  Usage: python <this_script> <entrypoint_abs_path> <variable_name> <output_path>
7
29
  """
@@ -11,106 +33,584 @@ from __future__ import annotations
11
33
  import importlib.util
12
34
  import json
13
35
  import os
36
+ import re
14
37
  import sys
15
- from dataclasses import asdict, dataclass
16
- from typing import TYPE_CHECKING, cast
38
+ from dataclasses import asdict, dataclass, field
39
+ from functools import cached_property
40
+ from typing import TYPE_CHECKING
17
41
 
18
- from starlette.routing import BaseRoute, Mount, Router
42
+ from starlette.convertors import Convertor, PathConvertor
43
+ from starlette.routing import BaseRoute, Mount, Route, Router
19
44
  from starlette.staticfiles import StaticFiles
20
45
 
21
46
  if TYPE_CHECKING:
22
47
  from fastapi.routing import _EffectiveRouteContext, _FrontendRouteGroup
23
48
 
24
49
 
25
- @dataclass
50
+ @dataclass(frozen=True)
51
+ class Fallback:
52
+ file: str
53
+ status: int
54
+
55
+
56
+ @dataclass(frozen=True)
26
57
  class StaticMount:
58
+ """One mount for the "mounts" JSON array; field names are the JSON keys."""
59
+
27
60
  urlPath: str
28
61
  directory: str
62
+ fallback: Fallback | None = None
63
+ frontend: bool = False
29
64
 
30
65
  @classmethod
31
- def from_route(cls, route: Mount, prefix: str = "") -> StaticMount | None:
32
- static_app = route.app
33
- if not isinstance(static_app, StaticFiles):
66
+ def from_route(
67
+ cls, route: BaseRoute, prefix: str = "", *, frontend: bool
68
+ ) -> StaticMount | None:
69
+ # Duck-typed on `path`/`app`: matches both a Mount and fastapi's
70
+ # _FrontendRoute (a BaseRoute that is not a Mount subclass).
71
+ static_app = getattr(route, "app", None)
72
+ path = getattr(route, "path", None)
73
+ if not isinstance(static_app, StaticFiles) or path is None:
34
74
  return None
35
75
  directory = static_app.directory
36
76
  if directory is None:
37
77
  return None
38
78
  return cls(
39
- urlPath=prefix + route.path,
79
+ urlPath=(prefix + path).rstrip("/") or "/",
40
80
  directory=os.path.abspath(str(directory)),
81
+ fallback=_resolve_fallback(static_app),
82
+ frontend=frontend,
41
83
  )
42
84
 
43
85
 
44
- def get_low_priority_routes(router: Router) -> list[_FrontendRouteGroup]:
86
+ def _resolve_fallback(static_app: StaticFiles) -> Fallback | None:
87
+ """Resolve a frontend StaticFiles fallback setting to a concrete file.
88
+
89
+ Plain `StaticFiles` mounts have no `fallback` attribute and return None.
90
+ `app.frontend(..., fallback=...)` uses "auto" | "index.html" | "404.html" |
91
+ None; in "auto" mode 404.html (served for every miss) takes precedence over
92
+ index.html, mirroring the runtime behavior.
93
+ """
94
+ fallback = getattr(static_app, "fallback", None)
95
+ if not fallback or static_app.directory is None:
96
+ return None
97
+ directory = str(static_app.directory)
98
+
99
+ def exists(name: str) -> bool:
100
+ return os.path.isfile(os.path.join(directory, name))
101
+
102
+ if fallback == "404.html" or (fallback == "auto" and exists("404.html")):
103
+ return Fallback(file="404.html", status=404)
104
+ if fallback == "index.html" or (fallback == "auto" and exists("index.html")):
105
+ return Fallback(file="index.html", status=200)
106
+ return None
107
+
108
+
109
+ # Regex-special characters, matching the JS `escapeRegExp` used in the builder.
110
+ _ESCAPE_RE = re.compile(r"[.*+?^${}()|[\]\\]")
111
+ # A `{name}` or `{name:convertor}` path placeholder; group 1 is the bare name
112
+ # (top-level routes drop the convertor, an included router's paths keep it).
113
+ _PARAM_RE = re.compile(r"\{([^}:]+)(?::[^}]+)?\}")
114
+
115
+
116
+ def _escape(text: str) -> str:
117
+ return _ESCAPE_RE.sub(lambda m: "\\" + m.group(0), text)
118
+
119
+
120
+ def _to_non_capturing(regex: str) -> str:
121
+ """Make a convertor regex safe to embed: turn capturing and named groups
122
+ into `(?:`. Escaped parens and other `(?...)` groups are left alone; named
123
+ groups are normalized because duplicate names across OR'd bodies are invalid.
124
+ """
125
+
126
+ def repl(m: re.Match[str]) -> str:
127
+ return m.group(0) if m.group(0).startswith("\\") else "(?:"
128
+
129
+ return re.sub(r"\\.|\(\?P<[^>]+>|\((?!\?)", repl, regex)
130
+
131
+
132
+ def _escape_path(path: str) -> str:
133
+ """A literal URL path as a shadow body, with boundary slashes removed.
134
+
135
+ The builder wraps every body as `^/(<body>)/?$` (see `fastapiShadowingRoutes`),
136
+ so it owns the leading slash and an optional trailing one. Bodies therefore
137
+ carry only the inner segments.
138
+ """
139
+ return _escape(path.strip("/"))
140
+
141
+
142
+ def _subtree_shadow(prefix: str, mounts: list[StaticMount]) -> str:
143
+ """A shadow body routing a mounted sub-app's whole subtree to the Lambda.
144
+
145
+ The app owns `prefix` (e.g. "/sub") at runtime, so everything under it is
146
+ shadowed, including the bare mount root ("/sub" itself, which the app 307s
147
+ to "/sub/"), except the nested StaticFiles `mounts` discovered inside it,
148
+ which stay on the CDN and are excluded via a negative lookahead.
149
+
150
+ A nested mount at the sub-app root ("") owns the entire subtree, so the CDN
151
+ serves all of it and the sub-app's routes carry their own shadow bodies.
152
+ Only the bare mount root is shadowed then.
153
+ """
154
+ start = len(prefix) + 1
155
+ nested = [m.urlPath[start:] for m in mounts]
156
+ base = _escape_path(prefix)
157
+ if "" in nested:
158
+ return base
159
+ guard = ""
160
+ if nested:
161
+ alternatives = "|".join(_escape(n) for n in nested)
162
+ guard = f"(?!(?:{alternatives})(?:/|$))"
163
+ if base == "":
164
+ # Root mount ("/"): the builder adds the leading slash, so the body is
165
+ # the guarded rest of the path.
166
+ return f"{guard}.*"
167
+ # The tail is optional so the bare mount root ("/sub") is shadowed too, not
168
+ # only paths under it ("/sub/...").
169
+ return f"{base}(?:/{guard}.*)?"
170
+
171
+
172
+ def _index_subdir_shadows(url_prefix: str, directory: str) -> set[str]:
173
+ """Regex bodies for subdirectories that hold an index.html.
174
+
175
+ A directory with an index.html diverges on the CDN. StaticFiles(html=True)
176
+ and frontends 307 the bare directory URL to its trailing-slash form and serve
177
+ the index there; StaticFiles(html=False) 404s both forms. The CDN instead
178
+ serves the index at the bare path with the wrong relative-URL base, or misses
179
+ it. Shadow each such subdirectory so the Lambda matches the app; the files
180
+ below it stay on the CDN. The mount root is `_divergent_url_shadows`'s job.
181
+ """
182
+ shadows: set[str] = set()
183
+ for dirpath, _dirnames, filenames in os.walk(directory):
184
+ if "index.html" not in filenames:
185
+ continue
186
+ rel = os.path.relpath(dirpath, directory)
187
+ if rel == ".":
188
+ continue
189
+ shadows.add(_escape_path(url_prefix + "/" + rel.replace(os.sep, "/")))
190
+ return shadows
191
+
192
+
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
+
196
+ When a directory holds an index.html, the app and the CDN serve that
197
+ directory's URL differently. This affects every such subdirectory and the
198
+ mount root. A root "/" html mount is the exception: it serves its index at
199
+ "/" with no redirect, so the app and the CDN already agree.
200
+
201
+ html=True and frontends shadow only the bare form. The CDN serves the index
202
+ at the trailing-slash form too, matching the app's 200, so that form stays
203
+ on the CDN. The app 307s the bare form while the CDN serves the index in
204
+ place, so the bare form must reach the Lambda. The `(?!/)` suffix stops the
205
+ builder's `/?` wrap from also matching the slash form.
206
+
207
+ html=False shadows both forms. The app 404s both while the CDN serves the
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.
212
+ """
213
+ bodies = _index_subdir_shadows(mount.urlPath, mount.directory)
214
+ if not (html and mount.urlPath == "/"):
215
+ bodies.add(_escape_path(mount.urlPath))
216
+ if html:
217
+ bodies = {f"{body}(?!/)" for body in bodies}
218
+ return [ShadowRoute(body=body, methods=("GET",)) for body in bodies]
219
+
220
+
221
+ def _frontend_groups(router: Router) -> list[_FrontendRouteGroup]:
222
+ """app.frontend() builds, kept off the route table as low-priority routes."""
45
223
  return getattr(router, "_low_priority_routes", [])
46
224
 
47
225
 
48
- def get_effective_low_priority_routes(route: BaseRoute) -> list[_EffectiveRouteContext]:
226
+ def _frontend_has_dependencies(source: object) -> bool:
227
+ """True if FastAPI dependencies (e.g. Depends(auth)) guard a frontend build.
228
+
229
+ _FrontendRouteGroup.handle solves the dependant's dependencies before serving
230
+ a file, so a guarded build gates every file and fallback at runtime. The CDN
231
+ cannot run dependencies, so a guarded build is neither copied nor given a
232
+ fallback; it stays on the Lambda, which enforces the check.
233
+ """
234
+ dependant = getattr(source, "dependant", None)
235
+ return bool(dependant and getattr(dependant, "dependencies", None))
236
+
237
+
238
+ def _effective_low_priority_routes(route: BaseRoute) -> list[_EffectiveRouteContext]:
49
239
  if fn := getattr(route, "effective_low_priority_routes", None):
50
240
  return fn()
51
241
  return []
52
242
 
53
243
 
54
- def collect_mounts(router: Router, prefix: str = "") -> list[StaticMount]:
55
- mounts = []
244
+ def _effective_route_contexts(route: BaseRoute) -> list[_EffectiveRouteContext]:
245
+ """`app.include_router(...)` surfaces its (prefixed) routes here rather than
246
+ flattening them into the parent table, so this is how we see them.
247
+ """
248
+ if fn := getattr(route, "effective_route_contexts", None):
249
+ return fn()
250
+ return []
251
+
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
+
269
+ @dataclass(frozen=True)
270
+ class PriorRoute:
271
+ """An HTTP route that outranks static files.
272
+
273
+ Any static file whose path this route matches must be served by the app
274
+ (Lambda), not the CDN, so FastAPI's declaration-order precedence is
275
+ preserved. `path_format` is the full route path (e.g. "/items/{id}") and
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.
278
+ """
279
+
280
+ path_format: str
281
+ convertors: dict[str, Convertor]
282
+ methods: frozenset[str] | None = None
283
+
284
+ def shadows(self, mount: StaticMount) -> bool:
285
+ """True if this route can match paths the mount serves.
286
+
287
+ The mount's URL-prefix segments are walked against the route's: a
288
+ literal route segment must equal the mount's, a `{param}` covers one
289
+ segment, and a `{param:path}` covers this segment and everything after.
290
+ So a param in or above the prefix (`/{full:path}`, `/{x}/items`) still
291
+ shadows. Matching broader than the route's exact runtime matches only
292
+ over-shadows (routes extra paths to the Lambda, which serves the same
293
+ content), never under.
294
+ """
295
+ base = mount.urlPath.rstrip("/")
296
+ if base == "":
297
+ return True
298
+ route_segments = self.path_format.strip("/").split("/")
299
+ convertors = self.convertors or {}
300
+ for i, mount_segment in enumerate(base.strip("/").split("/")):
301
+ if i >= len(route_segments):
302
+ return False
303
+ names = _PARAM_RE.findall(route_segments[i])
304
+ if not names:
305
+ if route_segments[i] != mount_segment:
306
+ return False
307
+ continue
308
+ if any(isinstance(convertors.get(n), PathConvertor) for n in names):
309
+ return True
310
+ return True
311
+
312
+ @cached_property
313
+ def shadow_body(self) -> str:
314
+ """This route's path as a `shadowRoutes` regex body.
315
+
316
+ Literal text is escaped and each `{name}` placeholder becomes its
317
+ Starlette convertor regex (str -> `[^/]+`, int -> `[0-9]+`, …), so the
318
+ route shadows exactly the paths it matches. Inner groups are made
319
+ non-capturing so the builder can wrap the whole body in one capture
320
+ group. Boundary slashes are stripped upfront because the builder's wrap
321
+ owns them (see `_escape_path`).
322
+ """
323
+ path = self.path_format.strip("/")
324
+ parts: list[str] = []
325
+ last = 0
326
+ for m in _PARAM_RE.finditer(path):
327
+ parts.append(_escape(path[last : m.start()]))
328
+ convertor = (self.convertors or {}).get(m.group(1))
329
+ regex = getattr(convertor, "regex", None) or "[^/]+"
330
+ parts.append("(?:" + _to_non_capturing(regex) + ")")
331
+ last = m.end()
332
+ parts.append(_escape(path[last:]))
333
+ return "".join(parts)
334
+
335
+
336
+ @dataclass
337
+ class Precedence:
338
+ """What has been declared *before* the current position and therefore
339
+ outranks a static mount, in Starlette's first-match-wins order.
340
+
341
+ Two independent effects:
342
+ * `routes` — higher-precedence routes that SHADOW individual static paths
343
+ (the path is routed to the app instead of served from the CDN).
344
+ * `mount_prefixes` — earlier mount prefixes that ECLIPSE any later mount
345
+ nested beneath them (the nested mount is unreachable, so it is dropped).
346
+
347
+ Flows down into nested routers: a nested `collect` starts from `child()`, an
348
+ independent copy, so its own additions never leak back to an ancestor.
349
+ """
350
+
351
+ routes: list[PriorRoute] = field(default_factory=list)
352
+ mount_prefixes: list[str] = field(default_factory=list)
353
+
354
+ def child(self) -> Precedence:
355
+ return Precedence(list(self.routes), list(self.mount_prefixes))
356
+
357
+ def add_route(self, route: PriorRoute) -> None:
358
+ self.routes.append(route)
359
+
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
385
+
386
+ def add_mount(self, url_prefix: str) -> None:
387
+ self.mount_prefixes.append(url_prefix.rstrip("/"))
388
+
389
+ def eclipses(self, url_prefix: str) -> bool:
390
+ """True if an earlier mount already owns `url_prefix`'s whole subtree."""
391
+ p = url_prefix.rstrip("/")
392
+ return any(p == q or p.startswith(q + "/") for q in self.mount_prefixes)
393
+
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
+
400
+ def _collect_mount(
401
+ route: Mount, prefix: str, prior: Precedence
402
+ ) -> tuple[list[StaticMount], list[ShadowRoute]]:
403
+ """Discover one app.mount(): a StaticFiles mount serves from the CDN, a
404
+ Router/sub-app recurses, and a raw ASGI app owns its subtree opaquely.
405
+ """
406
+ url_prefix = prefix + route.path
407
+ if prior.eclipses(url_prefix):
408
+ return [], []
409
+
410
+ mounts: list[StaticMount] = []
411
+ shadow_routes: list[ShadowRoute] = []
412
+
413
+ static = StaticMount.from_route(route, prefix, frontend=False)
414
+ if static:
415
+ mounts.append(static)
416
+ shadow_routes += prior.shadow_bodies(static)
417
+ shadow_routes += _divergent_url_shadows(static, html=route.app.html)
418
+
419
+ # A Starlette/FastAPI sub-app isn't a Router but exposes one as `.router`;
420
+ # a StaticFiles or raw ASGI app exposes neither.
421
+ sub_router = (
422
+ route.app
423
+ if isinstance(route.app, Router)
424
+ else getattr(route.app, "router", None)
425
+ )
426
+ if isinstance(sub_router, Router):
427
+ # Recurse for the sub-app's own StaticFiles mounts, then shadow the
428
+ # rest of its subtree to the Lambda.
429
+ sub_prefix = prefix + route.path.rstrip("/")
430
+ sub_mounts, sub_shadow = collect(sub_router, sub_prefix, prior)
431
+ mounts.extend(sub_mounts)
432
+ shadow_routes += sub_shadow
433
+ shadow_routes.append(_subtree_shadow_route(sub_prefix, sub_mounts))
434
+ elif static is None:
435
+ # A raw ASGI app (e.g. WSGIMiddleware) owns its subtree with nothing on
436
+ # the CDN. Shadow it so a lower-priority source's leaked copy there
437
+ # routes to the Lambda.
438
+ shadow_routes.append(_subtree_shadow_route(url_prefix, []))
439
+
440
+ prior.add_mount(url_prefix)
441
+ return mounts, shadow_routes
442
+
443
+
444
+ def collect(
445
+ router: Router,
446
+ prefix: str = "",
447
+ prior: Precedence | None = None,
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
+ """
454
+ prior = prior.child() if prior else Precedence()
455
+
456
+ mounts: list[StaticMount] = []
457
+ shadow_routes: list[ShadowRoute] = []
458
+ frontends: list[StaticMount] = []
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
+
465
+ def collect_mount(mount_route: Mount) -> None:
466
+ sub_mounts, sub_shadow = _collect_mount(mount_route, prefix, prior)
467
+ mounts.extend(sub_mounts)
468
+ shadow_routes.extend(sub_shadow)
56
469
 
57
470
  for route in router.routes:
58
- # app.mount("/path", StaticFiles(...))
59
- if isinstance(route, Mount):
60
- if m := StaticMount.from_route(route, prefix):
61
- mounts.append(m)
62
- if isinstance(route.app, Router):
63
- sub_prefix = prefix + route.path.rstrip("/")
64
- mounts.extend(collect_mounts(route.app, sub_prefix))
65
-
66
- # app.include_router(router, prefix="/path")
67
- for ctx in get_effective_low_priority_routes(route):
471
+ if isinstance(route, Route):
472
+ prior.add_route(
473
+ PriorRoute(prefix + route.path_format, route.param_convertors, _route_methods(route))
474
+ )
475
+ elif isinstance(route, Mount):
476
+ collect_mount(route)
477
+
478
+ # app.include_router(): harvest its effective routes, prefixed with this
479
+ # router's prefix so a route inside a mounted sub-app shadows the right
480
+ # subtree. A Mount surfaces here too (router.mount() then
481
+ # app.include_router()): ctx.starlette_route is a Mount copy with the
482
+ # prefixed path, collected like a direct app.mount().
483
+ for ctx in _effective_route_contexts(route):
484
+ if isinstance(ctx.original_route, Mount):
485
+ if isinstance(ctx.starlette_route, Mount):
486
+ collect_mount(ctx.starlette_route)
487
+ elif isinstance(ctx.original_route, Route):
488
+ # An APIRoute carries its path on ctx; a plain Starlette route
489
+ # only on its compiled starlette_route.
490
+ if ctx.starlette_route is not None:
491
+ path = ctx.starlette_route.path_format
492
+ convertors = ctx.starlette_route.param_convertors
493
+ methods = _route_methods(ctx.starlette_route)
494
+ else:
495
+ path = ctx.path
496
+ convertors = ctx.param_convertors
497
+ methods = _route_methods(ctx.original_route)
498
+ prior.add_route(PriorRoute(prefix + path, convertors, methods))
499
+
500
+ # app.include_router() with a frontend build.
501
+ for ctx in _effective_low_priority_routes(route):
502
+ if _frontend_has_dependencies(ctx):
503
+ continue
68
504
  for r in ctx.original_route.routes:
69
- if m := StaticMount.from_route(r, ctx.frontend_prefix):
70
- mounts.append(m)
505
+ if m := StaticMount.from_route(
506
+ r, prefix + ctx.frontend_prefix, frontend=True
507
+ ):
508
+ frontends.append(m)
71
509
 
72
- # app.frontend()
73
- for group in get_low_priority_routes(router):
510
+ # app.frontend() builds.
511
+ for group in _frontend_groups(router):
512
+ if _frontend_has_dependencies(group):
513
+ continue
74
514
  for route in group.routes:
75
- if m := StaticMount.from_route(route, prefix):
76
- mounts.append(m)
515
+ if m := StaticMount.from_route(route, prefix, frontend=True):
516
+ frontends.append(m)
77
517
 
78
- return mounts
518
+ # A frontend is low-priority: every route shadows it, and one whose prefix
519
+ # an earlier mount owns is unreachable (Starlette dispatches to the mount
520
+ # and never consults the build), so it is dropped entirely — mount,
521
+ # fallback, and shadows.
522
+ for m in frontends:
523
+ if prior.eclipses(m.urlPath):
524
+ continue
525
+ mounts.append(m)
526
+ shadow_routes += prior.shadow_bodies(m)
527
+ # A frontend is html=True StaticFiles, so its URLs diverge the same way.
528
+ shadow_routes += _divergent_url_shadows(m, html=True)
79
529
 
530
+ return mounts, _merge_shadow_routes(shadow_routes)
80
531
 
81
- def write_output(output_path: str, data: list[object]) -> None:
82
- with open(output_path, "w") as f:
83
- json.dump(data, f)
84
532
 
533
+ def _merge_shadow_routes(routes: list[ShadowRoute]) -> list[ShadowRoute]:
534
+ """Combine shadow routes that share a body by unioning their methods.
85
535
 
86
- def main() -> None:
87
- entrypoint_abs = sys.argv[1]
88
- variable_name = sys.argv[2]
89
- output_path = sys.argv[3]
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
+ )
90
563
 
564
+
565
+ @dataclass(frozen=True)
566
+ class Output:
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
+ """
572
+
573
+ mounts: list[StaticMount] = 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
+ }
581
+
582
+
583
+ def write_output(output_path: str, discovery: Output) -> None:
584
+ with open(output_path, "w") as f:
585
+ json.dump(discovery.to_dict(), f)
586
+
587
+
588
+ def discover(entrypoint_abs: str, variable_name: str) -> Output:
589
+ """Import the entrypoint and walk its app's router; empty on any failure."""
91
590
  spec = importlib.util.spec_from_file_location("__vc_app", entrypoint_abs)
92
591
  if spec is None or spec.loader is None:
93
- write_output(output_path, [])
94
- return
592
+ return Output()
95
593
 
96
594
  mod = importlib.util.module_from_spec(spec)
97
595
  try:
98
596
  spec.loader.exec_module(mod) # type: ignore[union-attr]
99
597
  except Exception as exc:
100
598
  print(f"vc_fastapi_static: exec_module failed: {exc}", file=sys.stderr)
101
- write_output(output_path, [])
102
- return
599
+ return Output()
103
600
 
104
601
  app = getattr(mod, variable_name, None)
105
- if app is None:
106
- write_output(output_path, [])
107
- return
602
+ router = getattr(app, "router", None)
603
+ if router is None:
604
+ return Output()
108
605
 
109
- mounts = []
110
- if router := getattr(app, "router", None):
111
- mounts = collect_mounts(router)
606
+ mounts, shadow_routes = collect(router)
607
+ return Output(mounts=mounts, shadowRoutes=shadow_routes)
112
608
 
113
- write_output(output_path, [asdict(m) for m in mounts])
609
+
610
+ def main() -> None:
611
+ entrypoint_abs, variable_name, output_path = sys.argv[1:4]
612
+ write_output(output_path, discover(entrypoint_abs, variable_name))
114
613
 
115
614
 
116
- main()
615
+ if __name__ == "__main__":
616
+ main()