Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/content/sdk-features/assets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ Custom asset types follow the same storage lifecycle as built-in types. Your upl

Asset records use the migration system to evolve their schema. Each asset type has its own migration sequence that handles adding properties, renaming fields, and validating data. When you load a document with old asset records, migrations transform them to the current schema automatically.

Validators ensure asset data matches the expected structure at runtime. The [assetValidator](?) uses a discriminated union on the `type` field. If you're creating custom asset types, create validators following the same pattern and add migration sequences to handle schema changes.
Validators ensure asset data matches the expected structure at runtime. Use [createAssetValidator](?) to build a validator for your custom asset type—it produces a discriminated union on the `type` field. Add migration sequences to handle schema changes over time.

## Security

Expand Down
33 changes: 32 additions & 1 deletion apps/dotcom/sync-worker/src/healthCheckRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createRouter, notFound } from '@tldraw/worker-shared'
import { sql } from 'kysely'
import { createPostgresConnectionPool } from './postgres'
import { isDebugLogging, type Environment } from './types'
import { getStatsDurableObjct } from './utils/durableObjects'
Expand Down Expand Up @@ -48,8 +49,9 @@ export const healthCheckRoutes = createRouter<Environment>()
}
})
.get('/health-check/db', async (_, env) => {
const db = createPostgresConnectionPool(env, '/health-check/db')
try {
await createPostgresConnectionPool(env, '/health-check/db')
await db
.selectFrom('user')
.select('name')
.where('email', '=', 'mitja@tldraw.com')
Expand All @@ -58,6 +60,35 @@ export const healthCheckRoutes = createRouter<Environment>()
return new Response('ok', { status: 200 })
} catch (_e) {
return new Response('Could not reach the database', { status: 500 })
} finally {
await db.destroy()
}
})
.get('/health-check/zero-replicator', async (_, env) => {
const db = createPostgresConnectionPool(env, '/health-check/zero-replicator')
try {
const result = await sql<{ status: string }>`
SELECT
CASE
WHEN write_lsn IS NULL THEN 'STALLED'
WHEN write_lag > interval '1 minute' THEN 'LAGGING'
ELSE 'HEALTHY'
END AS status
FROM pg_stat_replication
WHERE application_name = 'zero-replicator'
`.execute(db)
if (result.rows.length === 0) {
return new Response('zero-replicator not connected', { status: 500 })
}
const status = result.rows[0].status
if (status !== 'HEALTHY') {
return new Response(`zero-replicator: ${status}`, { status: 500 })
}
return new Response('ok', { status: 200 })
} catch (_e) {
return new Response('Could not check zero-replicator status', { status: 500 })
} finally {
await db.destroy()
}
})
.all('*', notFound)
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
import {
AssetUtil,
BaseBoxShapeUtil,
HTMLContainer,
T,
TLAsset,
TLAssetId,
TLBaseAsset,
TLShape,
TLShapePartial,
Tldraw,
VecModel,
createShapeId,
toRichText,
} from 'tldraw'
import 'tldraw/tldraw.css'

// There's a guide at the bottom of this file!

// --- Custom asset type ---

// [1]
const FILE_ASSET_TYPE = 'file' as const

interface FileAssetProps {
name: string
size: number
mimeType: string
src: string | null
}

type TLFileAsset = TLBaseAsset<typeof FILE_ASSET_TYPE, FileAssetProps>

declare module 'tldraw' {
interface TLGlobalAssetPropsMap {
[FILE_ASSET_TYPE]: FileAssetProps
}
}

// [2]
class FileAssetUtil extends AssetUtil<TLFileAsset> {
static override type = FILE_ASSET_TYPE

static supportedMimeTypes = [
'application/pdf',
'text/plain',
'text/csv',
'application/json',
'application/zip',
'application/xml',
'text/xml',
] as const

static supportedExtensions = ['.pdf', '.txt', '.csv', '.json', '.zip', '.xml'] as const

// [3]
static override props = {
name: T.string,
size: T.number,
mimeType: T.string,
src: T.string.nullable(),
}

override getDefaultProps(): TLFileAsset['props'] {
return {
name: '',
size: 0,
mimeType: '',
src: null,
}
}

// [4]
override getSupportedMimeTypes() {
return [...FileAssetUtil.supportedMimeTypes]
}

// [5]
override async getAssetFromFile(file: File, assetId: TLAssetId): Promise<TLFileAsset> {
return {
id: assetId,
type: FILE_ASSET_TYPE,
typeName: 'asset',
props: {
name: file.name,
size: file.size,
mimeType: file.type,
src: null,
},
meta: {},
}
}
}

// --- Custom shape to display file assets ---

const FILE_CARD_TYPE = 'file-card' as const

declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[FILE_CARD_TYPE]: {
assetId: TLAssetId | null
w: number
h: number
}
}
}

type FileCardShape = TLShape<typeof FILE_CARD_TYPE>

function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
const value = bytes / Math.pow(1024, i)
return `${value % 1 === 0 ? value : value.toFixed(1)} ${units[i]}`
}

// [6]
class FileCardShapeUtil extends BaseBoxShapeUtil<FileCardShape> {
static override type = FILE_CARD_TYPE
static override handledAssetTypes = [FILE_ASSET_TYPE] as const

override getDefaultProps() {
return {
assetId: null as TLAssetId | null,
w: 200,
h: 64,
}
}

// [7]
override createShapeForAsset(asset: TLAsset, position: VecModel): TLShapePartial {
return {
id: createShapeId(),
type: FILE_CARD_TYPE,
x: position.x,
y: position.y,
props: {
assetId: asset.id,
w: 200,
h: 64,
},
}
}

override component(shape: FileCardShape) {
const asset = shape.props.assetId
? (this.editor.getAsset(shape.props.assetId) as unknown as TLFileAsset | undefined)
: null

const name = asset?.props.name ?? 'Unknown file'
const size = asset?.props.size ?? 0
const src = asset?.props.src

return (
<HTMLContainer>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
background: 'var(--color-background)',
border: '1px solid var(--color-muted-2)',
borderRadius: 8,
height: '100%',
boxSizing: 'border-box',
fontFamily: 'sans-serif',
overflow: 'hidden',
}}
>
<div style={{ fontSize: 24, flexShrink: 0 }}>📄</div>
<div style={{ overflow: 'hidden', flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
color: 'var(--color-text-1)',
}}
>
{/* [8] */}
{src ? (
<a
href={src}
target="_blank"
rel="noreferrer"
style={{ color: 'inherit', textDecoration: 'underline' }}
>
{name}
</a>
) : (
name
)}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-3)', marginTop: 2 }}>
{formatFileSize(size)}
</div>
</div>
</div>
</HTMLContainer>
)
}

override indicator(shape: FileCardShape) {
return <rect width={shape.props.w} height={shape.props.h} rx={8} ry={8} />
}
}

// [9]
export default function CustomAssetTypeExample() {
const instructionText = `Drag a file with these supported extensions ${FileAssetUtil.supportedExtensions.join(', ')} onto the board`

return (
<div className="tldraw__editor">
<Tldraw
assetUtils={[FileAssetUtil]}
shapeUtils={[FileCardShapeUtil]}
persistenceKey="custom-asset-type-example"
onMount={(editor) => {
if (editor.getCurrentPageShapes().length === 0) {
editor.createShapes([
{
id: createShapeId(),
type: 'text',
x: 100,
y: 100,
props: {
richText: toRichText(instructionText),
},
},
])
}
}}
/>
</div>
)
}

/*
This example shows how to use AssetUtil and ShapeUtil together to add support for
non-media file types. By default, tldraw supports images, videos, and bookmarks.
With a custom AssetUtil and ShapeUtil, you can handle any file type—like PDFs, CSVs,
or text files—and display them on the canvas with a custom shape.

[1]
Define a custom asset type using TLBaseAsset. The props describe what information we
store for each file: its name, size, MIME type, and a source URL for downloading.
We augment TLGlobalAssetPropsMap so that TLAsset includes our custom type.

[2]
FileAssetUtil extends AssetUtil and tells the editor how to handle our custom asset type.
It handles file-to-asset conversion and MIME type matching.

[3]
Static props define the schema validators for the asset's properties. These use
validators from T (e.g. T.string, T.number) for store validation.

[4]
getSupportedMimeTypes returns the MIME types this asset util handles. When a user drags
a file onto the canvas, the editor checks each registered AssetUtil to find one that
accepts the file's MIME type.

[5]
getAssetFromFile creates an asset record from a dropped file. This is called during the
file-handling pipeline to extract metadata before upload. The src is left as null here
because TLAssetStore.upload will provide the URL after the file is stored.

[6]
FileCardShapeUtil declares handledAssetTypes to tell the editor that this shape can be
created from file assets. It renders files as cards on the canvas.

[7]
createShapeForAsset returns a shape partial that the editor places on the canvas when
this asset is created. The shape util declares which asset types it handles, and the
editor calls this method to produce the shape.

[8]
If the asset has a src URL, the filename becomes a clickable download link.

[9]
We pass both the custom AssetUtil and ShapeUtil to the Tldraw component. The assetUtils
prop registers our FileAssetUtil alongside the default image, video, and bookmark utils.
No custom file handler is needed — the default handler automatically uses our AssetUtil
for matching MIME types and uploads files via TLAssetStore. In a real app, you'd provide
a custom TLAssetStore via the `assets` prop to upload files to your server (see the
"hosted images" example). Here we use the default store which inlines files as data URLs.

Try it: drag a PDF, text file, or CSV onto the canvas!
*/
12 changes: 12 additions & 0 deletions apps/examples/src/examples/data/assets/custom-asset-type/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
title: Custom asset type
component: ./CustomAssetTypeExample.tsx
keywords: [asset, assetutil, custom, file, upload, drag, drop, pdf, document, non-media]
priority: 4
---

Add support for non-media file uploads using a custom `AssetUtil`.

---

This example shows how to create a custom asset type to support dragging non-media files (like PDFs, CSVs, or text files) onto the canvas. It defines a custom `AssetUtil` and a custom shape to display the uploaded files.
4 changes: 2 additions & 2 deletions internal/scripts/deploy-dotcom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ const zeroConnectionLimits = {
vs: { upstream: 2, cvr: 3, change: 1 },
},
production: {
rm: { upstream: 1, cvr: 3, change: 5 },
rm: { upstream: 5, cvr: 10, change: 15 },
vs: { upstream: 8, cvr: 10, change: 3 },
},
// Previews use Supabase branch DB
Expand Down Expand Up @@ -669,7 +669,7 @@ async function vercelCli(command: string, args: string[], opts?: ExecOpts) {

function withStatementTimeout(connString: string): string {
const separator = connString.includes('?') ? '&' : '?'
return `${connString}${separator}statement_timeout=0`
return `${connString}${separator}statement_timeout=1800000`
}

function updateFlyioToml(appName: string): void {
Expand Down
Loading
Loading