Files
upstream-tldraw/apps/dotcom/client/scripts/browser-run-thumbnail.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

299 lines
9.7 KiB
TypeScript

import { mkdir, writeFile } from 'fs/promises'
import path from 'path'
import { pathToFileURL } from 'url'
import {
DEFAULT_THUMBNAIL_HEIGHT,
DEFAULT_THUMBNAIL_WIDTH,
MAX_THUMBNAIL_DIMENSION,
MIN_THUMBNAIL_DIMENSION,
THUMBNAIL_RENDER_TIMEOUT_MS,
THUMBNAIL_SETTLED_SELECTOR,
getThumbnailScreenshotRequestBody,
} from '@tldraw/dotcom-shared'
// The fixture page owns the per-fixture snapshots and camera defaults; this script only names one.
const FIXTURE_NAMES = ['snapshot-example', 'layer-panel'] as const
type FixtureName = (typeof FIXTURE_NAMES)[number]
type Mode = 'auto' | 'browser-run' | 'local'
interface Options {
baseUrl: string
fixture: FixtureName
height: number
mode: Mode
output: string
theme: 'light' | 'dark'
width: number
x?: number
y?: number
z?: number
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const renderUrl = buildRenderUrl(options)
const mode = chooseMode(options.mode, options.baseUrl)
writeLine(`Rendering ${renderUrl}`)
writeLine(`Mode: ${mode}`)
const png =
mode === 'browser-run'
? await captureWithBrowserRun(renderUrl, options)
: await captureWithLocalPlaywright(renderUrl, options)
const outputPath = path.resolve(options.output)
await mkdir(path.dirname(outputPath), { recursive: true })
await writeFile(outputPath, png)
writeLine(`Wrote ${outputPath}`)
}
function parseArgs(args: string[]): Options {
const options: Options = {
baseUrl: 'http://127.0.0.1:3000',
fixture: 'snapshot-example',
height: DEFAULT_THUMBNAIL_HEIGHT,
mode: 'auto',
output: 'tmp/browser-run-thumbnail/thumbnail.png',
theme: 'light',
width: DEFAULT_THUMBNAIL_WIDTH,
}
for (let i = 0; i < args.length; i++) {
const arg = args[i]
const next = args[i + 1]
switch (arg) {
case '--base-url':
options.baseUrl = requireValue(arg, next)
i++
break
case '--fixture':
options.fixture = requireFixture(requireValue(arg, next))
i++
break
case '--mode':
options.mode = requireMode(requireValue(arg, next))
i++
break
case '--output':
options.output = requireValue(arg, next)
i++
break
case '--theme':
options.theme = requireTheme(requireValue(arg, next))
i++
break
case '--width':
options.width = requireDimension(arg, next)
i++
break
case '--height':
options.height = requireDimension(arg, next)
i++
break
case '--x':
case '--y':
case '--z':
options[arg.slice(2) as 'x' | 'y' | 'z'] = requireNumber(arg, next)
i++
break
case '--help':
printHelp()
process.exit(0)
default:
throw new Error(`Unknown argument: ${arg}`)
}
}
return options
}
function buildRenderUrl(options: Options) {
const url = new URL('/dev/browser-run-thumbnail', options.baseUrl)
url.searchParams.set('fixture', options.fixture)
// Camera params are only sent when explicitly overridden; the fixture page owns the per-fixture
// camera defaults.
if (options.x !== undefined) url.searchParams.set('x', String(options.x))
if (options.y !== undefined) url.searchParams.set('y', String(options.y))
if (options.z !== undefined) url.searchParams.set('z', String(options.z))
url.searchParams.set('width', String(options.width))
url.searchParams.set('height', String(options.height))
url.searchParams.set('theme', options.theme)
return url.toString()
}
function chooseMode(mode: Mode, baseUrl: string): Mode {
if (mode !== 'auto') return mode
// Browser Run executes inside Cloudflare and cannot reach a local dev server, so a loopback
// base URL always uses the local Playwright path even when Cloudflare credentials are present.
if (isLoopbackHost(baseUrl)) return 'local'
if (process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_API_TOKEN) {
return 'browser-run'
}
return 'local'
}
function isLoopbackHost(baseUrl: string) {
try {
const { hostname } = new URL(baseUrl)
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
} catch {
return false
}
}
async function captureWithBrowserRun(renderUrl: string, options: Options) {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
const apiToken = process.env.CLOUDFLARE_API_TOKEN
if (!accountId || !apiToken) {
throw new Error(
'Browser Run mode requires CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN with Browser Rendering - Edit permission.'
)
}
const browserRunFetch = globalThis.fetch.bind(globalThis)
const response = await browserRunFetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/browser-rendering/screenshot`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(
// The exact request body the production worker sends its Quick Action, so this path
// exercises the same wait strategy, terminal selectors, and capture target.
getThumbnailScreenshotRequestBody({
renderUrl,
width: options.width,
height: options.height,
timeoutMs: THUMBNAIL_RENDER_TIMEOUT_MS,
})
),
}
)
if (!response.ok) {
throw new Error(`Browser Run failed (${response.status}): ${await response.text()}`)
}
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('image/png') && !contentType.includes('application/octet-stream')) {
throw new Error(`Browser Run returned ${contentType}: ${await response.text()}`)
}
return Buffer.from(await response.arrayBuffer())
}
// The fixture page produces the thumbnail itself with editor.toImage and exposes it as a data
// URL, so the local path reads the exact export bytes instead of screenshotting the viewport.
async function captureWithLocalPlaywright(renderUrl: string, options: Options) {
const { chromium } = await import('@playwright/test')
const browser = await chromium.launch()
try {
const page = await browser.newPage({
viewport: { width: options.width, height: options.height },
deviceScaleFactor: 1,
})
// The terminal selectors are the real completion signal; waiting for network idle is both
// unnecessary and fragile (background app requests like feature-flag polling can keep the
// network busy indefinitely). Waiting on the error marker too makes a failed render fail
// immediately with the page's own message instead of timing out.
await page.goto(renderUrl, {
waitUntil: 'domcontentloaded',
timeout: THUMBNAIL_RENDER_TIMEOUT_MS,
})
await page.waitForSelector(THUMBNAIL_SETTLED_SELECTOR, {
timeout: THUMBNAIL_RENDER_TIMEOUT_MS,
})
const renderError = await page.evaluate(() => document.body.dataset.thumbnailError)
if (renderError !== undefined) {
throw new Error(`Fixture page failed to render: ${renderError}`)
}
const dataUrl = await page.evaluate(
() => (window as any).__tldrawThumbnailDataUrl as string | undefined
)
const prefix = 'data:image/png;base64,'
if (!dataUrl?.startsWith(prefix)) {
throw new Error('Fixture page did not produce a thumbnail data URL')
}
return Buffer.from(dataUrl.slice(prefix.length), 'base64')
} finally {
await browser.close()
}
}
function requireValue(arg: string, value: string | undefined) {
if (!value) throw new Error(`${arg} requires a value`)
return value
}
function requireFixture(value: string): FixtureName {
if ((FIXTURE_NAMES as readonly string[]).includes(value)) return value as FixtureName
throw new Error(`Unknown fixture: ${value}`)
}
function requireMode(value: string): Mode {
if (value === 'auto' || value === 'browser-run' || value === 'local') return value
throw new Error(`Unknown mode: ${value}`)
}
function requireTheme(value: string): 'light' | 'dark' {
if (value === 'light' || value === 'dark') return value
throw new Error(`Unknown theme: ${value}`)
}
function requireDimension(arg: string, value: string | undefined) {
const number = Math.floor(requireNumber(arg, value))
if (number < MIN_THUMBNAIL_DIMENSION || number > MAX_THUMBNAIL_DIMENSION) {
throw new Error(
`${arg} must be between ${MIN_THUMBNAIL_DIMENSION} and ${MAX_THUMBNAIL_DIMENSION}`
)
}
return number
}
function requireNumber(arg: string, value: string | undefined) {
const number = Number(requireValue(arg, value))
if (!Number.isFinite(number)) throw new Error(`${arg} must be a finite number`)
return number
}
function printHelp() {
writeLine(`Usage:
yarn workspace dotcom browser-run-thumbnail [options]
Options:
--base-url <url> Origin running the dotcom client. Default: http://127.0.0.1:3000
--fixture <name> ${FIXTURE_NAMES.join(' | ')}. Default: snapshot-example
--mode <mode> auto | browser-run | local. Default: auto
--output <path> PNG output path. Default: tmp/browser-run-thumbnail/thumbnail.png
--theme <theme> light | dark. Default: light
--width <number> Output width, ${MIN_THUMBNAIL_DIMENSION}-${MAX_THUMBNAIL_DIMENSION}. Default: ${DEFAULT_THUMBNAIL_WIDTH}
--height <number> Output height, ${MIN_THUMBNAIL_DIMENSION}-${MAX_THUMBNAIL_DIMENSION}. Default: ${DEFAULT_THUMBNAIL_HEIGHT}
--x <number> Camera x override (defaults to the fixture's own camera)
--y <number> Camera y override (defaults to the fixture's own camera)
--z <number> Camera zoom override (defaults to the fixture's own camera)
Captures the dev-only /dev/browser-run-thumbnail fixture page for local iteration on render
behavior. The fixture page produces the image with editor.toImage; local mode reads the exact
export bytes, while browser-run mode screenshots the page after it has swapped to displaying
the export. It does not accept arbitrary screenshot URLs.`)
}
function writeLine(message: string) {
process.stdout.write(`${message}\n`)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.stack || error.message : String(error)}\n`
)
process.exit(1)
})
}