From cbac3ad3d0d27ff52e8fd35b639bac12d0c37359 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 8 Jul 2024 17:25:53 +0100 Subject: [PATCH] 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` --- .github/workflows/deploy-dotcom.yml | 27 ++-- apps/dotcom-asset-upload/wrangler.toml | 2 +- apps/dotcom/setupTests.js | 3 +- apps/dotcom/src/utils/assetHandler.test.ts | 119 +++++++----------- apps/dotcom/src/utils/assetHandler.ts | 39 ++++-- apps/dotcom/src/utils/config.ts | 10 +- apps/dotcom/vite.config.ts | 6 +- apps/images.tldraw.xyz/package.json | 37 ++++++ apps/images.tldraw.xyz/src/worker.ts | 103 +++++++++++++++ apps/images.tldraw.xyz/tsconfig.json | 17 +++ apps/images.tldraw.xyz/wrangler.toml | 7 ++ package.json | 4 +- packages/worker-shared/src/sentry.ts | 6 +- .../worker-shared/src/userAssetUploads.ts | 7 +- scripts/deploy-dotcom.ts | 19 +-- yarn.lock | 13 ++ 16 files changed, 289 insertions(+), 130 deletions(-) create mode 100644 apps/images.tldraw.xyz/package.json create mode 100644 apps/images.tldraw.xyz/src/worker.ts create mode 100644 apps/images.tldraw.xyz/tsconfig.json create mode 100644 apps/images.tldraw.xyz/wrangler.toml diff --git a/.github/workflows/deploy-dotcom.yml b/.github/workflows/deploy-dotcom.yml index 4f3ceb8c4..6a98f963b 100644 --- a/.github/workflows/deploy-dotcom.yml +++ b/.github/workflows/deploy-dotcom.yml @@ -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 }} diff --git a/apps/dotcom-asset-upload/wrangler.toml b/apps/dotcom-asset-upload/wrangler.toml index 16224c743..7901b94bb 100644 --- a/apps/dotcom-asset-upload/wrangler.toml +++ b/apps/dotcom-asset-upload/wrangler.toml @@ -1,5 +1,5 @@ main = "src/worker.ts" -compatibility_date = "2022-09-22" +compatibility_date = "2024-06-20" [dev] port = 8788 diff --git a/apps/dotcom/setupTests.js b/apps/dotcom/setupTests.js index eccb905b1..7eea631f7 100644 --- a/apps/dotcom/setupTests.js +++ b/apps/dotcom/setupTests.js @@ -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 diff --git a/apps/dotcom/src/utils/assetHandler.test.ts b/apps/dotcom/src/utils/assetHandler.test.ts index 2fbb0a536..7f35ee729 100644 --- a/apps/dotcom/src/utils/assetHandler.test.ts +++ b/apps/dotcom/src/utils/assetHandler.test.ts @@ -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') }) }) diff --git a/apps/dotcom/src/utils/assetHandler.ts b/apps/dotcom/src/utils/assetHandler.ts index cce2f759f..d22fd6122 100644 --- a/apps/dotcom/src/utils/assetHandler.ts +++ b/apps/dotcom/src/utils/assetHandler.ts @@ -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>() @@ -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) { diff --git a/apps/dotcom/src/utils/config.ts b/apps/dotcom/src/utils/config.ts index 3c820f80b..a778bdd0a 100644 --- a/apps/dotcom/src/utils/config.ts +++ b/apps/dotcom/src/utils/config.ts @@ -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' diff --git a/apps/dotcom/vite.config.ts b/apps/dotcom/vite.config.ts index 00647e793..49c3ff814 100644 --- a/apps/dotcom/vite.config.ts +++ b/apps/dotcom/vite.config.ts @@ -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 diff --git a/apps/images.tldraw.xyz/package.json b/apps/images.tldraw.xyz/package.json new file mode 100644 index 000000000..b1709259c --- /dev/null +++ b/apps/images.tldraw.xyz/package.json @@ -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": { + "^~(.*)": "/src/$1" + }, + "transformIgnorePatterns": [ + "node_modules/(?!(nanoid|escape-string-regexp)/)" + ] + } +} diff --git a/apps/images.tldraw.xyz/src/worker.ts b/apps/images.tldraw.xyz/src/worker.ts new file mode 100644 index 000000000..79a37597f --- /dev/null +++ b/apps/images.tldraw.xyz/src/worker.ts @@ -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 { + 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 { + 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 +} diff --git a/apps/images.tldraw.xyz/tsconfig.json b/apps/images.tldraw.xyz/tsconfig.json new file mode 100644 index 000000000..d71b7a4f9 --- /dev/null +++ b/apps/images.tldraw.xyz/tsconfig.json @@ -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" + } + ] +} diff --git a/apps/images.tldraw.xyz/wrangler.toml b/apps/images.tldraw.xyz/wrangler.toml new file mode 100644 index 000000000..a319992d6 --- /dev/null +++ b/apps/images.tldraw.xyz/wrangler.toml @@ -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 diff --git a/package.json b/package.json index 2ceb30899..8b901faa7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/worker-shared/src/sentry.ts b/packages/worker-shared/src/sentry.ts index c51712b4d..4c1f70afd 100644 --- a/packages/worker-shared/src/sentry.ts +++ b/packages/worker-shared/src/sentry.ts @@ -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) { diff --git a/packages/worker-shared/src/userAssetUploads.ts b/packages/worker-shared/src/userAssetUploads.ts index ad40fe0c9..ca84c86de 100644 --- a/packages/worker-shared/src/userAssetUploads.ts +++ b/packages/worker-shared/src/userAssetUploads.ts @@ -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') diff --git a/scripts/deploy-dotcom.ts b/scripts/deploy-dotcom.ts index 04bc10e80..2013b105c 100644 --- a/scripts/deploy-dotcom.ts +++ b/scripts/deploy-dotcom.ts @@ -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, diff --git a/yarn.lock b/yarn.lock index b5fbfe7a4..6353c9569 100644 --- a/yarn.lock +++ b/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"