Demo assets server (#4055)
Adds an assets server to the demo worker, and reworks the existing asset server to use the same code. There are a few simplifications to the code due to some DX improvements working with R2 and caches. I also removed the `HEAD` request from the assets server: i took a look at our logs and it's not actually used at all. This also fixes an issue where users could overwrite the contents of the asset uploads bucket. ### Change type - [x] `other`
This commit is contained in:
parent
adb84d97e3
commit
dcfc6da604
21 changed files with 240 additions and 209 deletions
1
.github/workflows/deploy-dotcom.yml
vendored
1
.github/workflows/deploy-dotcom.yml
vendored
|
@ -54,6 +54,7 @@ jobs:
|
||||||
VERCEL_PROJECT_ID: ${{ vars.VERCEL_DOTCOM_PROJECT_ID }}
|
VERCEL_PROJECT_ID: ${{ vars.VERCEL_DOTCOM_PROJECT_ID }}
|
||||||
VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
|
VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
|
||||||
|
|
||||||
|
ASSET_UPLOAD_SENTRY_DSN: ${{ secrets.ASSET_UPLOAD_SENTRY_DSN }}
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
|
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
|
||||||
|
|
|
@ -3,10 +3,12 @@ import { BemoDO } from './BemoDO'
|
||||||
export interface Environment {
|
export interface Environment {
|
||||||
// bindings
|
// bindings
|
||||||
BEMO_DO: DurableObjectNamespace<BemoDO>
|
BEMO_DO: DurableObjectNamespace<BemoDO>
|
||||||
|
BEMO_BUCKET: R2Bucket
|
||||||
|
CF_VERSION_METADATA: WorkerVersionMetadata
|
||||||
|
|
||||||
|
// environment variables
|
||||||
TLDRAW_ENV: string | undefined
|
TLDRAW_ENV: string | undefined
|
||||||
SENTRY_DSN: string | undefined
|
SENTRY_DSN: string | undefined
|
||||||
IS_LOCAL: string | undefined
|
IS_LOCAL: string | undefined
|
||||||
WORKER_NAME: string | undefined
|
WORKER_NAME: string | undefined
|
||||||
CF_VERSION_METADATA: WorkerVersionMetadata
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
/// <reference no-default-lib="true"/>
|
/// <reference no-default-lib="true"/>
|
||||||
/// <reference types="@cloudflare/workers-types" />
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
import { createSentry } from '@tldraw/worker-shared'
|
import {
|
||||||
|
createSentry,
|
||||||
|
handleUserAssetGet,
|
||||||
|
handleUserAssetUpload,
|
||||||
|
notFound,
|
||||||
|
} from '@tldraw/worker-shared'
|
||||||
import { WorkerEntrypoint } from 'cloudflare:workers'
|
import { WorkerEntrypoint } from 'cloudflare:workers'
|
||||||
import { Router, createCors } from 'itty-router'
|
import { Router, createCors } from 'itty-router'
|
||||||
import { Environment } from './types'
|
import { Environment } from './types'
|
||||||
|
@ -13,12 +18,28 @@ const cors = createCors({ origins: ['*'] })
|
||||||
export default class Worker extends WorkerEntrypoint<Environment> {
|
export default class Worker extends WorkerEntrypoint<Environment> {
|
||||||
private readonly router = Router()
|
private readonly router = Router()
|
||||||
.all('*', cors.preflight)
|
.all('*', cors.preflight)
|
||||||
|
.get('/v1/uploads/:objectName', (request) => {
|
||||||
|
return handleUserAssetGet({
|
||||||
|
request,
|
||||||
|
bucket: this.env.BEMO_BUCKET,
|
||||||
|
objectName: `asset-uploads/${request.params.objectName}`,
|
||||||
|
context: this.ctx,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post('/v1/uploads/:objectName', async (request) => {
|
||||||
|
return handleUserAssetUpload({
|
||||||
|
request,
|
||||||
|
bucket: this.env.BEMO_BUCKET,
|
||||||
|
objectName: `asset-uploads/${request.params.objectName}`,
|
||||||
|
context: this.ctx,
|
||||||
|
})
|
||||||
|
})
|
||||||
.get('/do', async (request) => {
|
.get('/do', async (request) => {
|
||||||
const bemo = this.env.BEMO_DO.get(this.env.BEMO_DO.idFromName('bemo-do'))
|
const bemo = this.env.BEMO_DO.get(this.env.BEMO_DO.idFromName('bemo-do'))
|
||||||
const message = await (await bemo.fetch(request)).json()
|
const message = await (await bemo.fetch(request)).json()
|
||||||
return Response.json(message)
|
return Response.json(message)
|
||||||
})
|
})
|
||||||
.all('*', async () => new Response('Not found', { status: 404 }))
|
.all('*', notFound)
|
||||||
|
|
||||||
override async fetch(request: Request): Promise<Response> {
|
override async fetch(request: Request): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -9,7 +9,7 @@ ip = "0.0.0.0"
|
||||||
# these migrations are append-only. you can't change them. if you do need to change something, do so
|
# these migrations are append-only. you can't change them. if you do need to change something, do so
|
||||||
# by creating new migrations
|
# by creating new migrations
|
||||||
[[migrations]]
|
[[migrations]]
|
||||||
tag = "v1" # Should be unique for each entry
|
tag = "v1" # Should be unique for each entry
|
||||||
new_classes = ["BemoDO"]
|
new_classes = ["BemoDO"]
|
||||||
|
|
||||||
|
|
||||||
|
@ -23,44 +23,51 @@ name = "dev-bemo"
|
||||||
# staging is the same as a preview on main:
|
# staging is the same as a preview on main:
|
||||||
[env.staging]
|
[env.staging]
|
||||||
name = "canary-bemo"
|
name = "canary-bemo"
|
||||||
routes = [
|
routes = [{ pattern = "canary-demo.tldraw.xyz", custom_domain = true }]
|
||||||
{ pattern = "canary-demo.tldraw.xyz", custom_domain = true }
|
|
||||||
]
|
|
||||||
|
|
||||||
# production gets the proper name
|
# production gets the proper name
|
||||||
[env.production]
|
[env.production]
|
||||||
name = "production-bemo"
|
name = "production-bemo"
|
||||||
routes = [
|
routes = [{ pattern = "demo.tldraw.xyz", custom_domain = true }]
|
||||||
{ pattern = "demo.tldraw.xyz", custom_domain = true }
|
|
||||||
]
|
|
||||||
|
|
||||||
#################### Durable objects ####################
|
#################### Durable objects ####################
|
||||||
# durable objects have the same configuration in all environments:
|
# durable objects have the same configuration in all environments:
|
||||||
|
|
||||||
[durable_objects]
|
[durable_objects]
|
||||||
bindings = [
|
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
|
||||||
{ name = "BEMO_DO", class_name = "BemoDO" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[env.dev.durable_objects]
|
[env.dev.durable_objects]
|
||||||
bindings = [
|
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
|
||||||
{ name = "BEMO_DO", class_name = "BemoDO" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[env.preview.durable_objects]
|
[env.preview.durable_objects]
|
||||||
bindings = [
|
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
|
||||||
{ name = "BEMO_DO", class_name = "BemoDO" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[env.staging.durable_objects]
|
[env.staging.durable_objects]
|
||||||
bindings = [
|
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
|
||||||
{ name = "BEMO_DO", class_name = "BemoDO" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[env.production.durable_objects]
|
[env.production.durable_objects]
|
||||||
bindings = [
|
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
|
||||||
{ name = "BEMO_DO", class_name = "BemoDO" },
|
|
||||||
]
|
#################### R2 bucket ####################
|
||||||
|
# in dev, we write to the preview bucket and need a `preview_bucket_name`
|
||||||
|
[[env.dev.r2_buckets]]
|
||||||
|
binding = 'BEMO_BUCKET'
|
||||||
|
bucket_name = 'sync-demo-preview'
|
||||||
|
preview_bucket_name = 'sync-demo-preview'
|
||||||
|
|
||||||
|
# in preview and staging we write to the preview bucket
|
||||||
|
[[env.preview.r2_buckets]]
|
||||||
|
binding = 'BEMO_BUCKET'
|
||||||
|
bucket_name = 'sync-demo-preview'
|
||||||
|
|
||||||
|
[[env.stating.r2_buckets]]
|
||||||
|
binding = 'BEMO_BUCKET'
|
||||||
|
bucket_name = 'sync-demo-preview'
|
||||||
|
|
||||||
|
# in production, we write to the main bucket
|
||||||
|
[[env.production.r2_buckets]]
|
||||||
|
binding = "BEMO_BUCKET"
|
||||||
|
bucket_name = "sync-demo"
|
||||||
|
|
||||||
#################### Version metadata ####################
|
#################### Version metadata ####################
|
||||||
[version_metadata]
|
[version_metadata]
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
"lint": "yarn run -T tsx ../../scripts/lint.ts"
|
"lint": "yarn run -T tsx ../../scripts/lint.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tldraw/worker-shared": "workspace:*",
|
||||||
"itty-cors": "^0.3.4",
|
"itty-cors": "^0.3.4",
|
||||||
"itty-router": "^4.0.13"
|
"itty-router": "^4.0.13"
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
export interface Env {
|
import { R2Bucket, WorkerVersionMetadata } from '@cloudflare/workers-types'
|
||||||
UPLOADS: R2Bucket
|
|
||||||
|
|
||||||
KV: KVNamespace
|
export interface Environment {
|
||||||
ASSET_UPLOADER_AUTH_TOKEN: string | undefined
|
// bindings
|
||||||
|
UPLOADS: R2Bucket
|
||||||
|
CF_VERSION_METADATA: WorkerVersionMetadata
|
||||||
|
|
||||||
|
// environment variables
|
||||||
|
TLDRAW_ENV: string | undefined
|
||||||
|
SENTRY_DSN: string | undefined
|
||||||
|
IS_LOCAL: string | undefined
|
||||||
|
WORKER_NAME: string | undefined
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,166 +1,52 @@
|
||||||
/// <reference no-default-lib="true"/>
|
/// <reference no-default-lib="true"/>
|
||||||
/// <reference types="@cloudflare/workers-types" />
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSentry,
|
||||||
|
handleUserAssetGet,
|
||||||
|
handleUserAssetUpload,
|
||||||
|
notFound,
|
||||||
|
} from '@tldraw/worker-shared'
|
||||||
|
import { WorkerEntrypoint } from 'cloudflare:workers'
|
||||||
import { createCors } from 'itty-cors'
|
import { createCors } from 'itty-cors'
|
||||||
import { Router } from 'itty-router'
|
import { Router } from 'itty-router'
|
||||||
|
import { Environment } from './types'
|
||||||
|
|
||||||
const { preflight, corsify } = createCors({ origins: ['*'] })
|
const { preflight, corsify } = createCors({ origins: ['*'] })
|
||||||
|
|
||||||
interface Env {
|
export default class Worker extends WorkerEntrypoint<Environment> {
|
||||||
UPLOADS: R2Bucket
|
readonly router = Router()
|
||||||
}
|
.all('*', preflight)
|
||||||
|
.get('/uploads/:objectName', async (request) => {
|
||||||
function parseRange(
|
return handleUserAssetGet({
|
||||||
encoded: string | null
|
request,
|
||||||
): undefined | { offset: number; end: number; length: number } {
|
bucket: this.env.UPLOADS,
|
||||||
if (encoded === null) {
|
objectName: request.params.objectName,
|
||||||
return
|
context: this.ctx,
|
||||||
}
|
|
||||||
|
|
||||||
const parts = (encoded.split('bytes=')[1]?.split('-') ?? []).filter(Boolean)
|
|
||||||
if (parts.length !== 2) {
|
|
||||||
console.error('Not supported to skip specifying the beginning/ending byte at this time')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
offset: Number(parts[0]),
|
|
||||||
end: Number(parts[1]),
|
|
||||||
length: Number(parts[1]) + 1 - Number(parts[0]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function objectNotFound(objectName: string): Response {
|
|
||||||
return new Response(`<html><body>R2 object "<b>${objectName}</b>" not found</body></html>`, {
|
|
||||||
status: 404,
|
|
||||||
headers: {
|
|
||||||
'content-type': 'text/html; charset=UTF-8',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const CACHE_CONTROL_SETTING = 's-maxage=604800'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
router
|
|
||||||
.all('*', preflight)
|
|
||||||
.get('/uploads/:objectName', async (request: Request, env: Env, ctx: ExecutionContext) => {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
|
|
||||||
const range = parseRange(request.headers.get('range'))
|
|
||||||
|
|
||||||
// NOTE: caching will only work when this is deployed to
|
|
||||||
// a custom domain, not a workers.dev domain. It's a no-op
|
|
||||||
// otherwise.
|
|
||||||
|
|
||||||
// Construct the cache key from the cache URL
|
|
||||||
const cacheKey = new Request(url.toString(), request)
|
|
||||||
const cache = caches.default as Cache
|
|
||||||
|
|
||||||
// Check whether the value is already available in the cache
|
|
||||||
// if not, you will need to fetch it from R2, and store it in the cache
|
|
||||||
// for future access
|
|
||||||
let cachedResponse
|
|
||||||
if (!range) {
|
|
||||||
cachedResponse = await cache.match(cacheKey)
|
|
||||||
|
|
||||||
if (cachedResponse) {
|
|
||||||
return cachedResponse
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ifNoneMatch = request.headers.get('if-none-match')
|
|
||||||
let hs = request.headers
|
|
||||||
if (ifNoneMatch?.startsWith('W/')) {
|
|
||||||
hs = new Headers(request.headers)
|
|
||||||
hs.set('if-none-match', ifNoneMatch.slice(2))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: infer types from path
|
|
||||||
// @ts-expect-error
|
|
||||||
const object = await env.UPLOADS.get(request.params.objectName, {
|
|
||||||
range,
|
|
||||||
onlyIf: hs,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (object === null) {
|
|
||||||
// TODO: infer types from path
|
|
||||||
// @ts-expect-error
|
|
||||||
return objectNotFound(request.params.objectName)
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = new Headers()
|
|
||||||
object.writeHttpMetadata(headers)
|
|
||||||
headers.set('etag', object.httpEtag)
|
|
||||||
if (range) {
|
|
||||||
headers.set('content-range', `bytes ${range.offset}-${range.end}/${object.size}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache API respects Cache-Control headers. Setting s-max-age to 7 days
|
|
||||||
// Any changes made to the response here will be reflected in the cached value
|
|
||||||
headers.append('Cache-Control', CACHE_CONTROL_SETTING)
|
|
||||||
|
|
||||||
const hasBody = 'body' in object && object.body
|
|
||||||
const status = hasBody ? (range ? 206 : 200) : 304
|
|
||||||
const response = new Response(hasBody ? object.body : undefined, {
|
|
||||||
headers,
|
|
||||||
status,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Store the response in the cache for future access
|
|
||||||
if (!range) {
|
|
||||||
const clonedResponse = response.clone()
|
|
||||||
// If the request was made with no-cache, we should not cache that in the headers.
|
|
||||||
clonedResponse?.headers.set('Cache-Control', CACHE_CONTROL_SETTING)
|
|
||||||
ctx.waitUntil(cache.put(cacheKey, clonedResponse))
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
})
|
|
||||||
.head('/uploads/:objectName', async (request: Request, env: Env) => {
|
|
||||||
// TODO: infer types from path
|
|
||||||
// @ts-expect-error
|
|
||||||
const object = await env.UPLOADS.head(request.params.objectName)
|
|
||||||
|
|
||||||
if (object === null) {
|
|
||||||
// TODO: infer types from path
|
|
||||||
// @ts-expect-error
|
|
||||||
return objectNotFound(request.params.objectName)
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = new Headers()
|
|
||||||
object.writeHttpMetadata(headers)
|
|
||||||
headers.set('etag', object.httpEtag)
|
|
||||||
return new Response(null, {
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.post('/uploads/:objectName', async (request: Request, env: Env) => {
|
|
||||||
// TODO: infer types from path
|
|
||||||
// @ts-expect-error
|
|
||||||
const object = await env.UPLOADS.put(request.params.objectName, request.body, {
|
|
||||||
httpMetadata: request.headers,
|
|
||||||
})
|
|
||||||
return new Response(null, {
|
|
||||||
headers: {
|
|
||||||
etag: object.httpEtag,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.get('*', () => new Response('Not found', { status: 404 }))
|
|
||||||
|
|
||||||
const Worker = {
|
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
|
||||||
return router
|
|
||||||
.handle(request, env, ctx)
|
|
||||||
.catch((err) => {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(err, err.stack)
|
|
||||||
return new Response((err as Error).message, { status: 500 })
|
|
||||||
})
|
})
|
||||||
.then(corsify)
|
})
|
||||||
},
|
.post('/uploads/:objectName', async (request) => {
|
||||||
}
|
return handleUserAssetUpload({
|
||||||
|
request,
|
||||||
|
bucket: this.env.UPLOADS,
|
||||||
|
objectName: request.params.objectName,
|
||||||
|
context: this.ctx,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.all('*', notFound)
|
||||||
|
|
||||||
export default Worker
|
override async fetch(request: Request) {
|
||||||
|
try {
|
||||||
|
return await this.router.handle(request, this.env, this.ctx).then(corsify)
|
||||||
|
} catch (error) {
|
||||||
|
const sentry = createSentry(this.ctx, this.env, request)
|
||||||
|
console.error(error)
|
||||||
|
// eslint-disable-next-line deprecation/deprecation
|
||||||
|
sentry?.captureException(error)
|
||||||
|
return new Response('Something went wrong', {
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -5,5 +5,10 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"emitDeclarationOnly": false
|
"emitDeclarationOnly": false
|
||||||
}
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../../packages/worker-shared"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -63,3 +63,19 @@ binding = "MEASURE"
|
||||||
pattern = 'assets.tldraw.xyz'
|
pattern = 'assets.tldraw.xyz'
|
||||||
custom_domain = true
|
custom_domain = true
|
||||||
zone_name = 'tldraw.xyz'
|
zone_name = 'tldraw.xyz'
|
||||||
|
|
||||||
|
#################### Version metadata ####################
|
||||||
|
[version_metadata]
|
||||||
|
binding = "CF_VERSION_METADATA"
|
||||||
|
|
||||||
|
[env.dev.version_metadata]
|
||||||
|
binding = "CF_VERSION_METADATA"
|
||||||
|
|
||||||
|
[env.preview.version_metadata]
|
||||||
|
binding = "CF_VERSION_METADATA"
|
||||||
|
|
||||||
|
[env.staging.version_metadata]
|
||||||
|
binding = "CF_VERSION_METADATA"
|
||||||
|
|
||||||
|
[env.production.version_metadata]
|
||||||
|
binding = "CF_VERSION_METADATA"
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
|
import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
|
||||||
|
import { notFound } from '@tldraw/worker-shared'
|
||||||
import { IRequest } from 'itty-router'
|
import { IRequest } from 'itty-router'
|
||||||
import { Environment } from '../types'
|
import { Environment } from '../types'
|
||||||
import { fourOhFour } from '../utils/fourOhFour'
|
|
||||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||||
|
|
||||||
// Forwards a room request to the durable object associated with that room
|
// Forwards a room request to the durable object associated with that room
|
||||||
export async function forwardRoomRequest(request: IRequest, env: Environment): Promise<Response> {
|
export async function forwardRoomRequest(request: IRequest, env: Environment): Promise<Response> {
|
||||||
const roomId = request.params.roomId
|
const roomId = request.params.roomId
|
||||||
|
|
||||||
if (!roomId) return fourOhFour()
|
if (!roomId) return notFound()
|
||||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||||
|
|
||||||
// Set up the durable object for this room
|
// Set up the durable object for this room
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
|
import { notFound } from '@tldraw/worker-shared'
|
||||||
import { IRequest } from 'itty-router'
|
import { IRequest } from 'itty-router'
|
||||||
import { getR2KeyForRoom } from '../r2'
|
import { getR2KeyForRoom } from '../r2'
|
||||||
import { Environment } from '../types'
|
import { Environment } from '../types'
|
||||||
import { fourOhFour } from '../utils/fourOhFour'
|
|
||||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||||
|
|
||||||
// Returns the history of a room as a list of objects with timestamps
|
// Returns the history of a room as a list of objects with timestamps
|
||||||
export async function getRoomHistory(request: IRequest, env: Environment): Promise<Response> {
|
export async function getRoomHistory(request: IRequest, env: Environment): Promise<Response> {
|
||||||
const roomId = request.params.roomId
|
const roomId = request.params.roomId
|
||||||
|
|
||||||
if (!roomId) return fourOhFour()
|
if (!roomId) return notFound()
|
||||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||||
|
|
||||||
const versionCacheBucket = env.ROOMS_HISTORY_EPHEMERAL
|
const versionCacheBucket = env.ROOMS_HISTORY_EPHEMERAL
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
|
import { notFound } from '@tldraw/worker-shared'
|
||||||
import { IRequest } from 'itty-router'
|
import { IRequest } from 'itty-router'
|
||||||
import { getR2KeyForRoom } from '../r2'
|
import { getR2KeyForRoom } from '../r2'
|
||||||
import { Environment } from '../types'
|
import { Environment } from '../types'
|
||||||
import { fourOhFour } from '../utils/fourOhFour'
|
|
||||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||||
|
|
||||||
// Get a snapshot of the room at a given point in time
|
// Get a snapshot of the room at a given point in time
|
||||||
|
@ -11,7 +11,7 @@ export async function getRoomHistorySnapshot(
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const roomId = request.params.roomId
|
const roomId = request.params.roomId
|
||||||
|
|
||||||
if (!roomId) return fourOhFour()
|
if (!roomId) return notFound()
|
||||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||||
|
|
||||||
const timestamp = request.params.timestamp
|
const timestamp = request.params.timestamp
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { RoomSnapshot } from '@tldraw/tlsync'
|
import { RoomSnapshot } from '@tldraw/tlsync'
|
||||||
|
import { notFound } from '@tldraw/worker-shared'
|
||||||
import { IRequest } from 'itty-router'
|
import { IRequest } from 'itty-router'
|
||||||
import { getR2KeyForSnapshot } from '../r2'
|
import { getR2KeyForSnapshot } from '../r2'
|
||||||
import { Environment } from '../types'
|
import { Environment } from '../types'
|
||||||
import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseClient'
|
import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseClient'
|
||||||
import { fourOhFour } from '../utils/fourOhFour'
|
|
||||||
import { getSnapshotsTable } from '../utils/getSnapshotsTable'
|
import { getSnapshotsTable } from '../utils/getSnapshotsTable'
|
||||||
import { R2Snapshot } from './createRoomSnapshot'
|
import { R2Snapshot } from './createRoomSnapshot'
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ function generateReponse(roomId: string, data: RoomSnapshot) {
|
||||||
// Returns a snapshot of the room at a given point in time
|
// Returns a snapshot of the room at a given point in time
|
||||||
export async function getRoomSnapshot(request: IRequest, env: Environment): Promise<Response> {
|
export async function getRoomSnapshot(request: IRequest, env: Environment): Promise<Response> {
|
||||||
const roomId = request.params.roomId
|
const roomId = request.params.roomId
|
||||||
if (!roomId) return fourOhFour()
|
if (!roomId) return notFound()
|
||||||
|
|
||||||
// Get the parent slug if it exists
|
// Get the parent slug if it exists
|
||||||
const parentSlug = await env.SNAPSHOT_SLUG_TO_PARENT_SLUG.get(roomId)
|
const parentSlug = await env.SNAPSHOT_SLUG_TO_PARENT_SLUG.get(roomId)
|
||||||
|
@ -53,7 +53,7 @@ export async function getRoomSnapshot(request: IRequest, env: Environment): Prom
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
const data = result.data?.drawing as RoomSnapshot
|
const data = result.data?.drawing as RoomSnapshot
|
||||||
|
|
||||||
if (!data) return fourOhFour()
|
if (!data) return notFound()
|
||||||
|
|
||||||
// Send back the snapshot!
|
// Send back the snapshot!
|
||||||
return generateReponse(roomId, data)
|
return generateReponse(roomId, data)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { ROOM_PREFIX, RoomOpenMode } from '@tldraw/dotcom-shared'
|
import { ROOM_PREFIX, RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||||
|
import { notFound } from '@tldraw/worker-shared'
|
||||||
import { IRequest } from 'itty-router'
|
import { IRequest } from 'itty-router'
|
||||||
import { Environment } from '../types'
|
import { Environment } from '../types'
|
||||||
import { fourOhFour } from '../utils/fourOhFour'
|
|
||||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||||
import { getSlug } from '../utils/roomOpenMode'
|
import { getSlug } from '../utils/roomOpenMode'
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ export async function joinExistingRoom(
|
||||||
roomOpenMode: RoomOpenMode
|
roomOpenMode: RoomOpenMode
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const roomId = await getSlug(env, request.params.roomId, roomOpenMode)
|
const roomId = await getSlug(env, request.params.roomId, roomOpenMode)
|
||||||
if (!roomId) return fourOhFour()
|
if (!roomId) return notFound()
|
||||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||||
|
|
||||||
// This needs to be a websocket request!
|
// This needs to be a websocket request!
|
||||||
|
@ -21,5 +21,5 @@ export async function joinExistingRoom(
|
||||||
return env.TLDR_DOC.get(id).fetch(request)
|
return env.TLDR_DOC.get(id).fetch(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fourOhFour()
|
return notFound()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
export async function fourOhFour() {
|
|
||||||
return new Response('Not found', {
|
|
||||||
status: 404,
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
ROOM_PREFIX,
|
ROOM_PREFIX,
|
||||||
} from '@tldraw/dotcom-shared'
|
} from '@tldraw/dotcom-shared'
|
||||||
import { T } from '@tldraw/validate'
|
import { T } from '@tldraw/validate'
|
||||||
import { createSentry } from '@tldraw/worker-shared'
|
import { createSentry, notFound } from '@tldraw/worker-shared'
|
||||||
import { Router, createCors, json } from 'itty-router'
|
import { Router, createCors, json } from 'itty-router'
|
||||||
import { createRoom } from './routes/createRoom'
|
import { createRoom } from './routes/createRoom'
|
||||||
import { createRoomSnapshot } from './routes/createRoomSnapshot'
|
import { createRoomSnapshot } from './routes/createRoomSnapshot'
|
||||||
|
@ -18,7 +18,6 @@ import { getRoomHistorySnapshot } from './routes/getRoomHistorySnapshot'
|
||||||
import { getRoomSnapshot } from './routes/getRoomSnapshot'
|
import { getRoomSnapshot } from './routes/getRoomSnapshot'
|
||||||
import { joinExistingRoom } from './routes/joinExistingRoom'
|
import { joinExistingRoom } from './routes/joinExistingRoom'
|
||||||
import { Environment } from './types'
|
import { Environment } from './types'
|
||||||
import { fourOhFour } from './utils/fourOhFour'
|
|
||||||
import { unfurl } from './utils/unfurl'
|
import { unfurl } from './utils/unfurl'
|
||||||
export { TLDrawDurableObject } from './TLDrawDurableObject'
|
export { TLDrawDurableObject } from './TLDrawDurableObject'
|
||||||
|
|
||||||
|
@ -51,7 +50,7 @@ const router = Router()
|
||||||
return json(await unfurl(req.query.url))
|
return json(await unfurl(req.query.url))
|
||||||
})
|
})
|
||||||
.post(`/${ROOM_PREFIX}/:roomId/restore`, forwardRoomRequest)
|
.post(`/${ROOM_PREFIX}/:roomId/restore`, forwardRoomRequest)
|
||||||
.all('*', fourOhFour)
|
.all('*', notFound)
|
||||||
|
|
||||||
const Worker = {
|
const Worker = {
|
||||||
fetch(request: Request, env: Environment, context: ExecutionContext) {
|
fetch(request: Request, env: Environment, context: ExecutionContext) {
|
||||||
|
|
3
packages/worker-shared/src/errors.ts
Normal file
3
packages/worker-shared/src/errors.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export function notFound() {
|
||||||
|
return Response.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
}
|
|
@ -1,4 +1,6 @@
|
||||||
/// <reference no-default-lib="true"/>
|
/// <reference no-default-lib="true"/>
|
||||||
/// <reference types="@cloudflare/workers-types" />
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
export { notFound } from './errors'
|
||||||
export { createSentry } from './sentry'
|
export { createSentry } from './sentry'
|
||||||
|
export { handleUserAssetGet, handleUserAssetUpload } from './userAssetUploads'
|
||||||
|
|
75
packages/worker-shared/src/userAssetUploads.ts
Normal file
75
packages/worker-shared/src/userAssetUploads.ts
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import { ExecutionContext, R2Bucket } from '@cloudflare/workers-types'
|
||||||
|
import { notFound } from './errors'
|
||||||
|
|
||||||
|
interface UserAssetOpts {
|
||||||
|
request: Request
|
||||||
|
bucket: R2Bucket
|
||||||
|
objectName: string
|
||||||
|
context: ExecutionContext
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUserAssetUpload({
|
||||||
|
request,
|
||||||
|
bucket,
|
||||||
|
objectName,
|
||||||
|
}: UserAssetOpts): Promise<Response> {
|
||||||
|
if (await bucket.head(objectName)) {
|
||||||
|
return Response.json({ error: 'Asset already exists' }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const object = await bucket.put(objectName, request.body, {
|
||||||
|
httpMetadata: request.headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({ object: objectName }, { headers: { etag: object.httpEtag } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUserAssetGet({ request, bucket, objectName, context }: UserAssetOpts) {
|
||||||
|
const cacheUrl = new URL(request.url)
|
||||||
|
const cacheKey = new Request(cacheUrl.toString(), request)
|
||||||
|
|
||||||
|
// this cache automatically handles range responses etc.
|
||||||
|
const cachedResponse = await caches.default.match(cacheKey)
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
const object = await bucket.get(objectName, {
|
||||||
|
range: request.headers,
|
||||||
|
onlyIf: request.headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!object) {
|
||||||
|
return notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers()
|
||||||
|
object.writeHttpMetadata(headers)
|
||||||
|
headers.set('etag', object.httpEtag)
|
||||||
|
if (object.range) {
|
||||||
|
let start
|
||||||
|
let end
|
||||||
|
if ('suffix' in object.range) {
|
||||||
|
start = object.size - object.range.suffix
|
||||||
|
end = object.size - 1
|
||||||
|
} else {
|
||||||
|
start = object.range.offset ?? 0
|
||||||
|
end = object.range.length ? start + object.range.length - 1 : object.size - 1
|
||||||
|
}
|
||||||
|
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')
|
||||||
|
|
||||||
|
const body = 'body' in object && object.body ? object.body : null
|
||||||
|
const status = body ? (request.headers.get('range') !== null ? 206 : 200) : 304
|
||||||
|
|
||||||
|
if (status === 200) {
|
||||||
|
const [cacheBody, responseBody] = body!.tee()
|
||||||
|
// cache the response
|
||||||
|
context.waitUntil(caches.default.put(cacheKey, new Response(cacheBody, { headers, status })))
|
||||||
|
return new Response(responseBody, { headers, status })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(body, { headers, status })
|
||||||
|
}
|
|
@ -30,6 +30,7 @@ const dotcom = path.relative(process.cwd(), path.resolve(__dirname, '../apps/dot
|
||||||
const env = makeEnv([
|
const env = makeEnv([
|
||||||
'APP_ORIGIN',
|
'APP_ORIGIN',
|
||||||
'ASSET_UPLOAD',
|
'ASSET_UPLOAD',
|
||||||
|
'ASSET_UPLOAD_SENTRY_DSN',
|
||||||
'ASSET_BUCKET_ORIGIN',
|
'ASSET_BUCKET_ORIGIN',
|
||||||
'CLOUDFLARE_ACCOUNT_ID',
|
'CLOUDFLARE_ACCOUNT_ID',
|
||||||
'CLOUDFLARE_API_TOKEN',
|
'CLOUDFLARE_API_TOKEN',
|
||||||
|
@ -164,8 +165,9 @@ async function prepareDotcomApp() {
|
||||||
|
|
||||||
let didUpdateAssetUploadWorker = false
|
let didUpdateAssetUploadWorker = false
|
||||||
async function deployAssetUploadWorker({ dryRun }: { dryRun: boolean }) {
|
async function deployAssetUploadWorker({ dryRun }: { dryRun: boolean }) {
|
||||||
|
const workerId = `${previewId ?? env.TLDRAW_ENV}-tldraw-assets`
|
||||||
if (previewId && !didUpdateAssetUploadWorker) {
|
if (previewId && !didUpdateAssetUploadWorker) {
|
||||||
await setWranglerPreviewConfig(assetUpload, { name: `${previewId}-tldraw-assets` })
|
await setWranglerPreviewConfig(assetUpload, { name: workerId })
|
||||||
didUpdateAssetUploadWorker = true
|
didUpdateAssetUploadWorker = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -173,7 +175,15 @@ async function deployAssetUploadWorker({ dryRun }: { dryRun: boolean }) {
|
||||||
location: assetUpload,
|
location: assetUpload,
|
||||||
dryRun,
|
dryRun,
|
||||||
env: env.TLDRAW_ENV,
|
env: env.TLDRAW_ENV,
|
||||||
vars: {},
|
vars: {
|
||||||
|
SENTRY_DSN: env.ASSET_UPLOAD_SENTRY_DSN,
|
||||||
|
TLDRAW_ENV: env.TLDRAW_ENV,
|
||||||
|
WORKER_NAME: workerId,
|
||||||
|
},
|
||||||
|
sentry: {
|
||||||
|
project: 'asset-upload-worker',
|
||||||
|
authToken: env.SENTRY_AUTH_TOKEN,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10312,6 +10312,7 @@ __metadata:
|
||||||
resolution: "dotcom-asset-upload@workspace:apps/dotcom-asset-upload"
|
resolution: "dotcom-asset-upload@workspace:apps/dotcom-asset-upload"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@cloudflare/workers-types": "npm:^4.20240620.0"
|
"@cloudflare/workers-types": "npm:^4.20240620.0"
|
||||||
|
"@tldraw/worker-shared": "workspace:*"
|
||||||
"@types/ws": "npm:^8.5.9"
|
"@types/ws": "npm:^8.5.9"
|
||||||
itty-cors: "npm:^0.3.4"
|
itty-cors: "npm:^0.3.4"
|
||||||
itty-router: "npm:^4.0.13"
|
itty-router: "npm:^4.0.13"
|
||||||
|
|
Loading…
Reference in a new issue