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