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 /** Original file bytes (empty when formatId is null) */
20 bytes: Uint8Array<ArrayBuffer>
21}
23const HASH_PREFIX = '#share='
25/**
26 * Longest URL we are willing to produce. Browsers themselves accept far
27 * longer, but links beyond roughly 64k characters commonly get truncated or
28 * de-linkified by messengers, email clients, and older tooling.
29 */
30export const MAX_SHARE_URL_CHARS = 65000
32interface ShareHeader {
33 v: number
34 name: string
35 format: string | null
36 export: string
37 view: string
38}
40async function pipeThrough(
41 bytes: Uint8Array<ArrayBuffer>,
42 transform: CompressionStream | DecompressionStream,
43): Promise<Uint8Array<ArrayBuffer>> {
44 const stream = new Blob([bytes]).stream().pipeThrough(transform)
45 return new Uint8Array(await new Response(stream).arrayBuffer())
46}
48function toBase64Url(bytes: Uint8Array): string {
49 let binary = ''
50 const chunk = 0x8000 // keep String.fromCharCode argument counts sane
51 for (let i = 0; i < bytes.length; i += chunk) {
52 binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
53 }
54 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
55}
57function fromBase64Url(encoded: string): Uint8Array<ArrayBuffer> {
58 const b64 = encoded.replace(/-/g, '+').replace(/_/g, '/')
59 const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
60 const binary = atob(padded)
61 const bytes = new Uint8Array(binary.length)
62 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
63 return bytes
64}
66export async function encodeShare(payload: SharePayload): Promise<string> {
67 const header: ShareHeader = {
68 v: 1,
69 name: payload.name,
70 format: payload.formatId,
71 export: payload.exportFormatId,
72 view: payload.viewMode,
73 }
74 const headerBytes = new TextEncoder().encode(JSON.stringify(header))
75 const container = new Uint8Array(4 + headerBytes.length + payload.bytes.length)
76 new DataView(container.buffer).setUint32(0, headerBytes.length, true)
77 container.set(headerBytes, 4)
78 container.set(payload.bytes, 4 + headerBytes.length)
79 const compressed = await pipeThrough(container, new CompressionStream('deflate-raw'))
80 return toBase64Url(compressed)
81}
83export async function decodeShare(encoded: string): Promise<SharePayload> {
84 let container: Uint8Array<ArrayBuffer>
85 try {
86 container = await pipeThrough(fromBase64Url(encoded), new DecompressionStream('deflate-raw'))
87 } catch {
88 throw new Error('the share data in the URL is damaged or truncated')
89 }
90 if (container.length < 4) throw new Error('the share data in the URL is truncated')
91 const headerLength = new DataView(container.buffer, container.byteOffset, 4).getUint32(0, true)
92 if (4 + headerLength > container.length) {
93 throw new Error('the share data in the URL is truncated')
94 }
95 let header: ShareHeader
96 try {
97 header = JSON.parse(new TextDecoder().decode(container.subarray(4, 4 + headerLength)))
98 } catch {
99 throw new Error('the share data in the URL is not valid')
100 }
101 if (header.v !== 1 || typeof header.name !== 'string' || typeof header.export !== 'string') {
102 throw new Error('this share link was made by an incompatible version of the app')
103 }
104 return {
105 name: header.name,
106 formatId: header.format ?? null,
107 exportFormatId: header.export,
108 viewMode: header.view,
109 bytes: container.slice(4 + headerLength),
110 }
111}
113/** Full shareable URL for the current page, e.g. "https://…/index.html#share=…". */
114export async function buildShareUrl(payload: SharePayload): Promise<string> {
115 const base = window.location.href.split('#')[0]
116 return base + HASH_PREFIX + (await encodeShare(payload))
117}
119/** Decode a location.hash; null if it is not a share link. */
120export async function parseShareHash(hash: string): Promise<SharePayload | null> {
121 if (!hash.startsWith(HASH_PREFIX)) return null
122 return decodeShare(hash.slice(HASH_PREFIX.length))
123}