@vercel/python 6.56.1 → 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 +287 -85
- package/package.json +4 -4
- package/templates/vc_fastapi_static.py +449 -50
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:
|
|
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}${
|
|
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.
|
|
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
|
|
5144
|
-
|
|
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
|
|
5207
|
+
async function hasExplicitWorkflowSdkDependency(pythonPackage) {
|
|
5168
5208
|
const names = await getDeclaredDependencyNames(
|
|
5169
5209
|
pythonPackage?.manifest?.data?.project?.dependencies
|
|
5170
5210
|
);
|
|
5171
|
-
return
|
|
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
|
|
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",
|
|
5252
|
+
args: ["python", "-c", versionQueryScript(distributions)]
|
|
5208
5253
|
});
|
|
5209
|
-
|
|
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
|
|
5257
|
+
`Failed to query installed ${label} version: ${err instanceof Error ? err.message : String(err)}`
|
|
5216
5258
|
);
|
|
5217
5259
|
}
|
|
5218
|
-
|
|
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
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
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
|
|
5277
|
+
`Failed to resolve ${label} version from uv.lock: ${err instanceof Error ? err.message : String(err)}`
|
|
5231
5278
|
);
|
|
5232
5279
|
}
|
|
5233
5280
|
}
|
|
5234
|
-
return
|
|
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
|
|
5299
|
+
if (!await hasExplicitWorkflowSdkDependency(pythonPackage)) {
|
|
5244
5300
|
return "workers";
|
|
5245
5301
|
}
|
|
5246
|
-
const
|
|
5302
|
+
const installed = await getInstalledWorkflowSdkVersion({
|
|
5247
5303
|
uv,
|
|
5248
5304
|
venvPath,
|
|
5249
5305
|
projectDir,
|
|
5250
5306
|
uvLockPath
|
|
5251
5307
|
});
|
|
5252
|
-
|
|
5253
|
-
|
|
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
|
|
5316
|
+
function isQueueWorkflowSdk(installed) {
|
|
5317
|
+
if (!installed) {
|
|
5318
|
+
return false;
|
|
5319
|
+
}
|
|
5262
5320
|
try {
|
|
5263
|
-
return (0, import_pep440.ge)(
|
|
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
|
|
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)(
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
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
|
|
5344
|
+
`Failed to query installed ${distributions.join("/")} version: ${err instanceof Error ? err.message : String(err)}`
|
|
5285
5345
|
);
|
|
5286
5346
|
}
|
|
5287
|
-
return
|
|
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
|
|
@@ -6939,12 +7008,24 @@ functions.`,
|
|
|
6939
7008
|
action: "Learn More"
|
|
6940
7009
|
});
|
|
6941
7010
|
}
|
|
7011
|
+
const toolingFiles = {};
|
|
7012
|
+
for (const key of [
|
|
7013
|
+
`${UV_BUNDLE_DIR}/uv`,
|
|
7014
|
+
`${UV_BUNDLE_DIR}/_runtime_config.json`,
|
|
7015
|
+
`${UV_BUNDLE_DIR}/pyproject.toml`,
|
|
7016
|
+
`${UV_BUNDLE_DIR}/uv.lock`
|
|
7017
|
+
]) {
|
|
7018
|
+
if (files[key])
|
|
7019
|
+
toolingFiles[key] = files[key];
|
|
7020
|
+
}
|
|
7021
|
+
const runtimeToolingBytes = await calculateBundleSize(toolingFiles);
|
|
6942
7022
|
return {
|
|
6943
7023
|
fellBackToFullBundle: false,
|
|
6944
7024
|
packingMode,
|
|
6945
7025
|
alwaysBundledPackages: alwaysBundled,
|
|
6946
7026
|
bundledPublicPackages: bundledPublic,
|
|
6947
|
-
externalizedPublicPackages: externalizedPublic
|
|
7027
|
+
externalizedPublicPackages: externalizedPublic,
|
|
7028
|
+
runtimeToolingBytes
|
|
6948
7029
|
};
|
|
6949
7030
|
}
|
|
6950
7031
|
/**
|
|
@@ -9513,12 +9594,13 @@ If you are using a virtual environment, activate it before running "vercel dev",
|
|
|
9513
9594
|
if (queueSidecarKind) {
|
|
9514
9595
|
let useQueueServing = !legacyProject;
|
|
9515
9596
|
if (useQueueServing && queueSidecarKind === "workflow") {
|
|
9516
|
-
|
|
9517
|
-
|
|
9518
|
-
|
|
9519
|
-
|
|
9520
|
-
|
|
9521
|
-
|
|
9597
|
+
useQueueServing = isQueueWorkflowSdk(
|
|
9598
|
+
await queryPythonWorkflowSdkVersion({
|
|
9599
|
+
pythonBin: spawnCommand,
|
|
9600
|
+
cwd: workPath,
|
|
9601
|
+
env
|
|
9602
|
+
})
|
|
9603
|
+
);
|
|
9522
9604
|
}
|
|
9523
9605
|
if (useQueueServing) {
|
|
9524
9606
|
env.VERCEL_DEV_QUEUE_SERVING = "1";
|
|
@@ -10228,18 +10310,12 @@ var import_path17 = require("path");
|
|
|
10228
10310
|
var import_execa9 = __toESM(require_execa());
|
|
10229
10311
|
var import_build_utils20 = require("@vercel/build-utils");
|
|
10230
10312
|
var scriptPath3 = (0, import_path17.join)(__dirname, "..", "templates", "vc_fastapi_static.py");
|
|
10231
|
-
var
|
|
10232
|
-
async function
|
|
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) {
|
|
10233
10315
|
const pythonPath = getVenvPythonBin(venvPath);
|
|
10234
|
-
const
|
|
10235
|
-
|
|
10236
|
-
|
|
10237
|
-
"python",
|
|
10238
|
-
"vc_fastapi_static_output.json"
|
|
10239
|
-
);
|
|
10240
|
-
await import_fs17.default.promises.mkdir((0, import_path17.join)(workPath, ".vercel", "python"), {
|
|
10241
|
-
recursive: true
|
|
10242
|
-
});
|
|
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 });
|
|
10243
10319
|
try {
|
|
10244
10320
|
const { stderr } = await (0, import_execa9.default)(
|
|
10245
10321
|
pythonPath,
|
|
@@ -10250,28 +10326,65 @@ async function getFastAPIStaticMounts(venvPath, entrypointAbs, variableName, env
|
|
|
10250
10326
|
(0, import_build_utils20.debug)(`FastAPI shim stderr:
|
|
10251
10327
|
${stderr}`);
|
|
10252
10328
|
}
|
|
10253
|
-
} catch (err) {
|
|
10254
|
-
console.error(_STATIC_FILE_COLLECTION_ERROR_MESSAGE);
|
|
10255
|
-
(0, import_build_utils20.debug)(
|
|
10256
|
-
`FastAPI: could not discover static mounts: ${err?.stderr ?? err?.message ?? err}`
|
|
10257
|
-
);
|
|
10258
|
-
return [];
|
|
10259
|
-
}
|
|
10260
|
-
try {
|
|
10261
10329
|
const raw = await import_fs17.default.promises.readFile(outputPath, "utf8");
|
|
10262
10330
|
const parsed = JSON.parse(raw);
|
|
10263
|
-
(0, import_build_utils20.debug)(`FastAPI: discovered
|
|
10331
|
+
(0, import_build_utils20.debug)(`FastAPI: discovered: ${JSON.stringify(parsed)}`);
|
|
10264
10332
|
return parsed;
|
|
10265
|
-
} catch {
|
|
10266
|
-
console.error(
|
|
10267
|
-
(0, import_build_utils20.debug)(
|
|
10268
|
-
|
|
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: [] };
|
|
10269
10339
|
} finally {
|
|
10270
10340
|
await import_fs17.default.promises.rm(outputPath, { force: true });
|
|
10271
10341
|
}
|
|
10272
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;
|
|
10273
10386
|
async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir, entrypointAbs, variableName) {
|
|
10274
|
-
const mounts = await
|
|
10387
|
+
const { mounts, shadowRoutes } = await getFastAPIStaticDiscovery(
|
|
10275
10388
|
venvPath,
|
|
10276
10389
|
entrypointAbs,
|
|
10277
10390
|
variableName,
|
|
@@ -10285,18 +10398,93 @@ async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir,
|
|
|
10285
10398
|
(0, import_build_utils20.debug)(
|
|
10286
10399
|
`Found ${mounts.length} FastAPI static mount(s): ${mounts.map((m) => m.urlPath).join(", ")}`
|
|
10287
10400
|
);
|
|
10288
|
-
|
|
10289
|
-
|
|
10290
|
-
|
|
10291
|
-
await import_fs17.default.promises.mkdir(dest, { recursive: true });
|
|
10292
|
-
await import_fs17.default.promises.cp(mount.directory, dest, { recursive: true });
|
|
10293
|
-
(0, import_build_utils20.debug)(`copied ${mount.directory} -> ${dest}`);
|
|
10401
|
+
const copiedMounts = await copyFastAPIStaticMounts(mounts, outputStaticDir);
|
|
10402
|
+
if (copiedMounts.length === 0) {
|
|
10403
|
+
return null;
|
|
10294
10404
|
}
|
|
10295
10405
|
return {
|
|
10296
|
-
|
|
10297
|
-
|
|
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
|
+
)
|
|
10298
10415
|
};
|
|
10299
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
|
+
}
|
|
10300
10488
|
|
|
10301
10489
|
// src/index.ts
|
|
10302
10490
|
var import_python_analysis12 = require("@vercel/python-analysis");
|
|
@@ -11232,10 +11420,15 @@ import os
|
|
|
11232
11420
|
import os.path
|
|
11233
11421
|
import site
|
|
11234
11422
|
import sys
|
|
11423
|
+
import time
|
|
11424
|
+
|
|
11425
|
+
# Cold start baseline, read back by vercel_runtime.vc_init
|
|
11426
|
+
_vc_boot_start_ms = int(time.monotonic() * 1000)
|
|
11235
11427
|
|
|
11236
11428
|
_here = os.path.dirname(__file__)
|
|
11237
11429
|
|
|
11238
11430
|
os.environ.update({
|
|
11431
|
+
"__VC_PY_BOOT_START_MS": str(_vc_boot_start_ms),
|
|
11239
11432
|
"__VC_HANDLER_MODULE_NAME": "${moduleName}",
|
|
11240
11433
|
"__VC_HANDLER_ENTRYPOINT": "${entrypoint}",
|
|
11241
11434
|
"__VC_HANDLER_ENTRYPOINT_ABS": os.path.join(_here, "${entrypoint}"),
|
|
@@ -11713,7 +11906,7 @@ var build = async ({
|
|
|
11713
11906
|
if (workflowMode === "workers" && workflows.length > 1) {
|
|
11714
11907
|
throw new import_build_utils24.NowBuildError({
|
|
11715
11908
|
code: "PYTHON_INVALID_WORKFLOW_CONFIG",
|
|
11716
|
-
message: `"tool.vercel.workflows" declares multiple entrypoints, which requires
|
|
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`
|
|
11717
11910
|
});
|
|
11718
11911
|
}
|
|
11719
11912
|
}
|
|
@@ -12177,12 +12370,14 @@ var build = async ({
|
|
|
12177
12370
|
`Function "${entrypoint ?? rawEntrypoint}" exceeds the standard size limit; enabling large functions (beta).`
|
|
12178
12371
|
);
|
|
12179
12372
|
let packingMode;
|
|
12373
|
+
let runtimeToolingBytes = 0;
|
|
12180
12374
|
if (depAnalysis.runtimeInstallEnabled) {
|
|
12181
12375
|
packingMode = "runtime-install";
|
|
12182
12376
|
const bytecodeFirst = compileAllEnabled && pythonVersion.major != null && pythonVersion.minor != null;
|
|
12183
12377
|
const bundleResult = await depExternalizer.generateBundle(files, {
|
|
12184
12378
|
bytecodeFirst
|
|
12185
12379
|
});
|
|
12380
|
+
runtimeToolingBytes = bundleResult.runtimeToolingBytes ?? 0;
|
|
12186
12381
|
if (bundleResult.fellBackToFullBundle) {
|
|
12187
12382
|
packingMode = "hive";
|
|
12188
12383
|
announceLargeFunction();
|
|
@@ -12231,6 +12426,9 @@ var build = async ({
|
|
|
12231
12426
|
}
|
|
12232
12427
|
bundleSpan.setAttributes({
|
|
12233
12428
|
"python.bundle.totalSizeBytes": String(
|
|
12429
|
+
depAnalysis.totalBundleSize + runtimeToolingBytes
|
|
12430
|
+
),
|
|
12431
|
+
"python.bundle.shippedSizeBytes": String(
|
|
12234
12432
|
await calculateBundleSize(files)
|
|
12235
12433
|
),
|
|
12236
12434
|
"python.bundle.packingMode": packingMode
|
|
@@ -12433,9 +12631,13 @@ var build = async ({
|
|
|
12433
12631
|
src: `/${outputPath}`,
|
|
12434
12632
|
dest: `/${outputPath}`
|
|
12435
12633
|
}));
|
|
12634
|
+
const shadowingRoutes = fastapiStatic ? fastapiShadowingRoutes(fastapiStatic, lambdaPath) : [];
|
|
12635
|
+
const fallbackRoutes = fastapiStatic ? fastapiFallbackRoutes(fastapiStatic) : [];
|
|
12436
12636
|
const routes = isNonWebService || !output ? queueRoutes.length > 0 ? queueRoutes : void 0 : [
|
|
12637
|
+
...shadowingRoutes,
|
|
12437
12638
|
{ handle: "filesystem" },
|
|
12438
12639
|
...queueRoutes,
|
|
12640
|
+
...fallbackRoutes,
|
|
12439
12641
|
// This route matches the resolved destination after rewrites. Copy
|
|
12440
12642
|
// that path into the runtime request before dispatching the shared
|
|
12441
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.
|
|
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",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"smol-toml": "1.5.2",
|
|
36
36
|
"vitest": "4.1.10",
|
|
37
37
|
"which": "3.0.0",
|
|
38
|
-
"@vercel/
|
|
39
|
-
"@vercel/
|
|
40
|
-
"@vercel/
|
|
38
|
+
"@vercel/error-utils": "2.2.1",
|
|
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.
|
|
4
|
-
|
|
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
|
|
38
|
+
from dataclasses import asdict, dataclass, field
|
|
39
|
+
from functools import cached_property
|
|
40
|
+
from typing import TYPE_CHECKING
|
|
17
41
|
|
|
18
|
-
from starlette.
|
|
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(
|
|
32
|
-
|
|
33
|
-
|
|
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 +
|
|
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
|
|
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
|
|
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
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
#
|
|
67
|
-
|
|
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(
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
456
|
+
if m := StaticMount.from_route(route, prefix, frontend=True):
|
|
457
|
+
frontends.append(m)
|
|
77
458
|
|
|
78
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
return
|
|
498
|
+
return Output()
|
|
103
499
|
|
|
104
500
|
app = getattr(mod, variable_name, None)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return
|
|
501
|
+
router = getattr(app, "router", None)
|
|
502
|
+
if router is None:
|
|
503
|
+
return Output()
|
|
108
504
|
|
|
109
|
-
mounts =
|
|
110
|
-
|
|
111
|
-
mounts = collect_mounts(router)
|
|
505
|
+
mounts, shadow_routes = collect(router)
|
|
506
|
+
return Output(mounts=mounts, shadowRoutes=sorted(shadow_routes))
|
|
112
507
|
|
|
113
|
-
|
|
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
|
-
|
|
514
|
+
if __name__ == "__main__":
|
|
515
|
+
main()
|