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 }}
|
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 }}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
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",
|
"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",
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
13
yarn.lock
13
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue