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:
370
packages/worker-shared/DOCS.md
Normal file
370
packages/worker-shared/DOCS.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# @tldraw/worker-shared
|
||||
|
||||
The @tldraw/worker-shared package provides common utilities and handlers for Cloudflare Workers used across the tldraw infrastructure. It handles API routing, error responses, asset management, bookmark metadata extraction, and Sentry integration for worker environments.
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
This package contains shared functionality designed specifically for Cloudflare Workers. It provides a structured approach to building APIs with proper error handling, request routing, and asset management capabilities. The utilities are designed to work seamlessly with Cloudflare's R2 storage, Sentry error tracking, and the Worker runtime environment.
|
||||
|
||||
**Key capabilities:**
|
||||
|
||||
- Type-safe API routing with `itty-router`
|
||||
- Unified error handling and status responses
|
||||
- Asset upload and retrieval with caching
|
||||
- Bookmark metadata extraction with image processing
|
||||
- Sentry integration for error tracking
|
||||
|
||||
## 2. Core Components
|
||||
|
||||
### API Request Handling
|
||||
|
||||
The package provides utilities for creating robust API handlers with proper error handling and routing.
|
||||
|
||||
```ts
|
||||
import { createRouter, handleApiRequest, parseRequestQuery } from '@tldraw/worker-shared'
|
||||
import { T } from '@tldraw/validate'
|
||||
|
||||
// Create a type-safe router
|
||||
const router = createRouter<Env, ExecutionContext>()
|
||||
|
||||
// Add routes with typed handlers
|
||||
router.get('/api/health', () => {
|
||||
return Response.json({ status: 'ok' })
|
||||
})
|
||||
|
||||
// Handle requests with automatic error handling
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
return handleApiRequest({
|
||||
router,
|
||||
request,
|
||||
env,
|
||||
ctx,
|
||||
after: (response) => {
|
||||
// Add CORS headers or other post-processing
|
||||
response.headers.set('Access-Control-Allow-Origin', '*')
|
||||
return response
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The `handleApiRequest` function automatically:
|
||||
|
||||
- Catches and formats errors with proper HTTP status codes
|
||||
- Integrates with Sentry for error reporting
|
||||
- Applies post-processing through the `after` callback
|
||||
|
||||
### Query and Body Validation
|
||||
|
||||
Parse and validate request parameters using tldraw's validation system:
|
||||
|
||||
```ts
|
||||
import { parseRequestQuery } from '@tldraw/worker-shared'
|
||||
import { T } from '@tldraw/validate'
|
||||
|
||||
const queryValidator = T.object({
|
||||
url: T.httpUrl,
|
||||
limit: T.number.optional(),
|
||||
})
|
||||
|
||||
router.get('/api/process', (request) => {
|
||||
// Automatically validates and throws 400 errors for invalid input
|
||||
const { url, limit } = parseRequestQuery(request, queryValidator)
|
||||
|
||||
console.log(url) // Guaranteed to be a valid HTTP URL
|
||||
console.log(limit) // number | undefined
|
||||
|
||||
return Response.json({ processed: url })
|
||||
})
|
||||
```
|
||||
|
||||
## 3. Error Handling
|
||||
|
||||
### Standard Error Responses
|
||||
|
||||
The package provides consistent error response utilities:
|
||||
|
||||
```ts
|
||||
import { notFound, forbidden } from '@tldraw/worker-shared'
|
||||
|
||||
router.get('/api/resource/:id', (request) => {
|
||||
const { id } = request.params
|
||||
|
||||
if (!hasPermission(id)) {
|
||||
return forbidden() // Returns 403 with standard error format
|
||||
}
|
||||
|
||||
const resource = getResource(id)
|
||||
if (!resource) {
|
||||
return notFound() // Returns 404 with standard error format
|
||||
}
|
||||
|
||||
return Response.json(resource)
|
||||
})
|
||||
```
|
||||
|
||||
Both functions return standardized JSON error responses:
|
||||
|
||||
- `forbidden()`: `{ "error": "Forbidden" }` with status 403
|
||||
- `notFound()`: `{ "error": "Not found" }` with status 404
|
||||
|
||||
### Sentry Integration
|
||||
|
||||
Error tracking is automatically configured when environment variables are present:
|
||||
|
||||
```ts
|
||||
import { createSentry } from '@tldraw/worker-shared'
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
// Sentry is automatically initialized in handleApiRequest
|
||||
// Or create manually for custom error handling
|
||||
const sentry = createSentry(ctx, env, request)
|
||||
|
||||
try {
|
||||
// Your worker logic
|
||||
} catch (error) {
|
||||
sentry?.captureException(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The Sentry integration requires these environment variables:
|
||||
|
||||
- `SENTRY_DSN`: Your Sentry project DSN
|
||||
- `WORKER_NAME`: Name of the worker for release tracking
|
||||
- `CF_VERSION_METADATA`: Cloudflare version metadata for releases
|
||||
|
||||
## 4. Asset Management
|
||||
|
||||
### Upload Handling
|
||||
|
||||
Handle asset uploads to Cloudflare R2 with conflict detection:
|
||||
|
||||
```ts
|
||||
import { handleUserAssetUpload } from '@tldraw/worker-shared'
|
||||
|
||||
router.put('/assets/:objectName', async (request, env) => {
|
||||
const { objectName } = request.params
|
||||
|
||||
return handleUserAssetUpload({
|
||||
objectName,
|
||||
bucket: env.ASSETS_BUCKET, // Your R2 bucket binding
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
The upload handler:
|
||||
|
||||
- Checks for existing assets (returns 409 if exists)
|
||||
- Stores the asset with HTTP metadata
|
||||
- Returns JSON with object name and ETag
|
||||
|
||||
### Asset Retrieval with Caching
|
||||
|
||||
Serve assets with automatic caching and range request support:
|
||||
|
||||
```ts
|
||||
import { handleUserAssetGet } from '@tldraw/worker-shared'
|
||||
|
||||
router.get('/assets/:objectName', async (request, env, ctx) => {
|
||||
const { objectName } = request.params
|
||||
|
||||
return handleUserAssetGet({
|
||||
request,
|
||||
bucket: env.ASSETS_BUCKET,
|
||||
objectName,
|
||||
context: ctx,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
The retrieval handler provides:
|
||||
|
||||
- **Automatic caching**: Uses Cloudflare's cache API for performance
|
||||
- **Range requests**: Supports partial content requests (206 responses)
|
||||
- **CORS headers**: Allows cross-origin access to assets
|
||||
- **Immutable caching**: Sets long-term cache headers for assets
|
||||
- **Content-Range headers**: Properly handles byte range responses
|
||||
|
||||
## 5. Bookmark Metadata Extraction
|
||||
|
||||
### Basic Metadata Extraction
|
||||
|
||||
Extract metadata from web pages for bookmark creation:
|
||||
|
||||
```ts
|
||||
import { handleExtractBookmarkMetadataRequest } from '@tldraw/worker-shared'
|
||||
|
||||
router.get('/api/bookmark', (request) => {
|
||||
return handleExtractBookmarkMetadataRequest({ request })
|
||||
})
|
||||
|
||||
// Usage: GET /api/bookmark?url=https://example.com
|
||||
// Returns: { title: "Example", description: "...", image: "...", favicon: "..." }
|
||||
```
|
||||
|
||||
### Enhanced Extraction with Image Upload
|
||||
|
||||
For production use, provide image uploading to store optimized versions:
|
||||
|
||||
```ts
|
||||
router.post('/api/bookmark', (request) => {
|
||||
return handleExtractBookmarkMetadataRequest({
|
||||
request,
|
||||
uploadImage: async (headers, body, objectName) => {
|
||||
// Store the processed image and return its URL
|
||||
await env.ASSETS_BUCKET.put(objectName, body, { httpMetadata: headers })
|
||||
return `https://assets.example.com/${objectName}`
|
||||
},
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
When `uploadImage` is provided, the handler:
|
||||
|
||||
- Processes images through Cloudflare's image resizing
|
||||
- Creates optimized versions (600px for main image, 64px for favicon)
|
||||
- Replaces original URLs with your hosted versions
|
||||
- Handles errors gracefully (logs but doesn't fail the request)
|
||||
|
||||
## 6. Integration Patterns
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
Use the `requiredEnv` utility to validate required environment variables:
|
||||
|
||||
```ts
|
||||
import { requiredEnv } from '@tldraw/worker-shared'
|
||||
|
||||
export interface Env {
|
||||
readonly DATABASE_URL?: string
|
||||
readonly API_KEY?: string
|
||||
readonly ASSETS_BUCKET: R2Bucket
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
// Throws clear error if variables are missing
|
||||
const config = requiredEnv(env, {
|
||||
DATABASE_URL: true,
|
||||
API_KEY: true,
|
||||
})
|
||||
|
||||
// config.DATABASE_URL and config.API_KEY are guaranteed non-null
|
||||
console.log(`Connecting to ${config.DATABASE_URL}`)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Worker Example
|
||||
|
||||
Here's a complete worker using all the shared utilities:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRouter,
|
||||
handleApiRequest,
|
||||
handleUserAssetGet,
|
||||
handleUserAssetUpload,
|
||||
handleExtractBookmarkMetadataRequest,
|
||||
notFound,
|
||||
} from '@tldraw/worker-shared'
|
||||
|
||||
export interface Env {
|
||||
readonly ASSETS_BUCKET: R2Bucket
|
||||
readonly SENTRY_DSN?: string
|
||||
readonly WORKER_NAME?: string
|
||||
}
|
||||
|
||||
const router = createRouter<Env, ExecutionContext>()
|
||||
|
||||
// Asset management
|
||||
router.put('/assets/:objectName', async (request, env) => {
|
||||
const { objectName } = request.params
|
||||
return handleUserAssetUpload({
|
||||
objectName,
|
||||
bucket: env.ASSETS_BUCKET,
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/assets/:objectName', async (request, env, ctx) => {
|
||||
const { objectName } = request.params
|
||||
return handleUserAssetGet({
|
||||
request,
|
||||
bucket: env.ASSETS_BUCKET,
|
||||
objectName,
|
||||
context: ctx,
|
||||
})
|
||||
})
|
||||
|
||||
// Bookmark extraction
|
||||
router.post('/api/bookmark', (request) => {
|
||||
return handleExtractBookmarkMetadataRequest({
|
||||
request,
|
||||
uploadImage: async (headers, body, objectName) => {
|
||||
await env.ASSETS_BUCKET.put(objectName, body, { httpMetadata: headers })
|
||||
return `https://assets.example.com/${objectName}`
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Fallback
|
||||
router.all('*', () => notFound())
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
return handleApiRequest({
|
||||
router,
|
||||
request,
|
||||
env,
|
||||
ctx,
|
||||
after: (response) => {
|
||||
response.headers.set('Access-Control-Allow-Origin', '*')
|
||||
return response
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Type Safety
|
||||
|
||||
The package is built with TypeScript and provides full type safety:
|
||||
|
||||
### Router Types
|
||||
|
||||
```ts
|
||||
import { ApiRouter, ApiRoute } from '@tldraw/worker-shared'
|
||||
|
||||
// Router type includes environment and context constraints
|
||||
const router: ApiRouter<MyEnv, ExecutionContext> = createRouter()
|
||||
|
||||
// Route type ensures handlers match the expected signature
|
||||
const getRoute: ApiRoute<MyEnv, ExecutionContext> = router.get
|
||||
```
|
||||
|
||||
### SentryEnvironment Interface
|
||||
|
||||
Environment interfaces can extend `SentryEnvironment` for automatic Sentry integration:
|
||||
|
||||
```ts
|
||||
import { SentryEnvironment } from '@tldraw/worker-shared'
|
||||
|
||||
interface MyEnv extends SentryEnvironment {
|
||||
readonly DATABASE_URL: string
|
||||
readonly ASSETS_BUCKET: R2Bucket
|
||||
}
|
||||
```
|
||||
|
||||
This ensures your environment has the necessary Sentry configuration properties while maintaining type safety for your custom variables.
|
||||
|
||||
> Note: All handlers are designed to work with Cloudflare Workers' execution model and integrate seamlessly with the Worker runtime environment.
|
||||
27
packages/worker-shared/README.md
Normal file
27
packages/worker-shared/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# @tldraw/worker-shared
|
||||
|
||||
Shared helpers for the tldraw.com workers (e.g. bemo-worker, dotcom-worker, and more).
|
||||
|
||||
## License
|
||||
|
||||
The code in this folder is Copyright (c) 2024-present tldraw Inc. The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Distributions
|
||||
|
||||
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
33
packages/worker-shared/package.json
Normal file
33
packages/worker-shared/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@tldraw/worker-shared",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./.tsbuild/index.d.ts",
|
||||
"type": "module",
|
||||
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
|
||||
"files": [],
|
||||
"dependencies": {
|
||||
"@tldraw/utils": "workspace:*",
|
||||
"@tldraw/validate": "workspace:*",
|
||||
"cloudflare-workers-unfurl": "^0.0.9",
|
||||
"itty-router": "^5.0.18",
|
||||
"toucan-js": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260302.0",
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
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 })
|
||||
}
|
||||
7
packages/worker-shared/tsconfig.json
Normal file
7
packages/worker-shared/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../internal/config/tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", ".tsbuild*"],
|
||||
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "src" },
|
||||
"references": [{ "path": "../utils" }, { "path": "../validate" }]
|
||||
}
|
||||
6
packages/worker-shared/vitest.config.ts
Normal file
6
packages/worker-shared/vitest.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vitest" />
|
||||
import { mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../internal/config/vitest/node-preset'
|
||||
|
||||
// worker-shared package uses the standard node preset with no overrides
|
||||
export default mergeConfig(baseConfig, {})
|
||||
Reference in New Issue
Block a user