/ concept-collection / mesh-converter
Sign in
concept-collection / mesh-converter
mesh-converter / src / share.ts
129 lines · 4.8 KBBlameHistoryRaw
1/**
2 * Share links: the loaded mesh — as the original file bytes in its original
3 * format — plus viewer state, packed into the URL fragment. Container layout
4 * before compression is [u32 LE header length][JSON header][file bytes];
5 * that is deflate-raw compressed and base64url-encoded after "#share=".
6 * The fragment stays in the browser — nothing is uploaded anywhere.
7 *
8 * This module is deliberately dependency-free (no DOM imports beyond what
9 * Node also provides) so the codec can be exercised outside the browser.
10 */
12export interface SharePayload {
13 /** Base filename without extension */
14 name: string
15 /** meshio format id of `bytes`, or null for the built-in sample mesh */
16 formatId: string | null
17 exportFormatId: string
18 viewMode: string
19 /** Anaglyph (red/cyan 3D glasses) rendering toggle */
20 anaglyph: boolean
21 /** Original file bytes (empty when formatId is null) */
22 bytes: Uint8Array<ArrayBuffer>
25const HASH_PREFIX = '#share='
27/**
28 * Longest URL we are willing to produce. Browsers themselves accept far
29 * longer, but links beyond roughly 64k characters commonly get truncated or
30 * de-linkified by messengers, email clients, and older tooling.
31 */
32export const MAX_SHARE_URL_CHARS = 65000
34interface ShareHeader {
35 v: number
36 name: string
37 format: string | null
38 export: string
39 view: string
40 /** 1 when anaglyph mode is on; absent in links from older versions */
41 ana?: number
44async function pipeThrough(
45 bytes: Uint8Array<ArrayBuffer>,
46 transform: CompressionStream | DecompressionStream,
47): Promise<Uint8Array<ArrayBuffer>> {
48 const stream = new Blob([bytes]).stream().pipeThrough(transform)
49 return new Uint8Array(await new Response(stream).arrayBuffer())
52function toBase64Url(bytes: Uint8Array): string {
53 let binary = ''
54 const chunk = 0x8000 // keep String.fromCharCode argument counts sane
55 for (let i = 0; i < bytes.length; i += chunk) {
56 binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
57 }
58 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
61function fromBase64Url(encoded: string): Uint8Array<ArrayBuffer> {
62 const b64 = encoded.replace(/-/g, '+').replace(/_/g, '/')
63 const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
64 const binary = atob(padded)
65 const bytes = new Uint8Array(binary.length)
66 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
67 return bytes
70export async function encodeShare(payload: SharePayload): Promise<string> {
71 const header: ShareHeader = {
72 v: 1,
73 name: payload.name,
74 format: payload.formatId,
75 export: payload.exportFormatId,
76 view: payload.viewMode,
77 ana: payload.anaglyph ? 1 : 0,
78 }
79 const headerBytes = new TextEncoder().encode(JSON.stringify(header))
80 const container = new Uint8Array(4 + headerBytes.length + payload.bytes.length)
81 new DataView(container.buffer).setUint32(0, headerBytes.length, true)
82 container.set(headerBytes, 4)
83 container.set(payload.bytes, 4 + headerBytes.length)
84 const compressed = await pipeThrough(container, new CompressionStream('deflate-raw'))
85 return toBase64Url(compressed)
88export async function decodeShare(encoded: string): Promise<SharePayload> {
89 let container: Uint8Array<ArrayBuffer>
90 try {
91 container = await pipeThrough(fromBase64Url(encoded), new DecompressionStream('deflate-raw'))
92 } catch {
93 throw new Error('the share data in the URL is damaged or truncated')
94 }
95 if (container.length < 4) throw new Error('the share data in the URL is truncated')
96 const headerLength = new DataView(container.buffer, container.byteOffset, 4).getUint32(0, true)
97 if (4 + headerLength > container.length) {
98 throw new Error('the share data in the URL is truncated')
99 }
100 let header: ShareHeader
101 try {
102 header = JSON.parse(new TextDecoder().decode(container.subarray(4, 4 + headerLength)))
103 } catch {
104 throw new Error('the share data in the URL is not valid')
105 }
106 if (header.v !== 1 || typeof header.name !== 'string' || typeof header.export !== 'string') {
107 throw new Error('this share link was made by an incompatible version of the app')
108 }
109 return {
110 name: header.name,
111 formatId: header.format ?? null,
112 exportFormatId: header.export,
113 viewMode: header.view,
114 anaglyph: header.ana === 1,
115 bytes: container.slice(4 + headerLength),
116 }
119/** Full shareable URL for the current page, e.g. "https://…/index.html#share=…". */
120export async function buildShareUrl(payload: SharePayload): Promise<string> {
121 const base = window.location.href.split('#')[0]
122 return base + HASH_PREFIX + (await encodeShare(payload))
125/** Decode a location.hash; null if it is not a share link. */
126export async function parseShareHash(hash: string): Promise<SharePayload | null> {
127 if (!hash.startsWith(HASH_PREFIX)) return null
128 return decodeShare(hash.slice(HASH_PREFIX.length))
moveopenescclose