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:
parent
e3cdf34007
commit
cbac3ad3d0
16 changed files with 289 additions and 130 deletions
27
.github/workflows/deploy-dotcom.yml
vendored
27
.github/workflows/deploy-dotcom.yml
vendored
|
@ -47,30 +47,27 @@ jobs:
|
|||
RELEASE_COMMIT_HASH: ${{ github.sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
APP_ORIGIN: ${{ vars.APP_ORIGIN }}
|
||||
ASSET_UPLOAD: ${{ vars.ASSET_UPLOAD }}
|
||||
ASSET_BUCKET_ORIGIN: ${{ vars.ASSET_BUCKET_ORIGIN }}
|
||||
IMAGE_WORKER: ${{ vars.IMAGE_WORKER }}
|
||||
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 }}
|
||||
VERCEL_PROJECT_ID: ${{ vars.VERCEL_DOTCOM_PROJECT_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 }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_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 }}
|
||||
WORKER_SENTRY_DSN: ${{ secrets.WORKER_SENTRY_DSN }}
|
||||
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 }}
|
||||
|
||||
HEALTH_WORKER_UPDOWN_WEBHOOK_PATH: ${{ secrets.HEALTH_WORKER_UPDOWN_WEBHOOK_PATH }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }}
|
||||
|
||||
APP_ORIGIN: ${{ vars.APP_ORIGIN }}
|
||||
|
||||
NEXT_PUBLIC_GOOGLE_CLOUD_PROJECT_NUMBER: ${{ vars.NEXT_PUBLIC_GOOGLE_CLOUD_PROJECT_NUMBER }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_CSP_REPORT_URI: ${{ secrets.SENTRY_CSP_REPORT_URI }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SUPABASE_LITE_ANON_KEY: ${{ secrets.SUPABASE_LITE_ANON_KEY }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
WORKER_SENTRY_DSN: ${{ secrets.WORKER_SENTRY_DSN }}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
main = "src/worker.ts"
|
||||
compatibility_date = "2022-09-22"
|
||||
compatibility_date = "2024-06-20"
|
||||
|
||||
[dev]
|
||||
port = 8788
|
||||
|
|
|
@ -2,7 +2,8 @@ global.crypto ??= new (require('@peculiar/webcrypto').Crypto)()
|
|||
|
||||
process.env.MULTIPLAYER_SERVER = 'https://localhost:8787'
|
||||
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.TextDecoder = require('util').TextDecoder
|
||||
|
|
|
@ -43,7 +43,7 @@ describe('resolveAsset', () => {
|
|||
it('should return the original src for video types', async () => {
|
||||
const asset = {
|
||||
type: 'video',
|
||||
props: { src: 'http://example.com/video.mp4', fileSize: FILE_SIZE },
|
||||
props: { src: 'http://assets.tldraw.dev/video.mp4', fileSize: FILE_SIZE },
|
||||
}
|
||||
expect(
|
||||
await resolver(asset as TLAsset, {
|
||||
|
@ -52,11 +52,41 @@ describe('resolveAsset', () => {
|
|||
dpr: 1,
|
||||
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 () => {
|
||||
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(
|
||||
await resolver(asset as TLAsset, {
|
||||
screenScale: -1,
|
||||
|
@ -65,7 +95,7 @@ describe('resolveAsset', () => {
|
|||
networkEffectiveType: '4g',
|
||||
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 () => {
|
||||
|
@ -84,7 +114,7 @@ describe('resolveAsset', () => {
|
|||
const asset = {
|
||||
type: 'image',
|
||||
props: {
|
||||
src: 'http://example.com/animated.gif',
|
||||
src: 'http://assets.tldraw.dev/animated.gif',
|
||||
mimeType: 'image/gif',
|
||||
w: 100,
|
||||
fileSize: FILE_SIZE,
|
||||
|
@ -97,14 +127,14 @@ describe('resolveAsset', () => {
|
|||
dpr: 1,
|
||||
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 () => {
|
||||
const asset = {
|
||||
type: 'image',
|
||||
props: {
|
||||
src: 'http://example.com/vector.svg',
|
||||
src: 'http://assets.tldraw.dev/vector.svg',
|
||||
mimeType: 'image/svg+xml',
|
||||
w: 100,
|
||||
fileSize: FILE_SIZE,
|
||||
|
@ -117,28 +147,13 @@ describe('resolveAsset', () => {
|
|||
dpr: 1,
|
||||
networkEffectiveType: '4g',
|
||||
})
|
||||
).toBe('http://example.com/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')
|
||||
).toBe('http://assets.tldraw.dev/vector.svg')
|
||||
})
|
||||
|
||||
it("should return null if the asset type is not 'image'", async () => {
|
||||
const asset = {
|
||||
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(
|
||||
await resolver(asset as TLAsset, {
|
||||
|
@ -153,7 +168,7 @@ describe('resolveAsset', () => {
|
|||
it('should handle if network compensation is not available and zoom correctly', async () => {
|
||||
const asset = {
|
||||
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(
|
||||
await resolver(asset as TLAsset, {
|
||||
|
@ -162,15 +177,13 @@ describe('resolveAsset', () => {
|
|||
dpr: 2,
|
||||
networkEffectiveType: null,
|
||||
})
|
||||
).toBe(
|
||||
'https://localhost:8788/cdn-cgi/image/format=auto,width=50,dpr=2,fit=scale-down,quality=92/http://example.com/image.jpg'
|
||||
)
|
||||
).toBe('https://images.tldraw.xyz/assets.tldraw.dev/image.jpg?w=100')
|
||||
})
|
||||
|
||||
it('should handle network compensation and zoom correctly', async () => {
|
||||
const asset = {
|
||||
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(
|
||||
await resolver(asset as TLAsset, {
|
||||
|
@ -179,59 +192,21 @@ describe('resolveAsset', () => {
|
|||
dpr: 2,
|
||||
networkEffectiveType: '3g',
|
||||
})
|
||||
).toBe(
|
||||
'https://localhost:8788/cdn-cgi/image/format=auto,width=25,dpr=2,fit=scale-down,quality=92/http://example.com/image.jpg'
|
||||
)
|
||||
).toBe('https://images.tldraw.xyz/assets.tldraw.dev/image.jpg?w=50')
|
||||
})
|
||||
|
||||
it('should round zoom to powers of 2', async () => {
|
||||
it('should not scale image above natural size', async () => {
|
||||
const asset = {
|
||||
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(
|
||||
await resolver(asset as TLAsset, {
|
||||
screenScale: -1,
|
||||
steppedScreenScale: 4,
|
||||
steppedScreenScale: 5,
|
||||
dpr: 1,
|
||||
networkEffectiveType: '4g',
|
||||
})
|
||||
).toBe(
|
||||
'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'
|
||||
)
|
||||
).toBe('https://images.tldraw.xyz/assets.tldraw.dev/image.jpg?w=100')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -6,7 +6,8 @@ import {
|
|||
WeakCache,
|
||||
getAssetFromIndexedDb,
|
||||
} 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>>()
|
||||
|
||||
|
@ -44,24 +45,36 @@ export const resolveAsset =
|
|||
// Don't try to transform vector images.
|
||||
if (MediaHelpers.isVectorImageType(asset?.props.mimeType)) return asset.props.src
|
||||
|
||||
const url = new URL(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).
|
||||
if (asset.props.fileSize === -1 || asset.props.fileSize < 1024 * 1024 * 1.5 /* 1.5 MB */)
|
||||
return asset.props.src
|
||||
// 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
|
||||
|
||||
// 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.
|
||||
const networkCompensation =
|
||||
!context.networkEffectiveType || context.networkEffectiveType === '4g' ? 1 : 0.5
|
||||
if (isWorthResizing) {
|
||||
// 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.
|
||||
const networkCompensation =
|
||||
!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') {
|
||||
return asset.props.src
|
||||
url.searchParams.set('w', width.toString())
|
||||
}
|
||||
|
||||
// On preview, builds the origin for the asset won't be the right one for the Cloudflare transform.
|
||||
const src = asset.props.src.replace(ASSET_UPLOADER_URL, ASSET_BUCKET_ORIGIN)
|
||||
return `${ASSET_BUCKET_ORIGIN}/cdn-cgi/image/format=auto,width=${width},dpr=${context.dpr},fit=scale-down,quality=92/${src}`
|
||||
const newUrl = `${IMAGE_WORKER}/${url.host}/${url.toString().slice(url.origin.length + 1)}`
|
||||
return newUrl
|
||||
}
|
||||
|
||||
async function getLocalAssetObjectURL(persistenceKey: string, assetId: TLAssetId) {
|
||||
|
|
|
@ -5,14 +5,12 @@ export const BOOKMARK_ENDPOINT = '/api/unfurl'
|
|||
if (!process.env.ASSET_UPLOAD) {
|
||||
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_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 =
|
||||
process.env.NEXT_PUBLIC_CONTROL_SERVER || 'http://localhost:3001'
|
||||
|
|
|
@ -46,11 +46,7 @@ export default defineConfig((env) => ({
|
|||
),
|
||||
'process.env.MULTIPLAYER_SERVER': urlOrLocalFallback(env.mode, getMultiplayerServerURL(), 8787),
|
||||
'process.env.ASSET_UPLOAD': urlOrLocalFallback(env.mode, process.env.ASSET_UPLOAD, 8788),
|
||||
'process.env.ASSET_BUCKET_ORIGIN': urlOrLocalFallback(
|
||||
env.mode,
|
||||
process.env.ASSET_BUCKET_ORIGIN,
|
||||
8788
|
||||
),
|
||||
'process.env.IMAGE_WORKER': urlOrLocalFallback(env.mode, process.env.IMAGE_WORKER, 8786),
|
||||
'process.env.TLDRAW_ENV': JSON.stringify(process.env.TLDRAW_ENV ?? 'development'),
|
||||
// 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
|
||||
|
|
37
apps/images.tldraw.xyz/package.json
Normal file
37
apps/images.tldraw.xyz/package.json
Normal 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)/)"
|
||||
]
|
||||
}
|
||||
}
|
103
apps/images.tldraw.xyz/src/worker.ts
Normal file
103
apps/images.tldraw.xyz/src/worker.ts
Normal 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
|
||||
}
|
17
apps/images.tldraw.xyz/tsconfig.json
Normal file
17
apps/images.tldraw.xyz/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
7
apps/images.tldraw.xyz/wrangler.toml
Normal file
7
apps/images.tldraw.xyz/wrangler.toml
Normal 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
|
|
@ -37,9 +37,9 @@
|
|||
"clean": "scripts/clean.sh",
|
||||
"postinstall": "husky install && yarn 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-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-huppy": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter 'apps/huppy'",
|
||||
"build": "lazy build",
|
||||
|
|
|
@ -8,10 +8,10 @@ interface Context {
|
|||
}
|
||||
|
||||
export interface SentryEnvironment {
|
||||
readonly SENTRY_DSN: string | undefined
|
||||
readonly SENTRY_DSN?: string | undefined
|
||||
readonly TLDRAW_ENV?: string | undefined
|
||||
readonly WORKER_NAME: string | undefined
|
||||
readonly CF_VERSION_METADATA: WorkerVersionMetadata
|
||||
readonly WORKER_NAME?: string | undefined
|
||||
readonly CF_VERSION_METADATA?: WorkerVersionMetadata
|
||||
}
|
||||
|
||||
export function createSentry(ctx: Context, env: SentryEnvironment, request?: Request) {
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { ExecutionContext, R2Bucket } from '@cloudflare/workers-types'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { notFound } from './errors'
|
||||
|
||||
interface UserAssetOpts {
|
||||
request: Request
|
||||
request: IRequest
|
||||
bucket: R2Bucket
|
||||
objectName: string
|
||||
context: ExecutionContext
|
||||
|
@ -25,8 +26,7 @@ export async function handleUserAssetUpload({
|
|||
}
|
||||
|
||||
export async function handleUserAssetGet({ request, bucket, objectName, context }: UserAssetOpts) {
|
||||
const cacheUrl = new URL(request.url)
|
||||
const cacheKey = new Request(cacheUrl.toString(), request)
|
||||
const cacheKey = new URL(request.url)
|
||||
|
||||
// this cache automatically handles range responses etc.
|
||||
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}`)
|
||||
}
|
||||
|
||||
// assets are immutable, so we can cache them basically forever:
|
||||
headers.set('cache-control', 'public, max-age=31536000, immutable')
|
||||
|
||||
|
|
|
@ -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.
|
||||
const env = makeEnv([
|
||||
'APP_ORIGIN',
|
||||
'ASSET_UPLOAD',
|
||||
'ASSET_UPLOAD_SENTRY_DSN',
|
||||
'ASSET_BUCKET_ORIGIN',
|
||||
'ASSET_UPLOAD',
|
||||
'CLOUDFLARE_ACCOUNT_ID',
|
||||
'CLOUDFLARE_API_TOKEN',
|
||||
'DISCORD_DEPLOY_WEBHOOK_URL',
|
||||
'DISCORD_HEALTH_WEBHOOK_URL',
|
||||
'HEALTH_WORKER_UPDOWN_WEBHOOK_PATH',
|
||||
'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',
|
||||
'SENTRY_AUTH_TOKEN',
|
||||
'SENTRY_DSN',
|
||||
'SENTRY_CSP_REPORT_URI',
|
||||
'SENTRY_DSN',
|
||||
'SUPABASE_LITE_ANON_KEY',
|
||||
'SUPABASE_LITE_URL',
|
||||
'TLDRAW_ENV',
|
||||
'VERCEL_PROJECT_ID',
|
||||
'VERCEL_ORG_ID',
|
||||
'VERCEL_PROJECT_ID',
|
||||
'VERCEL_TOKEN',
|
||||
'WORKER_SENTRY_DSN',
|
||||
'MULTIPLAYER_SERVER',
|
||||
'GH_TOKEN',
|
||||
'R2_ACCESS_KEY_ID',
|
||||
'R2_ACCESS_KEY_SECRET',
|
||||
])
|
||||
|
||||
const discord = new Discord({
|
||||
|
@ -148,6 +148,7 @@ async function prepareDotcomApp() {
|
|||
ASSET_UPLOAD: previewId
|
||||
? `https://${previewId}-tldraw-assets.tldraw.workers.dev`
|
||||
: env.ASSET_UPLOAD,
|
||||
IMAGE_WORKER: env.IMAGE_WORKER,
|
||||
MULTIPLAYER_SERVER: previewId
|
||||
? `https://${previewId}-tldraw-multiplayer.tldraw.workers.dev`
|
||||
: env.MULTIPLAYER_SERVER,
|
||||
|
|
13
yarn.lock
13
yarn.lock
|
@ -13647,6 +13647,19 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 3.0.6
|
||||
resolution: "immediate@npm:3.0.6"
|
||||
|
|
Loading…
Reference in a new issue