introduce images.tldraw.xyz image optimisation worker (#4069)

Fixes asset loading/processing on staging/previews by introducing a new
image processing worker. This worker acts as a proxy for our various
image hosts and resizes/optimizes/caches images on the fly. Like the old
bookmark worker, this one is deployed in an ad-hoc fashion as it works
across environments and we're not likely to change it often.

### Change type

- [x] `other`
This commit is contained in:
alex 2024-07-08 17:25:53 +01:00 committed by GitHub
parent e3cdf34007
commit cbac3ad3d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 289 additions and 130 deletions

View file

@ -47,30 +47,27 @@ jobs:
RELEASE_COMMIT_HASH: ${{ github.sha }} RELEASE_COMMIT_HASH: ${{ github.sha }}
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
APP_ORIGIN: ${{ vars.APP_ORIGIN }}
ASSET_UPLOAD: ${{ vars.ASSET_UPLOAD }} ASSET_UPLOAD: ${{ vars.ASSET_UPLOAD }}
ASSET_BUCKET_ORIGIN: ${{ vars.ASSET_BUCKET_ORIGIN }} IMAGE_WORKER: ${{ vars.IMAGE_WORKER }}
MULTIPLAYER_SERVER: ${{ vars.MULTIPLAYER_SERVER }} MULTIPLAYER_SERVER: ${{ vars.MULTIPLAYER_SERVER }}
NEXT_PUBLIC_GOOGLE_CLOUD_PROJECT_NUMBER: ${{ vars.NEXT_PUBLIC_GOOGLE_CLOUD_PROJECT_NUMBER }}
SUPABASE_LITE_URL: ${{ vars.SUPABASE_LITE_URL }} SUPABASE_LITE_URL: ${{ vars.SUPABASE_LITE_URL }}
VERCEL_PROJECT_ID: ${{ vars.VERCEL_DOTCOM_PROJECT_ID }}
VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ vars.VERCEL_DOTCOM_PROJECT_ID }}
ASSET_UPLOAD_SENTRY_DSN: ${{ secrets.ASSET_UPLOAD_SENTRY_DSN }} ASSET_UPLOAD_SENTRY_DSN: ${{ secrets.ASSET_UPLOAD_SENTRY_DSN }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
DISCORD_HEALTH_WEBHOOK_URL: ${{ secrets.DISCORD_HEALTH_WEBHOOK_URL }} DISCORD_HEALTH_WEBHOOK_URL: ${{ secrets.DISCORD_HEALTH_WEBHOOK_URL }}
HEALTH_WORKER_UPDOWN_WEBHOOK_PATH: ${{ secrets.HEALTH_WORKER_UPDOWN_WEBHOOK_PATH }}
GC_MAPS_API_KEY: ${{ secrets.GC_MAPS_API_KEY }} GC_MAPS_API_KEY: ${{ secrets.GC_MAPS_API_KEY }}
WORKER_SENTRY_DSN: ${{ secrets.WORKER_SENTRY_DSN }} HEALTH_WORKER_UPDOWN_WEBHOOK_PATH: ${{ secrets.HEALTH_WORKER_UPDOWN_WEBHOOK_PATH }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_CSP_REPORT_URI: ${{ secrets.SENTRY_CSP_REPORT_URI }}
SUPABASE_LITE_ANON_KEY: ${{ secrets.SUPABASE_LITE_ANON_KEY }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }} R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
APP_ORIGIN: ${{ vars.APP_ORIGIN }} SENTRY_CSP_REPORT_URI: ${{ secrets.SENTRY_CSP_REPORT_URI }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
NEXT_PUBLIC_GOOGLE_CLOUD_PROJECT_NUMBER: ${{ vars.NEXT_PUBLIC_GOOGLE_CLOUD_PROJECT_NUMBER }} SUPABASE_LITE_ANON_KEY: ${{ secrets.SUPABASE_LITE_ANON_KEY }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
WORKER_SENTRY_DSN: ${{ secrets.WORKER_SENTRY_DSN }}

View file

@ -1,5 +1,5 @@
main = "src/worker.ts" main = "src/worker.ts"
compatibility_date = "2022-09-22" compatibility_date = "2024-06-20"
[dev] [dev]
port = 8788 port = 8788

View file

@ -2,7 +2,8 @@ global.crypto ??= new (require('@peculiar/webcrypto').Crypto)()
process.env.MULTIPLAYER_SERVER = 'https://localhost:8787' process.env.MULTIPLAYER_SERVER = 'https://localhost:8787'
process.env.ASSET_UPLOAD = 'https://localhost:8788' process.env.ASSET_UPLOAD = 'https://localhost:8788'
process.env.ASSET_BUCKET_ORIGIN = 'https://localhost:8788' process.env.IMAGE_WORKER = 'https://images.tldraw.xyz'
process.env.TLDRAW_ENV = 'test'
global.TextEncoder = require('util').TextEncoder global.TextEncoder = require('util').TextEncoder
global.TextDecoder = require('util').TextDecoder global.TextDecoder = require('util').TextDecoder

View file

@ -43,7 +43,7 @@ describe('resolveAsset', () => {
it('should return the original src for video types', async () => { it('should return the original src for video types', async () => {
const asset = { const asset = {
type: 'video', type: 'video',
props: { src: 'http://example.com/video.mp4', fileSize: FILE_SIZE }, props: { src: 'http://assets.tldraw.dev/video.mp4', fileSize: FILE_SIZE },
} }
expect( expect(
await resolver(asset as TLAsset, { await resolver(asset as TLAsset, {
@ -52,11 +52,41 @@ describe('resolveAsset', () => {
dpr: 1, dpr: 1,
networkEffectiveType: '4g', networkEffectiveType: '4g',
}) })
).toBe('http://example.com/video.mp4') ).toBe('http://assets.tldraw.dev/video.mp4')
})
it('should return the original src for non-tldraw assets', async () => {
const asset = {
type: 'video',
props: { src: 'http://assets.not-tldraw.dev/video.mp4', fileSize: FILE_SIZE },
}
expect(
await resolver(asset as TLAsset, {
screenScale: -1,
steppedScreenScale: 1,
dpr: 1,
networkEffectiveType: '4g',
})
).toBe('http://assets.not-tldraw.dev/video.mp4')
})
it('should return the a transformed URL for small image types', async () => {
const asset = {
type: 'image',
props: { src: 'http://assets.tldraw.dev/image.jpg', fileSize: 1000 },
}
expect(
await resolver(asset as TLAsset, {
screenScale: -1,
steppedScreenScale: 1,
dpr: 1,
networkEffectiveType: '4g',
})
).toBe('https://images.tldraw.xyz/assets.tldraw.dev/image.jpg')
}) })
it('should return the original src for if original is asked for', async () => { it('should return the original src for if original is asked for', async () => {
const asset = { type: 'image', props: { src: 'http://example.com/image.jpg', w: 100 } } const asset = { type: 'image', props: { src: 'http://assets.tldraw.dev/image.jpg', w: 100 } }
expect( expect(
await resolver(asset as TLAsset, { await resolver(asset as TLAsset, {
screenScale: -1, screenScale: -1,
@ -65,7 +95,7 @@ describe('resolveAsset', () => {
networkEffectiveType: '4g', networkEffectiveType: '4g',
shouldResolveToOriginalImage: true, shouldResolveToOriginalImage: true,
}) })
).toBe('http://example.com/image.jpg') ).toBe('http://assets.tldraw.dev/image.jpg')
}) })
it('should return the original src if it does not start with http or https', async () => { it('should return the original src if it does not start with http or https', async () => {
@ -84,7 +114,7 @@ describe('resolveAsset', () => {
const asset = { const asset = {
type: 'image', type: 'image',
props: { props: {
src: 'http://example.com/animated.gif', src: 'http://assets.tldraw.dev/animated.gif',
mimeType: 'image/gif', mimeType: 'image/gif',
w: 100, w: 100,
fileSize: FILE_SIZE, fileSize: FILE_SIZE,
@ -97,14 +127,14 @@ describe('resolveAsset', () => {
dpr: 1, dpr: 1,
networkEffectiveType: '4g', networkEffectiveType: '4g',
}) })
).toBe('http://example.com/animated.gif') ).toBe('http://assets.tldraw.dev/animated.gif')
}) })
it('should return the original src if it is a vector image', async () => { it('should return the original src if it is a vector image', async () => {
const asset = { const asset = {
type: 'image', type: 'image',
props: { props: {
src: 'http://example.com/vector.svg', src: 'http://assets.tldraw.dev/vector.svg',
mimeType: 'image/svg+xml', mimeType: 'image/svg+xml',
w: 100, w: 100,
fileSize: FILE_SIZE, fileSize: FILE_SIZE,
@ -117,28 +147,13 @@ describe('resolveAsset', () => {
dpr: 1, dpr: 1,
networkEffectiveType: '4g', networkEffectiveType: '4g',
}) })
).toBe('http://example.com/vector.svg') ).toBe('http://assets.tldraw.dev/vector.svg')
})
it('should return the original src if it is under a certain file size', async () => {
const asset = {
type: 'image',
props: { src: 'http://example.com/small.png', w: 100, fileSize: 1024 * 1024 },
}
expect(
await resolver(asset as TLAsset, {
screenScale: -1,
steppedScreenScale: 1,
dpr: 1,
networkEffectiveType: '4g',
})
).toBe('http://example.com/small.png')
}) })
it("should return null if the asset type is not 'image'", async () => { it("should return null if the asset type is not 'image'", async () => {
const asset = { const asset = {
type: 'document', type: 'document',
props: { src: 'http://example.com/doc.pdf', w: 100, fileSize: FILE_SIZE }, props: { src: 'http://assets.tldraw.dev/doc.pdf', w: 100, fileSize: FILE_SIZE },
} }
expect( expect(
await resolver(asset as TLAsset, { await resolver(asset as TLAsset, {
@ -153,7 +168,7 @@ describe('resolveAsset', () => {
it('should handle if network compensation is not available and zoom correctly', async () => { it('should handle if network compensation is not available and zoom correctly', async () => {
const asset = { const asset = {
type: 'image', type: 'image',
props: { src: 'http://example.com/image.jpg', w: 100, fileSize: FILE_SIZE }, props: { src: 'http://assets.tldraw.dev/image.jpg', w: 100, fileSize: FILE_SIZE },
} }
expect( expect(
await resolver(asset as TLAsset, { await resolver(asset as TLAsset, {
@ -162,15 +177,13 @@ describe('resolveAsset', () => {
dpr: 2, dpr: 2,
networkEffectiveType: null, networkEffectiveType: null,
}) })
).toBe( ).toBe('https://images.tldraw.xyz/assets.tldraw.dev/image.jpg?w=100')
'https://localhost:8788/cdn-cgi/image/format=auto,width=50,dpr=2,fit=scale-down,quality=92/http://example.com/image.jpg'
)
}) })
it('should handle network compensation and zoom correctly', async () => { it('should handle network compensation and zoom correctly', async () => {
const asset = { const asset = {
type: 'image', type: 'image',
props: { src: 'http://example.com/image.jpg', w: 100, fileSize: FILE_SIZE }, props: { src: 'http://assets.tldraw.dev/image.jpg', w: 100, fileSize: FILE_SIZE },
} }
expect( expect(
await resolver(asset as TLAsset, { await resolver(asset as TLAsset, {
@ -179,59 +192,21 @@ describe('resolveAsset', () => {
dpr: 2, dpr: 2,
networkEffectiveType: '3g', networkEffectiveType: '3g',
}) })
).toBe( ).toBe('https://images.tldraw.xyz/assets.tldraw.dev/image.jpg?w=50')
'https://localhost:8788/cdn-cgi/image/format=auto,width=25,dpr=2,fit=scale-down,quality=92/http://example.com/image.jpg'
)
}) })
it('should round zoom to powers of 2', async () => { it('should not scale image above natural size', async () => {
const asset = { const asset = {
type: 'image', type: 'image',
props: { src: 'https://example.com/image.jpg', w: 100, fileSize: FILE_SIZE }, props: { src: 'https://assets.tldraw.dev/image.jpg', w: 100, fileSize: FILE_SIZE },
} }
expect( expect(
await resolver(asset as TLAsset, { await resolver(asset as TLAsset, {
screenScale: -1, screenScale: -1,
steppedScreenScale: 4, steppedScreenScale: 5,
dpr: 1, dpr: 1,
networkEffectiveType: '4g', networkEffectiveType: '4g',
}) })
).toBe( ).toBe('https://images.tldraw.xyz/assets.tldraw.dev/image.jpg?w=100')
'https://localhost:8788/cdn-cgi/image/format=auto,width=400,dpr=1,fit=scale-down,quality=92/https://example.com/image.jpg'
)
})
it('should round zoom to the nearest 0.25 and apply network compensation', async () => {
const asset = {
type: 'image',
props: { src: 'https://example.com/image.jpg', w: 100, fileSize: FILE_SIZE },
}
expect(
await resolver(asset as TLAsset, {
screenScale: -1,
steppedScreenScale: 0.5,
dpr: 1,
networkEffectiveType: '2g',
})
).toBe(
'https://localhost:8788/cdn-cgi/image/format=auto,width=25,dpr=1,fit=scale-down,quality=92/https://example.com/image.jpg'
)
})
it('should set zoom to a minimum of 0.25 if zoom is below 0.25', async () => {
const asset = {
type: 'image',
props: { src: 'https://example.com/image.jpg', w: 100, fileSize: FILE_SIZE },
}
expect(
await resolver(asset as TLAsset, {
screenScale: -1,
steppedScreenScale: 0.25,
dpr: 1,
networkEffectiveType: '4g',
})
).toBe(
'https://localhost:8788/cdn-cgi/image/format=auto,width=25,dpr=1,fit=scale-down,quality=92/https://example.com/image.jpg'
)
}) })
}) })

View file

@ -6,7 +6,8 @@ import {
WeakCache, WeakCache,
getAssetFromIndexedDb, getAssetFromIndexedDb,
} from 'tldraw' } from 'tldraw'
import { ASSET_BUCKET_ORIGIN, ASSET_UPLOADER_URL } from './config' import { IMAGE_WORKER } from './config'
import { isDevelopmentEnv } from './env'
const objectURLCache = new WeakCache<TLAsset, ReturnType<typeof getLocalAssetObjectURL>>() const objectURLCache = new WeakCache<TLAsset, ReturnType<typeof getLocalAssetObjectURL>>()
@ -44,24 +45,36 @@ export const resolveAsset =
// Don't try to transform vector images. // Don't try to transform vector images.
if (MediaHelpers.isVectorImageType(asset?.props.mimeType)) return asset.props.src if (MediaHelpers.isVectorImageType(asset?.props.mimeType)) return asset.props.src
// Assets that are under a certain file size aren't worth transforming (and incurring cost). const url = new URL(asset.props.src)
if (asset.props.fileSize === -1 || asset.props.fileSize < 1024 * 1024 * 1.5 /* 1.5 MB */)
return asset.props.src
// we only transform images that are hosted on domains we control
const isTldrawImage =
isDevelopmentEnv || /\.tldraw\.(?:com|xyz|dev|workers\.dev)$/.test(url.host)
if (!isTldrawImage) return asset.props.src
// Assets that are under a certain file size aren't worth transforming (and incurring cost).
// We still send them through the image worker to get them optimized though.
const isWorthResizing = asset.props.fileSize !== -1 && asset.props.fileSize >= 1024 * 1024 * 1.5
if (isWorthResizing) {
// N.B. navigator.connection is only available in certain browsers (mainly Blink-based browsers) // N.B. navigator.connection is only available in certain browsers (mainly Blink-based browsers)
// 4g is as high the 'effectiveType' goes and we can pick a lower effective image quality for slower connections. // 4g is as high the 'effectiveType' goes and we can pick a lower effective image quality for slower connections.
const networkCompensation = const networkCompensation =
!context.networkEffectiveType || context.networkEffectiveType === '4g' ? 1 : 0.5 !context.networkEffectiveType || context.networkEffectiveType === '4g' ? 1 : 0.5
const width = Math.ceil(asset.props.w * context.steppedScreenScale * networkCompensation) const width = Math.ceil(
Math.min(
asset.props.w * context.steppedScreenScale * networkCompensation * context.dpr,
asset.props.w
)
)
if (process.env.NODE_ENV === 'development') { url.searchParams.set('w', width.toString())
return asset.props.src
} }
// On preview, builds the origin for the asset won't be the right one for the Cloudflare transform. const newUrl = `${IMAGE_WORKER}/${url.host}/${url.toString().slice(url.origin.length + 1)}`
const src = asset.props.src.replace(ASSET_UPLOADER_URL, ASSET_BUCKET_ORIGIN) return newUrl
return `${ASSET_BUCKET_ORIGIN}/cdn-cgi/image/format=auto,width=${width},dpr=${context.dpr},fit=scale-down,quality=92/${src}`
} }
async function getLocalAssetObjectURL(persistenceKey: string, assetId: TLAssetId) { async function getLocalAssetObjectURL(persistenceKey: string, assetId: TLAssetId) {

View file

@ -5,14 +5,12 @@ export const BOOKMARK_ENDPOINT = '/api/unfurl'
if (!process.env.ASSET_UPLOAD) { if (!process.env.ASSET_UPLOAD) {
throw new Error('Missing ASSET_UPLOAD env var') throw new Error('Missing ASSET_UPLOAD env var')
} }
if (!process.env.ASSET_BUCKET_ORIGIN) {
throw new Error('Missing ASSET_BUCKET_ORIGIN env var')
}
export const ASSET_UPLOADER_URL: string = process.env.ASSET_UPLOAD export const ASSET_UPLOADER_URL: string = process.env.ASSET_UPLOAD
export const ASSET_BUCKET_ORIGIN: string = process.env.ASSET_BUCKET_ORIGIN if (!process.env.IMAGE_WORKER) {
throw new Error('Missing IMAGE_WORKER env var')
}
export const IMAGE_WORKER = process.env.IMAGE_WORKER
export const CONTROL_SERVER: string = export const CONTROL_SERVER: string =
process.env.NEXT_PUBLIC_CONTROL_SERVER || 'http://localhost:3001' process.env.NEXT_PUBLIC_CONTROL_SERVER || 'http://localhost:3001'

View file

@ -46,11 +46,7 @@ export default defineConfig((env) => ({
), ),
'process.env.MULTIPLAYER_SERVER': urlOrLocalFallback(env.mode, getMultiplayerServerURL(), 8787), 'process.env.MULTIPLAYER_SERVER': urlOrLocalFallback(env.mode, getMultiplayerServerURL(), 8787),
'process.env.ASSET_UPLOAD': urlOrLocalFallback(env.mode, process.env.ASSET_UPLOAD, 8788), 'process.env.ASSET_UPLOAD': urlOrLocalFallback(env.mode, process.env.ASSET_UPLOAD, 8788),
'process.env.ASSET_BUCKET_ORIGIN': urlOrLocalFallback( 'process.env.IMAGE_WORKER': urlOrLocalFallback(env.mode, process.env.IMAGE_WORKER, 8786),
env.mode,
process.env.ASSET_BUCKET_ORIGIN,
8788
),
'process.env.TLDRAW_ENV': JSON.stringify(process.env.TLDRAW_ENV ?? 'development'), 'process.env.TLDRAW_ENV': JSON.stringify(process.env.TLDRAW_ENV ?? 'development'),
// Fall back to staging DSN for local develeopment, although you still need to // Fall back to staging DSN for local develeopment, although you still need to
// modify the env check in 'sentry.client.config.ts' to get it reporting errors // modify the env check in 'sentry.client.config.ts' to get it reporting errors

View file

@ -0,0 +1,37 @@
{
"name": "images.tldraw.com",
"description": "A Cloudflare Worker to resize and optimize images",
"version": "2.0.0",
"private": true,
"author": {
"name": "tldraw GB Ltd.",
"email": "hello@tldraw.com"
},
"main": "src/index.ts",
"scripts": {
"dev": "yarn run -T tsx ../../scripts/workers/dev.ts",
"test-ci": "lazy inherit --passWithNoTests",
"test": "yarn run -T jest --passWithNoTests",
"test-coverage": "lazy inherit --passWithNoTests",
"lint": "yarn run -T tsx ../../scripts/lint.ts"
},
"dependencies": {
"@tldraw/validate": "workspace:*",
"@tldraw/worker-shared": "workspace:*",
"itty-router": "^4.0.13"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240620.0",
"lazyrepo": "0.0.0-alpha.27",
"wrangler": "3.62.0"
},
"jest": {
"preset": "config/jest/node",
"moduleNameMapper": {
"^~(.*)": "<rootDir>/src/$1"
},
"transformIgnorePatterns": [
"node_modules/(?!(nanoid|escape-string-regexp)/)"
]
}
}

View file

@ -0,0 +1,103 @@
import { T } from '@tldraw/validate'
import { createRouter, handleApiRequest, notFound, parseRequestQuery } from '@tldraw/worker-shared'
import { WorkerEntrypoint } from 'cloudflare:workers'
declare const fetch: typeof import('@cloudflare/workers-types').fetch
interface Environment {
IS_LOCAL?: string
SENTRY_DSN?: undefined
}
const queryValidator = T.object({
w: T.string.optional(),
q: T.string.optional(),
})
export default class Worker extends WorkerEntrypoint<Environment> {
readonly router = createRouter()
.get('/:origin/:path+', async (request) => {
const { origin, path } = request.params
const query = parseRequestQuery(request, queryValidator)
if (!this.isValidOrigin(origin)) return notFound()
const accept = request.headers.get('Accept') ?? ''
const format = accept.includes('image/avif')
? ('avif' as const)
: accept.includes('image/webp')
? ('webp' as const)
: null
const protocol = this.env.IS_LOCAL ? 'http' : 'https'
const passthroughUrl = new URL(`${protocol}://${origin}/${path}`)
const cacheKey = new URL(passthroughUrl)
cacheKey.searchParams.set('format', format ?? 'original')
for (const [key, value] of Object.entries(query)) {
cacheKey.searchParams.set(key, value)
}
const cachedResponse = await caches.default.match(cacheKey)
if (cachedResponse) {
// for some reason, cloudflare's cache doesn't seem to handle the etag properly:
if (cachedResponse.status === 200) {
const ifNoneMatch = request.headers.get('If-None-Match')
const etag = cachedResponse.headers.get('etag')
if (ifNoneMatch && etag) {
const parsedEtag = parseEtag(etag)
for (const tag of ifNoneMatch.split(', ')) {
if (parseEtag(tag) === parsedEtag) {
return new Response(null, { status: 304 })
}
}
}
}
return cachedResponse
}
const imageOptions: RequestInitCfPropertiesImage = {
fit: 'scale-down',
}
if (format) imageOptions.format = format
if (query.w) imageOptions.width = Number(query.w)
if (query.q) imageOptions.quality = Number(query.q)
const actualResponse = await fetch(passthroughUrl, { cf: { image: imageOptions } })
if (!actualResponse.headers.get('content-type')?.startsWith('image/')) return notFound()
if (actualResponse.status === 200) {
this.ctx.waitUntil(caches.default.put(cacheKey, actualResponse.clone()))
}
return actualResponse
})
.all('*', notFound)
override fetch(request: Request): Promise<Response> {
return handleApiRequest({
request,
router: this.router,
env: this.env,
ctx: this.ctx,
after: (response) => response,
})
}
isValidOrigin(origin: string) {
if (this.env.IS_LOCAL) {
return true
}
return (
origin.endsWith('.tldraw.com') ||
origin.endsWith('.tldraw.xyz') ||
origin.endsWith('.tldraw.dev') ||
origin.endsWith('.tldraw.workers.dev')
)
}
}
function parseEtag(etag: string) {
const match = etag.match(/^(?:W\/)"(.*)"$/)
return match ? match[1] : null
}

View file

@ -0,0 +1,17 @@
{
"extends": "../../config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", "dist", ".tsbuild*"],
"compilerOptions": {
"noEmit": true,
"emitDeclarationOnly": false
},
"references": [
{
"path": "../../packages/worker-shared"
},
{
"path": "../../packages/validate"
}
]
}

View file

@ -0,0 +1,7 @@
main = "src/worker.ts"
compatibility_date = "2024-06-20"
name = "image-optimizer"
route = { pattern = "images.tldraw.xyz", custom_domain = true }
[dev]
port = 8786

View file

@ -37,9 +37,9 @@
"clean": "scripts/clean.sh", "clean": "scripts/clean.sh",
"postinstall": "husky install && yarn refresh-assets", "postinstall": "husky install && yarn refresh-assets",
"refresh-assets": "lazy refresh-assets", "refresh-assets": "lazy refresh-assets",
"dev": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/examples' --filter='packages/tldraw' --filter='apps/bemo-worker'", "dev": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/examples' --filter='packages/tldraw' --filter='apps/{bemo-worker,images.tldraw.xyz}'",
"dev-vscode": "code ./apps/vscode/extension && lazy run dev --filter='apps/vscode/{extension,editor}'", "dev-vscode": "code ./apps/vscode/extension && lazy run dev --filter='apps/vscode/{extension,editor}'",
"dev-app": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/{dotcom,dotcom-asset-upload,dotcom-worker}' --filter='packages/tldraw'", "dev-app": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/{dotcom,dotcom-asset-upload,dotcom-worker,images.tldraw.xyz}' --filter='packages/tldraw'",
"dev-docs": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/docs'", "dev-docs": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/docs'",
"dev-huppy": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter 'apps/huppy'", "dev-huppy": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter 'apps/huppy'",
"build": "lazy build", "build": "lazy build",

View file

@ -8,10 +8,10 @@ interface Context {
} }
export interface SentryEnvironment { export interface SentryEnvironment {
readonly SENTRY_DSN: string | undefined readonly SENTRY_DSN?: string | undefined
readonly TLDRAW_ENV?: string | undefined readonly TLDRAW_ENV?: string | undefined
readonly WORKER_NAME: string | undefined readonly WORKER_NAME?: string | undefined
readonly CF_VERSION_METADATA: WorkerVersionMetadata readonly CF_VERSION_METADATA?: WorkerVersionMetadata
} }
export function createSentry(ctx: Context, env: SentryEnvironment, request?: Request) { export function createSentry(ctx: Context, env: SentryEnvironment, request?: Request) {

View file

@ -1,8 +1,9 @@
import { ExecutionContext, R2Bucket } from '@cloudflare/workers-types' import { ExecutionContext, R2Bucket } from '@cloudflare/workers-types'
import { IRequest } from 'itty-router'
import { notFound } from './errors' import { notFound } from './errors'
interface UserAssetOpts { interface UserAssetOpts {
request: Request request: IRequest
bucket: R2Bucket bucket: R2Bucket
objectName: string objectName: string
context: ExecutionContext context: ExecutionContext
@ -25,8 +26,7 @@ export async function handleUserAssetUpload({
} }
export async function handleUserAssetGet({ request, bucket, objectName, context }: UserAssetOpts) { export async function handleUserAssetGet({ request, bucket, objectName, context }: UserAssetOpts) {
const cacheUrl = new URL(request.url) const cacheKey = new URL(request.url)
const cacheKey = new Request(cacheUrl.toString(), request)
// this cache automatically handles range responses etc. // this cache automatically handles range responses etc.
const cachedResponse = await caches.default.match(cacheKey) const cachedResponse = await caches.default.match(cacheKey)
@ -58,6 +58,7 @@ export async function handleUserAssetGet({ request, bucket, objectName, context
} }
headers.set('content-range', `bytes ${start}-${end}/${object.size}`) headers.set('content-range', `bytes ${start}-${end}/${object.size}`)
} }
// assets are immutable, so we can cache them basically forever: // assets are immutable, so we can cache them basically forever:
headers.set('cache-control', 'public, max-age=31536000, immutable') headers.set('cache-control', 'public, max-age=31536000, immutable')

View file

@ -29,30 +29,30 @@ const dotcom = path.relative(process.cwd(), path.resolve(__dirname, '../apps/dot
// `env` instead. This makes sure that all required env vars are present. // `env` instead. This makes sure that all required env vars are present.
const env = makeEnv([ const env = makeEnv([
'APP_ORIGIN', 'APP_ORIGIN',
'ASSET_UPLOAD',
'ASSET_UPLOAD_SENTRY_DSN', 'ASSET_UPLOAD_SENTRY_DSN',
'ASSET_BUCKET_ORIGIN', 'ASSET_UPLOAD',
'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_ACCOUNT_ID',
'CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_API_TOKEN',
'DISCORD_DEPLOY_WEBHOOK_URL', 'DISCORD_DEPLOY_WEBHOOK_URL',
'DISCORD_HEALTH_WEBHOOK_URL', 'DISCORD_HEALTH_WEBHOOK_URL',
'HEALTH_WORKER_UPDOWN_WEBHOOK_PATH',
'GC_MAPS_API_KEY', 'GC_MAPS_API_KEY',
'GH_TOKEN',
'HEALTH_WORKER_UPDOWN_WEBHOOK_PATH',
'IMAGE_WORKER',
'MULTIPLAYER_SERVER',
'R2_ACCESS_KEY_ID',
'R2_ACCESS_KEY_SECRET',
'RELEASE_COMMIT_HASH', 'RELEASE_COMMIT_HASH',
'SENTRY_AUTH_TOKEN', 'SENTRY_AUTH_TOKEN',
'SENTRY_DSN',
'SENTRY_CSP_REPORT_URI', 'SENTRY_CSP_REPORT_URI',
'SENTRY_DSN',
'SUPABASE_LITE_ANON_KEY', 'SUPABASE_LITE_ANON_KEY',
'SUPABASE_LITE_URL', 'SUPABASE_LITE_URL',
'TLDRAW_ENV', 'TLDRAW_ENV',
'VERCEL_PROJECT_ID',
'VERCEL_ORG_ID', 'VERCEL_ORG_ID',
'VERCEL_PROJECT_ID',
'VERCEL_TOKEN', 'VERCEL_TOKEN',
'WORKER_SENTRY_DSN', 'WORKER_SENTRY_DSN',
'MULTIPLAYER_SERVER',
'GH_TOKEN',
'R2_ACCESS_KEY_ID',
'R2_ACCESS_KEY_SECRET',
]) ])
const discord = new Discord({ const discord = new Discord({
@ -148,6 +148,7 @@ async function prepareDotcomApp() {
ASSET_UPLOAD: previewId ASSET_UPLOAD: previewId
? `https://${previewId}-tldraw-assets.tldraw.workers.dev` ? `https://${previewId}-tldraw-assets.tldraw.workers.dev`
: env.ASSET_UPLOAD, : env.ASSET_UPLOAD,
IMAGE_WORKER: env.IMAGE_WORKER,
MULTIPLAYER_SERVER: previewId MULTIPLAYER_SERVER: previewId
? `https://${previewId}-tldraw-multiplayer.tldraw.workers.dev` ? `https://${previewId}-tldraw-multiplayer.tldraw.workers.dev`
: env.MULTIPLAYER_SERVER, : env.MULTIPLAYER_SERVER,

View file

@ -13647,6 +13647,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"images.tldraw.com@workspace:apps/images.tldraw.xyz":
version: 0.0.0-use.local
resolution: "images.tldraw.com@workspace:apps/images.tldraw.xyz"
dependencies:
"@cloudflare/workers-types": "npm:^4.20240620.0"
"@tldraw/validate": "workspace:*"
"@tldraw/worker-shared": "workspace:*"
itty-router: "npm:^4.0.13"
lazyrepo: "npm:0.0.0-alpha.27"
wrangler: "npm:3.62.0"
languageName: unknown
linkType: soft
"immediate@npm:~3.0.5": "immediate@npm:~3.0.5":
version: 3.0.6 version: 3.0.6
resolution: "immediate@npm:3.0.6" resolution: "immediate@npm:3.0.6"