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
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:
298
apps/dotcom/client/scripts/browser-run-thumbnail.ts
Normal file
298
apps/dotcom/client/scripts/browser-run-thumbnail.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
336
apps/dotcom/client/scripts/build.ts
Normal file
336
apps/dotcom/client/scripts/build.ts
Normal 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()
|
||||
66
apps/dotcom/client/scripts/dev-app.test.ts
Normal file
66
apps/dotcom/client/scripts/dev-app.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
86
apps/dotcom/client/scripts/dev-app.ts
Normal file
86
apps/dotcom/client/scripts/dev-app.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
114
apps/dotcom/client/scripts/kill-tree.test.ts
Normal file
114
apps/dotcom/client/scripts/kill-tree.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
250
apps/dotcom/client/scripts/measure-bundle-size.test.ts
Normal file
250
apps/dotcom/client/scripts/measure-bundle-size.test.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
268
apps/dotcom/client/scripts/measure-bundle-size.ts
Normal file
268
apps/dotcom/client/scripts/measure-bundle-size.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
8
apps/dotcom/client/scripts/multiplayer-server-url.ts
Normal file
8
apps/dotcom/client/scripts/multiplayer-server-url.ts
Normal 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')
|
||||
}
|
||||
111
apps/dotcom/client/scripts/vercel-output-config.d.ts
vendored
Normal file
111
apps/dotcom/client/scripts/vercel-output-config.d.ts
vendored
Normal 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[]
|
||||
}
|
||||
176
apps/dotcom/client/scripts/vite-thumbnail-screenshot-plugin.ts
Normal file
176
apps/dotcom/client/scripts/vite-thumbnail-screenshot-plugin.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
7
apps/dotcom/client/scripts/vite-zod-locale-plugin.d.ts
vendored
Normal file
7
apps/dotcom/client/scripts/vite-zod-locale-plugin.d.ts
vendored
Normal 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
|
||||
40
apps/dotcom/client/scripts/vite-zod-locale-plugin.js
Normal file
40
apps/dotcom/client/scripts/vite-zod-locale-plugin.js
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
7
apps/dotcom/client/scripts/zod-locales-shim.js
Normal file
7
apps/dotcom/client/scripts/zod-locales-shim.js
Normal 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
|
||||
Reference in New Issue
Block a user