snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
239
packages/worker-shared/src/bookmarks.test.ts
Normal file
239
packages/worker-shared/src/bookmarks.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
127
packages/worker-shared/src/bookmarks.ts
Normal file
127
packages/worker-shared/src/bookmarks.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
49
packages/worker-shared/src/env.ts
Normal file
49
packages/worker-shared/src/env.ts
Normal 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
|
||||
}
|
||||
76
packages/worker-shared/src/errors.ts
Normal file
76
packages/worker-shared/src/errors.ts
Normal 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 })
|
||||
}
|
||||
252
packages/worker-shared/src/handleRequest.ts
Normal file
252
packages/worker-shared/src/handleRequest.ts
Normal 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
|
||||
}
|
||||
}
|
||||
3
packages/worker-shared/src/index.test.ts
Normal file
3
packages/worker-shared/src/index.test.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
it('works', () => {
|
||||
// we need a test for vitest to pass.
|
||||
})
|
||||
22
packages/worker-shared/src/index.ts
Normal file
22
packages/worker-shared/src/index.ts
Normal 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'
|
||||
107
packages/worker-shared/src/origins.test.ts
Normal file
107
packages/worker-shared/src/origins.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
110
packages/worker-shared/src/origins.ts
Normal file
110
packages/worker-shared/src/origins.ts
Normal 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
|
||||
}
|
||||
42
packages/worker-shared/src/sentry.test.ts
Normal file
42
packages/worker-shared/src/sentry.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
89
packages/worker-shared/src/sentry.ts
Normal file
89
packages/worker-shared/src/sentry.ts
Normal 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: /(.*)/,
|
||||
},
|
||||
})
|
||||
}
|
||||
106
packages/worker-shared/src/userAssetUploads.test.ts
Normal file
106
packages/worker-shared/src/userAssetUploads.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
229
packages/worker-shared/src/userAssetUploads.ts
Normal file
229
packages/worker-shared/src/userAssetUploads.ts
Normal 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user