Skip to content

Commit 0822e11

Browse files
committed
Compress share links using Zstandard.
1 parent 8149b2e commit 0822e11

File tree

10 files changed

+552
-232
lines changed

10 files changed

+552
-232
lines changed

build.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const options = {
2929
'globalThis.GIT_COMMIT': `"${mode === 'minify' ? gitCommit : 'HEAD'}"`,
3030
'globalThis.IS_PRODUCTION': (mode === 'minify' ? 'true' : 'false'),
3131
},
32-
target: 'es2021',
32+
target: 'es2022',
3333
format: 'esm',
3434
sourcemap: 'linked',
3535
minify: (mode === 'minify'),

package-lock.json

Lines changed: 67 additions & 208 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,26 @@
1818
"watch": "node build.mjs watch",
1919
"serve": "node build.mjs serve"
2020
},
21-
"devDependencies": {
21+
"dependencies": {
2222
"@chialab/esbuild-plugin-meta-url": "^0.18.0",
2323
"@emotion/react": "^11.11.3",
2424
"@emotion/styled": "^11.11.0",
2525
"@fontsource/inter": "^5.0.16",
2626
"@mui/icons-material": "^5.15.10",
2727
"@mui/joy": "^5.0.0-beta.28",
28-
"@types/d3": "^7.4.3",
29-
"@types/react": "^18.2.55",
30-
"@types/react-dom": "^18.2.19",
3128
"@yowasp/yosys": "release",
29+
"core-js": "^3.46.0",
3230
"d3-wave": "^1.1.5",
33-
"esbuild": "^0.25.0",
3431
"monaco-editor": "^0.46.0",
3532
"pyodide": "^0.25.0",
3633
"react": "^18.0.0",
37-
"react-dom": "^18.0.0",
34+
"react-dom": "^18.0.0"
35+
},
36+
"devDependencies": {
37+
"@types/d3": "^7.4.3",
38+
"@types/react": "^18.2.55",
39+
"@types/react-dom": "^18.2.19",
40+
"esbuild": "^0.25.0",
3841
"typescript": "^5.9.3"
3942
}
4043
}

src/app.tsx

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import 'core-js/modules/es.uint8-array.from-base64';
2+
import 'core-js/modules/es.uint8-array.to-base64';
3+
14
import { createRoot } from 'react-dom/client';
25

36
import * as React from 'react';
@@ -13,6 +16,7 @@ import IconButton from '@mui/joy/IconButton';
1316
import Select from '@mui/joy/Select';
1417
import Option from '@mui/joy/Option';
1518
import Link from '@mui/joy/Link';
19+
import CircularProgress from '@mui/joy/CircularProgress';
1620
import Snackbar from '@mui/joy/Snackbar';
1721
import Alert from '@mui/joy/Alert';
1822
import Tabs from '@mui/joy/Tabs';
@@ -30,26 +34,90 @@ import * as monaco from 'monaco-editor';
3034
import { EditorState, Editor } from './monaco';
3135
import { Viewer as WaveformViewer } from './d3wave';
3236
import { PythonError, runner } from './runner';
37+
import { compress, decompress } from './zstd';
3338
import data from './config';
3439

3540
import './app.css';
3641

37-
function stealHashQuery() {
42+
interface SharePayload {
43+
av: string;
44+
s: string;
45+
}
46+
47+
const zstdMagic = new Uint8Array([0x28, 0xb5, 0x2f, 0xfd]);
48+
49+
const getSharePayloadDictionary = (() => {
50+
let promise: Promise<Uint8Array> | undefined;
51+
return async () => {
52+
return promise ??=
53+
fetch(new URL('zstd/dictionary.bin', import.meta.url).toString())
54+
.then((response) => response.arrayBuffer())
55+
.then((buffer) => new Uint8Array(buffer));
56+
};
57+
})();
58+
59+
async function createShareFragment(sourceCode: string, amaranthVersion: string): Promise<string> {
60+
let payload = JSON.stringify({
61+
av: amaranthVersion,
62+
s: sourceCode,
63+
} satisfies SharePayload);
64+
65+
let compressed = await compress(
66+
new TextEncoder().encode(payload),
67+
getSharePayloadDictionary(),
68+
);
69+
70+
// Remove the Zstandard magic bytes and add a version field
71+
let result = new Uint8Array(1 + compressed.length - zstdMagic.length);
72+
result[0] = 1; // version
73+
result.set(compressed.subarray(zstdMagic.length), 1);
74+
75+
return result.toBase64();
76+
}
77+
78+
async function decodeShareFragment(hashQuery: string): Promise<SharePayload> {
79+
if (/^[0-9A-Za-z+/=]+$/.test(hashQuery)) {
80+
let bytes = Uint8Array.fromBase64(hashQuery);
81+
// Check the version
82+
if (bytes[0] === 1) {
83+
let zstdPayload = bytes.subarray(1);
84+
if (indexedDB.cmp(bytes.subarray(0, 4), zstdMagic) !== 0) {
85+
zstdPayload = (() => {
86+
let result = new Uint8Array(zstdMagic.length + zstdPayload.length);
87+
result.set(zstdMagic);
88+
result.set(zstdPayload, zstdMagic.length);
89+
return result;
90+
})();
91+
}
92+
bytes = await decompress(
93+
zstdPayload,
94+
getSharePayloadDictionary(),
95+
);
96+
} else if (bytes[0] !== '{'.charCodeAt(0)) {
97+
return;
98+
}
99+
return JSON.parse(new TextDecoder().decode(bytes));
100+
} else {
101+
// Legacy encoding, used 2024-02-16 to 2024-02-24.
102+
return JSON.parse(decodeURIComponent(hashQuery.replace('+', '%20')));
103+
}
104+
}
105+
106+
async function stealHashQuery() {
38107
const { hash } = window.location;
39108
if (hash !== '') {
40109
history.replaceState(null, '', ' '); // remove #... from URL entirely
41110
const hashQuery = hash.substring(1);
42111
try {
43-
return JSON.parse(atob(hashQuery));
44-
} catch {
45-
try {
46-
// Legacy encoding, used 2024-02-16 to 2024-02-24.
47-
return JSON.parse(decodeURIComponent(hashQuery.replace('+', '%20')));
48-
} catch {}
112+
return decodeShareFragment(hashQuery);
113+
} catch (error) {
114+
console.warn('Could not parse the URL fragment, ignoring.', error);
49115
}
50116
}
51117
}
52118

119+
const query: { av?: string, s?: string } | undefined = await stealHashQuery();
120+
53121
interface TerminalChunk {
54122
stream: 'stdout' | 'stderr';
55123
text: string;
@@ -64,14 +132,14 @@ function AppContent() {
64132
const {mode, setMode} = useColorScheme();
65133
useEffect(() => monaco.editor.setTheme(mode === 'light' ? 'vs' : 'vs-dark'), [mode]);
66134

67-
const query: { av?: string, s?: string } | undefined = stealHashQuery();
68135
const [amaranthVersion, setAmaranthVersion] = useState(
69136
query?.av
70137
?? localStorage.getItem('amaranth-playground.amaranthVersion')
71138
?? data.amaranthVersions[0]);
72139
useEffect(() => localStorage.setItem('amaranth-playground.amaranthVersion', amaranthVersion), [amaranthVersion]);
73140
const [running, setRunning] = useState(false);
74141
const [sharingOpen, setSharingOpen] = useState(false);
142+
const [shareURL, setShareURL] = useState('');
75143
const [tutorialDone, setTutorialDone] = useState(localStorage.getItem('amaranth-playground.tutorialDone') !== null);
76144
useEffect(() => tutorialDone ? localStorage.setItem('amaranth-playground.tutorialDone', '') : void 0, [tutorialDone]);
77145
const [activeTab, setActiveTab] = useState(tutorialDone ? 'amaranth-source' : 'tutorial');
@@ -160,6 +228,14 @@ function AppContent() {
160228
const runCodeRef = useRef(runCode);
161229
runCodeRef.current = runCode;
162230

231+
async function shareCode() {
232+
setSharingOpen(true);
233+
setShareURL('');
234+
let fragment = await createShareFragment(amaranthSource, amaranthVersion);
235+
let url = new URL('#' + fragment, window.location.href).toString();
236+
setShareURL(url);
237+
}
238+
163239
const amaranthSourceEditorActions = React.useMemo(() => [
164240
{
165241
id: 'amaranth-playground.run',
@@ -472,7 +548,7 @@ function AppContent() {
472548
color='neutral'
473549
variant='outlined'
474550
endDecorator={<ShareIcon/>}
475-
onClick={() => setSharingOpen(true)}
551+
onClick={() => shareCode()}
476552
>
477553
Share
478554
</Button>
@@ -482,14 +558,11 @@ function AppContent() {
482558
open={sharingOpen}
483559
onClose={(_event, _reason) => setSharingOpen(false)}
484560
>
485-
<Link href={
486-
// base64 overhead is fixed at 33%, urlencode overhead is variable, typ. 133% (!)
487-
new URL('#' + btoa(JSON.stringify({
488-
av: amaranthVersion, s: amaranthSource
489-
})), window.location.href).toString()
490-
}>
491-
Copy this link to share the source code
492-
</Link>
561+
{shareURL === '' ? <CircularProgress /> : (
562+
<Link href={shareURL}>
563+
Copy this link to share the source code
564+
</Link>
565+
)}
493566
</Snackbar>
494567

495568
<IconButton

src/types.d.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
interface Uint8Array<TArrayBuffer extends ArrayBufferLike> {
2+
/**
3+
* Converts the `Uint8Array` to a base64-encoded string.
4+
* @param options If provided, sets the alphabet and padding behavior used.
5+
* @returns A base64-encoded string.
6+
*/
7+
toBase64(
8+
options?: {
9+
alphabet?: "base64" | "base64url" | undefined;
10+
omitPadding?: boolean | undefined;
11+
},
12+
): string;
13+
}
14+
15+
interface Uint8ArrayConstructor {
16+
/**
17+
* Creates a new `Uint8Array` from a base64-encoded string.
18+
* @param string The base64-encoded string.
19+
* @param options If provided, specifies the alphabet and handling of the last chunk.
20+
* @returns A new `Uint8Array` instance.
21+
* @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last
22+
* chunk is inconsistent with the `lastChunkHandling` option.
23+
*/
24+
fromBase64(
25+
string: string,
26+
options?: {
27+
alphabet?: "base64" | "base64url" | undefined;
28+
lastChunkHandling?: "loose" | "strict" | "stop-before-partial" | undefined;
29+
},
30+
): Uint8Array<ArrayBuffer>;
31+
}

src/zstd/dictionary.bin

5 KB
Binary file not shown.

src/zstd/index.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
let modules = new Map<string, Promise<WebAssembly.Module>>();
2+
3+
async function createWasmInstance(moduleURL: string) {
4+
let instance: WebAssembly.Instance;
5+
if (!modules.has(moduleURL)) {
6+
let promiseWithResolvers = Promise.withResolvers<WebAssembly.Module>();
7+
modules.set(moduleURL, promiseWithResolvers.promise);
8+
let result = await WebAssembly.instantiateStreaming(fetch(moduleURL));
9+
instance = result.instance;
10+
promiseWithResolvers.resolve(result.module);
11+
} else {
12+
instance = await WebAssembly.instantiate(await modules.get(moduleURL));
13+
}
14+
return {
15+
instance,
16+
exports: instance.exports as {
17+
memory: WebAssembly.Memory;
18+
19+
allocate: (size: number) => number;
20+
21+
compress: (
22+
decompressed_data: number, decompressed_size: number,
23+
dictionary_data: number, dictionary_size: number,
24+
) => number;
25+
26+
decompress: (
27+
compressed_data: number, compressed_size: number,
28+
dictionary_data: number, dictionary_size: number,
29+
) => number;
30+
31+
get_result_error: (result: number) => number;
32+
get_result_size: (result: number) => number;
33+
get_result_data: (result: number) => number;
34+
}
35+
};
36+
}
37+
38+
export async function compress(data: Uint8Array, dictionaryPromise: Promise<Uint8Array>) {
39+
let [{ exports }, dictionary] = await Promise.all([
40+
createWasmInstance(new URL('zstd-wrapper.wasm', import.meta.url).toString()),
41+
dictionaryPromise,
42+
]);
43+
44+
let {
45+
memory, allocate, compress,
46+
get_result_error,
47+
get_result_size,
48+
get_result_data,
49+
} = exports;
50+
51+
let dictionaryPtr = allocate(dictionary.byteLength);
52+
new Uint8Array(memory.buffer, dictionaryPtr, dictionary.byteLength).set(dictionary);
53+
54+
let dataPtr = allocate(data.byteLength);
55+
new Uint8Array(memory.buffer, dataPtr, data.byteLength).set(data);
56+
57+
let resultPtr = compress(
58+
dataPtr, data.byteLength,
59+
dictionaryPtr, dictionary.byteLength,
60+
);
61+
62+
let error = get_result_error(resultPtr);
63+
let size = get_result_size(resultPtr);
64+
let compressed_data = get_result_data(resultPtr);
65+
66+
if (error !== 0) {
67+
throw new Error(`Zstandard compression error ${error}`);
68+
}
69+
70+
return new Uint8Array(memory.buffer, compressed_data, size);
71+
}
72+
73+
export async function decompress(data: Uint8Array, dictionaryPromise: Promise<Uint8Array>) {
74+
let [{ exports }, dictionary] = await Promise.all([
75+
createWasmInstance(new URL('zstd-wrapper.wasm', import.meta.url).toString()),
76+
dictionaryPromise,
77+
]);
78+
79+
let {
80+
memory, allocate, decompress,
81+
get_result_error,
82+
get_result_size,
83+
get_result_data,
84+
} = exports;
85+
86+
let dictionaryPtr = allocate(dictionary.byteLength);
87+
new Uint8Array(memory.buffer, dictionaryPtr, dictionary.byteLength).set(dictionary);
88+
89+
let dataPtr = allocate(data.byteLength);
90+
new Uint8Array(memory.buffer, dataPtr, data.byteLength).set(data);
91+
92+
let resultPtr = decompress(
93+
dataPtr, data.byteLength,
94+
dictionaryPtr, dictionary.byteLength,
95+
);
96+
97+
let error = get_result_error(resultPtr);
98+
let size = get_result_size(resultPtr);
99+
let compressed_data = get_result_data(resultPtr);
100+
101+
if (error !== 0) {
102+
throw new Error(`Zstandard decompression error ${error}`);
103+
}
104+
105+
return new Uint8Array(memory.buffer, compressed_data, size);
106+
}

0 commit comments

Comments
 (0)