jamdesk 1.1.162 → 1.1.164
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/__tests__/unit/deploy-proxy-slug.test.js +19 -1
- package/dist/__tests__/unit/deploy-proxy-slug.test.js.map +1 -1
- package/dist/__tests__/unit/deploy-templates.test.js +15 -0
- package/dist/__tests__/unit/deploy-templates.test.js.map +1 -1
- package/dist/__tests__/unit/deps-sync.test.js +1 -0
- package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
- package/dist/commands/deploy/cloudflare.js +9 -4
- package/dist/commands/deploy/cloudflare.js.map +1 -1
- package/dist/commands/deploy/templates.d.ts.map +1 -1
- package/dist/commands/deploy/templates.js +9 -5
- package/dist/commands/deploy/templates.js.map +1 -1
- package/dist/lib/deps.js +13 -13
- package/dist/lib/deps.js.map +1 -1
- package/package.json +8 -8
- package/vendored/app/(unlock)/jd/unlock/UnlockForm.tsx +4 -1
- package/vendored/app/api/jd/unlock/route.ts +3 -1
- package/vendored/lib/fonts.ts +44 -3
- package/vendored/lib/openapi-isr.ts +2 -2
- package/vendored/lib/r2-public-fetch.ts +9 -1
- package/vendored/lib/regenerate-screenshot-handler.ts +97 -0
- package/vendored/lib/rehype-class-to-classname.ts +16 -15
- package/vendored/lib/scanner-blocklist.ts +23 -0
- package/vendored/lib/screenshot-capture.ts +102 -18
- package/vendored/workspace-package-lock.json +82 -60
package/vendored/lib/fonts.ts
CHANGED
|
@@ -65,6 +65,31 @@ export function validateFontName(fontName: string | undefined): boolean {
|
|
|
65
65
|
return fontName.trim().length > 0 && fontName.length < 100;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Merge a configured body weight with the theme's heading-weight ladder.
|
|
70
|
+
*
|
|
71
|
+
* The theme renders headings at 500/600 (--prose-hN-weight). If we requested
|
|
72
|
+
* ONLY the client's chosen body weight, headings would have no bold face and
|
|
73
|
+
* collapse down to the body weight — flattening the type hierarchy (Inter avoids
|
|
74
|
+
* this because next/font loads it as a full variable font). So we always keep the
|
|
75
|
+
* heading weights loaded alongside the chosen body weight. Deduped + sorted; the
|
|
76
|
+
* body weight defaults to 300 (the theme's --prose-body-weight) when unset.
|
|
77
|
+
*/
|
|
78
|
+
function withHeadingWeights(bodyWeight: number | undefined, headingLadder: number[]): number[] {
|
|
79
|
+
return Array.from(new Set([bodyWeight ?? 300, ...headingLadder])).sort((a, b) => a - b);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The client-configured body-text weight, across config formats. Legacy configs
|
|
84
|
+
* have no weight concept. Returns undefined when unset, so callers can fall back
|
|
85
|
+
* to the theme default rather than forcing a weight.
|
|
86
|
+
*/
|
|
87
|
+
function getConfiguredBodyWeight(fonts: FontConfig): number | undefined {
|
|
88
|
+
if (isSingleFontConfig(fonts)) return fonts.weight;
|
|
89
|
+
if (isHeadingBodyFontConfig(fonts)) return fonts.body?.weight;
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
68
93
|
/**
|
|
69
94
|
* Type guard for legacy font config
|
|
70
95
|
*/
|
|
@@ -152,7 +177,9 @@ export function generateFontImports(fonts: FontConfig | undefined): string | nul
|
|
|
152
177
|
if (fonts.family && validateFontName(fonts.family)) {
|
|
153
178
|
// If source is provided, it's a custom font file - don't use Google Fonts
|
|
154
179
|
if (!fonts.source && !isPreloadedFont(fonts.family)) {
|
|
155
|
-
|
|
180
|
+
// Load the chosen body weight AND the theme's heading ladder, so headings
|
|
181
|
+
// keep a bold face instead of collapsing to the body weight.
|
|
182
|
+
const weights = withHeadingWeights(fonts.weight, [400, 500, 600, 700]);
|
|
156
183
|
imports.push(generateGoogleFontsUrl(fonts.family, weights));
|
|
157
184
|
}
|
|
158
185
|
}
|
|
@@ -167,7 +194,9 @@ export function generateFontImports(fonts: FontConfig | undefined): string | nul
|
|
|
167
194
|
|
|
168
195
|
if (fonts.body?.family && validateFontName(fonts.body.family)) {
|
|
169
196
|
if (!fonts.body.source && !isPreloadedFont(fonts.body.family)) {
|
|
170
|
-
|
|
197
|
+
// Keep the heading weights (incl. h1's 600) loaded — the body font is
|
|
198
|
+
// reused for headings when no separate heading font is configured.
|
|
199
|
+
const weights = withHeadingWeights(fonts.body.weight, [400, 500, 600]);
|
|
171
200
|
const bodyUrl = generateGoogleFontsUrl(fonts.body.family, weights);
|
|
172
201
|
if (!imports.includes(bodyUrl)) {
|
|
173
202
|
imports.push(bodyUrl);
|
|
@@ -229,5 +258,17 @@ export function generateFontVariables(fonts: FontConfig | undefined): string {
|
|
|
229
258
|
|
|
230
259
|
if (variables.length === 0) return '';
|
|
231
260
|
|
|
232
|
-
|
|
261
|
+
const blocks = [`:root {\n ${variables.join('\n ')}\n}`];
|
|
262
|
+
|
|
263
|
+
// Pin body text to the client's configured weight. The theme sets
|
|
264
|
+
// --prose-body-weight: 300 on :root (tuned for Inter); a custom font may not
|
|
265
|
+
// ship a 300, and the client may want a lighter/heavier body. Emit at
|
|
266
|
+
// :root:root specificity (0,0,2,0) so it beats the theme's :root default
|
|
267
|
+
// regardless of stylesheet order — the theme CSS is injected AFTER this block.
|
|
268
|
+
const bodyWeight = getConfiguredBodyWeight(fonts);
|
|
269
|
+
if (typeof bodyWeight === 'number' && Number.isFinite(bodyWeight)) {
|
|
270
|
+
blocks.push(`:root:root {\n --prose-body-weight: ${bodyWeight};\n}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return blocks.join('\n');
|
|
233
274
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* with caching for ISR mode rendering.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import
|
|
8
|
+
import {load as yamlLoad} from 'js-yaml';
|
|
9
9
|
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
10
10
|
import { fetchOpenApiFile } from './r2-content';
|
|
11
11
|
|
|
@@ -67,7 +67,7 @@ export async function fetchOpenApiSpecFromR2(
|
|
|
67
67
|
|
|
68
68
|
// Parse YAML or JSON into a plain object
|
|
69
69
|
const isYaml = /\.ya?ml$/i.test(specPath);
|
|
70
|
-
const raw = isYaml ?
|
|
70
|
+
const raw = isYaml ? yamlLoad(content) : JSON.parse(content);
|
|
71
71
|
|
|
72
72
|
// Dereference all $ref pointers (matching static mode's SwaggerParser.validate behavior).
|
|
73
73
|
// SwaggerParser.dereference accepts a loose Document type that does not line up with
|
|
@@ -78,7 +78,15 @@ async function fetchPublicR2Body(
|
|
|
78
78
|
const start = performance.now();
|
|
79
79
|
const region = process.env.VERCEL_REGION || 'unknown';
|
|
80
80
|
try {
|
|
81
|
-
|
|
81
|
+
// WAF bypass for the r2.jamdesk.com default-deny rule: the public
|
|
82
|
+
// custom domain rejects requests missing this shared secret so the
|
|
83
|
+
// bucket can't be scanned, while platform reads keep their CDN cache
|
|
84
|
+
// hits (WAF custom rules evaluate before cache). Env unset → send no
|
|
85
|
+
// header; the resulting 403 routes the read to the S3 fallback.
|
|
86
|
+
const headers: Record<string, string> = {};
|
|
87
|
+
const wafSecret = process.env.R2_PUBLIC_FETCH_SECRET;
|
|
88
|
+
if (wafSecret) headers['x-jd-r2-key'] = wafSecret;
|
|
89
|
+
const res = await fetch(url, { signal: controller.signal, headers });
|
|
82
90
|
const ms = Math.round(performance.now() - start);
|
|
83
91
|
logger.info('[r2-timing]', {
|
|
84
92
|
operation: `publicFetch(${key})`,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {Request, Response} from 'express';
|
|
2
|
+
import {getFirestore} from 'firebase-admin/firestore';
|
|
3
|
+
import {secretsEqual} from './crypto-helpers.js';
|
|
4
|
+
import {captureAndStoreScreenshot} from './screenshot-capture.js';
|
|
5
|
+
import {getR2S3Client, getR2Config} from './r2.js';
|
|
6
|
+
|
|
7
|
+
const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Re-run screenshot capture for a project's latest completed build, WITHOUT a
|
|
11
|
+
* full rebuild. Called by the dashboard `regenerateScreenshot` callable
|
|
12
|
+
* (secret-guarded, mirrors /gc-deploys and /delete-project-assets).
|
|
13
|
+
*
|
|
14
|
+
* Reads the capture params (firstPagePath/hostAtDocs) off the build doc — they
|
|
15
|
+
* were persisted by captureAndStoreScreenshot at build time. This avoids
|
|
16
|
+
* reading docs.json (fetchDocsConfig uses next/cache, which is unavailable in
|
|
17
|
+
* this plain Express Cloud Run process).
|
|
18
|
+
*
|
|
19
|
+
* IMPORTANT: this handler AWAITS the capture (does NOT return 202 and background
|
|
20
|
+
* it). The build service is deployed WITHOUT --no-cpu-throttling, so once the
|
|
21
|
+
* HTTP response is sent Cloud Run may throttle CPU to ~zero and kill any
|
|
22
|
+
* in-flight background work. Holding the request open keeps CPU allocated until
|
|
23
|
+
* the capture finishes. The caller (regenerateScreenshot callable) therefore
|
|
24
|
+
* uses a single long-timeout request with NO retry — see Task 5.
|
|
25
|
+
*/
|
|
26
|
+
export async function handleRegenerateScreenshot(req: Request, res: Response): Promise<void> {
|
|
27
|
+
const buildSecret = req.headers['x-build-service-secret'] as string | undefined;
|
|
28
|
+
const expectedSecret = process.env.BUILD_SERVICE_SECRET;
|
|
29
|
+
if (!expectedSecret) {
|
|
30
|
+
console.log(JSON.stringify({severity: 'ERROR', message: 'BUILD_SERVICE_SECRET not configured - rejecting /regenerate-screenshot'}));
|
|
31
|
+
res.status(500).json({error: 'Server misconfiguration'});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!buildSecret || !secretsEqual(buildSecret, expectedSecret)) {
|
|
35
|
+
console.log(JSON.stringify({severity: 'ERROR', message: 'Unauthorized /regenerate-screenshot request', hasSecret: !!buildSecret}));
|
|
36
|
+
res.status(401).json({error: 'Unauthorized'});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const {projectId, slug, buildId} = (req.body ?? {}) as {
|
|
41
|
+
projectId?: string; slug?: string; buildId?: string;
|
|
42
|
+
};
|
|
43
|
+
if (!projectId || typeof projectId !== 'string') {
|
|
44
|
+
res.status(400).json({error: 'invalid_projectId'});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!slug || typeof slug !== 'string' || !SLUG_RE.test(slug)) {
|
|
48
|
+
res.status(400).json({error: 'invalid_slug'});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!buildId || typeof buildId !== 'string') {
|
|
52
|
+
res.status(400).json({error: 'invalid_buildId'});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const buildRef = getFirestore()
|
|
57
|
+
.collection('projects').doc(projectId)
|
|
58
|
+
.collection('builds').doc(buildId);
|
|
59
|
+
|
|
60
|
+
const snap = await buildRef.get();
|
|
61
|
+
if (!snap.exists) {
|
|
62
|
+
res.status(404).json({error: 'build_not_found'});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const params = (snap.data()?.screenshotCapture ?? {}) as {
|
|
66
|
+
firstPagePath?: string; hostAtDocs?: boolean;
|
|
67
|
+
};
|
|
68
|
+
const firstPagePath = params.firstPagePath ?? 'introduction';
|
|
69
|
+
const hostAtDocs = params.hostAtDocs ?? true;
|
|
70
|
+
|
|
71
|
+
// Flip to 'capturing' so the dashboard shows the spinner (not Retry) while we
|
|
72
|
+
// work. Stamp the start time so the dashboard can fall back to Retry if this
|
|
73
|
+
// capture is stranded here (e.g. the instance dies before the terminal write).
|
|
74
|
+
await buildRef
|
|
75
|
+
.update({screenshotStatus: 'capturing', screenshotCapturingAt: Date.now()})
|
|
76
|
+
.catch(() => {});
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await captureAndStoreScreenshot({
|
|
80
|
+
slug,
|
|
81
|
+
buildId,
|
|
82
|
+
projectId,
|
|
83
|
+
siteUrl: `https://${slug}.jamdesk.app`,
|
|
84
|
+
hostAtDocs,
|
|
85
|
+
firstPagePath,
|
|
86
|
+
r2Client: getR2S3Client(),
|
|
87
|
+
r2Bucket: getR2Config().bucketName,
|
|
88
|
+
});
|
|
89
|
+
// captureAndStoreScreenshot has already written screenshotStatus ready|failed.
|
|
90
|
+
// (A soft capture failure returns void here and is written 'failed' by it.)
|
|
91
|
+
res.status(200).json({ok: true});
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.log(JSON.stringify({severity: 'ERROR', message: 'regenerate-screenshot capture threw', projectId, buildId, error: err instanceof Error ? err.message : String(err)}));
|
|
94
|
+
await buildRef.update({screenshotStatus: 'failed'}).catch(() => {});
|
|
95
|
+
res.status(502).json({error: 'capture_failed'});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -10,21 +10,22 @@ import type { Root, Element } from 'hast';
|
|
|
10
10
|
export function rehypeClassToClassName() {
|
|
11
11
|
return (tree: Root) => {
|
|
12
12
|
visit(tree, 'element', (node: Element) => {
|
|
13
|
-
if (node.properties) {
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
13
|
+
if (node.properties && 'class' in node.properties) {
|
|
14
|
+
// hast stores className as a space-separated token array, not a string.
|
|
15
|
+
// Split any incoming `class`/`className` value into tokens and merge, so
|
|
16
|
+
// the result satisfies the hast `Properties` type (the JSX runtime joins
|
|
17
|
+
// the array back into a string, so rendered output is unchanged).
|
|
18
|
+
const toTokens = (value: unknown): string[] =>
|
|
19
|
+
Array.isArray(value)
|
|
20
|
+
? value.map(String)
|
|
21
|
+
: value == null
|
|
22
|
+
? []
|
|
23
|
+
: String(value).split(/\s+/).filter(Boolean);
|
|
24
|
+
node.properties.className = [
|
|
25
|
+
...toTokens(node.properties.className),
|
|
26
|
+
...toTokens(node.properties.class),
|
|
27
|
+
];
|
|
28
|
+
delete node.properties.class;
|
|
28
29
|
}
|
|
29
30
|
});
|
|
30
31
|
};
|
|
@@ -273,6 +273,29 @@ export function classifyScannerProbe(pathname: string): ScannerCategory | null {
|
|
|
273
273
|
return null;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// Categories that are benign stale re-crawls of dead URLs, not hostile
|
|
277
|
+
// probes. Two consumers key off this set:
|
|
278
|
+
// - the middlewares answer them 410 Gone instead of 404 — 410 is the
|
|
279
|
+
// permanent-removal signal, so well-behaved crawlers evict the URL
|
|
280
|
+
// from their index instead of replaying it forever;
|
|
281
|
+
// - the scanner-block digest keeps them out of the per-IP WAF-candidate
|
|
282
|
+
// table (they would show up every window as noise) and reports them
|
|
283
|
+
// only as a category-count line.
|
|
284
|
+
// Never WAF-promote a category in this set.
|
|
285
|
+
export const BENIGN_SCANNER_CATEGORIES: ReadonlySet<ScannerCategory> = new Set(
|
|
286
|
+
['legacy-content'],
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Response for a blocked probe. Hostile probes get the plain 404 so the
|
|
290
|
+
// block is indistinguishable from a nonexistent path.
|
|
291
|
+
export function scannerBlockResponse(
|
|
292
|
+
category: ScannerCategory,
|
|
293
|
+
): { status: 404 | 410; body: string } {
|
|
294
|
+
return BENIGN_SCANNER_CATEGORIES.has(category)
|
|
295
|
+
? { status: 410, body: 'Gone' }
|
|
296
|
+
: { status: 404, body: 'Not Found' };
|
|
297
|
+
}
|
|
298
|
+
|
|
276
299
|
const LOG_PREFIX = '[middleware] scanner-block';
|
|
277
300
|
|
|
278
301
|
export type ClientIpSource = 'x-real-ip' | 'x-forwarded-for' | 'none';
|
|
@@ -31,9 +31,64 @@ interface CaptureResult {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// Headroom for the retry path: two 15s page.goto budgets + 3s settle wait
|
|
34
|
-
// already total 33s before the screenshot is even taken.
|
|
34
|
+
// already total 33s before the screenshot is even taken. Launch retries
|
|
35
|
+
// (closed-target only) fail fast (<~2s) so they stay inside this 45s race.
|
|
35
36
|
const SCREENSHOT_TIMEOUT_MS = 45000;
|
|
36
37
|
|
|
38
|
+
// Playwright launch flakes intermittently in the container — the Chromium
|
|
39
|
+
// process is reaped during the CDP handshake, surfacing as one of a few
|
|
40
|
+
// transient phrasings. Retry ONLY these. Do NOT match the generic
|
|
41
|
+
// "browserType.launch:" prefix — it also fronts permanent failures like
|
|
42
|
+
// "Executable doesn't exist", which must fail fast, not burn the 45s budget.
|
|
43
|
+
// Observed in prod logs: "Target page, context or browser has been closed".
|
|
44
|
+
// The crashed/disconnected/Protocol-error variants are the same reaped-process
|
|
45
|
+
// failure under load and are equally safe to retry.
|
|
46
|
+
const RETRIABLE_LAUNCH_RE =
|
|
47
|
+
/browser has been closed|browser has disconnected|Target (page, context or browser has been closed|closed|crashed)|Target crashed|Protocol error/i;
|
|
48
|
+
|
|
49
|
+
function defaultChromiumLaunch(): Promise<Browser> {
|
|
50
|
+
return chromium.launch({
|
|
51
|
+
// Undefined → Playwright uses its own bundled, version-matched Chromium
|
|
52
|
+
// (see Dockerfile). The env override stays as an escape hatch for envs
|
|
53
|
+
// that must point at a system binary. Do NOT hardcode /usr/bin/chromium:
|
|
54
|
+
// the unpinned apt chromium drifted out of sync with Playwright and
|
|
55
|
+
// SIGTRAPped at launch (2026-07-08 capture outage).
|
|
56
|
+
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,
|
|
57
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
58
|
+
// 8s (not 10s) so 3 hung launches (~24s) + backoff still leave headroom
|
|
59
|
+
// for a page.goto inside the 45s outer race. Closed-target flakes reject
|
|
60
|
+
// near-instantly in practice, so this only bounds the pathological case.
|
|
61
|
+
timeout: 8000,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function launchChromiumWithRetry(
|
|
66
|
+
launch: () => Promise<Browser> = defaultChromiumLaunch,
|
|
67
|
+
attempts = 3,
|
|
68
|
+
sleep: (ms: number) => Promise<void> = (ms) =>
|
|
69
|
+
new Promise((resolve) => setTimeout(resolve, ms)),
|
|
70
|
+
): Promise<Browser> {
|
|
71
|
+
let lastError: unknown;
|
|
72
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
return await launch();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
lastError = error;
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
if (!RETRIABLE_LAUNCH_RE.test(message) || attempt === attempts) {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
const delayMs = 500 * 2 ** (attempt - 1); // 500ms, 1000ms
|
|
82
|
+
console.log(
|
|
83
|
+
`Chromium launch attempt ${attempt} failed (${message}); ` +
|
|
84
|
+
`retrying in ${delayMs}ms`,
|
|
85
|
+
);
|
|
86
|
+
await sleep(delayMs);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw lastError;
|
|
90
|
+
}
|
|
91
|
+
|
|
37
92
|
/**
|
|
38
93
|
* Clean up old screenshots for a project, keeping only the current build's screenshot.
|
|
39
94
|
* This prevents R2 storage from accumulating old screenshots.
|
|
@@ -121,12 +176,18 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
|
|
|
121
176
|
});
|
|
122
177
|
|
|
123
178
|
const capturePromise = async (): Promise<CaptureResult> => {
|
|
124
|
-
// Launch browser with system Chromium
|
|
125
|
-
browser = await
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
179
|
+
// Launch browser with system Chromium (retry transient launch flakes)
|
|
180
|
+
browser = await launchChromiumWithRetry();
|
|
181
|
+
|
|
182
|
+
// The 45s outer race can fire while a retry is mid-backoff; if it already
|
|
183
|
+
// timed out, close the just-launched browser now so the retry path can't
|
|
184
|
+
// strand a Chromium process (the finally-block cleanup ran with browser
|
|
185
|
+
// still null). Widened by retry latency, so guard it explicitly.
|
|
186
|
+
if (timedOut) {
|
|
187
|
+
await browser.close().catch(() => {});
|
|
188
|
+
browser = null;
|
|
189
|
+
return { screenshotUrl: null, error: 'Screenshot capture timed out' };
|
|
190
|
+
}
|
|
130
191
|
|
|
131
192
|
const page = await browser.newPage();
|
|
132
193
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
@@ -222,22 +283,45 @@ interface CaptureAndStoreOptions extends CaptureOptions {
|
|
|
222
283
|
* This is the main entry point called from build.ts after build completion.
|
|
223
284
|
* Fire-and-forget - does NOT block build completion.
|
|
224
285
|
*/
|
|
225
|
-
export async function captureAndStoreScreenshot(
|
|
226
|
-
|
|
227
|
-
|
|
286
|
+
export async function captureAndStoreScreenshot(
|
|
287
|
+
options: CaptureAndStoreOptions,
|
|
288
|
+
capture: (o: CaptureOptions) => Promise<CaptureResult> = captureHomepageScreenshot,
|
|
289
|
+
): Promise<void> {
|
|
290
|
+
const {projectId, ...captureOptions} = options;
|
|
291
|
+
const buildRef = getFirestore()
|
|
292
|
+
.collection('projects')
|
|
293
|
+
.doc(projectId)
|
|
294
|
+
.collection('builds')
|
|
295
|
+
.doc(options.buildId);
|
|
296
|
+
|
|
297
|
+
// Persist the exact params so a later /regenerate-screenshot can reuse them
|
|
298
|
+
// without re-reading docs.json (which is unavailable in the Cloud Run server).
|
|
299
|
+
const screenshotCapture = {
|
|
300
|
+
firstPagePath: captureOptions.firstPagePath,
|
|
301
|
+
hostAtDocs: captureOptions.hostAtDocs,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const result = await capture(captureOptions);
|
|
228
305
|
|
|
229
306
|
if (!result.screenshotUrl) {
|
|
230
|
-
if (result.error) console.log(`Screenshot
|
|
307
|
+
if (result.error) console.log(`Screenshot failed: ${result.error}`);
|
|
308
|
+
// Record the definitive failure so the dashboard can offer Retry instead of
|
|
309
|
+
// spinning forever. Non-fatal: never throw back into the build.
|
|
310
|
+
await buildRef
|
|
311
|
+
.update({screenshotStatus: 'failed', screenshotCapture})
|
|
312
|
+
.catch((err) => console.warn('Failed to record screenshotStatus:failed:', err));
|
|
231
313
|
return;
|
|
232
314
|
}
|
|
233
315
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
.
|
|
239
|
-
|
|
240
|
-
|
|
316
|
+
await buildRef.update({
|
|
317
|
+
screenshotUrl: result.screenshotUrl,
|
|
318
|
+
screenshotStatus: 'ready',
|
|
319
|
+
screenshotCapture,
|
|
320
|
+
// Real capture time. The dashboard folds this into its cache-bust query
|
|
321
|
+
// param so a same-URL regenerate (same buildId → same key) still busts the
|
|
322
|
+
// browser cache and re-renders; it also gives onSnapshot a changed field.
|
|
323
|
+
screenshotUpdatedAt: Date.now(),
|
|
324
|
+
});
|
|
241
325
|
|
|
242
326
|
console.log(`Screenshot saved: ${result.screenshotUrl}`);
|
|
243
327
|
}
|