clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / apps / editor / src / persistence.js
persistence.js 10.5K
1import { normalizeMetadata, normalizePresentation } from './state.js'2import { APPEARANCES } from './client-svg.js'34export const API_PREFIX = '/api/v1'5export const ALIAS_ID_PATTERN = /^[A-Za-z0-9_-]{16}$/6export const WRITE_CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/78function toBase64Url(bytes) {9 let binary = ''10 for (const byte of bytes) binary += String.fromCharCode(byte)11 return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')12}1314export function generateWriteCapability() {15 return toBase64Url(crypto.getRandomValues(new Uint8Array(32)))16}1718export function aliasIdFromPath(pathname) {19 const match = pathname.match(/^\/d\/([A-Za-z0-9_-]{16})\/?$/)20 return match?.[1] ?? null21}2223export function readAliasUrl(origin, aliasId) {24 if (!ALIAS_ID_PATTERN.test(aliasId)) throw new Error('Invalid diagram alias.')25 return new URL(`/d/${aliasId}`, origin).toString()26}2728export function editAliasUrl(origin, aliasId, writeCapability) {29 if (!WRITE_CAPABILITY_PATTERN.test(writeCapability)) throw new Error('Invalid write capability.')30 const url = new URL(readAliasUrl(origin, aliasId))31 url.hash = `w=${writeCapability}`32 return url.toString()33}3435export function stableRenderUrl(origin, aliasId, format = 'svg', appearance = 'raw') {36 if (!ALIAS_ID_PATTERN.test(aliasId)) throw new Error('Invalid diagram alias.')37 if (format !== 'svg' && format !== 'png') throw new Error('Invalid render format.')38 if (!APPEARANCES.includes(appearance)) throw new Error('Invalid SVG appearance.')39 if (format !== 'svg' && appearance !== 'raw') throw new Error('SVG appearances require SVG format.')40 const url = new URL(`${API_PREFIX}/aliases/${aliasId}/renders/${format}`, origin)41 if (appearance !== 'raw') url.searchParams.set('appearance', appearance)42 return url.toString()43}4445export function writeCapabilityFromHash(hash) {46 const match = hash.match(/^#w=([A-Za-z0-9_-]{43})$/)47 return match?.[1] ?? null48}4950export class PersistenceError extends Error {51 constructor(message, { status, code } = {}) {52 super(message)53 this.status = status54 this.code = code55 }56}5758function openRequestPayload(state) {59 if (!state || typeof state !== 'object' || typeof state.type !== 'string' || typeof state.source !== 'string') {60 throw new Error('Invalid diagram state.')61 }62 return {63 mode: 'open',64 diagram: {65 type: state.type,66 source: state.source,67 options: state.options ?? {},68 presentation: normalizePresentation(state.presentation),69 },70 metadata: normalizeMetadata(state.meta),71 }72}7374function normalizeResponse(value) {75 if (!value || typeof value !== 'object'76 || !ALIAS_ID_PATTERN.test(value.aliasId)77 || !WRITE_CAPABILITY_PATTERN.test(value.contentId)78 || !WRITE_CAPABILITY_PATTERN.test(value.renderId)79 || !Number.isSafeInteger(value.revision)80 || (value.mode !== 'open' && value.mode !== 'locked')) {81 throw new PersistenceError('The persistence service returned an invalid response.', {82 code: 'invalid_response',83 })84 }85 const common = {86 aliasId: value.aliasId,87 contentId: value.contentId,88 renderId: value.renderId,89 revision: value.revision,90 mode: value.mode,91 createdAt: value.createdAt,92 updatedAt: value.updatedAt,93 }94 if (value.mode === 'locked') {95 if (!value.encryptedContent || !value.encryptedMetadata || !value.keyEnvelope) {96 throw new PersistenceError('The persistence service returned an invalid locked diagram.', {97 code: 'invalid_response',98 })99 }100 return {101 ...common,102 encryptedContent: value.encryptedContent,103 encryptedMetadata: value.encryptedMetadata,104 keyEnvelope: value.keyEnvelope,105 }106 }107 if (!value.diagram || typeof value.diagram !== 'object') {108 throw new PersistenceError('The persistence service returned an invalid open diagram.', {109 code: 'invalid_response',110 })111 }112 return {113 ...common,114 state: {115 type: value.diagram.type,116 source: value.diagram.source,117 options: value.diagram.options ?? {},118 presentation: normalizePresentation(value.diagram.presentation),119 meta: normalizeMetadata(value.metadata),120 },121 }122}123124export class PersistenceClient {125 constructor({126 fetchImpl = globalThis.fetch,127 origin = globalThis.location?.origin ?? 'http://localhost',128 apiPrefix = API_PREFIX,129 } = {}) {130 this.fetch = (...args) => fetchImpl(...args)131 this.origin = origin132 this.apiPrefix = apiPrefix.replace(/\/$/, '')133 }134135 url(path = '') {136 return new URL(`${this.apiPrefix}/aliases${path}`, this.origin)137 }138139 async request(url, init) {140 const response = await this.fetch(url, init)141 const body = await response.json().catch(() => null)142 if (!response.ok) {143 throw new PersistenceError(body?.error?.message || `Persistence request failed with HTTP ${response.status}.`, {144 status: response.status,145 code: body?.error?.code,146 })147 }148 return normalizeResponse(body)149 }150151 async errorFor(response) {152 const body = await response.json().catch(() => null)153 return new PersistenceError(body?.error?.message || `Persistence request failed with HTTP ${response.status}.`, {154 status: response.status,155 code: body?.error?.code,156 })157 }158159 async createPayload(payload) {160 const writeCapability = generateWriteCapability()161 const alias = await this.request(this.url(), {162 method: 'POST',163 headers: {164 Authorization: `Bearer ${writeCapability}`,165 'Content-Type': 'application/json',166 },167 body: JSON.stringify(payload),168 })169 return { ...alias, writeCapability }170 }171172 createAlias(state) {173 return this.createPayload(openRequestPayload(state))174 }175176 createLockedAlias(payload) {177 if (payload?.mode !== 'locked') throw new Error('Invalid locked diagram payload.')178 return this.createPayload(payload)179 }180181 getAlias(aliasId) {182 if (!ALIAS_ID_PATTERN.test(aliasId)) throw new Error('Invalid diagram alias.')183 return this.request(this.url(`/${aliasId}`), { headers: { Accept: 'application/json' } })184 }185186 updatePayload(aliasId, payload, revision, writeCapability) {187 if (!ALIAS_ID_PATTERN.test(aliasId)) throw new Error('Invalid diagram alias.')188 if (!Number.isSafeInteger(revision) || revision < 1) throw new Error('Invalid alias revision.')189 if (!WRITE_CAPABILITY_PATTERN.test(writeCapability)) throw new Error('Invalid write capability.')190 return this.request(this.url(`/${aliasId}`), {191 method: 'PUT',192 headers: {193 Accept: 'application/json',194 Authorization: `Bearer ${writeCapability}`,195 'Content-Type': 'application/json',196 'If-Match': `"${revision}"`,197 },198 body: JSON.stringify(payload),199 })200 }201202 updateAlias(aliasId, state, revision, writeCapability) {203 return this.updatePayload(aliasId, openRequestPayload(state), revision, writeCapability)204 }205206 updateLockedAlias(aliasId, payload, revision, writeCapability) {207 if (payload?.mode !== 'locked') throw new Error('Invalid locked diagram payload.')208 return this.updatePayload(aliasId, payload, revision, writeCapability)209 }210211212 async uploadRender({ aliasId, renderId, revision, writeCapability, format, mode, render, renderer }) {213 if (!ALIAS_ID_PATTERN.test(aliasId)) throw new Error('Invalid diagram alias.')214 if (!WRITE_CAPABILITY_PATTERN.test(renderId)) throw new Error('Invalid render ID.')215 if (!Number.isSafeInteger(revision) || revision < 1) throw new Error('Invalid alias revision.')216 if (!WRITE_CAPABILITY_PATTERN.test(writeCapability)) throw new Error('Invalid write capability.')217 if (format !== 'svg' && format !== 'png') throw new Error('Invalid render format.')218 if (mode !== 'open' && mode !== 'locked') throw new Error('Invalid diagram mode.')219 if (!renderer || !/^[a-z][a-z0-9-]{0,31}$/.test(renderer.unit)220 || typeof renderer.build !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/.test(renderer.build)221 || !Array.isArray(renderer.pipeline) || renderer.pipeline.length < 1222 || renderer.pipeline.some(value => !/^[a-z][a-z0-9-]{0,31}$/.test(value))) {223 throw new Error('Invalid renderer identity.')224 }225 const contentType = mode === 'locked'226 ? 'application/json'227 : format === 'svg' ? 'image/svg+xml' : 'image/png'228 const body = mode === 'locked' ? JSON.stringify(render) : render229 const response = await this.fetch(this.url(`/${aliasId}/renders/${format}`), {230 method: 'PUT',231 headers: {232 Authorization: `Bearer ${writeCapability}`,233 'Content-Type': contentType,234 'If-Match': `"${revision}"`,235 'X-Render-Id': renderId,236 'X-Renderer-Unit': renderer.unit,237 'X-Renderer-Build': renderer.build,238 'X-Renderer-Pipeline': renderer.pipeline.join(','),239 },240 body,241 })242 if (!response.ok) throw await this.errorFor(response)243 }244245 async getRenderIdentity(aliasId, format = 'svg', mode = 'open') {246 if (!ALIAS_ID_PATTERN.test(aliasId)) throw new Error('Invalid diagram alias.')247 if (format !== 'svg' && format !== 'png') throw new Error('Invalid render format.')248 if (mode !== 'open' && mode !== 'locked') throw new Error('Invalid diagram mode.')249 const url = this.url(`/${aliasId}/renders/${format}`)250 if (mode === 'locked') url.searchParams.set('encrypted', '1')251 const response = await this.fetch(url, { method: 'HEAD' })252 if (response.status === 404) return null253 if (!response.ok) throw await this.errorFor(response)254 const identity = {255 unit: response.headers.get('X-Renderer-Unit')?.trim().toLowerCase() ?? '',256 build: response.headers.get('X-Renderer-Build')?.trim() ?? '',257 pipeline: (response.headers.get('X-Renderer-Pipeline') ?? '')258 .split(',')259 .map(value => value.trim().toLowerCase())260 .filter(Boolean),261 }262 if (!/^[a-z][a-z0-9-]{0,31}$/.test(identity.unit)263 || !/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/.test(identity.build)264 || identity.pipeline.length < 1265 || identity.pipeline.some(value => !/^[a-z][a-z0-9-]{0,31}$/.test(value))) {266 throw new PersistenceError('The persistence service returned an invalid renderer identity.', {267 code: 'invalid_response',268 })269 }270 return identity271 }272273 async getEncryptedRender(aliasId, format) {274 if (!ALIAS_ID_PATTERN.test(aliasId)) throw new Error('Invalid diagram alias.')275 if (format !== 'svg' && format !== 'png') throw new Error('Invalid render format.')276 const url = this.url(`/${aliasId}/renders/${format}`)277 url.searchParams.set('encrypted', '1')278 const response = await this.fetch(url, { headers: { Accept: 'application/json' } })279 if (!response.ok) throw await this.errorFor(response)280 return response.json()281 }282}