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,239 @@
import { StatusError } from 'itty-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { handleExtractBookmarkMetadataRequest } from './bookmarks'
// Mock dependencies
vi.mock('cloudflare-workers-unfurl', () => ({
unfurl: vi.fn(),
}))
vi.mock('./handleRequest', () => ({
parseRequestQuery: vi.fn(),
}))
// Mock global fetch
const mockFetch = vi.fn()
Object.defineProperty(global, 'fetch', {
value: mockFetch,
writable: true,
})
// Mock crypto for image upload tests
Object.defineProperty(global, 'crypto', {
value: { randomUUID: vi.fn(() => 'test-uuid-123') },
writable: true,
})
// Import mocked modules
const { unfurl } = await import('cloudflare-workers-unfurl')
const { parseRequestQuery } = await import('./handleRequest')
describe('bookmarks', () => {
const mockRequest = {
method: 'GET',
query: { url: 'https://example.com' },
} as any
beforeEach(() => {
vi.clearAllMocks()
// Reset console.error mock
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('handleExtractBookmarkMetadataRequest', () => {
it('throws validation error for invalid URL', async () => {
const validationError = new StatusError(400, 'Query parameters: Invalid URL')
vi.mocked(parseRequestQuery).mockImplementation(() => {
throw validationError
})
await expect(handleExtractBookmarkMetadataRequest({ request: mockRequest })).rejects.toThrow(
validationError
)
})
it('successfully extracts metadata', async () => {
const mockMetadata = {
title: 'Test Page',
description: 'A test page',
image: 'https://example.com/image.jpg',
favicon: 'https://example.com/favicon.ico',
}
vi.mocked(parseRequestQuery).mockReturnValue({ url: 'https://example.com' })
vi.mocked(unfurl).mockResolvedValue({
ok: true,
value: mockMetadata,
})
const response = await handleExtractBookmarkMetadataRequest({ request: mockRequest })
const result = await response.json()
expect(result).toEqual(mockMetadata)
expect(response.status).toBe(200)
})
it('handles unfurl bad-param error', async () => {
vi.mocked(parseRequestQuery).mockReturnValue({ url: 'https://example.com' })
vi.mocked(unfurl).mockResolvedValue({
ok: false,
error: 'bad-param',
})
await expect(handleExtractBookmarkMetadataRequest({ request: mockRequest })).rejects.toThrow(
new StatusError(400, 'Bad URL')
)
})
it('handles unfurl failed-fetch error', async () => {
vi.mocked(parseRequestQuery).mockReturnValue({ url: 'https://example.com' })
vi.mocked(unfurl).mockResolvedValue({
ok: false,
error: 'failed-fetch',
})
await expect(handleExtractBookmarkMetadataRequest({ request: mockRequest })).rejects.toThrow(
new StatusError(422, 'Failed to fetch URL')
)
})
it('processes images when uploadImage is provided', async () => {
const mockMetadata = {
title: 'Test Page',
image: 'https://example.com/image.jpg',
favicon: 'https://example.com/favicon.ico',
}
vi.mocked(parseRequestQuery).mockReturnValue({ url: 'https://example.com' })
vi.mocked(unfurl).mockResolvedValue({
ok: true,
value: mockMetadata,
})
const mockImageResponse = {
ok: true,
headers: new Headers({ 'content-type': 'image/jpeg' }),
body: new ReadableStream(),
}
mockFetch.mockResolvedValue(mockImageResponse)
const mockUploadImage = vi
.fn()
.mockResolvedValueOnce('https://cdn.example.com/processed-image')
.mockResolvedValueOnce('https://cdn.example.com/processed-favicon')
const request = { ...mockRequest, method: 'POST' }
const response = await handleExtractBookmarkMetadataRequest({
request,
uploadImage: mockUploadImage,
})
const result = (await response.json()) as any
// Should update metadata with processed image URLs
expect(result.image).toBe('https://cdn.example.com/processed-image')
expect(result.favicon).toBe('https://cdn.example.com/processed-favicon')
})
it('skips image processing when URLs are not present', async () => {
const metadataWithoutImages = { title: 'Test Page' }
vi.mocked(parseRequestQuery).mockReturnValue({ url: 'https://example.com' })
vi.mocked(unfurl).mockResolvedValue({
ok: true,
value: metadataWithoutImages,
})
const mockUploadImage = vi.fn()
const request = { ...mockRequest, method: 'POST' }
await handleExtractBookmarkMetadataRequest({
request,
uploadImage: mockUploadImage,
})
expect(mockFetch).not.toHaveBeenCalled()
expect(mockUploadImage).not.toHaveBeenCalled()
})
it('handles image processing failures gracefully', async () => {
const mockMetadata = {
title: 'Test Page',
image: 'https://example.com/image.jpg',
favicon: 'https://example.com/favicon.ico',
}
vi.mocked(parseRequestQuery).mockReturnValue({ url: 'https://example.com' })
vi.mocked(unfurl).mockResolvedValue({
ok: true,
value: mockMetadata,
})
mockFetch.mockResolvedValue({ ok: false, status: 404 })
const mockUploadImage = vi.fn()
const request = { ...mockRequest, method: 'POST' }
const response = await handleExtractBookmarkMetadataRequest({
request,
uploadImage: mockUploadImage,
})
// Should not throw, should continue with original metadata
expect(response.status).toBe(200)
const result = await response.json()
expect(result).toEqual(mockMetadata)
})
})
it('handles complete bookmark extraction workflow with image processing', async () => {
const testUrl = 'https://blog.example.com/article'
const mockMetadata = {
title: 'Amazing Blog Post',
description: 'This is an amazing blog post about technology',
image: 'https://blog.example.com/hero.jpg',
favicon: 'https://blog.example.com/favicon.png',
author: 'John Doe',
}
vi.mocked(parseRequestQuery).mockReturnValue({ url: testUrl })
vi.mocked(unfurl).mockResolvedValue({
ok: true,
value: mockMetadata,
})
const mockImageResponse = {
ok: true,
headers: new Headers({ 'content-type': 'image/jpeg' }),
body: new ReadableStream(),
}
mockFetch.mockResolvedValue(mockImageResponse)
const mockUploadImage = vi
.fn()
.mockResolvedValueOnce('https://cdn.example.com/optimized-hero.jpg')
.mockResolvedValueOnce('https://cdn.example.com/optimized-favicon.png')
const request = { method: 'POST', query: { url: testUrl } } as any
const response = await handleExtractBookmarkMetadataRequest({
request,
uploadImage: mockUploadImage,
})
const result = await response.json()
expect(result).toEqual({
title: 'Amazing Blog Post',
description: 'This is an amazing blog post about technology',
image: 'https://cdn.example.com/optimized-hero.jpg',
favicon: 'https://cdn.example.com/optimized-favicon.png',
author: 'John Doe',
})
})
})

View File

@@ -0,0 +1,127 @@
import { assert, exhaustiveSwitchError } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { unfurl } from 'cloudflare-workers-unfurl'
import { IRequest, StatusError } from 'itty-router'
import { parseRequestQuery } from './handleRequest'
export { unfurl } from 'cloudflare-workers-unfurl'
declare const fetch: typeof import('@cloudflare/workers-types').fetch
const queryValidator = T.object({
url: T.httpUrl,
})
type UploadImage = (
headers: Headers,
body: ReadableStream | null,
objectName: string
) => Promise<string>
/**
* Handles requests to extract bookmark metadata from a URL, with optional image uploading.
*
* Extracts metadata including title, description, image, and favicon from a web page.
* When `uploadImage` is provided, processes and uploads optimized versions of images
* (600px for main image, 64px for favicon) through Cloudflare's image resizing.
*
* @param options - Configuration object
* - request - The incoming request containing URL query parameter
* - uploadImage - Optional function to upload processed images to storage
* - headers - HTTP headers from the image response
* - body - ReadableStream of the image data
* - objectName - Unique name for the stored object
* @returns Promise resolving to a JSON response containing the extracted metadata
* @throws StatusError 400 for invalid URLs, 422 for failed URL fetches
*
* @example
* ```ts
* // Basic usage without image upload
* router.get('/api/bookmark', (request) => {
* return handleExtractBookmarkMetadataRequest({ request })
* })
*
* // With image upload to R2 storage
* router.post('/api/bookmark', (request, env) => {
* return handleExtractBookmarkMetadataRequest({
* request,
* uploadImage: async (headers, body, objectName) => {
* await env.ASSETS_BUCKET.put(objectName, body, { httpMetadata: headers })
* return `https://assets.example.com/${objectName}`
* }
* })
* })
* ```
*
* @public
*/
export async function handleExtractBookmarkMetadataRequest({
request,
uploadImage,
}: {
request: IRequest
uploadImage?: UploadImage
}) {
assert(request.method === (uploadImage ? 'POST' : 'GET'))
const url = parseRequestQuery(request, queryValidator).url
const metadataResult = await unfurl(url)
if (!metadataResult.ok) {
switch (metadataResult.error) {
case 'bad-param':
throw new StatusError(400, 'Bad URL')
case 'failed-fetch':
throw new StatusError(422, 'Failed to fetch URL')
default:
exhaustiveSwitchError(metadataResult.error)
}
}
const metadata = metadataResult.value
if (uploadImage) {
const id = crypto.randomUUID()
await Promise.all([
trySaveImage('image', metadata, id, 600, uploadImage),
trySaveImage('favicon', metadata, id, 64, uploadImage),
])
}
return Response.json(metadata)
}
async function trySaveImage<const K extends string>(
key: K,
metadata: { [_ in K]?: string },
id: string,
size: number,
uploadImage: UploadImage
): Promise<void> {
const initialUrl = metadata[key]
if (!initialUrl) return
try {
const imageResponse = await fetch(initialUrl, {
cf: {
image: {
width: size,
fit: 'scale-down',
quality: 80,
},
},
})
if (!imageResponse.ok) throw new Error('Failed to fetch image')
const contentType = imageResponse.headers.get('content-type')
if (!contentType) throw new Error('No content type')
if (!contentType.startsWith('image/')) throw new Error('Not an image')
const objectName = `bookmark-${key}-${id}`
metadata[key] = await uploadImage(
imageResponse.headers as unknown as Headers,
imageResponse.body as unknown as ReadableStream | null,
objectName
)
} catch (error) {
console.error(`Error saving ${key}:`, error)
}
}

View File

@@ -0,0 +1,49 @@
import { getOwnProperty } from '@tldraw/utils'
/**
* Validates that specified environment variables are present and non-nullish in the provided environment object.
* Throws an error if any required variables are missing, otherwise returns the environment with guaranteed
* non-nullable types for the specified keys.
*
* @param env - The environment object containing potential environment variables
* @param keys - An object specifying which keys from the environment are required, where each key maps to `true`
* @returns The environment object with type-safe guarantees that the specified keys are non-null and defined
* @throws Error When any of the specified required environment variables are undefined
*
* @example
* ```typescript
* interface MyEnv {
* readonly DATABASE_URL?: string
* readonly API_KEY?: string
* readonly OPTIONAL_VAR?: string
* }
*
* const env: MyEnv = {
* DATABASE_URL: 'postgres://...',
* API_KEY: 'secret-key',
* // OPTIONAL_VAR is undefined
* }
*
* // Validate only the required variables
* const config = requiredEnv(env, {
* DATABASE_URL: true,
* API_KEY: true,
* })
*
* // config.DATABASE_URL and config.API_KEY are now guaranteed to be strings
* console.log(config.DATABASE_URL) // TypeScript knows this is string, not string | undefined
* ```
*
* @public
*/
export function requiredEnv<T extends object>(
env: T,
keys: { [K in keyof T]: true }
): { [K in keyof T]-?: NonNullable<T[K]> } {
for (const key of Object.keys(keys)) {
if (getOwnProperty(env, key) === undefined) {
throw new Error(`Missing required env var: ${key}`)
}
}
return env as any
}

View File

@@ -0,0 +1,76 @@
/**
* Creates a standardized 404 Not Found HTTP response with JSON error format.
*
* This function returns a consistent error response format used across tldraw's
* Cloudflare Workers infrastructure. It's designed to be used in API routes when
* requested resources cannot be found or when fallback handling is needed.
*
* @returns A Response object with status 404 and JSON body containing error message
*
* @example
* ```ts
* import { notFound } from '@tldraw/worker-shared'
*
* router.get('/api/resource/:id', (request) => {
* const { id } = request.params
* const resource = getResource(id)
*
* if (!resource) {
* return notFound() // Returns { "error": "Not found" } with 404 status
* }
*
* return Response.json(resource)
* })
*
* // Also commonly used as fallback for unmatched routes
* router.all('*', () => notFound())
* ```
*
* @public
*/
export function notFound() {
return Response.json({ error: 'Not found' }, { status: 404 })
}
/**
* Creates a standardized 403 Forbidden HTTP response with JSON error format.
*
* This function returns a consistent error response format used across tldraw's
* Cloudflare Workers infrastructure. It's designed to be used in API routes when
* access to a resource is denied due to insufficient permissions or authorization
* failures.
*
* @returns A Response object with status 403 and JSON body containing error message
*
* @example
* ```ts
* import { forbidden } from '@tldraw/worker-shared'
*
* router.get('/api/admin/:id', (request) => {
* const { id } = request.params
* const user = getCurrentUser(request)
*
* if (!user.isAdmin) {
* return forbidden() // Returns { "error": "Forbidden" } with 403 status
* }
*
* return Response.json(getAdminData(id))
* })
*
* // Can also be used for API key validation
* router.put('/api/protected', (request) => {
* const apiKey = request.headers.get('Authorization')
*
* if (!isValidApiKey(apiKey)) {
* return forbidden()
* }
*
* // Process protected operation
* })
* ```
*
* @public
*/
export function forbidden() {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}

View File

@@ -0,0 +1,252 @@
import { T } from '@tldraw/validate'
import { IRequest, RequestHandler, Router, RouterType, StatusError } from 'itty-router'
import { SentryEnvironment, createSentry } from './sentry'
/**
* Type definition for API route handlers with typed environment and context.
* Represents a function that creates typed routes for Cloudflare Workers with proper
* environment and execution context constraints.
*
* @param path - The URL path pattern for the route (e.g., '/api/users/:id')
* @param handlers - Variable number of request handlers for the route
* @returns A router type configured with the specified environment and context types
*
* @example
* ```ts
* interface MyEnv extends SentryEnvironment {
* DATABASE_URL: string
* }
*
* const getRoute: ApiRoute<MyEnv, ExecutionContext> = router.get
* getRoute('/users/:id', async (request, env, ctx) => {
* // Handler implementation with typed env and ctx
* return Response.json({ userId: request.params.id })
* })
* ```
*
* @public
*/
export type ApiRoute<Env extends SentryEnvironment, Ctx extends ExecutionContext> = (
path: string,
...handlers: RequestHandler<IRequest, [env: Env, ctx: Ctx]>[]
) => RouterType<IRequest, [env: Env, ctx: Ctx]>
/**
* Type definition for a configured API router with typed environment and context.
* Represents a router instance that handles requests with specific environment
* and execution context types for Cloudflare Workers.
*
* @example
* ```ts
* interface WorkerEnv extends SentryEnvironment {
* ASSETS_BUCKET: R2Bucket
* }
*
* const router: ApiRouter<WorkerEnv, ExecutionContext> = createRouter()
* router.get('/health', () => Response.json({ status: 'ok' }))
* ```
*
* @public
*/
export type ApiRouter<Env extends SentryEnvironment, Ctx extends ExecutionContext> = RouterType<
IRequest,
[env: Env, ctx: Ctx]
>
/**
* Creates a new type-safe API router for Cloudflare Workers.
* The router provides type safety for environment variables and execution context,
* enabling proper TypeScript inference for request handlers.
*
* @returns A configured router instance with type constraints
*
* @example
* ```ts
* interface MyEnv extends SentryEnvironment {
* DATABASE_URL: string
* ASSETS_BUCKET: R2Bucket
* }
*
* const router = createRouter<MyEnv>()
*
* router.get('/api/users', async (request, env, ctx) => {
* // env.DATABASE_URL is properly typed as string
* // env.ASSETS_BUCKET is properly typed as R2Bucket
* return Response.json({ users: [] })
* })
* ```
*
* @public
*/
export function createRouter<
Env extends SentryEnvironment,
Ctx extends ExecutionContext = ExecutionContext,
>() {
const router: ApiRouter<Env, Ctx> = Router()
return router
}
/**
* Handles API requests with comprehensive error handling and Sentry integration.
* This function wraps router request handling with automatic error catching,
* proper HTTP status code responses, and optional post-processing of responses.
*
* @param options - Configuration object for request handling
* - router - The configured API router to handle the request
* - request - The incoming HTTP request to process
* - env - Environment variables and bindings (R2, KV, etc.)
* - ctx - Execution context for the Worker request
* - after - Post-processing function for responses (e.g., adding CORS headers)
* @returns Promise that resolves to the HTTP response
*
* @example
* ```ts
* export default {
* async fetch(request: Request, env: Env, ctx: ExecutionContext) {
* return handleApiRequest({
* router,
* request,
* env,
* ctx,
* after: (response) => {
* // Add CORS headers to all responses
* response.headers.set('Access-Control-Allow-Origin', '*')
* return response
* },
* })
* },
* }
* ```
*
* @public
*/
export async function handleApiRequest<
Env extends SentryEnvironment,
Ctx extends ExecutionContext,
>({
router,
request,
env,
ctx,
after,
}: {
router: ApiRouter<Env, Ctx>
request: Request
env: Env
ctx: Ctx
after(response: Response, request: Request): Response | Promise<Response>
}) {
let response
try {
response = await router.fetch(request, env, ctx)
} catch (error: any) {
if (error instanceof StatusError) {
console.error(`${error.status}: ${error.stack}`)
response = Response.json({ error: error.message }, { status: error.status })
} else {
response = Response.json({ error: 'Internal server error' }, { status: 500 })
console.error(error.stack ?? error)
// eslint-disable-next-line @typescript-eslint/no-deprecated
createSentry(ctx, env, request)?.captureException(error)
}
}
try {
return await after(response, request)
} catch (error: any) {
console.error(error.stack ?? error)
// eslint-disable-next-line @typescript-eslint/no-deprecated
createSentry(ctx, env, request)?.captureException(error)
return Response.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* Parses and validates query parameters from an HTTP request.
* Uses tldraw's validation system to ensure query parameters match the expected schema.
* Automatically throws HTTP 400 errors for invalid parameters with descriptive messages.
*
* @param request - The incoming HTTP request containing query parameters
* @param validator - A tldraw validator that defines the expected query parameter schema
* @returns The validated and typed query parameters
* @throws StatusError with 400 status for validation failures
*
* @example
* ```ts
* import { T } from '@tldraw/validate'
*
* const queryValidator = T.object({
* url: T.httpUrl,
* limit: T.number.optional(),
* format: T.literalEnum('json', 'xml').optional()
* })
*
* router.get('/api/process', (request) => {
* const { url, limit, format } = parseRequestQuery(request, queryValidator)
* // url is guaranteed to be a valid HTTP URL string
* // limit is number | undefined
* // format is 'json' | 'xml' | undefined
* return Response.json({ processed: url, limit, format })
* })
* ```
*
* @public
*/
export function parseRequestQuery<Params>(request: IRequest, validator: T.Validator<Params>) {
try {
return validator.validate(request.query)
} catch (err) {
if (err instanceof T.ValidationError) {
throw new StatusError(400, `Query parameters: ${err.message}`)
}
throw err
}
}
/**
* Parses and validates JSON body content from an HTTP request.
* Automatically parses the request body as JSON and validates it against the provided schema.
* Throws HTTP 400 errors for both JSON parsing failures and validation errors.
*
* @param request - The incoming HTTP request containing JSON body content
* @param validator - A tldraw validator that defines the expected body schema
* @returns Promise that resolves to the validated and typed request body
* @throws StatusError with 400 status for parsing or validation failures
*
* @example
* ```ts
* import { T } from '@tldraw/validate'
*
* const bodyValidator = T.object({
* name: T.string,
* email: T.string,
* age: T.number.optional(),
* preferences: T.object({
* theme: T.literalEnum('light', 'dark'),
* notifications: T.boolean
* }).optional()
* })
*
* router.post('/api/users', async (request) => {
* const userData = await parseRequestBody(request, bodyValidator)
* // userData.name is guaranteed to be a string
* // userData.email is guaranteed to be a string
* // userData.age is number | undefined
* // userData.preferences is typed object | undefined
*
* return Response.json({ created: userData })
* })
* ```
*
* @public
*/
export async function parseRequestBody<Body>(request: IRequest, validator: T.Validator<Body>) {
try {
return validator.validate(await request.json())
} catch (err) {
if (err instanceof T.ValidationError) {
throw new StatusError(400, `Body: ${err.message}`)
}
throw err
}
}

View File

@@ -0,0 +1,3 @@
it('works', () => {
// we need a test for vitest to pass.
})

View File

@@ -0,0 +1,22 @@
/// <reference no-default-lib="true"/>
/// <reference types="@cloudflare/workers-types" />
export { retry } from '@tldraw/utils'
export { handleExtractBookmarkMetadataRequest } from './bookmarks'
export { forbidden, notFound } from './errors'
export {
createRouter,
handleApiRequest,
parseRequestQuery,
type ApiRoute,
type ApiRouter,
} from './handleRequest'
export { blockUnknownOrigins, isAllowedOrigin } from './origins'
export { createSentry, type SentryEnvironment } from './sentry'
export {
TRANSIENT_RETRY_OPTIONS,
handleUserAssetGet,
handleUserAssetUpload,
isValidR2ObjectName,
type R2BucketLike,
} from './userAssetUploads'

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import { blockUnknownOrigins, isAllowedOrigin } from './origins'
const env = { IS_LOCAL: undefined }
function request(headers: Record<string, string>, url = 'https://www.tldraw.com/api/app/admin/x') {
return new Request(url, { headers })
}
describe('isAllowedOrigin', () => {
it.each([
'https://tldraw.com',
'https://www.tldraw.com',
'https://tldraw.dev',
'https://tldrawusercontent.com',
'http://localhost:3000',
])('allows %s', (origin) => {
expect(isAllowedOrigin(origin)).toBe(origin)
})
it.each(['https://evil.com', 'https://tldraw.com.evil.com', ''])('rejects %s', (origin) => {
expect(isAllowedOrigin(origin)).toBeUndefined()
})
})
describe('blockUnknownOrigins', () => {
it('allows a same-origin request', async () => {
expect(
await blockUnknownOrigins(request({ 'sec-fetch-site': 'same-origin' }), env)
).toBeUndefined()
})
it('allows a request from an allowed origin', async () => {
expect(
await blockUnknownOrigins(
request({ origin: 'https://tldraw.com', 'sec-fetch-site': 'cross-site' }),
env
)
).toBeUndefined()
})
it('blocks a request from an unknown origin', async () => {
const response = await blockUnknownOrigins(request({ origin: 'https://evil.com' }), env)
expect(response?.status).toBe(403)
})
// Browsers omit Origin on a top-level GET navigation, so a link followed from another site
// arrives with no Origin but with the user's cookies. Sec-Fetch-Site is what distinguishes it
// from a request the user started themselves.
it('blocks a cross-site navigation that carries no origin', async () => {
const response = await blockUnknownOrigins(
request({ 'sec-fetch-site': 'cross-site', 'sec-fetch-mode': 'navigate' }),
env
)
expect(response?.status).toBe(403)
})
// Browsers omit Origin on no-cors subresource loads too, which is how these workers serve
// public assets: an <img> with no crossorigin attribute is indistinguishable from a link click
// by Sec-Fetch-Site alone.
it.each(['no-cors', 'cors'])(
'allows a cross-site %s subresource that carries no origin',
async (secFetchMode) => {
expect(
await blockUnknownOrigins(
request(
{ 'sec-fetch-site': 'cross-site', 'sec-fetch-mode': secFetchMode },
'https://tldrawusercontent.com/some-asset'
),
env
)
).toBeUndefined()
}
)
it.each(['none', 'same-site'])(
'allows a %s request that carries no origin',
async (secFetchSite) => {
expect(
await blockUnknownOrigins(request({ 'sec-fetch-site': secFetchSite }), env)
).toBeUndefined()
}
)
// Non-browser callers send neither header, and have no ambient session to borrow.
it('allows a request with neither origin nor sec-fetch-site', async () => {
expect(await blockUnknownOrigins(request({}), env)).toBeUndefined()
})
it('allows the auth callback regardless of origin', async () => {
expect(
await blockUnknownOrigins(
request({ origin: 'https://evil.com' }, 'https://www.tldraw.com/auth/callback'),
env
)
).toBeUndefined()
})
it('allows anything when running locally', async () => {
expect(
await blockUnknownOrigins(
request({ 'sec-fetch-site': 'cross-site', 'sec-fetch-mode': 'navigate' }),
{ IS_LOCAL: 'true' }
)
).toBeUndefined()
})
})

View File

@@ -0,0 +1,110 @@
/**
* Validates if an origin is allowed to make cross-origin requests.
* Returns the origin if allowed, or undefined if blocked.
*
* @param origin - The origin header from the incoming request
* @returns The origin string if allowed, undefined otherwise
*
* @example
* ```ts
* const origin = request.headers.get('origin')
* const allowed = isAllowedOrigin(origin)
* if (allowed) {
* // Process the request
* }
* ```
*
* @public
*/
export function isAllowedOrigin(origin: string) {
if (!origin) return undefined
if (origin === 'http://localhost:3000') return origin
if (origin === 'http://localhost:5420') return origin
if (origin === 'https://meet.google.com') return origin
if (origin === 'https://tldraw.dev') return origin
if (origin === 'https://tldraw.com') return origin
if (origin.endsWith('.tldraw.com')) return origin
if (origin.endsWith('.tldraw.dev')) return origin
if (origin.endsWith('.tldraw.club')) return origin
if (origin.endsWith('.tldraw.xyz')) return origin
if (origin.endsWith('.tldraw.workers.dev')) return origin
if (origin.endsWith('-tldraw.vercel.app')) return origin
if (origin === 'https://tldrawusercontent.com') return origin
if (origin.endsWith('.tldrawusercontent.com')) return origin
return undefined
}
/**
* Middleware to block requests from unknown/unauthorized origins.
* Allows same-origin requests, auth callbacks, and requests from allowed origins.
* Returns undefined to continue processing, or a 403 Response to block the request.
*
* @param request - The incoming HTTP request
* @param env - Environment variables, must contain IS_LOCAL property
* @returns undefined to continue, or Response with 403 status to block
*
* @example
* ```ts
* router.all('*', async (request, env) => {
* const blocked = await blockUnknownOrigins(request, env)
* if (blocked) return blocked
* // Continue processing...
* })
* ```
*
* @public
*/
export async function blockUnknownOrigins(
request: Request,
env: { IS_LOCAL?: string }
): Promise<undefined | Response> {
const secFetchSite = request.headers.get('sec-fetch-site')
// allow requests for the same origin (new rewrite routing for SPA)
if (secFetchSite === 'same-origin') {
return undefined
}
if (new URL(request.url).pathname === '/auth/callback') {
// allow auth callback because we use the special cookie to verify
// the request
return undefined
}
const origin = request.headers.get('origin')
if (!origin) {
// A missing Origin is not the same as "not cross-site": browsers omit it on top-level GET
// navigations, so following a link from another site arrives here with no Origin and the
// user's cookies attached. Sec-Fetch-Site is what separates the cases, and every browser
// that sends cookies sends it: 'none' means the user initiated the load themselves (typed
// URL, bookmark), and 'same-site' is another tldraw subdomain, which isAllowedOrigin trusts
// anyway. Anything else is a cross-document request we have no allowed origin for.
//
// Sec-Fetch-Mode has to agree that this is a navigation. Browsers also omit Origin on every
// no-cors subresource load, so a plain <img>/<video> pointed at another tldraw host arrives
// with cross-site and no Origin exactly like a link click does — that's how these workers
// serve public assets, and blocking it would 403 them.
//
// Callers that aren't browsers send neither header and are unaffected — and they have no
// ambient session to borrow in the first place.
const isCrossSiteNavigation =
secFetchSite &&
secFetchSite !== 'none' &&
secFetchSite !== 'same-site' &&
request.headers.get('sec-fetch-mode') === 'navigate'
if (env.IS_LOCAL !== 'true' && isCrossSiteNavigation) {
console.error('Blocking a cross-site request with no origin:', secFetchSite, request.url)
return new Response('Not allowed', { status: 403 })
}
return undefined
}
if (env.IS_LOCAL !== 'true' && !isAllowedOrigin(origin)) {
console.error('Attempting to connect from an invalid origin:', origin, request)
return new Response('Not allowed', { status: 403 })
}
// origin doesn't match, so we can continue
return undefined
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { createSentry, SentryEnvironment } from './sentry'
const ctx = { waitUntil: () => {} } as any
describe('sentry', () => {
describe('createSentry', () => {
it('returns null in development environment', () => {
const env: SentryEnvironment = {
TLDRAW_ENV: 'development',
}
const result = createSentry(ctx, env)
expect(result).toBe(null)
})
it('throws when SENTRY_DSN is missing in production', () => {
const env: SentryEnvironment = {
TLDRAW_ENV: 'production',
}
expect(() => createSentry(ctx, env)).toThrow('Missing required env var: SENTRY_DSN')
})
it('builds a client tagged with the worker name and version when fully configured', () => {
const sentry = createSentry(ctx, {
TLDRAW_ENV: 'production',
SENTRY_DSN: 'https://public@example.ingest.sentry.io/1',
WORKER_NAME: 'production-tldraw-image-optimizer',
CF_VERSION_METADATA: { id: 'version-id', tag: 'version-tag' } as any,
})
// release and environment are what make an event attributable to a specific deploy
const options = (sentry as any)?.getClient()?.getOptions()
expect(options).toMatchObject({
release: 'production-tldraw-image-optimizer.version-id',
environment: 'production-tldraw-image-optimizer',
})
})
})
})

View File

@@ -0,0 +1,89 @@
import { WorkerVersionMetadata } from '@cloudflare/workers-types'
import { Toucan } from 'toucan-js'
import { requiredEnv } from './env'
interface Context {
waitUntil: ExecutionContext['waitUntil']
request?: Request
}
/**
* Environment interface that defines the required configuration for Sentry integration.
* Workers environment interfaces should extend this interface to enable automatic
* error tracking and reporting through the Sentry service.
*
* @example
* ```ts
* interface MyWorkerEnv extends SentryEnvironment {
* DATABASE_URL: string
* ASSETS_BUCKET: R2Bucket
* }
*
* const router = createRouter<MyWorkerEnv>()
* ```
*
* @public
*/
export interface SentryEnvironment {
readonly SENTRY_DSN?: string | undefined
readonly TLDRAW_ENV?: string | undefined
readonly WORKER_NAME?: string | undefined
readonly CF_VERSION_METADATA?: WorkerVersionMetadata
}
/**
* Creates a configured Sentry client for error tracking in Cloudflare Workers.
* Automatically configures Sentry with proper release tracking, environment context,
* and request metadata for comprehensive error reporting.
*
* Returns null in development environment when SENTRY_DSN is not configured,
* allowing for graceful degradation during local development.
*
* @param ctx - Execution context providing waitUntil for async operations
* @param env - Environment variables containing Sentry configuration
* @param request - Optional HTTP request for additional context in error reports
* @returns Configured Toucan Sentry client instance, or null in development without DSN
* @throws Error if required environment variables are missing in production
*
* @example
* ```ts
* export default {
* async fetch(request: Request, env: Env, ctx: ExecutionContext) {
* const sentry = createSentry(ctx, env, request)
*
* try {
* // Your worker logic
* return Response.json({ success: true })
* } catch (error) {
* sentry?.captureException(error)
* throw error
* }
* }
* }
* ```
*
* @public
*/
export function createSentry(ctx: Context, env: SentryEnvironment, request?: Request) {
if (!env.SENTRY_DSN && env.TLDRAW_ENV === 'development') {
return null
}
const { SENTRY_DSN, WORKER_NAME, CF_VERSION_METADATA } = requiredEnv(env, {
SENTRY_DSN: true,
WORKER_NAME: true,
CF_VERSION_METADATA: true,
})
return new Toucan({
dsn: SENTRY_DSN,
release: `${WORKER_NAME}.${CF_VERSION_METADATA.id}`,
environment: WORKER_NAME,
context: ctx,
request,
requestDataOptions: {
allowedHeaders: ['user-agent'],
allowedSearchParams: /(.*)/,
},
})
}

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
MAX_R2_OBJECT_NAME_BYTES,
handleUserAssetGet,
handleUserAssetUpload,
} from './userAssetUploads'
describe('userAssetUploads', () => {
const match = vi.fn()
const putCache = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
match.mockResolvedValue(undefined)
putCache.mockResolvedValue(undefined)
vi.stubGlobal('caches', {
default: {
match,
put: putCache,
},
})
})
describe('handleUserAssetGet', () => {
it('returns 400 without calling R2 when the object name is too long', async () => {
const bucket = {
head: vi.fn(),
get: vi.fn(),
put: vi.fn(),
}
const response = await handleUserAssetGet({
request: new Request('https://example.com/assets/too-long') as any,
bucket,
objectName: 'a'.repeat(MAX_R2_OBJECT_NAME_BYTES + 1),
context: { waitUntil: vi.fn() } as any,
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid object name' })
expect(bucket.get).not.toHaveBeenCalled()
expect(match).not.toHaveBeenCalled()
})
it('returns 400 when R2 rejects a valid-looking object name as invalid', async () => {
const bucket = {
head: vi.fn(),
get: vi
.fn()
.mockRejectedValue(new Error('get: The specified object name is not valid. (10020)')),
put: vi.fn(),
}
const response = await handleUserAssetGet({
request: new Request('https://example.com/assets/test') as any,
bucket,
objectName: 'test',
context: { waitUntil: vi.fn() } as any,
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid object name' })
expect(bucket.get).toHaveBeenCalledTimes(1)
})
})
describe('handleUserAssetUpload', () => {
it('returns 400 without calling R2 when the object name is too long', async () => {
const bucket = {
head: vi.fn(),
get: vi.fn(),
put: vi.fn(),
}
const response = await handleUserAssetUpload({
body: null,
headers: new Headers(),
bucket,
objectName: 'a'.repeat(MAX_R2_OBJECT_NAME_BYTES + 1),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid object name' })
expect(bucket.head).not.toHaveBeenCalled()
expect(bucket.put).not.toHaveBeenCalled()
})
it('returns 400 when R2 rejects a valid-looking object name as invalid', async () => {
const bucket = {
head: vi
.fn()
.mockRejectedValue(new Error('get: The specified object name is not valid. (10020)')),
get: vi.fn(),
put: vi.fn(),
}
const response = await handleUserAssetUpload({
body: null,
headers: new Headers(),
bucket,
objectName: 'test',
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid object name' })
expect(bucket.head).toHaveBeenCalledTimes(1)
expect(bucket.put).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,229 @@
import { retry } from '@tldraw/utils'
import { IRequest } from 'itty-router'
import { notFound } from './errors'
export const MAX_R2_OBJECT_NAME_BYTES = 1024
function isTransientWorkerError(error: unknown): boolean {
const msg = String(error)
return /internal error|connectivity|network connection lost|service temporarily unavailable|proxy request failed|unspecified error|connection (refused|reset|timed?\s?out)/i.test(
msg
)
}
function isInvalidObjectNameError(error: unknown): boolean {
const msg = String(error)
return msg.includes('The specified object name is not valid') || msg.includes('(10020)')
}
export function isValidR2ObjectName(objectName: string): boolean {
return (
objectName.length > 0 && new TextEncoder().encode(objectName).length <= MAX_R2_OBJECT_NAME_BYTES
)
}
function invalidObjectNameResponse() {
return Response.json({ error: 'Invalid object name' }, { status: 400 })
}
export const TRANSIENT_RETRY_OPTIONS = {
attempts: 3,
waitDuration: 500,
matchError: isTransientWorkerError,
} as const
// Minimal interface for R2Bucket operations used in this file
// This avoids type conflicts between ambient and imported Cloudflare types
// Using 'any' for return types to allow compatibility with different R2Bucket type definitions
export interface R2BucketLike {
head(key: string): Promise<any>
get(key: string, options?: any): Promise<any>
put(key: string, value: any, options?: any): Promise<any>
}
// Cloudflare's caches global has a 'default' property for the default cache
declare const caches: {
default: {
match(request: unknown): Promise<Response | undefined>
put(request: unknown, response: Response): Promise<void>
}
}
/**
* Handles asset upload requests to Cloudflare R2 storage with conflict detection.
* Checks if the asset already exists and returns a 409 Conflict status if found,
* otherwise uploads the asset with the provided HTTP metadata.
*
* @param options - Configuration object for the upload
* - objectName - Unique identifier for the asset in R2 storage
* - bucket - Cloudflare R2 bucket instance for storage
* - body - ReadableStream containing the asset data to upload
* - headers - HTTP headers to store as metadata with the asset
* @returns Promise resolving to JSON response with object name and ETag, or 409 if exists
*
* @example
* ```ts
* router.put('/assets/:objectName', async (request, env) => {
* const { objectName } = request.params
*
* return handleUserAssetUpload({
* objectName,
* bucket: env.ASSETS_BUCKET,
* body: request.body,
* headers: request.headers,
* })
* })
* ```
*
* @public
*/
export async function handleUserAssetUpload({
body,
headers,
bucket,
objectName,
}: {
objectName: string
bucket: R2BucketLike
body: ReadableStream | null
headers: Headers
}): Promise<Response> {
if (!isValidR2ObjectName(objectName)) return invalidObjectNameResponse()
try {
const existing = await retry(() => bucket.head(objectName), TRANSIENT_RETRY_OPTIONS)
if (existing) {
return Response.json({ error: 'Asset already exists' }, { status: 409 })
}
// Buffer body so retries can re-send (ReadableStream is single-use)
const buffer = body ? await new Response(body).arrayBuffer() : null
const object = await retry(
() => bucket.put(objectName, buffer, { httpMetadata: headers }),
TRANSIENT_RETRY_OPTIONS
)
return Response.json({ object: objectName }, { headers: { etag: object.httpEtag } })
} catch (error) {
if (isInvalidObjectNameError(error)) return invalidObjectNameResponse()
throw error
}
}
/**
* Handles asset retrieval requests from Cloudflare R2 storage with comprehensive caching and range support.
* Provides automatic caching via Cloudflare's cache API, supports partial content requests (range headers),
* and includes proper CORS headers for cross-origin access. Assets are cached with immutable headers
* for optimal performance.
*
* @param options - Configuration object for asset retrieval
* - request - HTTP request containing potential range headers and cache keys
* - bucket - Cloudflare R2 bucket instance containing the asset
* - objectName - Unique identifier of the asset to retrieve
* - context - Execution context for background caching operations
* @returns Promise resolving to the asset response with appropriate headers and caching
*
* @example
* ```ts
* router.get('/assets/:objectName', async (request, env, ctx) => {
* const { objectName } = request.params
*
* return handleUserAssetGet({
* request,
* bucket: env.ASSETS_BUCKET,
* objectName,
* context: ctx,
* })
* })
* ```
*
* @public
*/
export async function handleUserAssetGet({
request,
bucket,
objectName,
context,
}: {
request: IRequest
bucket: R2BucketLike
objectName: string
context: ExecutionContext
}): Promise<Response> {
if (!isValidR2ObjectName(objectName)) return invalidObjectNameResponse()
// this cache automatically handles range responses etc.
const cacheKey = new Request(request.url, { headers: request.headers })
const cachedResponse = await caches.default.match(cacheKey)
if (cachedResponse) {
return cachedResponse
}
let object
try {
object = await retry(
() => bucket.get(objectName, { range: request.headers, onlyIf: request.headers }),
TRANSIENT_RETRY_OPTIONS
)
} catch (error) {
if (isInvalidObjectNameError(error)) return invalidObjectNameResponse()
throw error
}
if (!object) {
return notFound()
}
const headers = new Headers()
object.writeHttpMetadata(headers)
// assets are immutable, so we can cache them basically forever:
headers.set('cache-control', 'public, max-age=31536000, immutable')
headers.set('etag', object.httpEtag)
// we set CORS headers so all clients can access assets. we do this here so our `cors` helper in
// worker.ts doesn't try to set extra cors headers on responses that have been read from the
// cache, which isn't allowed by cloudflare.
headers.set('access-control-allow-origin', '*')
// Prevent XSS from user-uploaded SVGs (or any file served with an executable content-type).
// This is critical when assets are served from the same origin as the app.
headers.set('content-security-policy', "default-src 'none'")
headers.set('x-content-type-options', 'nosniff')
// cloudflare doesn't set the content-range header automatically in writeHttpMetadata, so we
// need to do it ourselves.
let contentRange
if (object.range) {
if ('suffix' in object.range) {
const start = object.size - object.range.suffix
const end = object.size - 1
contentRange = `bytes ${start}-${end}/${object.size}`
} else {
const start = object.range.offset ?? 0
const end = object.range.length ? start + object.range.length - 1 : object.size - 1
if (start !== 0 || end !== object.size - 1) {
contentRange = `bytes ${start}-${end}/${object.size}`
}
}
}
if (contentRange) {
headers.set('content-range', contentRange)
}
const body = 'body' in object && object.body ? object.body : null
const status = body ? (contentRange ? 206 : 200) : 304
if (status === 200) {
const [cacheBody, responseBody] = body!.tee()
// cache the response
context.waitUntil(
caches.default.put(cacheKey, new Response(cacheBody as BodyInit, { headers, status }))
)
return new Response(responseBody as BodyInit, { headers, status })
}
return new Response(body as BodyInit | null, { headers, status })
}