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,77 @@
import { existsSync, readFileSync, statSync, writeFileSync } from 'fs'
import path from 'path'
import { glob } from 'glob'
import { parse, print, visit } from 'recast'
const extensions = ['.js', '.mjs', '.cjs']
function resolveRelativePath(importingFile: string, relativePath: string) {
if (!relativePath.startsWith('.')) {
return relativePath
}
const containingDir = path.dirname(importingFile)
if (
existsSync(path.join(containingDir, relativePath)) &&
!statSync(path.join(containingDir, relativePath)).isDirectory()
) {
// if the file already exists, e.g. .css files, just use it
return relativePath
}
// strip the file extension if applicable
relativePath.replace(/\.(m|c)?js$/, '')
for (const extension of extensions) {
if (relativePath.endsWith(extension)) {
return relativePath
} else {
let candidate = `${relativePath}${extension}`
if (existsSync(path.join(containingDir, candidate))) {
return candidate
}
candidate = `${relativePath}/index${extension}`
if (existsSync(path.join(containingDir, candidate))) {
return candidate
}
}
}
throw new Error(`Could not resolve relative path ${relativePath} from ${importingFile}`)
}
export function addJsExtensions(distDir: string) {
for (const file of glob.sync(path.join(distDir, '**/*.{mjs,cjs,js}'))) {
const code = parse(readFileSync(file, 'utf8'), { parser: require('recast/parsers/typescript') })
visit(code, {
visitImportDeclaration(path) {
path.value.source.value = resolveRelativePath(file, path.value.source.value)
return false
},
visitExportAllDeclaration(path) {
path.value.source.value = resolveRelativePath(file, path.value.source.value)
return false
},
visitExportNamedDeclaration(path) {
if (path.value.source) {
path.value.source.value = resolveRelativePath(file, path.value.source.value)
}
this.traverse(path)
},
visitCallExpression(path) {
if (
path.value.callee.type === 'Import' &&
path.value.arguments[0]?.type === 'StringLiteral'
) {
path.value.arguments[0].value = resolveRelativePath(file, path.value.arguments[0].value)
}
this.traverse(path)
},
})
writeFileSync(file, print(code).code)
}
}

View File

@@ -0,0 +1,217 @@
import { readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import * as github from '@actions/github'
import { exec } from './exec'
export function getDeployInfo() {
const githubPrNumber = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/)?.[1]
let previewId = process.env.TLDRAW_PREVIEW_ID
if (!previewId && process.env.TLDRAW_ENV === 'preview') {
if (githubPrNumber) {
previewId = `pr-${githubPrNumber}`
} else {
throw new Error(
'If running preview deploys from outside of a PR action, TLDRAW_PREVIEW_ID env var must be set'
)
}
}
const sha: string | undefined =
// if the event is 'pull_request', github.context.sha is an ephemeral merge commit
// while the actual commit we want to create the deployment for is the 'head' of the PR.
github.context.eventName === 'pull_request'
? github.context.payload.pull_request?.head.sha
: github.context.sha
if (!sha) {
throw new Error('Could not determine the SHA of the commit to deploy')
}
return {
githubPrNumber,
previewId,
sha,
}
}
// Creates a github 'deployment', which creates a 'View Deployment' button in the PR timeline.
export async function createGithubDeployment(
env: { GH_TOKEN: string; TLDRAW_ENV: string },
{
app,
deploymentUrl,
inspectUrl,
sha,
}: { app: string; deploymentUrl: string; inspectUrl?: string; sha: string }
) {
const client = github.getOctokit(env.GH_TOKEN)
const deployment = await client.rest.repos.createDeployment({
owner: 'tldraw',
repo: 'tldraw',
ref: sha,
payload: { web_url: deploymentUrl },
environment: `${app}-${env.TLDRAW_ENV}`,
transient_environment: env.TLDRAW_ENV === 'preview',
production_environment: env.TLDRAW_ENV === 'production',
required_contexts: [],
auto_merge: false,
task: 'deploy',
})
await client.rest.repos.createDeploymentStatus({
owner: 'tldraw',
repo: 'tldraw',
deployment_id: (deployment.data as any).id,
state: 'success',
environment_url: deploymentUrl,
log_url: inspectUrl,
})
}
/** Deploy a worker to wrangler, returning the deploy ID */
export async function wranglerDeploy({
location,
dryRun,
env,
vars,
sentry,
}: {
location: string
dryRun: boolean
env: string
vars: Record<string, string>
sentry?: {
authToken: string
project: string
environment?: string
}
}) {
const varsArray = []
for (const [key, value] of Object.entries(vars)) {
varsArray.push('--var', `${key}:${value}`)
}
const out = await exec(
'yarn',
[
'wrangler',
'deploy',
dryRun ? '--dry-run' : null,
'--env',
env,
'--outdir',
'.wrangler/dist',
...varsArray,
],
{
pwd: location,
env: {
NODE_ENV: 'production',
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts
CI: '1',
},
}
)
if (dryRun) return
const versionMatch = out.match(/Current Version ID: (.+)/)
if (!versionMatch) {
throw new Error('Could not find the deploy ID in wrangler output')
}
const workerNameMatch = out.match(/Uploaded ([^ ]+)/)
if (!workerNameMatch) {
throw new Error('Could not find the worker name in wrangler output')
}
if (sentry) {
const release = `${sentry.environment ?? workerNameMatch[1]}.${versionMatch[1]}`
const sentryEnv = {
SENTRY_AUTH_TOKEN: sentry.authToken,
SENTRY_ORG: 'tldraw',
SENTRY_PROJECT: sentry.project,
}
// create a sentry release:
await exec('yarn', ['run', '-T', 'sentry-cli', 'releases', 'new', release], {
pwd: location,
env: sentryEnv,
})
// upload sourcemaps to the release:
await exec(
'yarn',
[
'run',
'-T',
'sentry-cli',
'releases',
'files',
release,
'upload-sourcemaps',
'.wrangler/dist',
],
{
pwd: location,
env: sentryEnv,
}
)
}
}
export interface ServiceBinding {
binding: string
service: string
}
export async function setWranglerPreviewConfig(
location: string,
{
name,
routeHostname,
serviceBinding,
}: { name: string; routeHostname?: string; serviceBinding?: ServiceBinding },
queueName?: string
) {
// A zone route instead of a custom domain: custom domains mint a dedicated
// certificate pack per hostname, while routes are covered by the zone's
// universal wildcard cert and the existing proxied *.tldraw.xyz DNS record.
const zoneName = routeHostname?.split('.').slice(-2).join('.')
const additionalProperties = `name = "${name}"
${routeHostname ? `routes = [ { pattern = "${routeHostname}/*", zone_name = "${zoneName}" } ]` : ''}
${serviceBinding ? `services = [ {binding = "${serviceBinding.binding}", service = "${serviceBinding.service}" } ]` : ''}`
const envPreviewSection = `\n[env.preview]\n${additionalProperties}\n`
const path = join(location, 'wrangler.toml')
let data = readFileSync(path).toString()
// Replace the entire [env.preview] section including all its properties
// Match from [env.preview] to the next section or end of file
const previewSectionRegex = /\n\[env\.preview\]\n(?:[^[](?:[^\n]*\n)*)?/
if (previewSectionRegex.test(data)) {
data = data.replace(previewSectionRegex, envPreviewSection)
} else {
data += envPreviewSection
}
if (queueName) {
const envPreviewQueuesSection = `\n[[env.preview.queues.producers]]
queue = "${queueName}"
binding = "QUEUE"
[[env.preview.queues.consumers]]
queue = "${queueName}"
max_retries =10
`
data += envPreviewQueuesSection
}
writeFileSync(path, data)
}

View File

@@ -0,0 +1,183 @@
import assert from 'assert'
import { writeFileSync } from 'fs'
import * as diff from 'diff'
import kleur from 'kleur'
import * as tar from 'tar'
import tmp from 'tmp'
import { exec } from './exec'
import { nicelog } from './nicelog'
import { PackageDetails, getAllPackageDetails } from './publishing'
async function getPackageFirstDiff(
pkg: PackageDetails,
publishedVersion: string
): Promise<Diff | null> {
assert(process.env.TLDRAW_BEMO_URL, 'TLDRAW_BEMO_URL env var must be set')
const dir = tmp.dirSync({ unsafeCleanup: true })
const dirPath = dir.name
try {
const unscopedName = pkg.name.replace('@tldraw/', '')
const url = `https://registry.npmjs.org/${pkg.name}/-/${unscopedName}-${publishedVersion}.tgz`
const res = await fetch(url)
if (res.status === 404) {
// The package is not published at this version. This happens when a
// new package was added to the monorepo since the last release, or
// when a previous publish was interrupted after the version-bump
// commit landed but before npm publish completed. In either case
// treat it as an added package so the release proceeds instead of
// failing the entire workflow.
nicelog(
`No published tarball for ${pkg.name}@${publishedVersion} (${url}); treating as added.`
)
return { type: 'added', packageName: pkg.name, filePath: pkg.name }
}
if (res.status >= 400) {
throw new Error(`Failed to fetch ${url}: ${res.status}`)
}
const publishedTarballPath = `${dirPath}/published-package.tgz`
writeFileSync(publishedTarballPath, Buffer.from(await res.arrayBuffer()))
const publishedManifest = getTarballManifestSync(publishedTarballPath)
const localTarballPath = `${dirPath}/local-package.tgz`
await exec('yarn', ['pack', '--out', localTarballPath], { pwd: pkg.dir, env: process.env })
const localManifest = getTarballManifestSync(localTarballPath)
return getManifestFirstDiff(pkg.name, publishedManifest, localManifest)
} finally {
dir.removeCallback()
}
}
type Diff =
| {
type: 'added' | 'removed'
packageName: string
filePath: string
}
| {
type: 'changed'
packageName: string
filePath: string
diff: string
}
// In the `tldraw` package these files are generated during publishing from the
// docs content in `apps/docs/content` (see `generate-tldraw-package-docs.ts`),
// not from SDK source. A docs-only change therefore alters them, which would
// otherwise make this diff check report a package change and cut a spurious SDK
// patch release. Ignore them for the `tldraw` package so that docs-only patches
// don't trigger a new SDK version. Other packages ship a hand-authored DOCS.md
// as real source, so we only skip these for `tldraw`.
//
// `generate-tldraw-package-docs.ts` writes these files to the package root, so
// in the tarball they live at exactly `package/<name>` (tarball entry paths are
// prefixed with `package/`). Match the full path rather than the basename so a
// same-named file in some other folder still counts as a real change.
const GENERATED_DOCS_FILES = new Set(['package/DOCS.md', 'package/RELEASE_NOTES.md'])
const GENERATED_DOCS_PACKAGE = 'tldraw'
function isGeneratedDocsFile(packageName: string, filePath: string) {
if (packageName !== GENERATED_DOCS_PACKAGE) return false
return GENERATED_DOCS_FILES.has(filePath)
}
function getManifestFirstDiff(
packageName: string,
a: Record<string, Buffer>,
b: Record<string, Buffer>
): Diff | null {
const aKeys = Object.keys(a).filter((key) => !isGeneratedDocsFile(packageName, key))
const bKeys = Object.keys(b).filter((key) => !isGeneratedDocsFile(packageName, key))
for (const key of aKeys) {
if (!bKeys.includes(key)) {
return { type: 'removed', packageName, filePath: key }
}
if (!a[key].equals(b[key])) {
return {
type: 'changed',
packageName,
filePath: key,
diff: diff.createTwoFilesPatch(key, key, a[key].toString(), b[key].toString()),
}
}
}
for (const key of bKeys) {
if (!aKeys.includes(key)) {
return { type: 'added', packageName, filePath: key }
}
}
return null
}
function getTarballManifestSync(tarballPath: string) {
const manifest: Record<string, Buffer> = {}
tar.list({
file: tarballPath,
onentry: (entry) => {
entry.on('data', (data) => {
// we could hash these to reduce memory but it's probably fine
const existing = manifest[entry.path]
if (existing) {
manifest[entry.path] = Buffer.concat([existing, data])
} else {
manifest[entry.path] = data
}
})
},
sync: true,
})
return manifest
}
export function formatDiff(diff: Diff) {
let message = kleur.cyan().bold(diff.packageName) + ' changed:'
switch (diff.type) {
case 'added':
message += `\n New file added ${kleur.green().bold(diff.filePath)}`
break
case 'removed':
message += `\n File removed ${kleur.red().bold(diff.filePath)}`
break
case 'changed':
message += `\n File changed ${kleur.yellow().bold(diff.filePath)}\n\n`
message += diff.diff
.split('\n')
.map((line) => {
if (line.startsWith('+')) {
return kleur.green(line)
} else if (line.startsWith('-')) {
return kleur.red(line)
} else {
return line
}
})
.join('\n')
break
}
return message
}
/**
* Compare each local package against the published tarball at
* `publishedVersion` on npm and return the first detected difference, or null
* if every package is identical to its published counterpart.
*
* Callers should pass the latest version that's actually published on npm
* (e.g. from `npm show <pkg> version`) rather than the version in the local
* `package.json`. The local version can drift ahead of npm if a previous
* publish was interrupted after the version-bump commit was pushed; using the
* known-published version keeps the diff check robust to that state.
*/
export async function getAnyPackageDiff(publishedVersion: string): Promise<Diff | null> {
const details = await getAllPackageDetails()
for (const pkg of Object.values(details)) {
const diff = await getPackageFirstDiff(pkg, publishedVersion)
if (diff) {
return diff
}
}
return null
}

View File

@@ -0,0 +1,124 @@
const MASK_PLACEHOLDER = '\x00MASKED\x00'
function sanitizeVariables(errorOutput: string, secretValues?: string[]): string {
let sanitized = errorOutput
// Value-based masking: replace any occurrence of known secret values (like GitHub Actions).
// Uses an internal placeholder so regex patterns below can't re-match masked output.
// Sort longest-first so a longer secret is masked before a shorter substring of it.
if (secretValues) {
const sorted = secretValues.filter((v) => v.length >= 8).sort((a, b) => b.length - a.length)
for (const value of sorted) {
sanitized = sanitized.replaceAll(value, MASK_PLACEHOLDER)
}
}
// Sanitize wrangler --var KEY:VALUE patterns
sanitized = sanitized.replace(/(--var\s+)(\w+):[^ \n]+/g, '$1$2:`***`')
// Sanitize KEY=VALUE patterns where KEY looks like an env var and value is long enough
// to be a secret (e.g. flyctl secrets set). Short values like =debug are left alone.
sanitized = sanitized.replace(/\b([A-Z][A-Z_0-9]{2,})=([^ \n]{8,})/g, '$1=`***`')
// Sanitize --token VALUE and --token=VALUE patterns (e.g. vercel --token xxx)
sanitized = sanitized.replace(/(--token[\s=])\S+/g, '$1`***`')
// Sanitize connection strings (postgres://, redis://, etc.)
sanitized = sanitized.replace(
/\b(postgres|postgresql|mysql|redis|mongodb|amqp):\/\/[^\s"']+/gi,
'$1://`***`'
)
// Replace internal placeholder with final mask
sanitized = sanitized.replaceAll(MASK_PLACEHOLDER, '`***`')
return sanitized
}
export class Discord {
static AT_TEAM_MENTION = '<@&1414904550917144586>'
constructor(opts: {
webhookUrl: string
shouldNotify: boolean
totalSteps?: number
messagePrefix?: string
secretValues?: string[]
}) {
this.webhookUrl = opts.webhookUrl
this.shouldNotify = opts.shouldNotify
this.totalSteps = opts.totalSteps ?? 0
this.messagePrefix = opts.messagePrefix ?? ''
this.secretValues = opts.secretValues
}
webhookUrl: string
shouldNotify: boolean
totalSteps: number
currentStep = 0
messagePrefix: string
private secretValues?: string[]
private async send(method: string, url: string, body: unknown): Promise<any> {
try {
const response = await fetch(`${this.webhookUrl}${url}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
console.warn(
`Discord webhook request failed: ${method} -> ${response.status} ${response.statusText}`
)
return null
}
return response.json()
} catch (err) {
const errString = err instanceof Error ? (err.stack ?? err.message) : String(err)
console.warn(
`Discord webhook request failed: ${method}\n${sanitizeVariables(errString, this.secretValues)}`
)
return null
}
}
async message(content: string, { always = false }: { always?: boolean } = {}) {
if (!always && !this.shouldNotify) {
return {
edit: () => {
// noop
},
}
}
const prefixedContent = this.messagePrefix ? `${this.messagePrefix} ${content}` : content
const message = await this.send('POST', '?wait=true', {
content: sanitizeVariables(prefixedContent, this.secretValues),
})
return {
edit: async (newContent: string) => {
if (!message?.id) return
const prefixedNewContent = this.messagePrefix
? `${this.messagePrefix} ${newContent}`
: newContent
await this.send('PATCH', `/messages/${message.id}`, {
content: sanitizeVariables(prefixedNewContent, this.secretValues),
})
},
}
}
async step<T>(content: string, cb: () => Promise<T>): Promise<T> {
this.currentStep++
const message = await this.message(`[${this.currentStep}/${this.totalSteps}] ${content}...`)
try {
const result = await cb()
await message.edit(`${content} ✅`)
return result
} catch (err) {
await message.edit(`${content} ❌`)
throw err
}
}
}

View File

@@ -0,0 +1,76 @@
import { execFile } from 'child_process'
import { nicelog } from './nicelog'
interface ExecOpts {
pwd?: string
processStdoutLine?(line: string): void
processStderrLine?(line: string): void
env?: Partial<NodeJS.ProcessEnv>
}
export function prefixOutput(prefix: string) {
return {
processStdoutLine: (line: string) => process.stdout.write(`${prefix}${line}\n`),
processStderrLine: (line: string) => process.stderr.write(`${prefix}${line}\n`),
}
}
export async function exec(
command: string,
args: (string | null)[],
{
pwd = process.cwd(),
processStdoutLine = (line) => process.stdout.write(`${line}\n`),
processStderrLine = (line) => process.stderr.write(`${line}\n`),
env,
}: ExecOpts = {}
): Promise<string> {
nicelog(`> $ ${command} ${args.join(' ')} (in ${pwd}))`)
return new Promise((resolve, reject) => {
const data: string[] = []
const childProcess = execFile(
command,
args.filter((arg): arg is string => !!arg),
{ cwd: pwd, env: { ...process.env, ...env } },
(err) => {
if (err) reject(err)
else resolve(data.join(''))
}
)
let pendingStdoutLine = ''
childProcess.stdout!.on('data', (chunk) => {
const chunkString: string = chunk.toString('utf-8')
data.push(chunkString)
const lines = chunkString.split('\n')
lines[0] = pendingStdoutLine + lines[0]
pendingStdoutLine = lines.pop() ?? ''
for (const line of lines) {
processStdoutLine(line)
}
})
childProcess.stdout!.on('close', () => {
processStdoutLine(pendingStdoutLine)
})
let pendingStderrLine = ''
childProcess.stderr!.on('data', (chunk) => {
const chunkString: string = chunk.toString('utf-8')
data.push(chunkString)
const lines = chunkString.split('\n')
lines[0] = pendingStderrLine + lines[0]
pendingStderrLine = lines.pop() ?? ''
for (const line of lines) {
processStderrLine(line)
}
})
childProcess.stderr!.on('close', () => {
processStderrLine(pendingStderrLine)
})
})
}

View File

@@ -0,0 +1,90 @@
import { existsSync, readFileSync } from 'fs'
import { readFile, writeFile as writeFileUnchecked } from 'fs/promises'
import { dirname, join, relative } from 'path'
import { fileURLToPath } from 'url'
import json5 from 'json5'
import { format } from 'oxfmt'
import { nicelog } from './nicelog'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
export const REPO_ROOT = join(__dirname, '../../..')
const _rootPackageJsonPath = join(REPO_ROOT, 'package.json')
if (!existsSync(_rootPackageJsonPath)) {
throw new Error('expected to find package.json in REPO_ROOT')
}
const _rootPackageJson = JSON.parse(readFileSync(_rootPackageJsonPath, 'utf8'))
if (_rootPackageJson['name'] !== '@tldraw/monorepo') {
throw new Error('expected to find @tldraw/monorepo in REPO_ROOT package.json')
}
export async function readJsonIfExists(file: string) {
const fileContents = await readFileIfExists(file)
if (fileContents === null) {
return null
}
return json5.parse(fileContents)
}
export async function readFileIfExists(file: string) {
try {
return await readFile(file, 'utf8')
} catch {
return null
}
}
const oxfmtConfig = json5.parse(readFileSync(join(REPO_ROOT, '.oxfmtrc.json'), 'utf8'))
export async function writeCodeFile(
generator: string | null,
language: 'typescript' | 'javascript',
filePath: string,
code: string
) {
const source = generator
? `
// This file is automatically generated by ${generator}.
// Do not edit manually. Or do, I'm a comment, not a cop.
${code}
`
: code
const ext = language === 'typescript' ? 'ts' : 'js'
const result = await format(`file.${ext}`, source, oxfmtConfig)
await writeStringFile(filePath, result.code)
}
export async function writeStringFile(filePath: string, contents: string) {
await writeFile(filePath, Buffer.from(contents, 'utf-8'))
}
export async function writeFile(filePath: string, contents: Buffer) {
if (process.env.CI && !process.env.ALLOW_REFRESH_ASSETS_CHANGES) {
let existingContents: Buffer | null = null
try {
existingContents = await readFile(filePath)
} catch {
// Ignore
}
if (existingContents && !existingContents.equals(contents)) {
nicelog(
`Asset file ${relative(
REPO_ROOT,
filePath
)} has changed. Please run this script again and commit the changes.`
)
nicelog('Contents before:')
nicelog(existingContents.toString('utf-8'))
nicelog('\nContents after:')
nicelog(contents.toString('utf-8'))
process.exit(1)
}
}
await writeFileUnchecked(filePath, contents, 'utf-8')
}
export async function writeJsonFile(filePath: string, contents: unknown) {
await writeStringFile(filePath, JSON.stringify(contents, null, '\t') + '\n')
}

View File

@@ -0,0 +1,40 @@
import { spawnSync } from 'child_process'
/**
* Collect every descendant PID of `pid` by walking the process tree with `pgrep -P`.
*
* Returned breadth-first (direct children first, deepest descendants last). Callers that SIGKILL
* the tree should reverse this so they kill deepest-first.
*/
export function descendantPids(pid: number): number[] {
const result = spawnSync('pgrep', ['-P', String(pid)], { encoding: 'utf8' })
const childPids = (result.stdout ?? '')
.split('\n')
.map((line) => Number(line.trim()))
.filter((n) => Number.isInteger(n) && n > 0)
return childPids.flatMap((childPid) => [childPid, ...descendantPids(childPid)])
}
/**
* SIGKILL `pid` and its entire descendant tree.
*
* Our dev orchestrators spawn children through wrapper layers (`yarn run -T tsx …`) and processes
* that fork their own grandchildren (vite → esbuild, wrangler → workerd, zero-cache → worker
* subprocesses). `child.kill()` only signals the direct child, and SIGKILL is uncatchable and does
* not cascade — so killing a wrapper or parent just orphans everything beneath it, leaving processes
* that keep holding the fixed dev ports. Walking by PID crosses those wrapper and process-group
* boundaries.
*
* Collect the PIDs up front so the tree does not reparent out from under us while we kill, then
* SIGKILL deepest-first. The root `pid` itself is left alone so the caller can finish its own
* shutdown and exit. Safe to call from signal and synchronous `exit` handlers.
*/
export function killProcessTree(pid: number) {
for (const target of descendantPids(pid).reverse()) {
try {
process.kill(target, 'SIGKILL')
} catch {
// already gone
}
}
}

View File

@@ -0,0 +1,44 @@
interface Label {
// this is what the label is 'called' on github
name: string
// this is how we describe the label in our pull request template
description: string
// this is the section title for the label in our changelogs
changelogTitle: string
}
const TYPE_LABELS = [
{ name: `bugfix`, description: `Bug fix`, changelogTitle: '🐛 Bug Fixes' },
{
name: `improvement`,
description: `Product improvement`,
changelogTitle: '💄 Product Improvements',
},
{
name: `feature`,
description: `New feature`,
changelogTitle: '🎉 New Features',
},
{
name: `api`,
description: `API change`,
changelogTitle: '🛠️ API Changes',
},
{
name: `other`,
description: `Changes that don't affect SDK users, e.g. internal or .com changes`,
changelogTitle: '🤷 Other',
},
] as const satisfies Label[]
export function getLabelNames() {
return [...TYPE_LABELS].map((label) => label.name)
}
function formatTemplateOption(label: Label) {
return `- [ ] \`${label.name}\``
}
export function formatLabelOptionsForPRTemplate() {
return TYPE_LABELS.map(formatTemplateOption).join('\n')
}

View File

@@ -0,0 +1,18 @@
export function makeEnv<const Keys extends readonly string[]>(
keys: Keys
): Record<Keys[number], string> {
const env = {} as Record<string, string>
const missingVars = []
for (const key of keys) {
const value = process.env[key]
if (value === undefined) {
missingVars.push(key)
continue
}
env[key] = value
}
if (missingVars.length > 0) {
throw new Error(`Missing environment variables: ${missingVars.join(', ')}`)
}
return env as Record<Keys[number], string>
}

View File

@@ -0,0 +1,3 @@
export function nicelog(...args: any[]) {
console.log(...args)
}

View File

@@ -0,0 +1,57 @@
import { nicelog } from './nicelog'
/**
* The PostHog project we send CI engineering metrics to. `phc_` keys are public, write-only
* ingest keys, so keeping this in the repo is fine — it's the same project the e2e performance
* reporter writes to. See https://eu.posthog.com/project/45972
*/
const CI_METRICS_API_KEY = 'phc_i8oKgMzgV38sn3GfjswW9mevQ3gFlo7bJXekZFeDN6'
const DEFAULT_API_HOST = 'https://analytics.tldraw.com/ingest'
export interface CiMetricEvent {
event: string
/**
* Groups a series together in PostHog. Use a stable string per metric (e.g.
* `dotcom-bundle-size`) rather than a per-run id, so a trend over time is one person's events
* instead of thousands of one-off people.
*/
distinctId: string
properties: Record<string, unknown>
}
/**
* Send events to the CI metrics PostHog project. Everything goes in a single request to the
* `/batch/` endpoint: a build reports a few dozen events, and sending them one at a time would
* mean a few dozen round trips at the tail of a deploy, where a failure partway through leaves a
* build with some of its events recorded and the rest missing.
*/
export async function captureCiMetricEvents(events: CiMetricEvent[]) {
if (events.length === 0) return
const apiHost = process.env.POSTHOG_API_HOST || DEFAULT_API_HOST
const timestamp = new Date().toISOString()
const response = await fetch(`${apiHost}/batch/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: CI_METRICS_API_KEY,
batch: events.map(({ event, distinctId, properties }) => ({
event,
properties: {
...properties,
distinct_id: distinctId,
$lib: 'tldraw-ci-metrics',
$lib_version: '1.0.0',
},
timestamp,
})),
}),
})
if (!response.ok) {
throw new Error(`PostHog API error: ${response.status} ${response.statusText}`)
}
nicelog(`Sent ${events.length} event(s) to PostHog`)
}

View File

@@ -0,0 +1,64 @@
import { Octokit } from 'octokit'
import { exec } from './exec'
import { nicelog } from './nicelog'
export async function getPrDetailsByNumber(octokit: Octokit, prNumber: number) {
return await octokit.rest.pulls
.get({
owner: 'tldraw',
repo: 'tldraw',
pull_number: prNumber,
})
.then((res) => res.data)
}
export async function getPrDetails(octokit: Octokit) {
const lastCommitMessage = (await exec('git', ['log', '-1', '--oneline'])).trim()
const lastPRNumber = lastCommitMessage.match(/\(#(\d+)\)$/)?.[1]
if (!lastPRNumber) {
nicelog('No PR number found in last commit message. Exiting...')
return null
}
return await getPrDetailsByNumber(octokit, parseInt(lastPRNumber))
}
export async function getPrDetailsAndCommitSha(octokit: Octokit) {
const prNumber = process.env.PR_NUMBER
nicelog(`PR_NUMBER environment variable: ${prNumber}`)
let pr: PullRequest | null
let commitSha: string | null = null
if (prNumber) {
// Post-merge mode: get PR details by number
nicelog(`Post-merge mode: getting PR details for #${prNumber}`)
pr = await getPrDetailsByNumber(octokit, parseInt(prNumber))
if (!pr) {
nicelog('Could not retrieve PR details. Exiting...')
return null
}
commitSha = pr.merge_commit_sha
} else {
// Merge-time mode: get PR details from commit message
nicelog('Merge-time mode: getting PR details from commit message')
pr = await getPrDetails(octokit)
if (!pr) {
nicelog('Could not retrieve PR details. Exiting...')
return null
}
commitSha = (await exec('git', ['rev-parse', 'HEAD'])).trim()
}
if (!commitSha) {
nicelog('PR is not merged or merge commit SHA not available. Exiting...')
return null
}
return { pr, commitSha }
}
export type PullRequest = NonNullable<Awaited<ReturnType<typeof getPrDetailsByNumber>>>
export function labelPresent(pr: PullRequest, labelName: string): boolean {
return pr.labels.some((label) => label.name === labelName)
}

View File

@@ -0,0 +1,295 @@
import { execSync } from 'child_process'
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
import path, { join } from 'path'
import { Octokit } from '@octokit/rest'
import { fetch } from 'cross-fetch'
import { glob } from 'glob'
import { parse } from 'semver'
import { exec } from './exec'
import { REPO_ROOT } from './file'
import { nicelog } from './nicelog'
import { getAllWorkspacePackages } from './workspace'
export interface PackageDetails {
name: string
dir: string
localDeps: string[]
version: string
}
async function getPackageDetails(dir: string): Promise<PackageDetails | null> {
const packageJsonPath = path.join(dir, 'package.json')
if (!existsSync(packageJsonPath)) {
return null
}
const packageJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'))
if (packageJson.private) {
return null
}
const workspacePackages = await getAllWorkspacePackages()
return {
name: packageJson.name,
dir,
version: packageJson.version,
localDeps: Object.keys(packageJson.dependencies ?? {}).filter((dep) =>
workspacePackages.some((p) => p.name === dep)
),
}
}
export async function getAllPackageDetails(): Promise<Record<string, PackageDetails>> {
const dirs = readdirSync(join(REPO_ROOT, 'packages'))
const details = await Promise.all(
dirs.map((dir) => getPackageDetails(path.join(REPO_ROOT, 'packages', dir)))
)
const results = details.filter((x): x is PackageDetails => Boolean(x))
return Object.fromEntries(results.map((result) => [result.name, result]))
}
export async function setAllVersions(version: string, options?: { stageChanges?: boolean }) {
const packages = await getAllPackageDetails()
for (const packageDetails of Object.values(packages)) {
const manifest = JSON.parse(readFileSync(path.join(packageDetails.dir, 'package.json'), 'utf8'))
manifest.version = version
writeFileSync(
path.join(packageDetails.dir, 'package.json'),
JSON.stringify(manifest, null, '\t') + '\n'
)
}
await exec('yarn', ['refresh-assets', '--force'], { env: { ALLOW_REFRESH_ASSETS_CHANGES: '1' } })
const lernaJson = JSON.parse(readFileSync('lerna.json', 'utf8'))
lernaJson.version = version
writeFileSync('lerna.json', JSON.stringify(lernaJson, null, '\t') + '\n')
execSync('yarn')
if (options?.stageChanges) {
await stageAllPackageJsonChanges()
}
}
async function stageAllPackageJsonChanges() {
// stage the changes
const packageJsonFilesToAdd = []
for (const workspace of await getAllWorkspacePackages()) {
if (workspace.relativePath.startsWith('packages/')) {
packageJsonFilesToAdd.push(`${workspace.relativePath}/package.json`)
}
}
const versionFilesToAdd = glob.sync('**/*/version.ts', {
ignore: ['node_modules/**'],
follow: false,
})
console.log('versionFilesToAdd', versionFilesToAdd)
await exec('git', [
'add',
'--update',
'lerna.json',
...packageJsonFilesToAdd,
...versionFilesToAdd,
])
}
function assertExists<T>(v: T | null | undefined): T {
if (v === null || v === undefined) throw new Error('Expected value to exist')
return v
}
export async function getLatestTldrawVersionFromNpm({
versionPrefix,
}: { versionPrefix?: string } = {}) {
if (!versionPrefix)
return assertExists(parse((await exec('npm', ['show', 'tldraw', 'version'])).trim()))
const versions = (await exec('npm', ['show', 'tldraw@~' + versionPrefix, 'version'])).trim()
if (versions.startsWith('tldraw')) {
return assertExists(parse(versions.split('\n').pop()?.split(' ')[1].replaceAll("'", '')))
}
return assertExists(parse(versions))
}
function topologicalSortPackages(packages: Record<string, PackageDetails>) {
const sorted: PackageDetails[] = []
const visited = new Set<string>()
function visit(packageName: string, path: string[]) {
if (visited.has(packageName)) {
return
}
visited.add(packageName)
const packageDetails = packages[packageName]
if (!packageDetails) {
throw new Error(`Could not find package ${packageName}. path: ${path.join(' -> ')}`)
}
packageDetails.localDeps.forEach((dep) => visit(dep, [...path, dep]))
sorted.push(packageDetails)
}
Object.keys(packages).forEach((packageName) => visit(packageName, [packageName]))
return sorted
}
export async function publish(distTag?: string) {
// Authentication uses npm's trusted publisher OIDC flow. The publish job in
// CI must grant `permissions: id-token: write` so yarn (>= 4.10, which we are
// on via `packageManager`) can exchange the GitHub-issued OIDC token for a
// short-lived publish token automatically.
//
// We invoke `yarn npm publish` rather than `npm publish` directly so that
// yarn rewrites `workspace:*` dependency specifiers in the published
// tarball into the concrete sibling versions. `npm publish` has no concept
// of yarn's workspace protocol and would ship `"workspace:*"` literally,
// breaking installs for any consumer outside this monorepo.
// See https://docs.npmjs.com/trusted-publishers
const packages = await getAllPackageDetails()
const publishOrder = topologicalSortPackages(packages)
// npm can take several minutes before a published version is readable. Waiting only
// on a package's own dependencies keeps a newly visible package installable, without
// making the run wait for every package's delay in turn.
const readable = new Map<string, Promise<void>>()
for (const packageDetails of publishOrder) {
await Promise.all(packageDetails.localDeps.map((dep) => readable.get(dep)))
const tag = distTag ?? parse(packageDetails.version)?.prerelease[0] ?? 'latest'
nicelog(
`Publishing ${packageDetails.name} with version ${packageDetails.version} under tag @${tag}`
)
await retry(
async () => {
let output = ''
const publishStart = Date.now()
nicelog(
`[publish] ${packageDetails.name}@${packageDetails.version} starting npm publish...`
)
try {
await exec(
`yarn`,
[
'npm',
'publish',
'--tag',
String(tag),
'--tolerate-republish',
'--provenance',
'--access',
'public',
],
{
pwd: packageDetails.dir,
processStdoutLine: (line) => {
output += line + '\n'
nicelog(line)
},
processStderrLine: (line) => {
output += line + '\n'
nicelog(line)
},
}
)
} catch (e) {
// A retry after a publish that actually landed is rejected as "published",
// or as "staged" (409) while npm is still processing the first attempt.
const lowerOutput = output.toLowerCase()
if (
lowerOutput.includes('cannot publish over the previously published versions') ||
lowerOutput.includes('cannot publish over previously staged version')
) {
nicelog(
`[publish] ${packageDetails.name}@${packageDetails.version} already published or staged, skipping`
)
return
}
throw e
}
const elapsed = ((Date.now() - publishStart) / 1000).toFixed(1)
nicelog(
`[publish] ${packageDetails.name}@${packageDetails.version} npm publish done (${elapsed}s)`
)
},
{
delay: 10_000,
numAttempts: 5,
}
)
const whenReadable = waitUntilReadable(packageDetails)
// Rejections surface when awaited below; without this, one arriving mid-loop would
// kill the process in the middle of another package's publish.
whenReadable.catch(() => {})
readable.set(packageDetails.name, whenReadable)
}
await Promise.all(readable.values())
}
function waitUntilReadable(packageDetails: PackageDetails) {
const url = `https://registry.npmjs.org/${packageDetails.name}/${packageDetails.version}`
return retry(
async ({ attempt, total }) => {
const res = await fetch(url, { method: 'HEAD' })
if (res.status >= 400) {
nicelog(
`[verify] ${packageDetails.name}@${packageDetails.version} not readable yet (${res.status}), attempt ${attempt + 1} of ${total}`
)
throw new Error(`Package not found: ${url} (${res.status})`)
}
nicelog(`[verify] ${packageDetails.name}@${packageDetails.version} is readable`)
},
{
delay: 10_000,
// 15 minutes; the slowest package took over 8 minutes in September 2026.
numAttempts: 90,
}
)
}
function retry(
fn: (args: { attempt: number; remaining: number; total: number }) => Promise<void>,
opts: {
numAttempts: number
delay: number
}
): Promise<void> {
return new Promise((resolve, reject) => {
let attempts = 0
function attempt() {
fn({ attempt: attempts, remaining: opts.numAttempts - attempts, total: opts.numAttempts })
.then(resolve)
.catch((err) => {
attempts++
if (attempts >= opts.numAttempts) {
reject(err)
} else {
setTimeout(attempt, opts.delay)
}
})
}
attempt()
})
}
export async function publishProductionDocsAndExamplesAndBemo({
gitRef = 'HEAD',
}: { gitRef?: string } = {}) {
await exec('git', ['push', 'origin', `${gitRef}:docs-production`, `--force`])
await exec('git', ['push', 'origin', `${gitRef}:bemo-production`, `--force`])
}
export async function triggerBumpVersionsWorkflow(ghToken: string) {
const octokit = new Octokit({ auth: ghToken })
await octokit.rest.actions.createWorkflowDispatch({
owner: 'tldraw',
repo: 'tldraw',
workflow_id: 'bump-versions.yml',
ref: 'main',
})
}

View File

@@ -0,0 +1,19 @@
// GitHub skips both push- and pull_request-triggered workflows when one of these markers appears
// in a commit message, in the positions documented at
// https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs
// `[^\S\r\n]` rather than `\s`: eating the newlines around a marker on its own body line would
// close the blank line separating subject from body, and `git commit --amend -m` would then fold
// the whole first paragraph into the subject.
const SKIP_CI_MARKER = /[^\S\r\n]*\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]/gi
/**
* Remove GitHub's skip-ci markers from a commit message or PR title.
*
* Release-notes PRs carry `[skip ci]` in their title on purpose, so their squash-merge onto `main`
* doesn't run the push workflows. The hotfix scripts cherry-pick that same commit onto a branch
* whose push *must* trigger a workflow (the release branch's publish, or the hotfixes branch's
* production build), so the marker has to go before the push.
*/
export function stripSkipCiMarkers(message: string): string {
return message.replace(SKIP_CI_MARKER, '').trim()
}

View File

@@ -0,0 +1,42 @@
import { readFileSync, writeFileSync } from 'fs'
import path from 'path'
import { namedTypes } from 'ast-types'
import { glob } from 'glob'
import { parse, print, visit } from 'recast'
export function sortUnions(tsbuildDir: string) {
for (const file of glob.sync(path.join(tsbuildDir, '**/*.d.ts'))) {
try {
const code = parse(readFileSync(file, 'utf8'), {
parser: require('recast/parsers/typescript'),
})
visit(code, {
visitTSUnionType(path) {
this.traverse(path)
const val = path.value as namedTypes.TSUnionType
val.types = val.types.sort((a, b) => {
const aText = print(a).code
const bText = print(b).code
return aText.localeCompare(bText)
})
return false
},
visitTSTypeLiteral(path) {
this.traverse(path)
const val = path.value as namedTypes.TSTypeLiteral
val.members = val.members.sort((a, b) => {
const aText = print(a).code
const bText = print(b).code
return aText.localeCompare(bText)
})
return false
},
})
writeFileSync(file, print(code).code)
} catch (e) {
throw new Error(`Error sorting unions in ${file}`, { cause: e })
}
}
}

View File

@@ -0,0 +1,80 @@
import { T } from '@tldraw/validate'
export const EXPORT_CONFIG_KEY = 'tldraw_template' as const
export const TemplateDeployConfig = T.object({
// Where the live demo is hosted. The vercel-hosted demos deploy through vercel's own git
// integration, so only cloudflare templates carry a `deploy` config.
target: T.literalEnum('cloudflare'),
// The hostname the demo is served from. Must match the custom domain route in wrangler.toml.
host: T.string,
})
export type TemplateDeployConfig = T.TypeOf<typeof TemplateDeployConfig>
export const TemplateConfig = T.object({
repo: T.string,
cli: T.object({
name: T.string,
description: T.string,
shortDescription: T.string.optional(),
order: T.number.optional(),
}).optional(),
deploy: TemplateDeployConfig.optional(),
scripts: T.dict(T.string, T.nullable(T.string)).optional(),
})
export type TemplateConfig = T.TypeOf<typeof TemplateConfig>
export const PRODUCT_CONFIG_KEY = 'tldraw_product' as const
/**
* Commercial product metadata for a published package. The `stableId` identifies the commercial
* component a package belongs to; it is referenced by legal in order forms and must never change,
* even if the package is renamed or moved. Several packages can share one stable id. See
* `internal/docs/product-stable-ids.md` for the full convention.
*/
export const ProductConfig = T.object({
// Immutable commercial identifier, e.g. `tldraw:sdk-core`. Never changes on rename/refactor.
stableId: T.string,
// Customer-facing label, matching the wording used on the order form, e.g. 'Collaboration
// module'. Unlike stableId this may be reworded; the id is what contracts reference.
name: T.string,
// `product` is a top-level order form line item; `feature` is an optional part of its parent
// that can be licensed on its own, listed indented beneath it on the order form.
type: T.literalEnum('product', 'feature'),
// The component this one belongs to, e.g. commenting's parent is the collaboration module.
// Components without a parent are top-level order form line items.
parent: T.string.optional(),
// Whether this component is separately licensed rather than part of the standard SDK offering.
premium: T.boolean,
// For premium components: the license key FLAGS bit (in packages/editor's LicenseManager) that
// entitles a customer to this component.
licenseFlag: T.string.optional(),
})
export type ProductConfig = T.TypeOf<typeof ProductConfig>
export const PackageJson = T.object({
name: T.string,
private: T.boolean.optional(),
workspaces: T.arrayOf(T.string).optional(),
[EXPORT_CONFIG_KEY]: TemplateConfig.optional(),
[PRODUCT_CONFIG_KEY]: ProductConfig.optional(),
license: T.string.optional(),
description: T.string.optional(),
scripts: T.dict(T.string, T.nullable(T.string)).optional(),
dependencies: T.dict(T.string, T.string).optional(),
devDependencies: T.dict(T.string, T.string).optional(),
peerDependencies: T.dict(T.string, T.string).optional(),
optionalDependencies: T.dict(T.string, T.string).optional(),
engines: T.dict(T.string, T.string).optional(),
packageManager: T.string.optional(),
}).allowUnknownProperties()
export type PackageJson = T.TypeOf<typeof PackageJson>
export const TsConfigJson = T.object({
references: T.arrayOf(
T.object({
path: T.string,
})
).optional(),
}).allowUnknownProperties()
export type TsConfigJson = T.TypeOf<typeof TsConfigJson>

View File

@@ -0,0 +1,98 @@
import fs from 'fs'
import path from 'path'
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import mime from 'mime'
import { exec } from './exec'
import { makeEnv } from './makeEnv'
const env = makeEnv(['R2_ACCESS_KEY_ID', 'R2_ACCESS_KEY_SECRET'])
const R2_URL = 'https://c34edc4e76350954b63adebde86d5eb1.r2.cloudflarestorage.com'
const R2_BUCKET = 'cdn'
const ASSETS_FOLDER = './packages/assets'
// How many files to upload to the CDN at once.
const UPLOAD_CONCURRENCY = 16
const R2 = new S3Client({
region: 'auto',
endpoint: R2_URL,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_ACCESS_KEY_SECRET,
},
})
async function uploadFile(key: string, fullPath: string) {
const fileStream = fs.createReadStream(fullPath)
const contentType = mime.getType(fullPath) ?? 'application/octet-stream'
const uploadParams = {
Bucket: R2_BUCKET,
Key: key,
Body: fileStream,
ContentType: contentType,
// these assets will never change, so we can cache them forever:
CacheControl: 'public, max-age=31536000, immutable',
}
await R2.send(new PutObjectCommand(uploadParams))
process.stdout.write(` • ${key} ✔️\n`)
}
function collectFiles(
prefix: string,
directoryPath: string
): Array<{ key: string; fullPath: string }> {
const files: Array<{ key: string; fullPath: string }> = []
for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) {
const fullPath = path.join(directoryPath, entry.name)
const key = path.join(prefix, entry.name)
if (entry.isDirectory()) {
files.push(...collectFiles(key, fullPath))
} else if (entry.isFile()) {
files.push({ key, fullPath })
}
}
return files
}
export async function uploadStaticAssets(version: string) {
try {
await exec('yarn', ['refresh-assets'], { env: { ALLOW_REFRESH_ASSETS_CHANGES: '1' } })
const entries = fs.readdirSync(ASSETS_FOLDER, { withFileTypes: true })
// Gather every file across all asset folders up front so we can upload
// them with bounded concurrency instead of one at a time.
const files: Array<{ key: string; fullPath: string }> = []
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
if (!entry.isDirectory()) continue
const folderName = entry.name
files.push(...collectFiles(`${version}/${folderName}`, `${ASSETS_FOLDER}/${folderName}`))
}
console.log(`Uploading ${files.length} static assets to CDN...`)
// Upload files in parallel, keeping at most UPLOAD_CONCURRENCY requests in
// flight at a time. Workers pull from a shared queue until it's empty.
let next = 0
async function worker() {
while (next < files.length) {
const { key, fullPath } = files[next++]
await uploadFile(key, fullPath)
}
}
await Promise.all(
Array.from({ length: Math.min(UPLOAD_CONCURRENCY, files.length) }, () => worker())
)
console.log('Uploaded!')
} catch (e) {
console.error(e)
process.exit(1)
}
}

View File

@@ -0,0 +1,27 @@
/**
* Node builtins (and cloudflare modules) that must stay external when bundling a worker
* entrypoint with esbuild. With nodejs_compat these resolve at runtime in workerd; esbuild just
* needs to be told not to bundle them. Shared between the dev size reporter
* (workers/dev.ts) and the CI bundle-size check (check-worker-bundle.ts) so the lists can't drift.
*/
export const WORKER_EXTERNAL_DEPS = [
'cloudflare:*',
'crypto',
'tls',
'net',
'stream',
'fs',
'os',
'perf_hooks',
'path',
'dns',
'util',
'util/types',
'node:child_process',
'node:events',
'node:path',
'node:process',
'node:os',
'node:timers',
'node:util',
]

View File

@@ -0,0 +1,62 @@
import path from 'path'
import { glob } from 'glob'
import { REPO_ROOT, readJsonIfExists } from './file'
import { PackageJson } from './types'
export interface Package {
packageJson: PackageJson
relativePath: string
path: string
name: string
}
async function readPackage(packageJsonFile: string): Promise<Package> {
const packageJsonPath = path.resolve(packageJsonFile)
const packagePath = path.dirname(packageJsonPath)
const relativePath = path.relative(REPO_ROOT, packagePath)
const packageJsonRaw = await readJsonIfExists(packageJsonFile)
if (!packageJsonRaw) {
throw new Error(`No package.json found at ${packageJsonPath}`)
}
let packageJson: PackageJson
try {
packageJson = PackageJson.validate(packageJsonRaw)
} catch (e: any) {
throw new Error(`Invalid package.json in ${relativePath}: ${e.message}`)
}
return {
packageJson,
relativePath,
path: packagePath,
name: packageJson.name,
}
}
async function getChildWorkspaces(parent: Package): Promise<Package[]> {
if (!parent.packageJson.workspaces) return []
const foundPackages = []
for (const workspace of parent.packageJson.workspaces) {
const workspacePath = path.join(parent.path, workspace)
for (const packageJsonFilePath of glob.sync(path.join(workspacePath, 'package.json'))) {
const child = await readPackage(packageJsonFilePath)
foundPackages.push(child)
if (child.packageJson.workspaces) {
foundPackages.push(...(await getChildWorkspaces(child)))
}
}
}
return foundPackages
}
export async function getRootPackage() {
return await readPackage(path.join(REPO_ROOT, 'package.json'))
}
export async function getAllWorkspacePackages() {
return await getChildWorkspaces(await getRootPackage())
}