@aglyn/tenant-data-admin 1.0.0-beta.257 → 1.0.0-beta.258
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/package.json +7 -7
- package/src/lib/server/media-cdn-icon.d.ts +2 -2
- package/src/lib/server/media-cdn-icon.js +4 -1
- package/src/lib/server/media-cdn-icon.js.map +1 -1
- package/src/lib/server/media-delivery-regeneration.d.ts +2 -2
- package/src/lib/server/media-delivery-regeneration.js +4 -1
- package/src/lib/server/media-delivery-regeneration.js.map +1 -1
- package/src/lib/server/storage-read-stream.js +4 -0
- package/src/lib/server/storage-read-stream.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-data-admin",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.258",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.258",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.258",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.258",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.258",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.258",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.258",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.5",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { type SiteIconSpec } from '@aglyn/aglyn/app-utils/site-icon-set';
|
|
18
18
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
19
19
|
import type { MediaCdnRateLimitRefusal } from './media-cdn-rate-limit';
|
|
20
|
+
import { type StorageReadableFile } from './storage-read-stream';
|
|
20
21
|
/**
|
|
21
22
|
* The `?icon=` representation of a media CDN URL (AGL-3484): one icon of a
|
|
22
23
|
* site's derived set, drawn from the asset's current bytes.
|
|
@@ -52,11 +53,10 @@ export interface MediaCdnIconSource {
|
|
|
52
53
|
/** `/api/media/cdn/{scope}/{mediaId}` — the asset's stable URL. */
|
|
53
54
|
stablePath: string;
|
|
54
55
|
/** The Storage object, as far as this reads it. */
|
|
55
|
-
file: {
|
|
56
|
+
file: StorageReadableFile & {
|
|
56
57
|
getMetadata(): Promise<[{
|
|
57
58
|
size?: string | number;
|
|
58
59
|
} | null | undefined]>;
|
|
59
|
-
download(): Promise<[Buffer]>;
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
/** The `ETag` of one derived icon: the source's hash plus the representation. */
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/ import { formatSiteIconSpec, normalizeSiteIconBackground, SITE_ICON_BACKGROUND_PARAM, SITE_ICON_PARAM, SITE_ICON_VERSION_PARAM, siteIconSourceTypeDerivable, siteIconUsesBackground, DEFAULT_SITE_ICON_BACKGROUND } from "@aglyn/aglyn/app-utils/site-icon-set";
|
|
17
17
|
import { MEDIA_VARIANT_SOURCE_MAX_BYTES } from "./media-variants.js";
|
|
18
18
|
import { renderSiteIcon } from "./site-icon-render.js";
|
|
19
|
+
import { readStorageObject } from "./storage-read-stream.js";
|
|
19
20
|
const firstValue = (value)=>{
|
|
20
21
|
const raw = Array.isArray(value) ? value[0] : value;
|
|
21
22
|
return typeof raw === 'string' && raw !== '' ? raw : undefined;
|
|
@@ -114,7 +115,9 @@ export async function serveMediaCdnIcon(options) {
|
|
|
114
115
|
}
|
|
115
116
|
let icon;
|
|
116
117
|
try {
|
|
117
|
-
|
|
118
|
+
// Not `file.download()`: that is the same read without the listener
|
|
119
|
+
// budget, so every rendered icon would log a false leak warning.
|
|
120
|
+
const bytes = await readStorageObject(source.file);
|
|
118
121
|
icon = await renderSiteIcon({
|
|
119
122
|
source: bytes,
|
|
120
123
|
spec,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-cdn-icon.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 {\n formatSiteIconSpec,\n normalizeSiteIconBackground,\n SITE_ICON_BACKGROUND_PARAM,\n SITE_ICON_PARAM,\n SITE_ICON_VERSION_PARAM,\n siteIconSourceTypeDerivable,\n siteIconUsesBackground,\n DEFAULT_SITE_ICON_BACKGROUND,\n type SiteIconSpec,\n} from '@aglyn/aglyn/app-utils/site-icon-set'\nimport type { NextApiRequest, NextApiResponse } from 'next'\n\nimport type { MediaCdnRateLimitRefusal } from './media-cdn-rate-limit'\nimport { MEDIA_VARIANT_SOURCE_MAX_BYTES } from './media-variants'\nimport { renderSiteIcon } from './site-icon-render'\n\n/**\n * The `?icon=` representation of a media CDN URL (AGL-3484): one icon of a\n * site's derived set, drawn from the asset's current bytes.\n *\n * `serveMediaCdn` calls this only after every gate it applies to the bytes —\n * scope, lockdown, quarantine, the private signature and the stale-pin\n * redirect — so an icon is served exactly where its source would be.\n *\n * ## Derived once, cached by version\n *\n * The URL a page names carries `v`, the asset's `contentHash`. While `v` is\n * current the icon is drawn once and held for a year by the edge and the\n * browser (`immutable`): the same URL can never need different bytes, because\n * a DAM Replace changes the hash and so the URL the next render names. A\n * stale `v` 302s to the current one under the stable URL's revalidated policy,\n * which is how a page rendered before a Replace reaches the new icon. A URL\n * with no `v` (the source's hash is unknown) is served under the stable\n * policy with an ETag keyed on the hash, exactly as the asset itself is.\n *\n * ## Anything the renderer cannot draw falls back to the original\n *\n * A non-image, an ICO (which `sharp` cannot decode), a source over the\n * variant generator's fetch ceiling, or a file that fails to decode: each\n * 302s to the asset's plain URL. A favicon link must never be the reason a\n * tab shows nothing.\n */\n\n/** The fields of a stored asset this representation reads. */\nexport interface MediaCdnIconSource {\n /** The media document's `contentType`. */\n contentType: unknown\n /** The media document's `contentHash`; `''` for a legacy asset. */\n contentHash: string\n /** `/api/media/cdn/{scope}/{mediaId}` — the asset's stable URL. */\n stablePath: string\n /** The Storage object, as far as this reads it. */\n file: {\n getMetadata(): Promise<[{ size?: string | number } | null | undefined]>\n download(): Promise<[Buffer]>\n }\n}\n\nconst firstValue = (value: unknown): string | undefined => {\n const raw = Array.isArray(value) ? value[0] : value\n return typeof raw === 'string' && raw !== '' ? raw : undefined\n}\n\n/**\n * The query this representation names, rebuilt from the parameters it reads —\n * never forwarded whole, for `mediaCdnForwardedQuery`'s reasons. `exp`/`sig`\n * ride along so a signed asset's redirect still serves.\n */\nfunction iconQuery(\n query: NextApiRequest['query'],\n pairs: Array<[string, string | undefined]>,\n): string {\n const params = new URLSearchParams()\n for (const [key, value] of pairs) if (value) params.set(key, value)\n for (const key of ['exp', 'sig']) {\n const value = firstValue(query[key])\n if (value) params.set(key, value)\n }\n const encoded = params.toString()\n return encoded ? `?${encoded}` : ''\n}\n\n/** The `ETag` of one derived icon: the source's hash plus the representation. */\nexport function mediaCdnIconEtag(\n contentHash: string,\n spec: SiteIconSpec,\n background: string | undefined,\n): string | null {\n if (!contentHash) return null\n const plate = background ? `-${background}` : ''\n return `\"${contentHash}-icon-${formatSiteIconSpec(spec)}${plate}\"`\n}\n\nexport async function serveMediaCdnIcon(options: {\n req: NextApiRequest\n res: NextApiResponse\n spec: SiteIconSpec\n source: MediaCdnIconSource\n /** Forces `private, no-store` on a signed asset, as the handler's does. */\n setCacheControl: (value: string) => void\n cacheControl: { stable: string; immutable: string }\n /** The caller's delivery count; `null` means serve. */\n rateLimit: () => Promise<MediaCdnRateLimitRefusal | null>\n}): Promise<void> {\n const { req, res, spec, source, setCacheControl, cacheControl } = options\n const background = siteIconUsesBackground(spec)\n ? (normalizeSiteIconBackground(\n `#${firstValue(req.query[SITE_ICON_BACKGROUND_PARAM]) ?? ''}`,\n ) ?? DEFAULT_SITE_ICON_BACKGROUND)\n : undefined\n const version = firstValue(req.query[SITE_ICON_VERSION_PARAM])\n const { contentHash } = source\n\n const original = (cache: string) => {\n res.removeHeader('ETag')\n setCacheControl(cache)\n res.setHeader('Location', `${source.stablePath}${iconQuery(req.query, [])}`)\n res.status(302).end()\n }\n\n if (!siteIconSourceTypeDerivable(source.contentType)) {\n original(cacheControl.stable)\n return\n }\n if (version && contentHash && version !== contentHash) {\n setCacheControl(cacheControl.stable)\n res.setHeader(\n 'Location',\n `${source.stablePath}${iconQuery(req.query, [\n [SITE_ICON_PARAM, formatSiteIconSpec(spec)],\n [SITE_ICON_BACKGROUND_PARAM, background],\n [SITE_ICON_VERSION_PARAM, contentHash],\n ])}`,\n )\n res.status(302).end()\n return\n }\n\n const etag = mediaCdnIconEtag(contentHash, spec, background)\n setCacheControl(\n version && version === contentHash\n ? cacheControl.immutable\n : cacheControl.stable,\n )\n if (etag) {\n res.setHeader('ETag', etag)\n if (req.headers['if-none-match'] === etag) {\n res.status(304).end()\n return\n }\n }\n\n const counted = req.method === 'GET' ? options.rateLimit() : null\n const [metadata] = await source.file.getMetadata().catch(() => [null])\n if (!metadata) {\n res.removeHeader('ETag')\n setCacheControl('public, max-age=60')\n res.status(404).json({ error: 'Not found' })\n return\n }\n if (Number(metadata.size ?? 0) > MEDIA_VARIANT_SOURCE_MAX_BYTES) {\n original(cacheControl.stable)\n return\n }\n const refused = await counted\n if (refused) {\n res.removeHeader('ETag')\n res.setHeader('Cache-Control', 'no-store')\n res.setHeader('Retry-After', String(refused.retryAfterSeconds))\n res.status(429).json({ error: 'Too many requests' })\n return\n }\n\n let icon: { body: Buffer; contentType: string }\n try {\n const [bytes] = await source.file.download()\n icon = await renderSiteIcon({ source: bytes, spec, background })\n } catch (error) {\n // A file that will not decode is a property of the upload, not of the\n // request, but it is not cached either: a Replace must be able to fix it.\n console.error('[media-cdn] site icon render failed', source.stablePath, error)\n original('no-store')\n return\n }\n\n res.setHeader('Content-Type', icon.contentType)\n res.setHeader('Content-Length', String(icon.body.length))\n res.setHeader(\n 'Content-Disposition',\n `inline; filename=\"icon-${formatSiteIconSpec(spec)}.${\n spec.plate === 'ico' ? 'ico' : 'png'\n }\"`,\n )\n if (req.method === 'HEAD') {\n res.status(200).end()\n return\n }\n res.status(200).end(icon.body)\n}\n"],"names":["formatSiteIconSpec","normalizeSiteIconBackground","SITE_ICON_BACKGROUND_PARAM","SITE_ICON_PARAM","SITE_ICON_VERSION_PARAM","siteIconSourceTypeDerivable","siteIconUsesBackground","DEFAULT_SITE_ICON_BACKGROUND","MEDIA_VARIANT_SOURCE_MAX_BYTES","renderSiteIcon","firstValue","value","raw","Array","isArray","undefined","iconQuery","query","pairs","params","URLSearchParams","key","set","encoded","toString","mediaCdnIconEtag","contentHash","spec","background","plate","serveMediaCdnIcon","options","metadata","req","res","source","setCacheControl","cacheControl","version","original","cache","removeHeader","setHeader","stablePath","status","end","contentType","stable","etag","immutable","headers","counted","method","rateLimit","file","getMetadata","catch","json","error","Number","size","refused","String","retryAfterSeconds","icon","bytes","download","console","body","length"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-cdn-icon.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 {\n formatSiteIconSpec,\n normalizeSiteIconBackground,\n SITE_ICON_BACKGROUND_PARAM,\n SITE_ICON_PARAM,\n SITE_ICON_VERSION_PARAM,\n siteIconSourceTypeDerivable,\n siteIconUsesBackground,\n DEFAULT_SITE_ICON_BACKGROUND,\n type SiteIconSpec,\n} from '@aglyn/aglyn/app-utils/site-icon-set'\nimport type { NextApiRequest, NextApiResponse } from 'next'\n\nimport type { MediaCdnRateLimitRefusal } from './media-cdn-rate-limit'\nimport { MEDIA_VARIANT_SOURCE_MAX_BYTES } from './media-variants'\nimport { renderSiteIcon } from './site-icon-render'\nimport { readStorageObject, type StorageReadableFile } from './storage-read-stream'\n\n/**\n * The `?icon=` representation of a media CDN URL (AGL-3484): one icon of a\n * site's derived set, drawn from the asset's current bytes.\n *\n * `serveMediaCdn` calls this only after every gate it applies to the bytes —\n * scope, lockdown, quarantine, the private signature and the stale-pin\n * redirect — so an icon is served exactly where its source would be.\n *\n * ## Derived once, cached by version\n *\n * The URL a page names carries `v`, the asset's `contentHash`. While `v` is\n * current the icon is drawn once and held for a year by the edge and the\n * browser (`immutable`): the same URL can never need different bytes, because\n * a DAM Replace changes the hash and so the URL the next render names. A\n * stale `v` 302s to the current one under the stable URL's revalidated policy,\n * which is how a page rendered before a Replace reaches the new icon. A URL\n * with no `v` (the source's hash is unknown) is served under the stable\n * policy with an ETag keyed on the hash, exactly as the asset itself is.\n *\n * ## Anything the renderer cannot draw falls back to the original\n *\n * A non-image, an ICO (which `sharp` cannot decode), a source over the\n * variant generator's fetch ceiling, or a file that fails to decode: each\n * 302s to the asset's plain URL. A favicon link must never be the reason a\n * tab shows nothing.\n */\n\n/** The fields of a stored asset this representation reads. */\nexport interface MediaCdnIconSource {\n /** The media document's `contentType`. */\n contentType: unknown\n /** The media document's `contentHash`; `''` for a legacy asset. */\n contentHash: string\n /** `/api/media/cdn/{scope}/{mediaId}` — the asset's stable URL. */\n stablePath: string\n /** The Storage object, as far as this reads it. */\n file: StorageReadableFile & {\n getMetadata(): Promise<[{ size?: string | number } | null | undefined]>\n }\n}\n\nconst firstValue = (value: unknown): string | undefined => {\n const raw = Array.isArray(value) ? value[0] : value\n return typeof raw === 'string' && raw !== '' ? raw : undefined\n}\n\n/**\n * The query this representation names, rebuilt from the parameters it reads —\n * never forwarded whole, for `mediaCdnForwardedQuery`'s reasons. `exp`/`sig`\n * ride along so a signed asset's redirect still serves.\n */\nfunction iconQuery(\n query: NextApiRequest['query'],\n pairs: Array<[string, string | undefined]>,\n): string {\n const params = new URLSearchParams()\n for (const [key, value] of pairs) if (value) params.set(key, value)\n for (const key of ['exp', 'sig']) {\n const value = firstValue(query[key])\n if (value) params.set(key, value)\n }\n const encoded = params.toString()\n return encoded ? `?${encoded}` : ''\n}\n\n/** The `ETag` of one derived icon: the source's hash plus the representation. */\nexport function mediaCdnIconEtag(\n contentHash: string,\n spec: SiteIconSpec,\n background: string | undefined,\n): string | null {\n if (!contentHash) return null\n const plate = background ? `-${background}` : ''\n return `\"${contentHash}-icon-${formatSiteIconSpec(spec)}${plate}\"`\n}\n\nexport async function serveMediaCdnIcon(options: {\n req: NextApiRequest\n res: NextApiResponse\n spec: SiteIconSpec\n source: MediaCdnIconSource\n /** Forces `private, no-store` on a signed asset, as the handler's does. */\n setCacheControl: (value: string) => void\n cacheControl: { stable: string; immutable: string }\n /** The caller's delivery count; `null` means serve. */\n rateLimit: () => Promise<MediaCdnRateLimitRefusal | null>\n}): Promise<void> {\n const { req, res, spec, source, setCacheControl, cacheControl } = options\n const background = siteIconUsesBackground(spec)\n ? (normalizeSiteIconBackground(\n `#${firstValue(req.query[SITE_ICON_BACKGROUND_PARAM]) ?? ''}`,\n ) ?? DEFAULT_SITE_ICON_BACKGROUND)\n : undefined\n const version = firstValue(req.query[SITE_ICON_VERSION_PARAM])\n const { contentHash } = source\n\n const original = (cache: string) => {\n res.removeHeader('ETag')\n setCacheControl(cache)\n res.setHeader('Location', `${source.stablePath}${iconQuery(req.query, [])}`)\n res.status(302).end()\n }\n\n if (!siteIconSourceTypeDerivable(source.contentType)) {\n original(cacheControl.stable)\n return\n }\n if (version && contentHash && version !== contentHash) {\n setCacheControl(cacheControl.stable)\n res.setHeader(\n 'Location',\n `${source.stablePath}${iconQuery(req.query, [\n [SITE_ICON_PARAM, formatSiteIconSpec(spec)],\n [SITE_ICON_BACKGROUND_PARAM, background],\n [SITE_ICON_VERSION_PARAM, contentHash],\n ])}`,\n )\n res.status(302).end()\n return\n }\n\n const etag = mediaCdnIconEtag(contentHash, spec, background)\n setCacheControl(\n version && version === contentHash\n ? cacheControl.immutable\n : cacheControl.stable,\n )\n if (etag) {\n res.setHeader('ETag', etag)\n if (req.headers['if-none-match'] === etag) {\n res.status(304).end()\n return\n }\n }\n\n const counted = req.method === 'GET' ? options.rateLimit() : null\n const [metadata] = await source.file.getMetadata().catch(() => [null])\n if (!metadata) {\n res.removeHeader('ETag')\n setCacheControl('public, max-age=60')\n res.status(404).json({ error: 'Not found' })\n return\n }\n if (Number(metadata.size ?? 0) > MEDIA_VARIANT_SOURCE_MAX_BYTES) {\n original(cacheControl.stable)\n return\n }\n const refused = await counted\n if (refused) {\n res.removeHeader('ETag')\n res.setHeader('Cache-Control', 'no-store')\n res.setHeader('Retry-After', String(refused.retryAfterSeconds))\n res.status(429).json({ error: 'Too many requests' })\n return\n }\n\n let icon: { body: Buffer; contentType: string }\n try {\n // Not `file.download()`: that is the same read without the listener\n // budget, so every rendered icon would log a false leak warning.\n const bytes = await readStorageObject(source.file)\n icon = await renderSiteIcon({ source: bytes, spec, background })\n } catch (error) {\n // A file that will not decode is a property of the upload, not of the\n // request, but it is not cached either: a Replace must be able to fix it.\n console.error('[media-cdn] site icon render failed', source.stablePath, error)\n original('no-store')\n return\n }\n\n res.setHeader('Content-Type', icon.contentType)\n res.setHeader('Content-Length', String(icon.body.length))\n res.setHeader(\n 'Content-Disposition',\n `inline; filename=\"icon-${formatSiteIconSpec(spec)}.${\n spec.plate === 'ico' ? 'ico' : 'png'\n }\"`,\n )\n if (req.method === 'HEAD') {\n res.status(200).end()\n return\n }\n res.status(200).end(icon.body)\n}\n"],"names":["formatSiteIconSpec","normalizeSiteIconBackground","SITE_ICON_BACKGROUND_PARAM","SITE_ICON_PARAM","SITE_ICON_VERSION_PARAM","siteIconSourceTypeDerivable","siteIconUsesBackground","DEFAULT_SITE_ICON_BACKGROUND","MEDIA_VARIANT_SOURCE_MAX_BYTES","renderSiteIcon","readStorageObject","firstValue","value","raw","Array","isArray","undefined","iconQuery","query","pairs","params","URLSearchParams","key","set","encoded","toString","mediaCdnIconEtag","contentHash","spec","background","plate","serveMediaCdnIcon","options","metadata","req","res","source","setCacheControl","cacheControl","version","original","cache","removeHeader","setHeader","stablePath","status","end","contentType","stable","etag","immutable","headers","counted","method","rateLimit","file","getMetadata","catch","json","error","Number","size","refused","String","retryAfterSeconds","icon","bytes","console","body","length"],"mappings":"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"}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
import { MEDIA_VARIANT_ENCODER_VERSION_FIELD } from '@aglyn/aglyn/app-utils/media-ref';
|
|
18
|
+
import { type StorageReadableFile } from './storage-read-stream';
|
|
18
19
|
/**
|
|
19
20
|
* Lazy regeneration of an asset's delivery copies (AGL-3486).
|
|
20
21
|
*
|
|
@@ -91,8 +92,7 @@ export interface MediaRegenerationFacts {
|
|
|
91
92
|
export declare function mediaDeliveryCopiesStale(facts: MediaRegenerationFacts, nowMs?: number): boolean;
|
|
92
93
|
/** The slice of a Storage bucket this needs. */
|
|
93
94
|
export interface MediaRegenerationBucket {
|
|
94
|
-
file(path: string): {
|
|
95
|
-
download(): Promise<[Buffer]>;
|
|
95
|
+
file(path: string): StorageReadableFile & {
|
|
96
96
|
save(bytes: Buffer, options: {
|
|
97
97
|
contentType: string;
|
|
98
98
|
metadata: {
|
|
@@ -17,6 +17,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
17
17
|
*/ import { FieldValue } from "firebase-admin/firestore";
|
|
18
18
|
import { MEDIA_VARIANT_ENCODER_VERSION, MEDIA_VARIANT_ENCODER_VERSION_FIELD, mediaDisplayObjectPath, mediaVariantEncoderVersionOf } from "@aglyn/aglyn/app-utils/media-ref";
|
|
19
19
|
import { generateMediaVariants, MEDIA_DISPLAY_TYPES, MEDIA_TYPES_WITHOUT_VARIANTS, MEDIA_VARIANT_SOURCE_MAX_BYTES, mediaVariantDocFields, mediaVariantWidthsFor } from "./media-variants.js";
|
|
20
|
+
import { readStorageObject } from "./storage-read-stream.js";
|
|
20
21
|
/**
|
|
21
22
|
* Lazy regeneration of an asset's delivery copies (AGL-3486).
|
|
22
23
|
*
|
|
@@ -147,7 +148,9 @@ import { generateMediaVariants, MEDIA_DISPLAY_TYPES, MEDIA_TYPES_WITHOUT_VARIANT
|
|
|
147
148
|
}).then(()=>undefined);
|
|
148
149
|
let outcome;
|
|
149
150
|
try {
|
|
150
|
-
|
|
151
|
+
// Read through the listener budget, which `file.download()` skips (see
|
|
152
|
+
// `storage-read-stream.ts`).
|
|
153
|
+
const source = await readStorageObject(bucket.file(basePath));
|
|
151
154
|
outcome = await generateMediaVariants({
|
|
152
155
|
buffer: source,
|
|
153
156
|
contentType,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-delivery-regeneration.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 { FieldValue } from 'firebase-admin/firestore'\nimport {\n MEDIA_VARIANT_ENCODER_VERSION,\n MEDIA_VARIANT_ENCODER_VERSION_FIELD,\n mediaDisplayObjectPath,\n mediaVariantEncoderVersionOf,\n} from '@aglyn/aglyn/app-utils/media-ref'\n\nimport {\n generateMediaVariants,\n MEDIA_DISPLAY_TYPES,\n MEDIA_TYPES_WITHOUT_VARIANTS,\n MEDIA_VARIANT_SOURCE_MAX_BYTES,\n mediaVariantDocFields,\n mediaVariantWidthsFor,\n type SaveMediaDerivedObject,\n} from './media-variants'\n\n/**\n * Lazy regeneration of an asset's delivery copies (AGL-3486).\n *\n * A change to the encoder — its quality, the width ladder, the display copy —\n * has to reach every asset already in every library, and the obvious tool is\n * a backfill script: a pass over the corpus that somebody has to remember to\n * run, against production, after each deploy that changes the encoder. The\n * last one (`backfill-media-variants.mjs`) was written, documented as\n * outstanding and never converged.\n *\n * So the CDN converges the corpus itself. A media document records the\n * encoder generation that made its copies\n * (`MEDIA_VARIANT_ENCODER_VERSION_FIELD`); a request that finds it older than\n * `MEDIA_VARIANT_ENCODER_VERSION` is SERVED what exists — an old variant, or\n * the original — and the asset is regenerated after the response. Every asset\n * a visitor actually sees reaches the new encoder on its first view; one\n * nobody requests costs nothing. An asset that never got variants at all,\n * which is what the old backfill was for, is simply an asset at generation 1.\n *\n * ## Why after the response, and not inside it\n *\n * Regenerating means fetching the original (up to 15 MB) and making up to ten\n * WebP encodes and a display copy: about two seconds for a large photo. A\n * visitor should not wait for that, and nothing about the old bytes is wrong —\n * only heavier — so the request is answered from what exists and the work\n * runs in `after()`.\n *\n * ## One regeneration per asset, not one per visitor\n *\n * A popular image is requested many times before its first regeneration\n * finishes. The first request takes a lease on the document in a transaction\n * (`variantRegeneration.startedAtMs`); every other request in the lease window\n * sees it on the snapshot it already read and schedules nothing, so the lease\n * costs one transaction per asset, not one per request. A failure records the\n * generation it failed at, so a source `sharp` cannot decode is tried once per\n * encoder generation rather than once per minute forever.\n */\n\n/** How long a regeneration's lease keeps others from starting one. */\nexport const MEDIA_REGENERATION_LEASE_MS = 2 * 60 * 1000\n\n/** The document field holding the lease and any failure. */\nexport const MEDIA_REGENERATION_FIELD = 'variantRegeneration'\n\nexport interface MediaRegenerationState {\n /** When the current (or last) regeneration took its lease. */\n startedAtMs?: number\n /** The encoder generation a regeneration last FAILED at. */\n failedVersion?: number\n /** Why, kept short — the same contract as `variantsError`. */\n error?: string\n}\n\n/** The fields of a media document this decision reads, among any others. */\nexport interface MediaRegenerationFacts {\n [field: string]: unknown\n contentType?: unknown\n cdnPath?: unknown\n sizeBytes?: unknown\n width?: unknown\n [MEDIA_VARIANT_ENCODER_VERSION_FIELD]?: unknown\n [MEDIA_REGENERATION_FIELD]?: unknown\n}\n\n/**\n * Whether this asset's delivery copies should be regenerated now.\n *\n * Pure, and decided from the snapshot the CDN already holds, so a request\n * that does not need to regenerate reads nothing more.\n *\n * - Only assets the generator would have worked on at upload: an image with a\n * CDN path (the `mediaCdn` entitlement and a public asset, the same gate the\n * upload routes apply), of a type that has variants or a display copy, no\n * larger than the generator will fetch back.\n * - Only when the recorded generation is older than the current one.\n * - Not while another regeneration holds the lease, and not again at a\n * generation that already failed.\n */\nexport function mediaDeliveryCopiesStale(\n facts: MediaRegenerationFacts,\n nowMs: number = Date.now(),\n): boolean {\n const contentType = typeof facts.contentType === 'string' ? facts.contentType : ''\n if (!contentType.startsWith('image/')) return false\n if (MEDIA_TYPES_WITHOUT_VARIANTS.has(contentType)) return false\n if (typeof facts.cdnPath !== 'string' || !facts.cdnPath) return false\n if (\n mediaVariantEncoderVersionOf(facts[MEDIA_VARIANT_ENCODER_VERSION_FIELD]) >=\n MEDIA_VARIANT_ENCODER_VERSION\n ) {\n return false\n }\n const sizeBytes = Number(facts.sizeBytes ?? 0)\n if (!Number.isFinite(sizeBytes) || sizeBytes > MEDIA_VARIANT_SOURCE_MAX_BYTES) {\n return false\n }\n const sourceWidth =\n typeof facts.width === 'number' && facts.width > 0 ? facts.width : null\n const hasWork =\n mediaVariantWidthsFor({ contentType, sourceWidth }).length > 0 ||\n MEDIA_DISPLAY_TYPES.has(contentType)\n if (!hasWork) return false\n const state = (facts[MEDIA_REGENERATION_FIELD] ?? {}) as MediaRegenerationState\n if (state.failedVersion === MEDIA_VARIANT_ENCODER_VERSION) return false\n if (\n typeof state.startedAtMs === 'number' &&\n nowMs - state.startedAtMs < MEDIA_REGENERATION_LEASE_MS\n ) {\n return false\n }\n return true\n}\n\n/** The slice of a Storage bucket this needs. */\nexport interface MediaRegenerationBucket {\n file(path: string): {\n download(): Promise<[Buffer]>\n save(\n bytes: Buffer,\n options: { contentType: string; metadata: { cacheControl: string } },\n ): Promise<unknown>\n delete(): Promise<unknown>\n }\n}\n\nexport type MediaRegenerationResult =\n | 'regenerated'\n | 'not-stale'\n | 'changed'\n | 'failed'\n\n/**\n * Regenerate one asset's delivery copies with the current encoder.\n *\n * `basePath` is the original's object path as the CDN resolved it — already\n * proved to be inside the asset's own scope (AGL-1881) — and every object this\n * writes or deletes is derived from it.\n *\n * Ordering, and why:\n *\n * 1. **Lease**, in a transaction that re-reads the document: two requests that\n * both saw it stale cannot both start.\n * 2. **Generate and save** the new copies. Their paths are the old ones, so a\n * request in this window may receive new bytes under an old validator —\n * harmless, both are the same picture.\n * 3. **Record**, in a transaction that refuses when the content hash moved: a\n * replace during the work owns the document, and copies of the bytes it\n * replaced must not be recorded over it.\n * 4. **Delete** what the new set no longer names — a width the new encoder\n * skipped, a display copy the new rules do not need — only once the\n * document stops naming them, so the CDN never points at a deleted object.\n */\nexport async function regenerateMediaDeliveryCopies(options: {\n docRef: FirebaseFirestore.DocumentReference\n bucket: MediaRegenerationBucket\n basePath: string\n nowMs?: number\n}): Promise<MediaRegenerationResult> {\n const { docRef, bucket, basePath } = options\n const nowMs = options.nowMs ?? Date.now()\n const { firestore } = docRef\n\n const leased = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(docRef)\n const data = snapshot.exists ? (snapshot.data() ?? {}) : null\n if (!data || data['deletedAt'] || !mediaDeliveryCopiesStale(data, nowMs)) {\n return null\n }\n const previous = (data[MEDIA_REGENERATION_FIELD] ?? {}) as MediaRegenerationState\n transaction.update(docRef, {\n [MEDIA_REGENERATION_FIELD]: { ...previous, startedAtMs: nowMs },\n })\n return data\n })\n if (!leased) return 'not-stale'\n\n const contentType = String(leased['contentType'])\n const contentHash = leased['contentHash']\n const previousVariants: number[] = Array.isArray(leased['variants'])\n ? (leased['variants'] as number[])\n : []\n const previousDisplay = Boolean(leased['display'])\n\n const saveVariant: SaveMediaDerivedObject = (path, bytes, type) =>\n bucket\n .file(path)\n .save(bytes, {\n contentType: type,\n metadata: { cacheControl: 'public, max-age=31536000, immutable' },\n })\n .then(() => undefined)\n\n let outcome: Awaited<ReturnType<typeof generateMediaVariants>>\n try {\n const [source] = await bucket.file(basePath).download()\n outcome = await generateMediaVariants({\n buffer: source,\n contentType,\n sourceWidth:\n typeof leased['width'] === 'number' ? (leased['width'] as number) : null,\n objectPath: basePath,\n saveVariant,\n display: true,\n })\n } catch (error) {\n outcome = {\n variants: [],\n error: `source download failed — ${error instanceof Error ? error.message : String(error)}`.slice(0, 300),\n }\n }\n\n if (outcome.error) {\n // The widths that DID land are real objects at the current generation and\n // the ones that did not are still the old encode, so the document names\n // both; the generation stays where it was, and the failure is recorded so\n // it is not retried until the encoder changes again.\n const written = new Set(outcome.variants)\n await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(docRef)\n if (!fresh.exists || fresh.data()?.['contentHash'] !== contentHash) return\n transaction.update(docRef, {\n variants: [...new Set([...previousVariants, ...written])].sort((a, b) => a - b),\n // A display copy that landed before the failure is a real object too.\n ...(outcome.display ? { display: outcome.display } : {}),\n variantsError: outcome.error,\n [MEDIA_REGENERATION_FIELD]: {\n startedAtMs: nowMs,\n failedVersion: MEDIA_VARIANT_ENCODER_VERSION,\n error: outcome.error,\n } satisfies MediaRegenerationState,\n })\n })\n return 'failed'\n }\n\n const recorded = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(docRef)\n if (!fresh.exists || fresh.data()?.['contentHash'] !== contentHash) return false\n transaction.update(docRef, {\n ...mediaVariantDocFields(outcome),\n variantsError: FieldValue.delete(),\n [MEDIA_REGENERATION_FIELD]: FieldValue.delete(),\n })\n return true\n })\n if (!recorded) return 'changed'\n\n const kept = new Set(outcome.variants)\n await Promise.all([\n ...previousVariants\n .filter((width) => !kept.has(width))\n .map((width) =>\n bucket.file(`${basePath}__w${width}.webp`).delete().catch(() => undefined),\n ),\n ...(previousDisplay && !outcome.display\n ? [bucket.file(mediaDisplayObjectPath(basePath)).delete().catch(() => undefined)]\n : []),\n ])\n return 'regenerated'\n}\n"],"names":["FieldValue","MEDIA_VARIANT_ENCODER_VERSION","MEDIA_VARIANT_ENCODER_VERSION_FIELD","mediaDisplayObjectPath","mediaVariantEncoderVersionOf","generateMediaVariants","MEDIA_DISPLAY_TYPES","MEDIA_TYPES_WITHOUT_VARIANTS","MEDIA_VARIANT_SOURCE_MAX_BYTES","mediaVariantDocFields","mediaVariantWidthsFor","MEDIA_REGENERATION_LEASE_MS","MEDIA_REGENERATION_FIELD","mediaDeliveryCopiesStale","facts","nowMs","Date","now","contentType","startsWith","has","cdnPath","sizeBytes","Number","isFinite","sourceWidth","width","hasWork","length","state","failedVersion","startedAtMs","regenerateMediaDeliveryCopies","options","docRef","bucket","basePath","firestore","leased","runTransaction","transaction","snapshot","data","get","exists","previous","update","String","contentHash","previousVariants","Array","isArray","previousDisplay","Boolean","saveVariant","path","bytes","type","file","save","metadata","cacheControl","then","undefined","outcome","source","download","buffer","objectPath","display","error","variants","Error","message","slice","written","Set","fresh","sort","a","b","variantsError","recorded","delete","kept","Promise","all","filter","map","catch"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-delivery-regeneration.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 { FieldValue } from 'firebase-admin/firestore'\nimport {\n MEDIA_VARIANT_ENCODER_VERSION,\n MEDIA_VARIANT_ENCODER_VERSION_FIELD,\n mediaDisplayObjectPath,\n mediaVariantEncoderVersionOf,\n} from '@aglyn/aglyn/app-utils/media-ref'\n\nimport {\n generateMediaVariants,\n MEDIA_DISPLAY_TYPES,\n MEDIA_TYPES_WITHOUT_VARIANTS,\n MEDIA_VARIANT_SOURCE_MAX_BYTES,\n mediaVariantDocFields,\n mediaVariantWidthsFor,\n type SaveMediaDerivedObject,\n} from './media-variants'\nimport { readStorageObject, type StorageReadableFile } from './storage-read-stream'\n\n/**\n * Lazy regeneration of an asset's delivery copies (AGL-3486).\n *\n * A change to the encoder — its quality, the width ladder, the display copy —\n * has to reach every asset already in every library, and the obvious tool is\n * a backfill script: a pass over the corpus that somebody has to remember to\n * run, against production, after each deploy that changes the encoder. The\n * last one (`backfill-media-variants.mjs`) was written, documented as\n * outstanding and never converged.\n *\n * So the CDN converges the corpus itself. A media document records the\n * encoder generation that made its copies\n * (`MEDIA_VARIANT_ENCODER_VERSION_FIELD`); a request that finds it older than\n * `MEDIA_VARIANT_ENCODER_VERSION` is SERVED what exists — an old variant, or\n * the original — and the asset is regenerated after the response. Every asset\n * a visitor actually sees reaches the new encoder on its first view; one\n * nobody requests costs nothing. An asset that never got variants at all,\n * which is what the old backfill was for, is simply an asset at generation 1.\n *\n * ## Why after the response, and not inside it\n *\n * Regenerating means fetching the original (up to 15 MB) and making up to ten\n * WebP encodes and a display copy: about two seconds for a large photo. A\n * visitor should not wait for that, and nothing about the old bytes is wrong —\n * only heavier — so the request is answered from what exists and the work\n * runs in `after()`.\n *\n * ## One regeneration per asset, not one per visitor\n *\n * A popular image is requested many times before its first regeneration\n * finishes. The first request takes a lease on the document in a transaction\n * (`variantRegeneration.startedAtMs`); every other request in the lease window\n * sees it on the snapshot it already read and schedules nothing, so the lease\n * costs one transaction per asset, not one per request. A failure records the\n * generation it failed at, so a source `sharp` cannot decode is tried once per\n * encoder generation rather than once per minute forever.\n */\n\n/** How long a regeneration's lease keeps others from starting one. */\nexport const MEDIA_REGENERATION_LEASE_MS = 2 * 60 * 1000\n\n/** The document field holding the lease and any failure. */\nexport const MEDIA_REGENERATION_FIELD = 'variantRegeneration'\n\nexport interface MediaRegenerationState {\n /** When the current (or last) regeneration took its lease. */\n startedAtMs?: number\n /** The encoder generation a regeneration last FAILED at. */\n failedVersion?: number\n /** Why, kept short — the same contract as `variantsError`. */\n error?: string\n}\n\n/** The fields of a media document this decision reads, among any others. */\nexport interface MediaRegenerationFacts {\n [field: string]: unknown\n contentType?: unknown\n cdnPath?: unknown\n sizeBytes?: unknown\n width?: unknown\n [MEDIA_VARIANT_ENCODER_VERSION_FIELD]?: unknown\n [MEDIA_REGENERATION_FIELD]?: unknown\n}\n\n/**\n * Whether this asset's delivery copies should be regenerated now.\n *\n * Pure, and decided from the snapshot the CDN already holds, so a request\n * that does not need to regenerate reads nothing more.\n *\n * - Only assets the generator would have worked on at upload: an image with a\n * CDN path (the `mediaCdn` entitlement and a public asset, the same gate the\n * upload routes apply), of a type that has variants or a display copy, no\n * larger than the generator will fetch back.\n * - Only when the recorded generation is older than the current one.\n * - Not while another regeneration holds the lease, and not again at a\n * generation that already failed.\n */\nexport function mediaDeliveryCopiesStale(\n facts: MediaRegenerationFacts,\n nowMs: number = Date.now(),\n): boolean {\n const contentType = typeof facts.contentType === 'string' ? facts.contentType : ''\n if (!contentType.startsWith('image/')) return false\n if (MEDIA_TYPES_WITHOUT_VARIANTS.has(contentType)) return false\n if (typeof facts.cdnPath !== 'string' || !facts.cdnPath) return false\n if (\n mediaVariantEncoderVersionOf(facts[MEDIA_VARIANT_ENCODER_VERSION_FIELD]) >=\n MEDIA_VARIANT_ENCODER_VERSION\n ) {\n return false\n }\n const sizeBytes = Number(facts.sizeBytes ?? 0)\n if (!Number.isFinite(sizeBytes) || sizeBytes > MEDIA_VARIANT_SOURCE_MAX_BYTES) {\n return false\n }\n const sourceWidth =\n typeof facts.width === 'number' && facts.width > 0 ? facts.width : null\n const hasWork =\n mediaVariantWidthsFor({ contentType, sourceWidth }).length > 0 ||\n MEDIA_DISPLAY_TYPES.has(contentType)\n if (!hasWork) return false\n const state = (facts[MEDIA_REGENERATION_FIELD] ?? {}) as MediaRegenerationState\n if (state.failedVersion === MEDIA_VARIANT_ENCODER_VERSION) return false\n if (\n typeof state.startedAtMs === 'number' &&\n nowMs - state.startedAtMs < MEDIA_REGENERATION_LEASE_MS\n ) {\n return false\n }\n return true\n}\n\n/** The slice of a Storage bucket this needs. */\nexport interface MediaRegenerationBucket {\n file(path: string): StorageReadableFile & {\n save(\n bytes: Buffer,\n options: { contentType: string; metadata: { cacheControl: string } },\n ): Promise<unknown>\n delete(): Promise<unknown>\n }\n}\n\nexport type MediaRegenerationResult =\n | 'regenerated'\n | 'not-stale'\n | 'changed'\n | 'failed'\n\n/**\n * Regenerate one asset's delivery copies with the current encoder.\n *\n * `basePath` is the original's object path as the CDN resolved it — already\n * proved to be inside the asset's own scope (AGL-1881) — and every object this\n * writes or deletes is derived from it.\n *\n * Ordering, and why:\n *\n * 1. **Lease**, in a transaction that re-reads the document: two requests that\n * both saw it stale cannot both start.\n * 2. **Generate and save** the new copies. Their paths are the old ones, so a\n * request in this window may receive new bytes under an old validator —\n * harmless, both are the same picture.\n * 3. **Record**, in a transaction that refuses when the content hash moved: a\n * replace during the work owns the document, and copies of the bytes it\n * replaced must not be recorded over it.\n * 4. **Delete** what the new set no longer names — a width the new encoder\n * skipped, a display copy the new rules do not need — only once the\n * document stops naming them, so the CDN never points at a deleted object.\n */\nexport async function regenerateMediaDeliveryCopies(options: {\n docRef: FirebaseFirestore.DocumentReference\n bucket: MediaRegenerationBucket\n basePath: string\n nowMs?: number\n}): Promise<MediaRegenerationResult> {\n const { docRef, bucket, basePath } = options\n const nowMs = options.nowMs ?? Date.now()\n const { firestore } = docRef\n\n const leased = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(docRef)\n const data = snapshot.exists ? (snapshot.data() ?? {}) : null\n if (!data || data['deletedAt'] || !mediaDeliveryCopiesStale(data, nowMs)) {\n return null\n }\n const previous = (data[MEDIA_REGENERATION_FIELD] ?? {}) as MediaRegenerationState\n transaction.update(docRef, {\n [MEDIA_REGENERATION_FIELD]: { ...previous, startedAtMs: nowMs },\n })\n return data\n })\n if (!leased) return 'not-stale'\n\n const contentType = String(leased['contentType'])\n const contentHash = leased['contentHash']\n const previousVariants: number[] = Array.isArray(leased['variants'])\n ? (leased['variants'] as number[])\n : []\n const previousDisplay = Boolean(leased['display'])\n\n const saveVariant: SaveMediaDerivedObject = (path, bytes, type) =>\n bucket\n .file(path)\n .save(bytes, {\n contentType: type,\n metadata: { cacheControl: 'public, max-age=31536000, immutable' },\n })\n .then(() => undefined)\n\n let outcome: Awaited<ReturnType<typeof generateMediaVariants>>\n try {\n // Read through the listener budget, which `file.download()` skips (see\n // `storage-read-stream.ts`).\n const source = await readStorageObject(bucket.file(basePath))\n outcome = await generateMediaVariants({\n buffer: source,\n contentType,\n sourceWidth:\n typeof leased['width'] === 'number' ? (leased['width'] as number) : null,\n objectPath: basePath,\n saveVariant,\n display: true,\n })\n } catch (error) {\n outcome = {\n variants: [],\n error: `source download failed — ${error instanceof Error ? error.message : String(error)}`.slice(0, 300),\n }\n }\n\n if (outcome.error) {\n // The widths that DID land are real objects at the current generation and\n // the ones that did not are still the old encode, so the document names\n // both; the generation stays where it was, and the failure is recorded so\n // it is not retried until the encoder changes again.\n const written = new Set(outcome.variants)\n await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(docRef)\n if (!fresh.exists || fresh.data()?.['contentHash'] !== contentHash) return\n transaction.update(docRef, {\n variants: [...new Set([...previousVariants, ...written])].sort((a, b) => a - b),\n // A display copy that landed before the failure is a real object too.\n ...(outcome.display ? { display: outcome.display } : {}),\n variantsError: outcome.error,\n [MEDIA_REGENERATION_FIELD]: {\n startedAtMs: nowMs,\n failedVersion: MEDIA_VARIANT_ENCODER_VERSION,\n error: outcome.error,\n } satisfies MediaRegenerationState,\n })\n })\n return 'failed'\n }\n\n const recorded = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(docRef)\n if (!fresh.exists || fresh.data()?.['contentHash'] !== contentHash) return false\n transaction.update(docRef, {\n ...mediaVariantDocFields(outcome),\n variantsError: FieldValue.delete(),\n [MEDIA_REGENERATION_FIELD]: FieldValue.delete(),\n })\n return true\n })\n if (!recorded) return 'changed'\n\n const kept = new Set(outcome.variants)\n await Promise.all([\n ...previousVariants\n .filter((width) => !kept.has(width))\n .map((width) =>\n bucket.file(`${basePath}__w${width}.webp`).delete().catch(() => undefined),\n ),\n ...(previousDisplay && !outcome.display\n ? [bucket.file(mediaDisplayObjectPath(basePath)).delete().catch(() => undefined)]\n : []),\n ])\n return 'regenerated'\n}\n"],"names":["FieldValue","MEDIA_VARIANT_ENCODER_VERSION","MEDIA_VARIANT_ENCODER_VERSION_FIELD","mediaDisplayObjectPath","mediaVariantEncoderVersionOf","generateMediaVariants","MEDIA_DISPLAY_TYPES","MEDIA_TYPES_WITHOUT_VARIANTS","MEDIA_VARIANT_SOURCE_MAX_BYTES","mediaVariantDocFields","mediaVariantWidthsFor","readStorageObject","MEDIA_REGENERATION_LEASE_MS","MEDIA_REGENERATION_FIELD","mediaDeliveryCopiesStale","facts","nowMs","Date","now","contentType","startsWith","has","cdnPath","sizeBytes","Number","isFinite","sourceWidth","width","hasWork","length","state","failedVersion","startedAtMs","regenerateMediaDeliveryCopies","options","docRef","bucket","basePath","firestore","leased","runTransaction","transaction","snapshot","data","get","exists","previous","update","String","contentHash","previousVariants","Array","isArray","previousDisplay","Boolean","saveVariant","path","bytes","type","file","save","metadata","cacheControl","then","undefined","outcome","source","buffer","objectPath","display","error","variants","Error","message","slice","written","Set","fresh","sort","a","b","variantsError","recorded","delete","kept","Promise","all","filter","map","catch"],"mappings":"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"}
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
* the measured fan-out plus a little headroom. The process default stays at
|
|
44
44
|
* 10, so a real leak anywhere still warns, and so does a real leak on this
|
|
45
45
|
* body once it grows past the budget.
|
|
46
|
+
*
|
|
47
|
+
* `File#download()` makes the same read but never exposes the stream, so its
|
|
48
|
+
* body cannot be budgeted and it still warns. A read that wants the whole
|
|
49
|
+
* object uses {@link readStorageObject} instead.
|
|
46
50
|
*/ import { buffer } from "node:stream/consumers";
|
|
47
51
|
/**
|
|
48
52
|
* The listener limit for a Storage response body. The measured peak is 12
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/storage-read-stream.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\n/**\n * Reading a Cloud Storage object without a false leak warning (AGL-3795).\n *\n * ## Why every Storage read used to warn\n *\n * `File#createReadStream` (and `File#download`, which is built on it) hands\n * the HTTP response body, a node-fetch `PassThrough`, to three `pipeline`s at\n * once:\n *\n * 1. node-fetch's own, from the socket into the body;\n * 2. teeny-request's, from the body into the retry-request stream, which\n * Storage only `resume()`s;\n * 3. Storage's, from the body into the read stream it returns.\n *\n * On Node 24 those add 12 `error` listeners and 11 `close` listeners to the\n * body. Node's limit is 10, so `MaxListenersExceededWarning` fired once per\n * read on every route that touches Storage. The count is a fixed fan-out: it\n * is the same for a 1-chunk and a 200-chunk object and for the first and the\n * hundredth request, and the listeners go away with the body when the read\n * ends. So nothing accumulates. The warning is a heuristic tripping on a\n * library's wiring.\n *\n * ## What this does\n *\n * Storage re-emits the body as the read stream's `response` event before it\n * or teeny-request attaches its `pipeline`. At that point, only on that one\n * per-request body, the limit is raised to {@link STORAGE_READ_BODY_LISTENER_BUDGET}:\n * the measured fan-out plus a little headroom. The process default stays at\n * 10, so a real leak anywhere still warns, and so does a real leak on this\n * body once it grows past the budget.\n */\n\nimport type { Readable } from 'node:stream'\nimport { buffer } from 'node:stream/consumers'\n\n/**\n * The listener limit for a Storage response body. The measured peak is 12\n * (`error`). Room for a few more lets a library patch add one without\n * bringing the warning back, but stays low enough that a per-chunk or\n * per-request leak would still cross it within one read.\n */\nexport const STORAGE_READ_BODY_LISTENER_BUDGET = 16\n\n/** The slice of a Storage `File` a read needs. */\nexport interface StorageReadableFile {\n createReadStream(options?: StorageReadOptions): NodeJS.ReadableStream\n}\n\n/** The `createReadStream` options the platform uses. */\nexport interface StorageReadOptions {\n start?: number\n end?: number\n validation?: 'crc32c' | 'md5' | boolean\n}\n\n/** Raises the limit on the response body Storage just received. */\nfunction budgetResponseBody(body: unknown): void {\n const emitter = body as\n | { getMaxListeners?: () => number; setMaxListeners?: (n: number) => unknown }\n | null\n | undefined\n if (typeof emitter?.setMaxListeners !== 'function') return\n const current = emitter.getMaxListeners?.() ?? 0\n // 0 means unlimited, and a higher limit was set on purpose by someone else.\n if (current === 0 || current >= STORAGE_READ_BODY_LISTENER_BUDGET) return\n emitter.setMaxListeners(STORAGE_READ_BODY_LISTENER_BUDGET)\n}\n\n/**\n * `file.createReadStream(options)`, with the response body's listener\n * limit sized to Storage's fixed fan-out (see the module note).\n */\nexport function openStorageReadStream(\n file: StorageReadableFile,\n options: StorageReadOptions = {},\n): NodeJS.ReadableStream {\n const stream = file.createReadStream(options)\n stream.once('response', budgetResponseBody)\n return stream\n}\n\n/**\n * The whole object as one Buffer, like `file.download()`, read through\n * {@link openStorageReadStream}. Rejects when the read fails.\n */\nexport async function readStorageObject(\n file: StorageReadableFile,\n options: StorageReadOptions = {},\n): Promise<Buffer> {\n return buffer(openStorageReadStream(file, options) as Readable)\n}\n"],"names":["buffer","STORAGE_READ_BODY_LISTENER_BUDGET","budgetResponseBody","body","emitter","setMaxListeners","current","getMaxListeners","openStorageReadStream","file","options","stream","createReadStream","once","readStorageObject"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/storage-read-stream.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\n/**\n * Reading a Cloud Storage object without a false leak warning (AGL-3795).\n *\n * ## Why every Storage read used to warn\n *\n * `File#createReadStream` (and `File#download`, which is built on it) hands\n * the HTTP response body, a node-fetch `PassThrough`, to three `pipeline`s at\n * once:\n *\n * 1. node-fetch's own, from the socket into the body;\n * 2. teeny-request's, from the body into the retry-request stream, which\n * Storage only `resume()`s;\n * 3. Storage's, from the body into the read stream it returns.\n *\n * On Node 24 those add 12 `error` listeners and 11 `close` listeners to the\n * body. Node's limit is 10, so `MaxListenersExceededWarning` fired once per\n * read on every route that touches Storage. The count is a fixed fan-out: it\n * is the same for a 1-chunk and a 200-chunk object and for the first and the\n * hundredth request, and the listeners go away with the body when the read\n * ends. So nothing accumulates. The warning is a heuristic tripping on a\n * library's wiring.\n *\n * ## What this does\n *\n * Storage re-emits the body as the read stream's `response` event before it\n * or teeny-request attaches its `pipeline`. At that point, only on that one\n * per-request body, the limit is raised to {@link STORAGE_READ_BODY_LISTENER_BUDGET}:\n * the measured fan-out plus a little headroom. The process default stays at\n * 10, so a real leak anywhere still warns, and so does a real leak on this\n * body once it grows past the budget.\n *\n * `File#download()` makes the same read but never exposes the stream, so its\n * body cannot be budgeted and it still warns. A read that wants the whole\n * object uses {@link readStorageObject} instead.\n */\n\nimport type { Readable } from 'node:stream'\nimport { buffer } from 'node:stream/consumers'\n\n/**\n * The listener limit for a Storage response body. The measured peak is 12\n * (`error`). Room for a few more lets a library patch add one without\n * bringing the warning back, but stays low enough that a per-chunk or\n * per-request leak would still cross it within one read.\n */\nexport const STORAGE_READ_BODY_LISTENER_BUDGET = 16\n\n/** The slice of a Storage `File` a read needs. */\nexport interface StorageReadableFile {\n createReadStream(options?: StorageReadOptions): NodeJS.ReadableStream\n}\n\n/** The `createReadStream` options the platform uses. */\nexport interface StorageReadOptions {\n start?: number\n end?: number\n validation?: 'crc32c' | 'md5' | boolean\n}\n\n/** Raises the limit on the response body Storage just received. */\nfunction budgetResponseBody(body: unknown): void {\n const emitter = body as\n | { getMaxListeners?: () => number; setMaxListeners?: (n: number) => unknown }\n | null\n | undefined\n if (typeof emitter?.setMaxListeners !== 'function') return\n const current = emitter.getMaxListeners?.() ?? 0\n // 0 means unlimited, and a higher limit was set on purpose by someone else.\n if (current === 0 || current >= STORAGE_READ_BODY_LISTENER_BUDGET) return\n emitter.setMaxListeners(STORAGE_READ_BODY_LISTENER_BUDGET)\n}\n\n/**\n * `file.createReadStream(options)`, with the response body's listener\n * limit sized to Storage's fixed fan-out (see the module note).\n */\nexport function openStorageReadStream(\n file: StorageReadableFile,\n options: StorageReadOptions = {},\n): NodeJS.ReadableStream {\n const stream = file.createReadStream(options)\n stream.once('response', budgetResponseBody)\n return stream\n}\n\n/**\n * The whole object as one Buffer, like `file.download()`, read through\n * {@link openStorageReadStream}. Rejects when the read fails.\n */\nexport async function readStorageObject(\n file: StorageReadableFile,\n options: StorageReadOptions = {},\n): Promise<Buffer> {\n return buffer(openStorageReadStream(file, options) as Readable)\n}\n"],"names":["buffer","STORAGE_READ_BODY_LISTENER_BUDGET","budgetResponseBody","body","emitter","setMaxListeners","current","getMaxListeners","openStorageReadStream","file","options","stream","createReadStream","once","readStorageObject"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GAGD,SAASA,MAAM,QAAQ,wBAAuB;AAE9C;;;;;CAKC,GACD,OAAO,MAAMC,oCAAoC,GAAE;AAcnD,iEAAiE,GACjE,SAASC,mBAAmBC,IAAa;;IACvC,MAAMC,UAAUD;IAIhB,IAAI,QAAOC,2BAAAA,QAASC,eAAe,MAAK,YAAY;IACpD,MAAMC,kBAAUF,QAAQG,eAAe,oBAAvBH,QAAQG,eAAe,MAAvBH,2BAA+B;IAC/C,4EAA4E;IAC5E,IAAIE,YAAY,KAAKA,WAAWL,mCAAmC;IACnEG,QAAQC,eAAe,CAACJ;AAC1B;AAEA;;;CAGC,GACD,OAAO,SAASO,sBACdC,IAAyB,EACzBC,UAA8B,CAAC,CAAC;IAEhC,MAAMC,SAASF,KAAKG,gBAAgB,CAACF;IACrCC,OAAOE,IAAI,CAAC,YAAYX;IACxB,OAAOS;AACT;AAEA;;;CAGC,GACD,OAAO,eAAeG,kBACpBL,IAAyB,EACzBC,UAA8B,CAAC,CAAC;IAEhC,OAAOV,OAAOQ,sBAAsBC,MAAMC;AAC5C"}
|