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
177 lines
6.8 KiB
TypeScript
177 lines
6.8 KiB
TypeScript
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)
|
|
})
|
|
}
|