@aglyn/tenant-runtime 1.0.0-beta.222 → 1.0.0-beta.223
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/README.md +30 -0
- package/package.json +6 -5
- package/src/lib/self-hosted-fonts.js +31 -32
- package/src/lib/self-hosted-fonts.js.map +1 -1
package/README.md
CHANGED
|
@@ -92,6 +92,36 @@ import { getScreen } from '@aglyn/tenant-runtime/get-screen'
|
|
|
92
92
|
const screen = await getScreen({ hostId, screenId })
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
## The render path never awaits unbounded I/O
|
|
96
|
+
|
|
97
|
+
A page render, and everything it awaits — the layouts, the page-data loader,
|
|
98
|
+
this pipeline, plugin page enrichers, resolvers, redirect resolvers, repeat
|
|
99
|
+
readers and computed variables, and the middleware in front of them — never
|
|
100
|
+
waits on outbound network I/O without a deadline of its own (AGL-3565).
|
|
101
|
+
|
|
102
|
+
- Wrap the await in `boundedAwait(work, ms, fallback, label)` from
|
|
103
|
+
`@aglyn/shared-util-http/bounded-await`: a real timer race that answers with
|
|
104
|
+
`fallback` when `ms` passes, whatever the work does. Pass its `signal` to
|
|
105
|
+
`fetch` so the request is canceled too, and put the body read inside the
|
|
106
|
+
work. An `AbortSignal` on its own is not a deadline: on Vercel, Next's
|
|
107
|
+
patched `fetch` handed back a promise that never settled with
|
|
108
|
+
`AbortSignal.timeout(2500)` on it, and every uncached page on the platform
|
|
109
|
+
waited for the 60-second function limit.
|
|
110
|
+
- Pick a fallback the page can render with, and say what it means: the
|
|
111
|
+
linked font stylesheet, the verdict "unreachable", a redirect rule held.
|
|
112
|
+
- A process cache that outlives a request holds settled values only
|
|
113
|
+
(`createSettledValueCache`, which refuses a promise). A cached in-flight
|
|
114
|
+
promise is awaited by every later render, each with a fresh deadline in
|
|
115
|
+
front of nothing.
|
|
116
|
+
- Work nothing needs the answer of is detached with `void`, never awaited.
|
|
117
|
+
|
|
118
|
+
`apps/tenant/specs/render-path-bounded-io.spec.ts` sweeps every symbol a
|
|
119
|
+
render can reach for a `fetch`, a `fetch` passed on as a value, a token
|
|
120
|
+
exchange or `http(s).request`, and refuses one that is not behind
|
|
121
|
+
`boundedAwait` or `void`. Its allowlist carries a reason per row and may only
|
|
122
|
+
shrink. Firestore reads through `firebase-admin` are outside it: they carry
|
|
123
|
+
the client's own deadlines.
|
|
124
|
+
|
|
95
125
|
## How it fits
|
|
96
126
|
|
|
97
127
|
This is the `tenant` scope of the package map. It depends only on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-runtime",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.223",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,10 +25,11 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-theme": "1.0.0-beta.
|
|
31
|
-
"@aglyn/
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.223",
|
|
29
|
+
"@aglyn/shared-data-types": "1.0.0-beta.223",
|
|
30
|
+
"@aglyn/shared-ui-theme": "1.0.0-beta.223",
|
|
31
|
+
"@aglyn/shared-util-http": "1.0.0-beta.223",
|
|
32
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.223",
|
|
32
33
|
"@swc/helpers": "0.5.23"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { boundedAwait, createSettledValueCache } from "@aglyn/shared-util-http/bounded-await";
|
|
17
18
|
// By path: the theme lib's barrel carries React providers a server module
|
|
18
19
|
// has no use for (AGL-405).
|
|
19
20
|
import { getGoogleFontsUrl } from "@aglyn/shared-ui-theme/util/host-theme";
|
|
@@ -33,46 +34,44 @@ import { googleFontFileUrl, isSelfHostedFontPath, parseGoogleFontFaces, selfHost
|
|
|
33
34
|
/** How long a page render waits for Google before falling back. */ const STYLESHEET_TIMEOUT_MS = 2500;
|
|
34
35
|
/** The largest font file the route will pass on. */ const MAX_FONT_BYTES = 2 * 1024 * 1024;
|
|
35
36
|
/** A font file's URL never changes its bytes: the version is in the path. */ export const SELF_HOSTED_FONT_CACHE_CONTROL = 'public, max-age=31536000, s-maxage=31536000, immutable';
|
|
36
|
-
|
|
37
|
+
/**
|
|
38
|
+
* What this process learned about each stylesheet: the faces, or null for a
|
|
39
|
+
* failure. VALUES, never an in-flight promise — the settled-value cache
|
|
40
|
+
* refuses one. A promise held across requests is awaited by renders that did
|
|
41
|
+
* not create it, and one that never settles stalled every page of every site
|
|
42
|
+
* on the instance until the function timed out (AGL-3565).
|
|
43
|
+
*/ const stylesheets = createSettledValueCache();
|
|
37
44
|
/** Test seam: the process cache would otherwise leak between cases. */ export function resetSelfHostedFontsForTests() {
|
|
38
45
|
stylesheets.clear();
|
|
39
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Google's faces for a stylesheet URL, or null; never waits past `ms`. The
|
|
49
|
+
* deadline is a real timer (`boundedAwait`): an abort signal alone did not
|
|
50
|
+
* bound Next's patched fetch on Vercel, whose promise could never settle.
|
|
51
|
+
*/ function fetchFaces(url, ms) {
|
|
52
|
+
return boundedAwait((signal)=>fetch(url, {
|
|
53
|
+
headers: {
|
|
54
|
+
'User-Agent': WOFF2_USER_AGENT
|
|
55
|
+
},
|
|
56
|
+
signal,
|
|
57
|
+
next: {
|
|
58
|
+
revalidate: STYLESHEET_TTL_MS / 1000
|
|
59
|
+
}
|
|
60
|
+
}).then(async (response)=>{
|
|
61
|
+
if (!response.ok) return null;
|
|
62
|
+
const parsed = parseGoogleFontFaces(await response.text());
|
|
63
|
+
return parsed.length ? parsed : null;
|
|
64
|
+
}).catch(()=>null), ms, null, 'self-hosted-fonts.stylesheet');
|
|
65
|
+
}
|
|
40
66
|
/**
|
|
41
67
|
* Google's faces for a stylesheet URL, from this process's cache when it has
|
|
42
|
-
* them. Null when the fetch failed or answered with nothing usable;
|
|
43
|
-
* then links the stylesheet as it always did, rather than losing its
|
|
44
|
-
*
|
|
45
|
-
* Asked through `fetch` with a day's revalidation, which on the server is
|
|
68
|
+
* them. Null when the fetch failed, timed out or answered with nothing usable;
|
|
69
|
+
* the page then links the stylesheet as it always did, rather than losing its
|
|
70
|
+
* font. Asked through `fetch` with a day's revalidation, which on the server is
|
|
46
71
|
* Next's data cache: a cold process reads the stored stylesheet rather than
|
|
47
72
|
* asking Google again.
|
|
48
73
|
*/ function facesFor(url) {
|
|
49
|
-
|
|
50
|
-
const held = stylesheets.get(url);
|
|
51
|
-
if (held && held.expires > now) return held.faces;
|
|
52
|
-
const faces = fetch(url, {
|
|
53
|
-
headers: {
|
|
54
|
-
'User-Agent': WOFF2_USER_AGENT
|
|
55
|
-
},
|
|
56
|
-
signal: AbortSignal.timeout(STYLESHEET_TIMEOUT_MS),
|
|
57
|
-
next: {
|
|
58
|
-
revalidate: STYLESHEET_TTL_MS / 1000
|
|
59
|
-
}
|
|
60
|
-
}).then(async (response)=>{
|
|
61
|
-
if (!response.ok) return null;
|
|
62
|
-
const parsed = parseGoogleFontFaces(await response.text());
|
|
63
|
-
return parsed.length ? parsed : null;
|
|
64
|
-
}).catch(()=>null);
|
|
65
|
-
stylesheets.set(url, {
|
|
66
|
-
expires: now + STYLESHEET_TTL_MS,
|
|
67
|
-
faces
|
|
68
|
-
});
|
|
69
|
-
void faces.then((found)=>{
|
|
70
|
-
if (!found) stylesheets.set(url, {
|
|
71
|
-
expires: now + FAILURE_TTL_MS,
|
|
72
|
-
faces
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
return faces;
|
|
74
|
+
return stylesheets.readThrough(url, ()=>fetchFaces(url, STYLESHEET_TIMEOUT_MS), (faces)=>faces ? STYLESHEET_TTL_MS : FAILURE_TTL_MS);
|
|
76
75
|
}
|
|
77
76
|
/**
|
|
78
77
|
* The theme's Google fonts as rules to inline and files to preload, or null
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/self-hosted-fonts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { HostTheme } from '@aglyn/shared-data-types'\n// By path: the theme lib's barrel carries React providers a server module\n// has no use for (AGL-405).\nimport { getGoogleFontsUrl } from '@aglyn/shared-ui-theme/util/host-theme'\nimport {\n type GoogleFontFace,\n googleFontFileUrl,\n isSelfHostedFontPath,\n parseGoogleFontFaces,\n selfHostedFontFaceCss,\n selfHostedFontPreloads,\n} from '@aglyn/shared-ui-theme/util/self-hosted-fonts'\n\n/**\n * The tenant's half of self-hosted theme fonts (AGL-3485): fetching Google's\n * stylesheet for a theme, and serving the files it names from the site's own\n * origin. What is done with the stylesheet, and why, is the note on\n * `@aglyn/shared-ui-theme/util/self-hosted-fonts`.\n */\n\n/**\n * A current desktop Chrome. Google's CSS2 API picks the font FORMAT from the\n * user agent, and a server's default agent is answered with TrueType; this\n * one is answered with WOFF2, the format every browser a site supports reads.\n */\nconst WOFF2_USER_AGENT =\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +\n '(KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36'\n\n/** How long one process trusts a stylesheet it fetched. */\nconst STYLESHEET_TTL_MS = 24 * 60 * 60 * 1000\n\n/** How long a failed fetch is remembered before it is tried again. */\nconst FAILURE_TTL_MS = 5 * 60 * 1000\n\n/** How long a page render waits for Google before falling back. */\nconst STYLESHEET_TIMEOUT_MS = 2_500\n\n/** The largest font file the route will pass on. */\nconst MAX_FONT_BYTES = 2 * 1024 * 1024\n\n/** A font file's URL never changes its bytes: the version is in the path. */\nexport const SELF_HOSTED_FONT_CACHE_CONTROL =\n 'public, max-age=31536000, s-maxage=31536000, immutable'\n\nconst stylesheets = new Map<\n string,\n { expires: number; faces: Promise<GoogleFontFace[] | null> }\n>()\n\n/** Test seam: the process cache would otherwise leak between cases. */\nexport function resetSelfHostedFontsForTests(): void {\n stylesheets.clear()\n}\n\n/**\n * Google's faces for a stylesheet URL, from this process's cache when it has\n * them. Null when the fetch failed or answered with nothing usable; the page\n * then links the stylesheet as it always did, rather than losing its font.\n *\n * Asked through `fetch` with a day's revalidation, which on the server is\n * Next's data cache: a cold process reads the stored stylesheet rather than\n * asking Google again.\n */\nfunction facesFor(url: string): Promise<GoogleFontFace[] | null> {\n const now = Date.now()\n const held = stylesheets.get(url)\n if (held && held.expires > now) return held.faces\n const faces = fetch(url, {\n headers: { 'User-Agent': WOFF2_USER_AGENT },\n signal: AbortSignal.timeout(STYLESHEET_TIMEOUT_MS),\n next: { revalidate: STYLESHEET_TTL_MS / 1000 },\n } as RequestInit)\n .then(async (response) => {\n if (!response.ok) return null\n const parsed = parseGoogleFontFaces(await response.text())\n return parsed.length ? parsed : null\n })\n .catch(() => null)\n stylesheets.set(url, { expires: now + STYLESHEET_TTL_MS, faces })\n void faces.then((found) => {\n if (!found) stylesheets.set(url, { expires: now + FAILURE_TTL_MS, faces })\n })\n return faces\n}\n\n/** What a page inlines in its head for its theme's fonts. */\nexport interface SelfHostedThemeFonts {\n /** The `@font-face` rules, every `src` on the site's own origin. */\n css: string\n /** The one or two files the first screen paints with, to preload. */\n preloads: string[]\n}\n\n/**\n * The theme's Google fonts as rules to inline and files to preload, or null\n * when the theme loads none or Google could not be read — in which case the\n * caller links the stylesheet as before.\n */\nexport async function selfHostedThemeFonts(\n theme: Pick<HostTheme, 'fonts' | 'typography'> | undefined,\n): Promise<SelfHostedThemeFonts | null> {\n const url = getGoogleFontsUrl(theme?.fonts)\n if (!url) return null\n const faces = await facesFor(url)\n if (!faces) return null\n return {\n css: selfHostedFontFaceCss(faces),\n preloads: selfHostedFontPreloads(theme, faces),\n }\n}\n\n/**\n * One font file, fetched from Google and answered from the site's origin with\n * a year-long `immutable` policy. `path` is the part below `/s/` on Google's\n * origin (`inter/v18/….woff2`); anything else is a 404, so this route cannot\n * be pointed at any other URL.\n *\n * The edge keeps the answer for the year too (`s-maxage`), so Google is asked\n * once per file per edge region rather than once per visitor.\n */\nexport async function serveSelfHostedFont(path: string): Promise<Response> {\n const notFound = () =>\n new Response('Not found', {\n status: 404,\n headers: { 'Cache-Control': 'public, max-age=300' },\n })\n if (!isSelfHostedFontPath(path)) return notFound()\n let upstream: Response\n try {\n upstream = await fetch(googleFontFileUrl(path), {\n headers: { 'User-Agent': WOFF2_USER_AGENT },\n cache: 'force-cache',\n })\n } catch {\n return new Response('Font unavailable', {\n status: 502,\n headers: { 'Cache-Control': 'no-store' },\n })\n }\n if (upstream.status === 404) return notFound()\n const length = Number(upstream.headers.get('content-length') ?? 0)\n if (!upstream.ok || !upstream.body || length > MAX_FONT_BYTES) {\n return new Response('Font unavailable', {\n status: 502,\n headers: { 'Cache-Control': 'no-store' },\n })\n }\n return new Response(upstream.body, {\n status: 200,\n headers: {\n 'Content-Type': 'font/woff2',\n 'Cache-Control': SELF_HOSTED_FONT_CACHE_CONTROL,\n 'X-Content-Type-Options': 'nosniff',\n // Fonts are fetched in CORS mode; a site reached on two hostnames (its\n // own domain and its platform subdomain) shares one cached answer.\n 'Access-Control-Allow-Origin': '*',\n ...(length ? { 'Content-Length': String(length) } : {}),\n },\n })\n}\n"],"names":["getGoogleFontsUrl","googleFontFileUrl","isSelfHostedFontPath","parseGoogleFontFaces","selfHostedFontFaceCss","selfHostedFontPreloads","WOFF2_USER_AGENT","STYLESHEET_TTL_MS","FAILURE_TTL_MS","STYLESHEET_TIMEOUT_MS","MAX_FONT_BYTES","SELF_HOSTED_FONT_CACHE_CONTROL","stylesheets","Map","resetSelfHostedFontsForTests","clear","facesFor","url","now","Date","held","get","expires","faces","fetch","headers","signal","AbortSignal","timeout","next","revalidate","then","response","ok","parsed","text","length","catch","set","found","selfHostedThemeFonts","theme","fonts","css","preloads","serveSelfHostedFont","path","upstream","notFound","Response","status","cache","Number","body","String"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/self-hosted-fonts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { HostTheme } from '@aglyn/shared-data-types'\nimport {\n boundedAwait,\n createSettledValueCache,\n} from '@aglyn/shared-util-http/bounded-await'\n// By path: the theme lib's barrel carries React providers a server module\n// has no use for (AGL-405).\nimport { getGoogleFontsUrl } from '@aglyn/shared-ui-theme/util/host-theme'\nimport {\n type GoogleFontFace,\n googleFontFileUrl,\n isSelfHostedFontPath,\n parseGoogleFontFaces,\n selfHostedFontFaceCss,\n selfHostedFontPreloads,\n} from '@aglyn/shared-ui-theme/util/self-hosted-fonts'\n\n/**\n * The tenant's half of self-hosted theme fonts (AGL-3485): fetching Google's\n * stylesheet for a theme, and serving the files it names from the site's own\n * origin. What is done with the stylesheet, and why, is the note on\n * `@aglyn/shared-ui-theme/util/self-hosted-fonts`.\n */\n\n/**\n * A current desktop Chrome. Google's CSS2 API picks the font FORMAT from the\n * user agent, and a server's default agent is answered with TrueType; this\n * one is answered with WOFF2, the format every browser a site supports reads.\n */\nconst WOFF2_USER_AGENT =\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +\n '(KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36'\n\n/** How long one process trusts a stylesheet it fetched. */\nconst STYLESHEET_TTL_MS = 24 * 60 * 60 * 1000\n\n/** How long a failed fetch is remembered before it is tried again. */\nconst FAILURE_TTL_MS = 5 * 60 * 1000\n\n/** How long a page render waits for Google before falling back. */\nconst STYLESHEET_TIMEOUT_MS = 2_500\n\n/** The largest font file the route will pass on. */\nconst MAX_FONT_BYTES = 2 * 1024 * 1024\n\n/** A font file's URL never changes its bytes: the version is in the path. */\nexport const SELF_HOSTED_FONT_CACHE_CONTROL =\n 'public, max-age=31536000, s-maxage=31536000, immutable'\n\n/**\n * What this process learned about each stylesheet: the faces, or null for a\n * failure. VALUES, never an in-flight promise — the settled-value cache\n * refuses one. A promise held across requests is awaited by renders that did\n * not create it, and one that never settles stalled every page of every site\n * on the instance until the function timed out (AGL-3565).\n */\nconst stylesheets = createSettledValueCache<string, GoogleFontFace[] | null>()\n\n/** Test seam: the process cache would otherwise leak between cases. */\nexport function resetSelfHostedFontsForTests(): void {\n stylesheets.clear()\n}\n\n/**\n * Google's faces for a stylesheet URL, or null; never waits past `ms`. The\n * deadline is a real timer (`boundedAwait`): an abort signal alone did not\n * bound Next's patched fetch on Vercel, whose promise could never settle.\n */\nfunction fetchFaces(url: string, ms: number): Promise<GoogleFontFace[] | null> {\n return boundedAwait(\n (signal) =>\n fetch(url, {\n headers: { 'User-Agent': WOFF2_USER_AGENT },\n signal,\n next: { revalidate: STYLESHEET_TTL_MS / 1000 },\n } as RequestInit)\n .then(async (response) => {\n if (!response.ok) return null\n const parsed = parseGoogleFontFaces(await response.text())\n return parsed.length ? parsed : null\n })\n .catch(() => null),\n ms,\n null,\n 'self-hosted-fonts.stylesheet',\n )\n}\n\n/**\n * Google's faces for a stylesheet URL, from this process's cache when it has\n * them. Null when the fetch failed, timed out or answered with nothing usable;\n * the page then links the stylesheet as it always did, rather than losing its\n * font. Asked through `fetch` with a day's revalidation, which on the server is\n * Next's data cache: a cold process reads the stored stylesheet rather than\n * asking Google again.\n */\nfunction facesFor(url: string): Promise<GoogleFontFace[] | null> {\n return stylesheets.readThrough(\n url,\n () => fetchFaces(url, STYLESHEET_TIMEOUT_MS),\n (faces) => (faces ? STYLESHEET_TTL_MS : FAILURE_TTL_MS),\n )\n}\n\n/** What a page inlines in its head for its theme's fonts. */\nexport interface SelfHostedThemeFonts {\n /** The `@font-face` rules, every `src` on the site's own origin. */\n css: string\n /** The one or two files the first screen paints with, to preload. */\n preloads: string[]\n}\n\n/**\n * The theme's Google fonts as rules to inline and files to preload, or null\n * when the theme loads none or Google could not be read — in which case the\n * caller links the stylesheet as before.\n */\nexport async function selfHostedThemeFonts(\n theme: Pick<HostTheme, 'fonts' | 'typography'> | undefined,\n): Promise<SelfHostedThemeFonts | null> {\n const url = getGoogleFontsUrl(theme?.fonts)\n if (!url) return null\n const faces = await facesFor(url)\n if (!faces) return null\n return {\n css: selfHostedFontFaceCss(faces),\n preloads: selfHostedFontPreloads(theme, faces),\n }\n}\n\n/**\n * One font file, fetched from Google and answered from the site's origin with\n * a year-long `immutable` policy. `path` is the part below `/s/` on Google's\n * origin (`inter/v18/….woff2`); anything else is a 404, so this route cannot\n * be pointed at any other URL.\n *\n * The edge keeps the answer for the year too (`s-maxage`), so Google is asked\n * once per file per edge region rather than once per visitor.\n */\nexport async function serveSelfHostedFont(path: string): Promise<Response> {\n const notFound = () =>\n new Response('Not found', {\n status: 404,\n headers: { 'Cache-Control': 'public, max-age=300' },\n })\n if (!isSelfHostedFontPath(path)) return notFound()\n let upstream: Response\n try {\n upstream = await fetch(googleFontFileUrl(path), {\n headers: { 'User-Agent': WOFF2_USER_AGENT },\n cache: 'force-cache',\n })\n } catch {\n return new Response('Font unavailable', {\n status: 502,\n headers: { 'Cache-Control': 'no-store' },\n })\n }\n if (upstream.status === 404) return notFound()\n const length = Number(upstream.headers.get('content-length') ?? 0)\n if (!upstream.ok || !upstream.body || length > MAX_FONT_BYTES) {\n return new Response('Font unavailable', {\n status: 502,\n headers: { 'Cache-Control': 'no-store' },\n })\n }\n return new Response(upstream.body, {\n status: 200,\n headers: {\n 'Content-Type': 'font/woff2',\n 'Cache-Control': SELF_HOSTED_FONT_CACHE_CONTROL,\n 'X-Content-Type-Options': 'nosniff',\n // Fonts are fetched in CORS mode; a site reached on two hostnames (its\n // own domain and its platform subdomain) shares one cached answer.\n 'Access-Control-Allow-Origin': '*',\n ...(length ? { 'Content-Length': String(length) } : {}),\n },\n })\n}\n"],"names":["boundedAwait","createSettledValueCache","getGoogleFontsUrl","googleFontFileUrl","isSelfHostedFontPath","parseGoogleFontFaces","selfHostedFontFaceCss","selfHostedFontPreloads","WOFF2_USER_AGENT","STYLESHEET_TTL_MS","FAILURE_TTL_MS","STYLESHEET_TIMEOUT_MS","MAX_FONT_BYTES","SELF_HOSTED_FONT_CACHE_CONTROL","stylesheets","resetSelfHostedFontsForTests","clear","fetchFaces","url","ms","signal","fetch","headers","next","revalidate","then","response","ok","parsed","text","length","catch","facesFor","readThrough","faces","selfHostedThemeFonts","theme","fonts","css","preloads","serveSelfHostedFont","path","upstream","notFound","Response","status","cache","Number","get","body","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SACEA,YAAY,EACZC,uBAAuB,QAClB,wCAAuC;AAC9C,0EAA0E;AAC1E,4BAA4B;AAC5B,SAASC,iBAAiB,QAAQ,yCAAwC;AAC1E,SAEEC,iBAAiB,EACjBC,oBAAoB,EACpBC,oBAAoB,EACpBC,qBAAqB,EACrBC,sBAAsB,QACjB,gDAA+C;AAEtD;;;;;CAKC,GAED;;;;CAIC,GACD,MAAMC,mBACJ,wEACA;AAEF,yDAAyD,GACzD,MAAMC,oBAAoB,KAAK,KAAK,KAAK;AAEzC,oEAAoE,GACpE,MAAMC,iBAAiB,IAAI,KAAK;AAEhC,iEAAiE,GACjE,MAAMC,wBAAwB;AAE9B,kDAAkD,GAClD,MAAMC,iBAAiB,IAAI,OAAO;AAElC,2EAA2E,GAC3E,OAAO,MAAMC,iCACX,yDAAwD;AAE1D;;;;;;CAMC,GACD,MAAMC,cAAcb;AAEpB,qEAAqE,GACrE,OAAO,SAASc;IACdD,YAAYE,KAAK;AACnB;AAEA;;;;CAIC,GACD,SAASC,WAAWC,GAAW,EAAEC,EAAU;IACzC,OAAOnB,aACL,CAACoB,SACCC,MAAMH,KAAK;YACTI,SAAS;gBAAE,cAAcd;YAAiB;YAC1CY;YACAG,MAAM;gBAAEC,YAAYf,oBAAoB;YAAK;QAC/C,GACGgB,IAAI,CAAC,OAAOC;YACX,IAAI,CAACA,SAASC,EAAE,EAAE,OAAO;YACzB,MAAMC,SAASvB,qBAAqB,MAAMqB,SAASG,IAAI;YACvD,OAAOD,OAAOE,MAAM,GAAGF,SAAS;QAClC,GACCG,KAAK,CAAC,IAAM,OACjBZ,IACA,MACA;AAEJ;AAEA;;;;;;;CAOC,GACD,SAASa,SAASd,GAAW;IAC3B,OAAOJ,YAAYmB,WAAW,CAC5Bf,KACA,IAAMD,WAAWC,KAAKP,wBACtB,CAACuB,QAAWA,QAAQzB,oBAAoBC;AAE5C;AAUA;;;;CAIC,GACD,OAAO,eAAeyB,qBACpBC,KAA0D;IAE1D,MAAMlB,MAAMhB,kBAAkBkC,yBAAAA,MAAOC,KAAK;IAC1C,IAAI,CAACnB,KAAK,OAAO;IACjB,MAAMgB,QAAQ,MAAMF,SAASd;IAC7B,IAAI,CAACgB,OAAO,OAAO;IACnB,OAAO;QACLI,KAAKhC,sBAAsB4B;QAC3BK,UAAUhC,uBAAuB6B,OAAOF;IAC1C;AACF;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeM,oBAAoBC,IAAY;QAoB9BC;IAnBtB,MAAMC,WAAW,IACf,IAAIC,SAAS,aAAa;YACxBC,QAAQ;YACRvB,SAAS;gBAAE,iBAAiB;YAAsB;QACpD;IACF,IAAI,CAAClB,qBAAqBqC,OAAO,OAAOE;IACxC,IAAID;IACJ,IAAI;QACFA,WAAW,MAAMrB,MAAMlB,kBAAkBsC,OAAO;YAC9CnB,SAAS;gBAAE,cAAcd;YAAiB;YAC1CsC,OAAO;QACT;IACF,EAAE,eAAM;QACN,OAAO,IAAIF,SAAS,oBAAoB;YACtCC,QAAQ;YACRvB,SAAS;gBAAE,iBAAiB;YAAW;QACzC;IACF;IACA,IAAIoB,SAASG,MAAM,KAAK,KAAK,OAAOF;IACpC,MAAMb,SAASiB,QAAOL,wBAAAA,SAASpB,OAAO,CAAC0B,GAAG,CAAC,6BAArBN,wBAA0C;IAChE,IAAI,CAACA,SAASf,EAAE,IAAI,CAACe,SAASO,IAAI,IAAInB,SAASlB,gBAAgB;QAC7D,OAAO,IAAIgC,SAAS,oBAAoB;YACtCC,QAAQ;YACRvB,SAAS;gBAAE,iBAAiB;YAAW;QACzC;IACF;IACA,OAAO,IAAIsB,SAASF,SAASO,IAAI,EAAE;QACjCJ,QAAQ;QACRvB,SAAS;YACP,gBAAgB;YAChB,iBAAiBT;YACjB,0BAA0B;YAC1B,uEAAuE;YACvE,mEAAmE;YACnE,+BAA+B;WAC3BiB,SAAS;YAAE,kBAAkBoB,OAAOpB;QAAQ,IAAI,CAAC;IAEzD;AACF"}
|