snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,298 @@
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)
})
}

View File

@@ -0,0 +1,336 @@
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'
import {
FILE_PREFIX,
PUBLISH_PREFIX,
READ_ONLY_LEGACY_PREFIX,
READ_ONLY_PREFIX,
ROOM_PREFIX,
SNAPSHOT_PREFIX,
SOCIAL_PREVIEW_BYPASS_PARAM,
THUMBNAIL_RENDER_PATH,
} from '@tldraw/dotcom-shared'
import { T } from '@tldraw/validate'
import { config } from 'dotenv'
import json5 from 'json5'
import regexgen from 'regexgen'
import { exec } from '../../../../internal/scripts/lib/exec'
import { nicelog } from '../../../../internal/scripts/lib/nicelog'
import { csp } from '../src/utils/csp'
import { reportBundleSize } from './measure-bundle-size'
import { getMultiplayerServerURL } from './multiplayer-server-url'
import { Config } from './vercel-output-config'
const commonSecurityHeaders = {
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer-when-downgrade',
'Content-Security-Policy': csp,
}
// RFC 8288 links from the homepage to the discovery documents an agent would otherwise have to guess
// at. Only the homepage carries these: an agent arriving anywhere else on tldraw.com is already
// looking at a board, and repeating ~200 bytes on every SPA route and asset buys nothing.
//
// `api-catalog` (RFC 9727), `ai-catalog` and `service-doc` are all registered relations, and the
// specific one matters: the AI Catalog spec has agents check for `rel="ai-catalog"` first and only
// *optionally* fall back to the well-known path, so a catalog advertised under any other relation is
// one a conformant client never sees.
const agentDiscoveryLinkHeader = [
'</.well-known/api-catalog>; rel="api-catalog"',
'</.well-known/ai-catalog.json>; rel="ai-catalog"; type="application/ai-catalog+json"',
'<https://tldraw.dev>; rel="service-doc"; type="text/html"',
].join(', ')
// Regex fragments matched against the user-agent of requests to board URLs. Matching requests are
// routed to the multiplayer worker, which renders the board name into the social preview metadata
// for link-unfurling crawlers that don't run JavaScript and so never see the SPA's runtime title
// updates.
//
// The generic `[Bb]ot` token catches the long tail of unfurlers (Twitterbot, Discordbot, Slackbot,
// TelegramBot, LinkedInBot, redditbot, ...) including tldraw's own link-unfurl service
// (`tldraw-bot/x.y.z`), so pasting a board link into a tldraw canvas shows the board name in the
// bookmark. The named entries are unfurlers that don't say "bot". LINE and KakaoTalk are covered
// too: their unfurlers identify as `facebookexternalhit`.
//
// Search-engine crawlers (Googlebot, bingbot) also match the generic token, but robots.txt already
// disallows all board routes, so compliant search engines never request these URLs; a bot that
// ignores robots.txt gets the preview stub, which is fine. Real people whose browser carries a
// matching token — in-app browsers (WhatsApp, Pinterest) or the odd device name containing "bot" —
// are bounced back to the app by the stub via the bypass param.
const SOCIAL_CRAWLER_USER_AGENTS = [
'[Bb]ot',
'facebookexternalhit',
'Slack-ImgProxy',
'WhatsApp',
'Pinterest',
'Embedly',
'Iframely',
'vkShare',
'W3C_Validator',
'SkypeUriPreview',
'Mastodon',
'Bluesky',
]
// The board routes whose social preview should include the board name. Only the bare board route is
// matched (not sub-routes like `/f/:slug/history`).
const SOCIAL_PREVIEW_PREFIXES = [
FILE_PREFIX,
PUBLISH_PREFIX,
SNAPSHOT_PREFIX,
ROOM_PREFIX,
READ_ONLY_PREFIX,
READ_ONLY_LEGACY_PREFIX,
]
const userAgent = `.*(?:${SOCIAL_CRAWLER_USER_AGENTS.join('|')}).*`
function socialPreviewRoute(multiplayerServerUrl: string) {
// Vercel matches `has.value` as `^value$`, so a bare `a|b|c` join anchors the first token to the
// start of the user-agent and the last token to the end. Wrap the alternation so every token is
// a substring match.
return {
src: `^/(${SOCIAL_PREVIEW_PREFIXES.join('|')})/([^/]+)/?$`,
has: [{ type: 'header' as const, key: 'user-agent', value: userAgent }],
// some in-app browsers used by real people carry a crawler token in their user-agent
// (WhatsApp, Pinterest). the stub page bounces those visitors back to the board with this
// param set, which makes this route not match so they fall through to the real app.
missing: [{ type: 'query' as const, key: SOCIAL_PREVIEW_BYPASS_PARAM }],
dest: `${multiplayerServerUrl}/app/social-preview/$1/$2`,
}
}
// We load the list of routes that should be forwarded to our SPA's index.html here.
// It uses a vitest snapshot file because deriving the set of routes from our
// react-router config works fine in our test environment, but is tricky to get running in this
// build script environment for various reasons (no global React, tsx being weird about decorators, etc).
function loadSpaRoutes() {
const routesJson = require('../src/__snapshots__/routes.test.tsx.snap')['the_routes 1']
const routes = T.arrayOf(
T.object({
reactRouterPattern: T.string,
vercelRouterPattern: T.string,
})
).validate(json5.parse(routesJson))
return routes.map((route) => ({
check: true,
src: route.vercelRouterPattern,
dest: '/index.html',
headers: commonSecurityHeaders,
}))
}
config({
path: './.env.local',
})
nicelog('The multiplayer server is', process.env.MULTIPLAYER_SERVER)
async function build() {
// make sure we have the latest routes
await exec('yarn', ['test', 'src/routes.test.tsx'])
const spaRoutes = loadSpaRoutes()
await exec('../../../node_modules/.bin/vite', ['build', '--emptyOutDir'])
await exec('yarn', ['run', '-T', 'sentry-cli', 'sourcemaps', 'inject', 'dist/assets'])
// Clear output static folder (in case we are running locally and have already built the app once before)
await exec('rm', ['-rf', '.vercel/output'])
mkdirSync('.vercel/output', { recursive: true })
await exec('cp', ['-r', 'dist', '.vercel/output/static'])
// We serve the .js.map files publicly. The client source is open at tldraw/tldraw, so
// there's nothing to hide, and serving the maps lets anyone debugging a deployed build get
// real names and lines in devtools without going through Sentry. Sentry still gets its own
// copy via the upload step, which reads from dist/assets before this point.
// Add fonts to preload into index.html
const assetsList = readdirSync('dist/assets')
const fontsToPreload = [
'Shantell_Sans-Informal_Regular',
'IBMPlexSerif-Medium',
'IBMPlexSans-Medium',
'IBMPlexMono-Medium',
]
const fontPreloads = fontsToPreload
.map(
(font) => `<link
rel="preload"
href="/assets/${assetsList.find((a) => a.startsWith(font))}"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>`
)
.join('\n')
const spritePreload = `<link
rel="preload"
href="/assets/${assetsList.find((a) => a.startsWith('0_merged-'))}"
as="image"
type="image/svg+xml"
crossorigin="anonymous"
/>`
const indexHtml = readFileSync('.vercel/output/static/index.html', 'utf8')
const newIndex = indexHtml
.replace('<!-- $PRELOADED_FONTS -->', fontPreloads)
.replace('<!-- $PRELOADED_SPRITES -->', spritePreload)
writeFileSync('.vercel/output/static/index.html', newIndex)
// The thumbnail render entry waits on these same faces during its settle phase, so it preloads
// them too — otherwise their fetch starts only after the SDK has booted and the editor mounted.
const thumbnailHtml = readFileSync('.vercel/output/static/thumbnail-render.html', 'utf8')
writeFileSync(
'.vercel/output/static/thumbnail-render.html',
thumbnailHtml.replace('<!-- $PRELOADED_FONTS -->', () => fontPreloads)
)
const multiplayerServerUrl = getMultiplayerServerURL() ?? 'http://localhost:8787'
// Includes the .js.map files: they're content-hashed like the chunks they describe, so they're
// safe to cache immutably, and we now serve them rather than stripping them from the deploy.
const assetsToCache = assetsList.map((f) => `/assets/${f}`)
// need to batch these because Vercel's route limit is 4096 characters
const assetsBatches: string[][] = []
for (let i = 0; i < assetsToCache.length; i += 50) {
assetsBatches.push(assetsToCache.slice(i, i + 50))
}
writeFileSync(
'.vercel/output/config.json',
JSON.stringify(
{
version: 3,
routes: [
// redirect /offline to the offline version of tldraw
{
src: '^/offline/?$',
status: 307,
headers: { Location: 'https://offline.tldraw.com/' },
},
// rewrite api calls to the multiplayer server
{
src: '^/api(/(.*))?$',
dest: `${multiplayerServerUrl}$1`,
check: true,
},
// MCP OAuth discovery (RFC 9728) lives at the origin, outside /api, so the
// rewrite above misses it. staging and production reach the worker through
// Cloudflare zone routes for this prefix (see the sync worker's wrangler.toml);
// previews have no zone routes, so this rewrite is their only path to it. the
// path is passed through unstripped — the worker registers the full well-known
// path, /api included.
{
src: '^/\\.well-known/oauth-protected-resource(/(.*))?$',
dest: `${multiplayerServerUrl}/.well-known/oauth-protected-resource$1`,
check: true,
},
// The MCP Server Card's `.well-known` alias, for the same reason: the canonical
// card lives at /api/app/mcp/server-card, but scanners probe this path, and it
// sits outside the /api rewrite above.
{
src: '^/\\.well-known/mcp/(.*)$',
dest: `${multiplayerServerUrl}/.well-known/mcp/$1`,
check: true,
},
// route social/link-unfurling crawlers to the worker so board link previews
// include the board name. must come before the SPA routes below. set
// SOCIAL_PREVIEW_DISABLED=true to turn this off without a code change.
...(process.env.SOCIAL_PREVIEW_DISABLED === 'true'
? []
: [socialPreviewRoute(multiplayerServerUrl)]),
{
src: '^/assets/(.*)$',
// we need `continue: true` here because we also want to apply the headers
// from the rule below if it matches.
continue: true,
headers: {
'X-Content-Type-Options': 'nosniff',
},
},
// RFC 9727 requires the catalog to answer a HEAD request with an api-catalog Link
// header, so that a client can find where the catalog really lives without
// fetching it. Ours is at the well-known path, so the link points at itself; a
// publisher serving the document elsewhere would point there instead.
//
// Vercel matches routes by path, not method, so this lands on GET too. Harmless,
// and the RFC's own example shows the relation on a GET response.
//
// The catalogs are also stated to be readable cross-origin — a browser-context
// agent is a normal consumer, and ARD requires it. Vercel already serves static
// files with `Access-Control-Allow-Origin: *`, but that is a platform default
// rather than something the spec lets us assume.
{
src: '^/\\.well-known/api-catalog$',
continue: true,
headers: {
Link: '</.well-known/api-catalog>; rel="api-catalog"',
'Access-Control-Allow-Origin': '*',
},
},
{
src: '^/\\.well-known/ai-catalog\\.json$',
continue: true,
headers: { 'Access-Control-Allow-Origin': '*' },
},
// cache static assets immutably. we match by extension to avoid exceeding
// Vercel's 4096-char route limit (see #8286).
...assetsBatches.map((batch) => ({
src: `^${regexgen(batch).source}$`,
headers: {
'Cache-Control': 'public, max-age=31536000, immutable',
},
})),
// server up index.html specifically because we want to include
// security headers. otherwise, it goes to the handle: 'miss'
// part below (and _not_ to the spaRoutes as maybe expected!)
{
check: true,
src: '/',
dest: '/index.html',
headers: { ...commonSecurityHeaders, Link: agentDiscoveryLinkHeader },
},
// The render page's own entry (see pages/thumbnail-render.tsx), rewritten so the URL
// the sync-worker renders never moves. Must come before the SPA fallback below,
// which would otherwise answer with index.html.
{
check: true,
src: `^${THUMBNAIL_RENDER_PATH}$`,
dest: '/thumbnail-render.html',
headers: commonSecurityHeaders,
},
// serve static files
{
handle: 'miss',
},
// finally handle SPA routing
...spaRoutes,
// react router will handle drawing the 404 page
{
check: true,
src: '.*',
dest: '/index.html',
status: 404,
headers: commonSecurityHeaders,
},
],
// Vercel types static files from their extension, which has nothing useful to say
// about `api-catalog` (extensionless, as RFC 9727 requires) and would serve
// `auth.md` as a download rather than something an agent reads.
overrides: {
'.well-known/api-catalog': {
contentType:
'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"',
},
'.well-known/ai-catalog.json': { contentType: 'application/ai-catalog+json' },
'auth.md': { contentType: 'text/markdown; charset=utf-8' },
},
} satisfies Config,
null,
2
)
)
await reportBundleSize('.vercel/output/static')
}
build()

View File

@@ -0,0 +1,66 @@
import { createServer, type Server } from 'net'
import { afterEach, describe, expect, it } from 'vitest'
import { assertPortFree, getViteArgs } from './dev-app'
let server: Server | null = null
afterEach(async () => {
if (!server) return
await new Promise<void>((resolve) => server!.close(() => resolve()))
server = null
})
function listenOnRandomPort(host: string) {
return new Promise<number>((resolve, reject) => {
server = createServer()
server.once('error', reject)
server.listen(0, host, () => {
const address = server!.address()
if (!address || typeof address === 'string') {
reject(new Error('Expected a TCP address'))
return
}
resolve(address.port)
})
})
}
describe('dev-app script', () => {
it('passes strict port args to Vite', () => {
expect(getViteArgs('dev')).toEqual(['dev', '--host', '--port', '3000', '--strictPort'])
expect(getViteArgs('preview')).toEqual(['preview', '--host', '--port', '3000', '--strictPort'])
})
it('rejects when the requested port is already occupied', async () => {
const port = await listenOnRandomPort('0.0.0.0')
await expect(assertPortFree(port, ['0.0.0.0'])).rejects.toThrow(
`Port ${port} is already in use`
)
})
it('skips unavailable host families when checking the client port', async () => {
const calls: string[] = []
await expect(
assertPortFree(3000, ['::', '0.0.0.0'], async (_port, host) => {
calls.push(host)
if (host === '::') {
throw Object.assign(new Error('IPv6 is not available'), {
code: 'EADDRNOTAVAIL',
})
}
})
).resolves.toBeUndefined()
expect(calls).toEqual(['::', '0.0.0.0'])
})
it('rejects unexpected port probe errors', async () => {
await expect(
assertPortFree(3000, ['0.0.0.0'], async () => {
throw Object.assign(new Error('Unexpected probe error'), { code: 'EPERM' })
})
).rejects.toThrow('Unexpected probe error')
})
})

View File

@@ -0,0 +1,86 @@
import { spawn } from 'child_process'
import { createServer } from 'net'
import { pathToFileURL } from 'url'
import { killProcessTree } from '../../../../internal/scripts/lib/kill-tree'
const CLIENT_PORT = 3000
const SKIPPABLE_PROBE_ERROR_CODES = new Set(['EADDRNOTAVAIL', 'EAFNOSUPPORT'])
type PortProbe = (port: number, host: string) => Promise<void>
function probePort(port: number, host: string) {
return new Promise<void>((resolve, reject) => {
const server = createServer()
server.unref()
server.once('error', reject)
server.listen(port, host, () => {
server.close(() => resolve())
})
})
}
export async function assertPortFree(
port: number,
hosts = ['0.0.0.0', '::'],
probe: PortProbe = probePort
) {
for (const host of hosts) {
try {
await probe(port, host)
} catch (error: any) {
if (error?.code === 'EADDRINUSE') {
throw new Error(
`Port ${port} is already in use. Stop the process using port ${port} before running yarn dev-app.`
)
}
if (SKIPPABLE_PROBE_ERROR_CODES.has(error?.code)) {
continue
}
throw error
}
}
}
export function getViteArgs(command: 'dev' | 'preview', port = CLIENT_PORT) {
return [command, '--host', '--port', String(port), '--strictPort']
}
async function main() {
await assertPortFree(CLIENT_PORT)
const args = getViteArgs(process.env.VITE_PREVIEW === '1' ? 'preview' : 'dev')
const child = spawn('vite', args, { stdio: 'inherit' })
// On shutdown, reap vite *and its children* (esbuild/optimizer helpers) by walking the PID tree
// and killing deepest-first. This runs in the signal handler, while the tree is still alive —
// not on `exit` — because once vite exits its helpers reparent to launchd and a `pgrep` walk from
// us can no longer find them, so they would orphan and keep holding port 3000.
let shuttingDown = false
const shutdown = () => {
if (shuttingDown) return
shuttingDown = true
killProcessTree(process.pid)
process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
process.on('SIGHUP', shutdown)
// Backstop for any exit path that bypassed the signal handler.
process.on('exit', () => killProcessTree(process.pid))
child.once('error', (error) => {
console.error(error)
process.exit(1)
})
child.once('exit', (code, signal) => {
if (shuttingDown) return
process.exit(signal ? 1 : (code ?? 0))
})
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error)
process.exit(1)
})
}

View File

@@ -0,0 +1,114 @@
import { spawn } from 'child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { killProcessTree } from '../../../../internal/scripts/lib/kill-tree'
// `killProcessTree` lives in internal/scripts/lib, but only apps/** and packages/** are wired into
// the root vitest config, so its regression test lives here alongside dev-app.test.ts (the dotcom
// dev script that depends on it).
// A process that records its own pid, optionally spawns one more level below itself, then idles.
// Running `node nest.cjs <depth> <pidfile>` builds a chain `depth` levels deep so we can prove the
// reap crosses more than one wrapper layer (the real tree is orchestrator → yarn → tsx → wrangler →
// workerd).
const NEST_SCRIPT = `
const { spawn } = require('child_process')
const { appendFileSync } = require('fs')
const depth = Number(process.argv[2])
const pidfile = process.argv[3]
appendFileSync(pidfile, process.pid + '\\n')
if (depth > 0) {
spawn(process.execPath, [__filename, String(depth - 1), pidfile], { stdio: 'ignore' })
}
setInterval(() => {}, 1 << 30)
`
let dir: string
let pidfile: string
let scriptPath: string
let spawned: number[] = []
function isAlive(pid: number) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function waitFor(predicate: () => boolean, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) return
await new Promise((r) => setTimeout(r, 25))
}
throw new Error('timed out waiting for condition')
}
function recordedPids() {
try {
return readFileSync(pidfile, 'utf8')
.split('\n')
.map((l) => Number(l.trim()))
.filter((n) => Number.isInteger(n) && n > 0)
} catch {
return []
}
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'kill-tree-'))
pidfile = join(dir, 'pids.txt')
scriptPath = join(dir, 'nest.cjs')
writeFileSync(scriptPath, NEST_SCRIPT)
writeFileSync(pidfile, '')
spawned = []
})
afterEach(() => {
// Clean up anything the test left alive so a failure can't leak processes.
for (const pid of [...spawned, ...recordedPids()]) {
try {
process.kill(pid, 'SIGKILL')
} catch {
// already gone
}
}
rmSync(dir, { recursive: true, force: true })
})
describe('killProcessTree', () => {
it('reaps the whole descendant tree while leaving the root alive', async () => {
// root → child → grandchild (3 processes total).
const root = spawn(process.execPath, [scriptPath, '2', pidfile], { stdio: 'ignore' })
spawned.push(root.pid!)
await waitFor(() => recordedPids().length === 3)
const [rootPid, childPid, grandchildPid] = recordedPids()
expect(rootPid).toBe(root.pid)
expect(isAlive(childPid)).toBe(true)
expect(isAlive(grandchildPid)).toBe(true)
killProcessTree(rootPid)
// The descendants (child and grandchild) are reaped; the root is intentionally left alone so a
// caller can finish its own shutdown and exit.
await waitFor(() => !isAlive(childPid) && !isAlive(grandchildPid))
expect(isAlive(childPid)).toBe(false)
expect(isAlive(grandchildPid)).toBe(false)
expect(isAlive(rootPid)).toBe(true)
})
it('does nothing when the pid has no descendants', async () => {
// A leaf process (depth 0) with no children: the reap should be a no-op and leave it running.
const leaf = spawn(process.execPath, [scriptPath, '0', pidfile], { stdio: 'ignore' })
spawned.push(leaf.pid!)
await waitFor(() => recordedPids().length === 1)
expect(() => killProcessTree(leaf.pid!)).not.toThrow()
expect(isAlive(leaf.pid!)).toBe(true)
})
})

View File

@@ -0,0 +1,250 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { gzipSync } from 'zlib'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
BundleAsset,
getAssetCategory,
getBundleSizeEvents,
getInitialAssetNames,
measureBundleSize,
stripContentHash,
summarizeAssets,
} from './measure-bundle-size'
describe('getAssetCategory', () => {
it('categorizes assets by extension', () => {
expect(getAssetCategory('index-BdIhNi0J.js')).toBe('js')
expect(getAssetCategory('index-BdIhNi0J.css')).toBe('css')
expect(getAssetCategory('IBMPlexSans-Medium-CxYz1234.woff2')).toBe('font')
expect(getAssetCategory('0_merged-abcd1234.svg')).toBe('image')
expect(getAssetCategory('en-abcd1234.json')).toBe('json')
expect(getAssetCategory('something-abcd1234.wasm')).toBe('other')
})
})
describe('stripContentHash', () => {
it('removes the content hash so a chunk is comparable across builds', () => {
expect(stripContentHash('index-BdIhNi0J.js')).toBe('index.js')
expect(stripContentHash('TlaEditor-_a1B2c3D.js')).toBe('TlaEditor.js')
expect(stripContentHash('IBMPlexSans-Medium-CxYz1234.woff2')).toBe('IBMPlexSans-Medium.woff2')
})
it('leaves names without a hash alone', () => {
expect(stripContentHash('index.js')).toBe('index.js')
expect(stripContentHash('IBMPlexSans-Medium.woff2')).toBe('IBMPlexSans-Medium.woff2')
})
})
describe('getInitialAssetNames', () => {
it('collects every asset index.html references', () => {
const html = `<!doctype html><html><head>
<script type="module" crossorigin src="/assets/index-BdIhNi0J.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-CxYz1234.js">
<link rel="stylesheet" crossorigin href="/assets/index-DeFg5678.css">
<link rel="preload" href="/assets/IBMPlexSans-Medium-HiJk9012.woff2" as="font">
<link rel="preload" href="/assets/0_merged-LmNo3456.svg" as="image">
<link rel="icon" href="/favicon.svg">
</head><body></body></html>`
expect(getInitialAssetNames(html)).toEqual(
new Set([
'index-BdIhNi0J.js',
'vendor-CxYz1234.js',
'index-DeFg5678.css',
'IBMPlexSans-Medium-HiJk9012.woff2',
'0_merged-LmNo3456.svg',
])
)
})
it('ignores query strings and assets outside /assets', () => {
const html = `<script src="/assets/index-BdIhNi0J.js?v=1"></script><img src="/logo.png">`
expect(getInitialAssetNames(html)).toEqual(new Set(['index-BdIhNi0J.js']))
})
})
describe('summarizeAssets', () => {
function asset(overrides: Partial<BundleAsset>): BundleAsset {
return {
fileName: 'index-BdIhNi0J.js',
name: 'index.js',
category: 'js',
bytes: 100,
gzipBytes: 40,
isInitial: false,
...overrides,
}
}
const noAssets = {
font: { count: 0, bytes: 0, gzipBytes: 0 },
image: { count: 0, bytes: 0, gzipBytes: 0 },
json: { count: 0, bytes: 0, gzipBytes: 0 },
other: { count: 0, bytes: 0, gzipBytes: 0 },
}
it('totals assets overall, by category, and across the initial payload', () => {
const report = summarizeAssets([
asset({ bytes: 100, gzipBytes: 40, isInitial: true }),
asset({ bytes: 200, gzipBytes: 60 }),
asset({ category: 'css', bytes: 50, gzipBytes: 20, isInitial: true }),
])
expect(report.total).toEqual({ count: 3, bytes: 350, gzipBytes: 120 })
expect(report.initial).toEqual({ count: 2, bytes: 150, gzipBytes: 60 })
expect(report.byCategory).toEqual({
js: { count: 2, bytes: 300, gzipBytes: 100 },
css: { count: 1, bytes: 50, gzipBytes: 20 },
...noAssets,
})
expect(report.initialByCategory).toEqual({
js: { count: 1, bytes: 100, gzipBytes: 40 },
css: { count: 1, bytes: 50, gzipBytes: 20 },
...noAssets,
})
})
})
describe('measureBundleSize', () => {
let staticDir: string
beforeEach(() => {
staticDir = mkdtempSync(join(tmpdir(), 'bundle-size-'))
mkdirSync(join(staticDir, 'assets'))
})
afterEach(() => {
rmSync(staticDir, { recursive: true, force: true })
})
function writeAsset(fileName: string, contents: string) {
writeFileSync(join(staticDir, 'assets', fileName), contents)
}
it('measures raw and gzipped sizes, and marks assets index.html loads as initial', () => {
const entry = 'a'.repeat(1000)
const lazy = 'b'.repeat(500)
writeAsset('index-BdIhNi0J.js', entry)
writeAsset('TlaEditor-CxYz1234.js', lazy)
writeFileSync(
join(staticDir, 'index.html'),
`<script type="module" src="/assets/index-BdIhNi0J.js"></script>`
)
const report = measureBundleSize(staticDir)
expect(report.assets).toEqual([
{
fileName: 'index-BdIhNi0J.js',
name: 'index.js',
category: 'js',
bytes: 1000,
gzipBytes: gzipSync(entry).byteLength,
isInitial: true,
},
{
fileName: 'TlaEditor-CxYz1234.js',
name: 'TlaEditor.js',
category: 'js',
bytes: 500,
gzipBytes: gzipSync(lazy).byteLength,
isInitial: false,
},
])
expect(report.initial.bytes).toBe(1000)
})
it('skips source maps, which are only downloaded with devtools open', () => {
writeAsset('index-BdIhNi0J.js', 'console.log(1)')
writeAsset('index-BdIhNi0J.js.map', 'x'.repeat(10000))
writeFileSync(join(staticDir, 'index.html'), '<html></html>')
const report = measureBundleSize(staticDir)
expect(report.assets.map((asset) => asset.fileName)).toEqual(['index-BdIhNi0J.js'])
})
})
describe('getBundleSizeEvents', () => {
it('reports totals once and the largest assets individually', () => {
const report = summarizeAssets([
{
fileName: 'index-BdIhNi0J.js',
name: 'index.js',
category: 'js',
bytes: 100,
gzipBytes: 40,
isInitial: true,
},
])
const [summary, ...assets] = getBundleSizeEvents(report, { git_commit: 'abc123' })
expect(summary).toEqual({
event: 'dotcom_bundle_size',
distinctId: 'dotcom-bundle-size',
properties: {
git_commit: 'abc123',
total_bytes: 100,
total_gzip_bytes: 40,
total_count: 1,
initial_bytes: 100,
initial_gzip_bytes: 40,
initial_count: 1,
js_bytes: 100,
js_gzip_bytes: 40,
js_count: 1,
initial_js_bytes: 100,
initial_js_gzip_bytes: 40,
initial_js_count: 1,
css_bytes: 0,
css_gzip_bytes: 0,
css_count: 0,
initial_css_bytes: 0,
initial_css_gzip_bytes: 0,
initial_css_count: 0,
font_bytes: 0,
font_gzip_bytes: 0,
font_count: 0,
initial_font_bytes: 0,
initial_font_gzip_bytes: 0,
initial_font_count: 0,
image_bytes: 0,
image_gzip_bytes: 0,
image_count: 0,
initial_image_bytes: 0,
initial_image_gzip_bytes: 0,
initial_image_count: 0,
json_bytes: 0,
json_gzip_bytes: 0,
json_count: 0,
initial_json_bytes: 0,
initial_json_gzip_bytes: 0,
initial_json_count: 0,
other_bytes: 0,
other_gzip_bytes: 0,
other_count: 0,
initial_other_bytes: 0,
initial_other_gzip_bytes: 0,
initial_other_count: 0,
},
})
expect(assets).toEqual([
{
event: 'dotcom_bundle_size_asset',
distinctId: 'dotcom-bundle-size',
properties: {
git_commit: 'abc123',
asset_name: 'index.js',
asset_file_name: 'index-BdIhNi0J.js',
asset_category: 'js',
asset_is_initial: true,
bytes: 100,
gzip_bytes: 40,
},
},
])
})
})

View File

@@ -0,0 +1,268 @@
import { readFileSync, readdirSync, statSync } from 'fs'
import { extname, join } from 'path'
import { gzipSync } from 'zlib'
import { nicelog } from '../../../../internal/scripts/lib/nicelog'
import { captureCiMetricEvents, CiMetricEvent } from '../../../../internal/scripts/lib/posthog'
// How many of the largest assets we report individually alongside the totals — enough to attribute
// a jump in the total to the chunk that caused it, without sending an event per file on every
// deploy. Assets in the initial payload are always reported, however small: they're the ones we
// most want to be able to explain a change in.
const TOP_ASSET_COUNT = 25
export type AssetCategory = 'js' | 'css' | 'font' | 'image' | 'json' | 'other'
const EXTENSIONS_BY_CATEGORY: Record<Exclude<AssetCategory, 'other'>, string[]> = {
js: ['.js', '.mjs'],
css: ['.css'],
font: ['.woff2', '.woff', '.ttf', '.otf'],
image: ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.avif', '.gif', '.ico'],
// mostly the compiled translations, which are worth telling apart from everything else
json: ['.json'],
}
export const ASSET_CATEGORIES: AssetCategory[] = ['js', 'css', 'font', 'image', 'json', 'other']
export interface BundleAsset {
fileName: string
/** `fileName` with the content hash removed, so the same chunk is comparable across builds. */
name: string
category: AssetCategory
bytes: number
gzipBytes: number
/** Whether the browser downloads this asset to render the app, before any lazy loading. */
isInitial: boolean
}
export interface BundleSizeGroup {
count: number
bytes: number
gzipBytes: number
}
export interface BundleSizeReport {
assets: BundleAsset[]
total: BundleSizeGroup
initial: BundleSizeGroup
byCategory: Record<AssetCategory, BundleSizeGroup>
/**
* The initial payload split up, because its two halves move for unrelated reasons: the js grows
* when we add application code, the fonts only when we change which ones we preload.
*/
initialByCategory: Record<AssetCategory, BundleSizeGroup>
}
export function getAssetCategory(fileName: string): AssetCategory {
const extension = extname(fileName).toLowerCase()
for (const [category, extensions] of Object.entries(EXTENSIONS_BY_CATEGORY)) {
if (extensions.includes(extension)) return category as AssetCategory
}
return 'other'
}
/**
* Strip vite's content hash from an asset file name, so `index-BdIhNi0J.js` becomes `index.js` and
* the same chunk can be followed from one build to the next. Hashes are 8 url-safe base64 chars,
* which is short enough that a real name segment could look like one — but only if it's exactly 8
* characters and sits right before the extension, which is rare enough to live with.
*/
export function stripContentHash(fileName: string) {
return fileName.replace(/-[A-Za-z0-9_-]{8}(\.[A-Za-z0-9]+)$/, '$1')
}
/**
* The assets referenced directly by index.html: the entry chunk, its modulepreloads, stylesheets,
* and the fonts and sprite sheet the build script preloads. Together these are what a visitor
* downloads before anything renders, which is the number that matters most for load time.
*/
export function getInitialAssetNames(indexHtml: string) {
const names = new Set<string>()
for (const match of indexHtml.matchAll(/(?:src|href)="\/assets\/([^"?#]+)/g)) {
names.add(match[1])
}
return names
}
function emptyGroup(): BundleSizeGroup {
return { count: 0, bytes: 0, gzipBytes: 0 }
}
function addToGroup(group: BundleSizeGroup, asset: BundleAsset) {
group.count += 1
group.bytes += asset.bytes
group.gzipBytes += asset.gzipBytes
}
function emptyCategoryGroups() {
return Object.fromEntries(ASSET_CATEGORIES.map((category) => [category, emptyGroup()])) as Record<
AssetCategory,
BundleSizeGroup
>
}
export function summarizeAssets(assets: BundleAsset[]): BundleSizeReport {
const report: BundleSizeReport = {
assets,
total: emptyGroup(),
initial: emptyGroup(),
byCategory: emptyCategoryGroups(),
initialByCategory: emptyCategoryGroups(),
}
for (const asset of assets) {
addToGroup(report.total, asset)
addToGroup(report.byCategory[asset.category], asset)
if (asset.isInitial) {
addToGroup(report.initial, asset)
addToGroup(report.initialByCategory[asset.category], asset)
}
}
return report
}
/**
* Measure every file in the built assets directory. Source maps are skipped: we serve them, but
* they're only fetched when devtools is open, so counting them would swamp the numbers a visitor
* actually pays for. Files in `public/` are skipped too — they're copied verbatim rather than
* bundled, so they don't move when application code changes.
*/
export function measureBundleSize(staticDir: string): BundleSizeReport {
const assetsDir = join(staticDir, 'assets')
const initialAssetNames = getInitialAssetNames(
readFileSync(join(staticDir, 'index.html'), 'utf8')
)
const assets = readdirSync(assetsDir)
.filter(
(fileName) => !fileName.endsWith('.map') && statSync(join(assetsDir, fileName)).isFile()
)
.map((fileName): BundleAsset => {
const contents = readFileSync(join(assetsDir, fileName))
return {
fileName,
name: stripContentHash(fileName),
category: getAssetCategory(fileName),
bytes: contents.byteLength,
gzipBytes: gzipSync(contents).byteLength,
isInitial: initialAssetNames.has(fileName),
}
})
.sort((a, b) => b.gzipBytes - a.gzipBytes)
return summarizeAssets(assets)
}
function formatKb(bytes: number) {
return `${(bytes / 1024).toFixed(1)} kB`
}
export function formatBundleSizeReport(report: BundleSizeReport) {
function section(label: string, total: BundleSizeGroup, groups: Record<string, BundleSizeGroup>) {
return [
[label, total] as [string, BundleSizeGroup],
...ASSET_CATEGORIES.filter((category) => groups[category].count > 0).map(
(category): [string, BundleSizeGroup] => [` ${category}`, groups[category]]
),
]
}
const rows = [
...section('initial payload', report.initial, report.initialByCategory),
...section('all assets', report.total, report.byCategory),
]
const labelWidth = Math.max(...rows.map(([label]) => label.length))
return rows
.map(
([label, group]) =>
` ${label.padEnd(labelWidth)} ${formatKb(group.gzipBytes).padStart(10)} gzipped` +
` ${formatKb(group.bytes).padStart(10)} raw (${group.count} files)`
)
.join('\n')
}
export function getBundleSizeEvents(
report: BundleSizeReport,
context: Record<string, unknown>
): CiMetricEvent[] {
const categoryProperties: Record<string, number> = {}
for (const category of ASSET_CATEGORIES) {
for (const [prefix, groups] of [
['', report.byCategory],
['initial_', report.initialByCategory],
] as const) {
const group = groups[category]
categoryProperties[`${prefix}${category}_bytes`] = group.bytes
categoryProperties[`${prefix}${category}_gzip_bytes`] = group.gzipBytes
categoryProperties[`${prefix}${category}_count`] = group.count
}
}
return [
{
event: 'dotcom_bundle_size',
distinctId: 'dotcom-bundle-size',
properties: {
...context,
total_bytes: report.total.bytes,
total_gzip_bytes: report.total.gzipBytes,
total_count: report.total.count,
initial_bytes: report.initial.bytes,
initial_gzip_bytes: report.initial.gzipBytes,
initial_count: report.initial.count,
...categoryProperties,
},
},
...report.assets
.filter((asset, index) => asset.isInitial || index < TOP_ASSET_COUNT)
.map(
(asset): CiMetricEvent => ({
event: 'dotcom_bundle_size_asset',
distinctId: 'dotcom-bundle-size',
properties: {
...context,
asset_name: asset.name,
asset_file_name: asset.fileName,
asset_category: asset.category,
asset_is_initial: asset.isInitial,
bytes: asset.bytes,
gzip_bytes: asset.gzipBytes,
},
})
),
]
}
/**
* Measure the built client and, when `BUNDLE_SIZE_ANALYTICS_ENABLED` is set, send the results to
* PostHog so we can watch the bundle over time. Reporting never fails the build: a bundle size
* datapoint isn't worth breaking a deploy over.
*
* Call this on the finished `.vercel/output/static` directory, and before the deploy script
* coalesces previous deploys' assets into it — those older files are still served for open tabs,
* but they aren't part of this build.
*/
export async function reportBundleSize(staticDir: string) {
try {
const report = measureBundleSize(staticDir)
nicelog(`Bundle size:\n${formatBundleSizeReport(report)}`)
if (process.env.BUNDLE_SIZE_ANALYTICS_ENABLED !== 'true') {
nicelog('Bundle size analytics disabled, not reporting to PostHog')
return
}
// See https://eu.posthog.com/project/45972
await captureCiMetricEvents(
getBundleSizeEvents(report, {
git_commit: process.env.RELEASE_COMMIT_HASH || process.env.GITHUB_SHA,
git_branch: process.env.GITHUB_REF_NAME,
tldraw_env: process.env.TLDRAW_ENV,
ci_environment: process.env.GITHUB_ACTIONS ? 'github-actions' : 'local',
})
)
} catch (error) {
console.error('Failed to measure or report bundle size:', error)
}
}

View File

@@ -0,0 +1,8 @@
/**
* Shared with `vite.config.ts` and Node build scripts. Kept separate from `vite.config.ts`
* so `tsx` can import it without loading Vite plugins (unplugin assumes full ESM
* `import.meta.dirname`, which is missing when unplugin is pulled in via tsx's require path).
*/
export function getMultiplayerServerURL() {
return process.env.MULTIPLAYER_SERVER?.replace(/^ws/, 'http')
}

View File

@@ -0,0 +1,111 @@
// copied from https://github.com/vercel/vercel/blob/f8c893bb156d12284866c801dcd3e5fe3ef08e20/packages/gatsby-plugin-vercel-builder/src/types.d.ts#L4
// seems like vercel don't export a good version of this type anywhere at the time of writing
import type { Images } from '@vercel/build-utils'
export type Config = {
version: 3
routes?: Route[]
images?: Images
wildcard?: WildcardConfig
overrides?: OverrideConfig
cache?: string[]
}
type Route = Source | Handler
type Source = {
src: string
dest?: string
headers?: Record<string, string>
methods?: string[]
continue?: boolean
caseSensitive?: boolean
check?: boolean
status?: number
has?: Array<HostHasField | HeaderHasField | CookieHasField | QueryHasField>
missing?: Array<HostHasField | HeaderHasField | CookieHasField | QueryHasField>
locale?: Locale
middlewarePath?: string
}
type Locale = {
redirect?: Record<string, string>
cookie?: string
}
type HostHasField = {
type: 'host'
value: string
}
type HeaderHasField = {
type: 'header'
key: string
value?: string
}
type CookieHasField = {
type: 'cookie'
key: string
value?: string
}
type QueryHasField = {
type: 'query'
key: string
value?: string
}
type HandleValue =
| 'rewrite'
| 'filesystem' // check matches after the filesystem misses
| 'resource'
| 'miss' // check matches after every filesystem miss
| 'hit'
| 'error' // check matches after error (500, 404, etc.)
type Handler = {
handle: HandleValue
src?: string
dest?: string
status?: number
}
type WildCard = {
domain: string
value: string
}
type WildcardConfig = Array<WildCard>
type Override = {
path?: string
contentType?: string
}
type OverrideConfig = Record<string, Override>
type ServerlessFunctionConfig = {
handler: string
runtime: string
memory?: number
maxDuration?: number
environment?: Record<string, string>[]
allowQuery?: string[]
regions?: string[]
}
export type NodejsServerlessFunctionConfig = ServerlessFunctionConfig & {
launcherType: 'Nodejs'
shouldAddHelpers?: boolean // default: false
shouldAddSourceMapSupport?: boolean // default: false
}
export type PrerenderFunctionConfig = {
expiration: number | false
group?: number
bypassToken?: string
fallback?: string
allowQuery?: string[]
}

View File

@@ -0,0 +1,176 @@
import type { Browser } from '@playwright/test'
import type { Connect, Plugin } from 'vite'
// Stands in for Cloudflare Browser Rendering while developing. The sync worker's screenshot surfaces
// (the MCP tool, the OG queue) need a browser to visit the render page, and local dev has no way to
// reach Browser Run: the dev `BROWSER` binding is deliberately non-functional and `[env.dev]` pins a
// `compatibility_date` older than the `quickAction` method needs. The worker isolate cannot drive a
// browser itself, but this dev server is a Node process that already depends on Playwright, so it
// can — the worker POSTs here and gets the PNG back.
//
// The request body is exactly what the worker would send Browser Run's `/screenshot` Quick Action
// (`getThumbnailScreenshotRequestBody`), so the wait strategy, capture target, and timeout come from
// the same place production gets them and cannot drift here. Note this file is imported by
// `vite.config.ts`, which Node loads directly, so it must not import `@tldraw/dotcom-shared`: that
// package's entry re-exports extensionless paths Node cannot resolve.
//
// This is NOT Browser Run: different Chrome build, different flags, no billing headers. It makes the
// local path work; it is not evidence that a render will succeed in production.
export const LOCAL_SCREENSHOT_PATH = '/__screenshot'
// The only page this will ever open. Deliberately asserted here rather than taken from the request:
// an allowlist the caller supplies is not an allowlist. Mirrors THUMBNAIL_RENDER_PATH.
const RENDER_PATH = '/__thumbnail-render'
// The dev counterpart of the edge rewrite in scripts/build.ts. Registered before Vite's own
// middlewares, so the SPA HTML fallback never sees the path — a broken rewrite fails loudly here
// instead of silently serving the app shell.
export function thumbnailRenderEntryPlugin(): Plugin {
const rewrite = (url: string | undefined) =>
url === RENDER_PATH || url?.startsWith(`${RENDER_PATH}?`)
? `/thumbnail-render.html${url.slice(RENDER_PATH.length)}`
: null
const middleware: Connect.NextHandleFunction = (req, _res, next) => {
const rewritten = rewrite(req.url)
if (rewritten) req.url = rewritten
next()
}
return {
name: 'thumbnail-render-entry',
configureServer(server) {
server.middlewares.use(middleware)
},
configurePreviewServer(server) {
server.middlewares.use(middleware)
},
}
}
export function thumbnailScreenshotPlugin(): Plugin {
// Chromium takes about a second to start, so it is launched once for the dev server's lifetime
// rather than per request.
let browserPromise: Promise<Browser> | undefined
// Assigns the promise synchronously, so concurrent first requests share one launch instead of
// each starting a Chromium and orphaning all but the last. Anything that leaves the cached
// browser unusable — a failed launch, or a crash later on — clears it, so the next request
// starts a new one rather than every request failing until the dev server restarts.
function getBrowser() {
if (!browserPromise) {
browserPromise = import('@playwright/test')
.then(({ chromium }) => chromium.launch())
.then((browser) => {
browser.on('disconnected', () => {
browserPromise = undefined
})
return browser
})
.catch((error) => {
browserPromise = undefined
throw error
})
}
return browserPromise
}
return {
name: 'thumbnail-screenshot',
// Dev server only: this never exists in a built client.
apply: 'serve',
configureServer(server) {
server.httpServer?.on('close', () => {
browserPromise?.then((browser) => browser.close()).catch(() => {})
browserPromise = undefined
})
server.middlewares.use(LOCAL_SCREENSHOT_PATH, async (req, res) => {
if (req.method !== 'POST') {
res.statusCode = 405
res.end('Use POST')
return
}
try {
const request = parseRequest(JSON.parse(await readBody(req)), req.headers.host)
const png = await capture(await getBrowser(), request)
res.statusCode = 200
res.setHeader('content-type', 'image/png')
res.end(png)
} catch (error) {
// The worker turns any non-200 into a render failure, so this body is only ever read
// by a developer looking at why a local thumbnail did not appear.
res.statusCode = 500
res.end(error instanceof Error ? error.message : String(error))
}
})
},
}
}
interface ScreenshotRequest {
captureSelector: string
height: number
settledSelector: string
timeoutMs: number
url: string
width: number
}
// Reads the Browser Run `/screenshot` request body the worker sends. Only the render page is
// reachable, and only on this origin: the caller's host is discarded and the path is fixed, so this
// cannot be pointed at an arbitrary URL — the same property production has, where Browser Run is
// never handed a caller-supplied URL.
function parseRequest(body: any, host: string | undefined): ScreenshotRequest {
if (typeof body?.url !== 'string') throw new Error('url is required')
const { pathname, search } = new URL(body.url)
if (pathname !== RENDER_PATH) {
throw new Error(`Only ${RENDER_PATH} can be captured, got ${pathname}`)
}
const settledSelector = body.waitForSelector?.selector
const captureSelector = body.selector
const timeoutMs = body.gotoOptions?.timeout
const { width, height } = body.viewport ?? {}
if (!settledSelector || !captureSelector || !timeoutMs || !width || !height) {
throw new Error('Request is not a thumbnail screenshot request body')
}
return {
captureSelector,
height,
settledSelector,
timeoutMs,
url: new URL(`${RENDER_PATH}${search}`, `http://${host ?? '127.0.0.1'}`).toString(),
width,
}
}
// Settle on either terminal marker, then capture the success-only element — the same sequence the
// Quick Action request body asks Browser Run for, so a failed render fails as soon as the page says
// so instead of burning the timeout.
async function capture(browser: Browser, request: ScreenshotRequest) {
const page = await browser.newPage({
viewport: { width: request.width, height: request.height },
deviceScaleFactor: 1,
})
try {
await page.goto(request.url, { waitUntil: 'domcontentloaded', timeout: request.timeoutMs })
await page.waitForSelector(request.settledSelector, { timeout: request.timeoutMs })
const target = await page.$(request.captureSelector)
if (!target) {
const reason = await page.evaluate(() => document.body.dataset.thumbnailError)
throw new Error(`Render page failed: ${reason || '(no message)'}`)
}
return await target.screenshot({ type: 'png' })
} finally {
await page.close()
}
}
function readBody(req: { on(event: string, fn: (chunk?: any) => void): void }) {
return new Promise<string>((resolve, reject) => {
let body = ''
req.on('data', (chunk) => (body += chunk))
req.on('end', () => resolve(body))
req.on('error', reject)
})
}

View File

@@ -0,0 +1,7 @@
import type { Plugin } from 'vite'
/**
* Vite plugin to replace zod locale imports with a minimal shim
* @param shimPath - Absolute path to the shim file (use fileURLToPath(new URL('./path', import.meta.url)))
*/
export function zodLocalePlugin(shimPath: string): Plugin

View File

@@ -0,0 +1,40 @@
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
/**
* Vite plugin to replace zod locale imports with our minimal shim
* This catches both direct imports and re-exports within the zod package
* @param {string} shimPath - Absolute path to the shim file
* @returns {import('vite').Plugin}
*/
export function zodLocalePlugin(shimPath) {
// If it's already an absolute path, use it; otherwise resolve relative to cwd
const resolvedShimPath = path.isAbsolute(shimPath)
? shimPath
: path.resolve(process.cwd(), shimPath)
return {
name: 'zod-locale-replacer',
enforce: 'pre', // Run before other plugins
resolveId(source, importer) {
// Catch absolute imports to zod locales
if (source === 'zod/v4/locales/index.js' || source === 'zod/v4/locales/index.cjs') {
return resolvedShimPath
}
// Catch relative imports within zod package
if (
importer &&
importer.includes('node_modules/zod') &&
(source.includes('../locales/index.js') || source.includes('../locales/index.cjs'))
) {
return resolvedShimPath
}
return null // Let other resolvers handle it
},
}
}

View File

@@ -0,0 +1,7 @@
// Custom shim to only load the English locale instead of all 47 locales
// This dramatically reduces bundle size while keeping error messages readable
import en from 'zod/v4/locales/en.js'
// Re-export as both default and named export
export { en }
export default en