@vercel/python 6.56.2 → 6.57.1

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,93 @@ 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
+ // All mounts (not just copied) so the guard carves out every subtree.
10407
+ mountPrefixes: mounts.map((m) => m.urlPath),
10408
+ cdnOutputDir: outputStaticDir,
10409
+ shadowRoutes,
10410
+ // Only copied mounts get a fallback: a check:true dest that is missing makes
10411
+ // the proxy exit with the status instead of reaching the Lambda.
10412
+ fallbacks: copiedMounts.flatMap(
10413
+ (m) => m.fallback ? [{ urlPath: m.urlPath, ...m.fallback }] : []
10414
+ )
10310
10415
  };
10311
10416
  }
10417
+ function fastapiShadowingRoutes(discovery, lambdaPath) {
10418
+ const chunks = [];
10419
+ let srcLen = 0;
10420
+ let dropped = 0;
10421
+ for (const body of discovery.shadowRoutes) {
10422
+ if (SHADOW_SRC_WRAPPER_LENGTH + body.length > MAX_ROUTE_SRC_LENGTH) {
10423
+ dropped += 1;
10424
+ continue;
10425
+ }
10426
+ const current = chunks[chunks.length - 1];
10427
+ if (current && srcLen + 1 + body.length <= MAX_ROUTE_SRC_LENGTH) {
10428
+ current.push(body);
10429
+ srcLen += 1 + body.length;
10430
+ } else {
10431
+ chunks.push([body]);
10432
+ srcLen = SHADOW_SRC_WRAPPER_LENGTH + body.length;
10433
+ }
10434
+ }
10435
+ if (dropped > 0) {
10436
+ (0, import_build_utils20.debug)(
10437
+ `FastAPI: ${dropped} shadow route(s) over the ${MAX_ROUTE_SRC_LENGTH} char cap left unshadowed`
10438
+ );
10439
+ }
10440
+ return chunks.map((bodies) => ({
10441
+ src: shadowSrc(bodies),
10442
+ dest: `/${lambdaPath}`,
10443
+ transforms: [
10444
+ { type: "request.path", op: "set", args: "/$1" }
10445
+ ]
10446
+ }));
10447
+ }
10448
+ function escapeRegex(text) {
10449
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10450
+ }
10451
+ var NAVIGATION_ACCEPT_RE = ".*(?:^|[,\\s])(?:text/html|application/xhtml\\+xml)(?=[,;\\s]|$)(?![^,]*;\\s*q=0(?:\\.0+)?(?:[,;\\s]|$)).*";
10452
+ function fastapiFallbackRoutes(discovery) {
10453
+ const mountPrefixes = discovery.mountPrefixes.map(trimTrailingSlashes);
10454
+ return discovery.fallbacks.flatMap((fb) => {
10455
+ const prefix = trimTrailingSlashes(fb.urlPath);
10456
+ const nested = mountPrefixes.filter((urlPath) => urlPath.startsWith(`${prefix}/`)).map((urlPath) => urlPath.slice(prefix.length + 1));
10457
+ const guard = nested.length ? `(?!(?:${nested.map(escapeRegex).join("|")})(?:/|$))` : "";
10458
+ const isNavigation = fb.status === 200;
10459
+ const navigationGuard = isNavigation ? "(?!.*[^/.]\\.[^/]*$)" : "";
10460
+ const src = `^${escapeRegex(prefix)}/${guard}${navigationGuard}.*$`;
10461
+ if (src.length > MAX_ROUTE_SRC_LENGTH) {
10462
+ (0, import_build_utils20.debug)(
10463
+ `FastAPI: fallback route for ${fb.urlPath} over the ${MAX_ROUTE_SRC_LENGTH} char cap, served by the Lambda`
10464
+ );
10465
+ return [];
10466
+ }
10467
+ const navigationOnly = isNavigation ? {
10468
+ has: [
10469
+ {
10470
+ type: "header",
10471
+ key: "accept",
10472
+ value: NAVIGATION_ACCEPT_RE
10473
+ }
10474
+ ]
10475
+ } : {};
10476
+ return [
10477
+ {
10478
+ src,
10479
+ dest: `${prefix}/${fb.file}`,
10480
+ status: fb.status,
10481
+ check: true,
10482
+ methods: ["GET", "HEAD"],
10483
+ ...navigationOnly
10484
+ }
10485
+ ];
10486
+ });
10487
+ }
10312
10488
 
10313
10489
  // src/index.ts
10314
10490
  var import_python_analysis12 = require("@vercel/python-analysis");
@@ -11730,7 +11906,7 @@ var build = async ({
11730
11906
  if (workflowMode === "workers" && workflows.length > 1) {
11731
11907
  throw new import_build_utils24.NowBuildError({
11732
11908
  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`
11909
+ 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
11910
  });
11735
11911
  }
11736
11912
  }
@@ -12455,9 +12631,13 @@ var build = async ({
12455
12631
  src: `/${outputPath}`,
12456
12632
  dest: `/${outputPath}`
12457
12633
  }));
12634
+ const shadowingRoutes = fastapiStatic ? fastapiShadowingRoutes(fastapiStatic, lambdaPath) : [];
12635
+ const fallbackRoutes = fastapiStatic ? fastapiFallbackRoutes(fastapiStatic) : [];
12458
12636
  const routes = isNonWebService || !output ? queueRoutes.length > 0 ? queueRoutes : void 0 : [
12637
+ ...shadowingRoutes,
12459
12638
  { handle: "filesystem" },
12460
12639
  ...queueRoutes,
12640
+ ...fallbackRoutes,
12461
12641
  // This route matches the resolved destination after rewrites. Copy
12462
12642
  // that path into the runtime request before dispatching the shared
12463
12643
  // 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.57.1",
4
4
  "main": "./dist/index.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://vercel.com/docs/runtimes#official-runtimes/python",
@@ -36,8 +36,8 @@
36
36
  "vitest": "4.1.10",
37
37
  "which": "3.0.0",
38
38
  "@vercel/error-utils": "2.2.1",
39
- "@vercel/python-runtime": "0.20.0",
40
- "@vercel/build-utils": "14.0.5"
39
+ "@vercel/build-utils": "14.1.1",
40
+ "@vercel/python-runtime": "0.21.0"
41
41
  },
42
42
  "scripts": {
43
43
  "build": "node ../../utils/build-builder.mjs",
@@ -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,483 @@ 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
+ """Shadow 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) -> set[str]:
194
+ """Shadow bodies 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
+ shadows = _index_subdir_shadows(mount.urlPath, mount.directory)
211
+ if not (html and mount.urlPath == "/"):
212
+ shadows.add(_escape_path(mount.urlPath))
213
+ if html:
214
+ return {f"{body}(?!/)" for body in shadows}
215
+ return shadows
216
+
217
+
218
+ def _frontend_groups(router: Router) -> list[_FrontendRouteGroup]:
219
+ """app.frontend() builds, kept off the route table as low-priority routes."""
45
220
  return getattr(router, "_low_priority_routes", [])
46
221
 
47
222
 
48
- def get_effective_low_priority_routes(route: BaseRoute) -> list[_EffectiveRouteContext]:
223
+ def _frontend_has_dependencies(source: object) -> bool:
224
+ """True if FastAPI dependencies (e.g. Depends(auth)) guard a frontend build.
225
+
226
+ _FrontendRouteGroup.handle solves the dependant's dependencies before serving
227
+ a file, so a guarded build gates every file and fallback at runtime. The CDN
228
+ cannot run dependencies, so a guarded build is neither copied nor given a
229
+ fallback; it stays on the Lambda, which enforces the check.
230
+ """
231
+ dependant = getattr(source, "dependant", None)
232
+ return bool(dependant and getattr(dependant, "dependencies", None))
233
+
234
+
235
+ def _effective_low_priority_routes(route: BaseRoute) -> list[_EffectiveRouteContext]:
49
236
  if fn := getattr(route, "effective_low_priority_routes", None):
50
237
  return fn()
51
238
  return []
52
239
 
53
240
 
54
- def collect_mounts(router: Router, prefix: str = "") -> list[StaticMount]:
55
- mounts = []
241
+ def _effective_route_contexts(route: BaseRoute) -> list[_EffectiveRouteContext]:
242
+ """`app.include_router(...)` surfaces its (prefixed) routes here rather than
243
+ flattening them into the parent table, so this is how we see them.
244
+ """
245
+ if fn := getattr(route, "effective_route_contexts", None):
246
+ return fn()
247
+ return []
248
+
249
+
250
+ @dataclass(frozen=True)
251
+ class PriorRoute:
252
+ """An HTTP route that outranks static files.
253
+
254
+ Any static file whose path this route matches must be served by the app
255
+ (Lambda), not the CDN, so FastAPI's declaration-order precedence is
256
+ preserved. `path_format` is the full route path (e.g. "/items/{id}") and
257
+ `convertors` maps each `{name}` placeholder to its Starlette convertor.
258
+ """
259
+
260
+ path_format: str
261
+ convertors: dict[str, Convertor]
262
+
263
+ def shadows(self, mount: StaticMount) -> bool:
264
+ """True if this route can match paths the mount serves.
265
+
266
+ The mount's URL-prefix segments are walked against the route's: a
267
+ literal route segment must equal the mount's, a `{param}` covers one
268
+ segment, and a `{param:path}` covers this segment and everything after.
269
+ So a param in or above the prefix (`/{full:path}`, `/{x}/items`) still
270
+ shadows. Matching broader than the route's exact runtime matches only
271
+ over-shadows (routes extra paths to the Lambda, which serves the same
272
+ content), never under.
273
+ """
274
+ base = mount.urlPath.rstrip("/")
275
+ if base == "":
276
+ return True
277
+ route_segments = self.path_format.strip("/").split("/")
278
+ convertors = self.convertors or {}
279
+ for i, mount_segment in enumerate(base.strip("/").split("/")):
280
+ if i >= len(route_segments):
281
+ return False
282
+ names = _PARAM_RE.findall(route_segments[i])
283
+ if not names:
284
+ if route_segments[i] != mount_segment:
285
+ return False
286
+ continue
287
+ if any(isinstance(convertors.get(n), PathConvertor) for n in names):
288
+ return True
289
+ return True
290
+
291
+ @cached_property
292
+ def shadow_body(self) -> str:
293
+ """This route's path as a `shadowRoutes` regex body.
294
+
295
+ Literal text is escaped and each `{name}` placeholder becomes its
296
+ Starlette convertor regex (str -> `[^/]+`, int -> `[0-9]+`, …), so the
297
+ route shadows exactly the paths it matches. Inner groups are made
298
+ non-capturing so the builder can wrap the whole body in one capture
299
+ group. Boundary slashes are stripped upfront because the builder's wrap
300
+ owns them (see `_escape_path`).
301
+ """
302
+ path = self.path_format.strip("/")
303
+ parts: list[str] = []
304
+ last = 0
305
+ for m in _PARAM_RE.finditer(path):
306
+ parts.append(_escape(path[last : m.start()]))
307
+ convertor = (self.convertors or {}).get(m.group(1))
308
+ regex = getattr(convertor, "regex", None) or "[^/]+"
309
+ parts.append("(?:" + _to_non_capturing(regex) + ")")
310
+ last = m.end()
311
+ parts.append(_escape(path[last:]))
312
+ return "".join(parts)
313
+
314
+
315
+ @dataclass
316
+ class Precedence:
317
+ """What has been declared *before* the current position and therefore
318
+ outranks a static mount, in Starlette's first-match-wins order.
319
+
320
+ Two independent effects:
321
+ * `routes` — higher-precedence routes that SHADOW individual static paths
322
+ (the path is routed to the app instead of served from the CDN).
323
+ * `mount_prefixes` — earlier mount prefixes that ECLIPSE any later mount
324
+ nested beneath them (the nested mount is unreachable, so it is dropped).
325
+
326
+ Flows down into nested routers: a nested `collect` starts from `child()`, an
327
+ independent copy, so its own additions never leak back to an ancestor.
328
+ """
329
+
330
+ routes: list[PriorRoute] = field(default_factory=list)
331
+ mount_prefixes: list[str] = field(default_factory=list)
332
+
333
+ def child(self) -> Precedence:
334
+ return Precedence(list(self.routes), list(self.mount_prefixes))
335
+
336
+ def add_route(self, route: PriorRoute) -> None:
337
+ self.routes.append(route)
338
+
339
+ def shadow_bodies(self, mount: StaticMount) -> set[str]:
340
+ """Shadow-route bodies for every prior route that shadows `mount`."""
341
+ return {r.shadow_body for r in self.routes if r.shadows(mount)}
342
+
343
+ def add_mount(self, url_prefix: str) -> None:
344
+ self.mount_prefixes.append(url_prefix.rstrip("/"))
345
+
346
+ def eclipses(self, url_prefix: str) -> bool:
347
+ """True if an earlier mount already owns `url_prefix`'s whole subtree."""
348
+ p = url_prefix.rstrip("/")
349
+ return any(p == q or p.startswith(q + "/") for q in self.mount_prefixes)
350
+
351
+
352
+ def _collect_mount(
353
+ route: Mount, prefix: str, prior: Precedence
354
+ ) -> tuple[list[StaticMount], set[str]]:
355
+ """Discover one app.mount(): a StaticFiles mount serves from the CDN, a
356
+ Router/sub-app recurses, and a raw ASGI app owns its subtree opaquely.
357
+ """
358
+ url_prefix = prefix + route.path
359
+ if prior.eclipses(url_prefix):
360
+ return [], set()
361
+
362
+ mounts: list[StaticMount] = []
363
+ shadow_routes: set[str] = set()
364
+
365
+ static = StaticMount.from_route(route, prefix, frontend=False)
366
+ if static:
367
+ mounts.append(static)
368
+ shadow_routes |= prior.shadow_bodies(static)
369
+ shadow_routes |= _divergent_url_shadows(static, html=route.app.html)
370
+
371
+ # A Starlette/FastAPI sub-app isn't a Router but exposes one as `.router`;
372
+ # a StaticFiles or raw ASGI app exposes neither.
373
+ sub_router = (
374
+ route.app
375
+ if isinstance(route.app, Router)
376
+ else getattr(route.app, "router", None)
377
+ )
378
+ if isinstance(sub_router, Router):
379
+ # Recurse for the sub-app's own StaticFiles mounts, then shadow the
380
+ # rest of its subtree to the Lambda.
381
+ sub_prefix = prefix + route.path.rstrip("/")
382
+ sub_mounts, sub_shadow = collect(sub_router, sub_prefix, prior)
383
+ mounts.extend(sub_mounts)
384
+ shadow_routes |= sub_shadow
385
+ shadow_routes.add(_subtree_shadow(sub_prefix, sub_mounts))
386
+ elif static is None:
387
+ # A raw ASGI app (e.g. WSGIMiddleware) owns its subtree with nothing on
388
+ # the CDN. Shadow it so a lower-priority source's leaked copy there
389
+ # routes to the Lambda.
390
+ shadow_routes.add(_subtree_shadow(url_prefix, []))
391
+
392
+ prior.add_mount(url_prefix)
393
+ return mounts, shadow_routes
394
+
395
+
396
+ def collect(
397
+ router: Router,
398
+ prefix: str = "",
399
+ prior: Precedence | None = None,
400
+ ) -> tuple[list[StaticMount], set[str]]:
401
+ """Walk the route table for (static mounts to copy, shadow-route bodies)."""
402
+ prior = prior.child() if prior else Precedence()
403
+
404
+ mounts: list[StaticMount] = []
405
+ shadow_routes: set[str] = set()
406
+ frontends: list[StaticMount] = []
407
+
408
+ def collect_mount(mount_route: Mount) -> None:
409
+ sub_mounts, sub_shadow = _collect_mount(mount_route, prefix, prior)
410
+ mounts.extend(sub_mounts)
411
+ shadow_routes.update(sub_shadow)
56
412
 
57
413
  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):
414
+ if isinstance(route, Route):
415
+ prior.add_route(
416
+ PriorRoute(prefix + route.path_format, route.param_convertors)
417
+ )
418
+ elif isinstance(route, Mount):
419
+ collect_mount(route)
420
+
421
+ # app.include_router(): harvest its effective routes, prefixed with this
422
+ # router's prefix so a route inside a mounted sub-app shadows the right
423
+ # subtree. A Mount surfaces here too (router.mount() then
424
+ # app.include_router()): ctx.starlette_route is a Mount copy with the
425
+ # prefixed path, collected like a direct app.mount().
426
+ for ctx in _effective_route_contexts(route):
427
+ if isinstance(ctx.original_route, Mount):
428
+ if isinstance(ctx.starlette_route, Mount):
429
+ collect_mount(ctx.starlette_route)
430
+ elif isinstance(ctx.original_route, Route):
431
+ # An APIRoute carries its path on ctx; a plain Starlette route
432
+ # only on its compiled starlette_route.
433
+ if ctx.starlette_route is not None:
434
+ path = ctx.starlette_route.path_format
435
+ convertors = ctx.starlette_route.param_convertors
436
+ else:
437
+ path = ctx.path
438
+ convertors = ctx.param_convertors
439
+ prior.add_route(PriorRoute(prefix + path, convertors))
440
+
441
+ # app.include_router() with a frontend build.
442
+ for ctx in _effective_low_priority_routes(route):
443
+ if _frontend_has_dependencies(ctx):
444
+ continue
68
445
  for r in ctx.original_route.routes:
69
- if m := StaticMount.from_route(r, ctx.frontend_prefix):
70
- mounts.append(m)
446
+ if m := StaticMount.from_route(
447
+ r, prefix + ctx.frontend_prefix, frontend=True
448
+ ):
449
+ frontends.append(m)
71
450
 
72
- # app.frontend()
73
- for group in get_low_priority_routes(router):
451
+ # app.frontend() builds.
452
+ for group in _frontend_groups(router):
453
+ if _frontend_has_dependencies(group):
454
+ continue
74
455
  for route in group.routes:
75
- if m := StaticMount.from_route(route, prefix):
76
- mounts.append(m)
456
+ if m := StaticMount.from_route(route, prefix, frontend=True):
457
+ frontends.append(m)
77
458
 
78
- return mounts
459
+ # A frontend is low-priority: every route shadows it, and one whose prefix
460
+ # an earlier mount owns is unreachable (Starlette dispatches to the mount
461
+ # and never consults the build), so it is dropped entirely — mount,
462
+ # fallback, and shadows.
463
+ for m in frontends:
464
+ if prior.eclipses(m.urlPath):
465
+ continue
466
+ mounts.append(m)
467
+ shadow_routes |= prior.shadow_bodies(m)
468
+ # A frontend is html=True StaticFiles, so its URLs diverge the same way.
469
+ shadow_routes |= _divergent_url_shadows(m, html=True)
79
470
 
471
+ return mounts, shadow_routes
80
472
 
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
473
 
474
+ @dataclass(frozen=True)
475
+ class Output:
476
+ """The JSON document written for the builder; field names are the JSON keys."""
85
477
 
86
- def main() -> None:
87
- entrypoint_abs = sys.argv[1]
88
- variable_name = sys.argv[2]
89
- output_path = sys.argv[3]
478
+ mounts: list[StaticMount] = field(default_factory=list)
479
+ shadowRoutes: list[str] = field(default_factory=list)
90
480
 
481
+
482
+ def write_output(output_path: str, discovery: Output) -> None:
483
+ with open(output_path, "w") as f:
484
+ json.dump(asdict(discovery), f)
485
+
486
+
487
+ def discover(entrypoint_abs: str, variable_name: str) -> Output:
488
+ """Import the entrypoint and walk its app's router; empty on any failure."""
91
489
  spec = importlib.util.spec_from_file_location("__vc_app", entrypoint_abs)
92
490
  if spec is None or spec.loader is None:
93
- write_output(output_path, [])
94
- return
491
+ return Output()
95
492
 
96
493
  mod = importlib.util.module_from_spec(spec)
97
494
  try:
98
495
  spec.loader.exec_module(mod) # type: ignore[union-attr]
99
496
  except Exception as exc:
100
497
  print(f"vc_fastapi_static: exec_module failed: {exc}", file=sys.stderr)
101
- write_output(output_path, [])
102
- return
498
+ return Output()
103
499
 
104
500
  app = getattr(mod, variable_name, None)
105
- if app is None:
106
- write_output(output_path, [])
107
- return
501
+ router = getattr(app, "router", None)
502
+ if router is None:
503
+ return Output()
108
504
 
109
- mounts = []
110
- if router := getattr(app, "router", None):
111
- mounts = collect_mounts(router)
505
+ mounts, shadow_routes = collect(router)
506
+ return Output(mounts=mounts, shadowRoutes=sorted(shadow_routes))
112
507
 
113
- write_output(output_path, [asdict(m) for m in mounts])
508
+
509
+ def main() -> None:
510
+ entrypoint_abs, variable_name, output_path = sys.argv[1:4]
511
+ write_output(output_path, discover(entrypoint_abs, variable_name))
114
512
 
115
513
 
116
- main()
514
+ if __name__ == "__main__":
515
+ main()