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 build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const options = {
'globalThis.GIT_COMMIT': `"${mode === 'minify' ? gitCommit : 'HEAD'}"`,
'globalThis.IS_PRODUCTION': (mode === 'minify' ? 'true' : 'false'),
},
target: 'es2021',
target: 'es2022',
format: 'esm',
sourcemap: 'linked',
minify: (mode === 'minify'),
Expand Down
288 changes: 81 additions & 207 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 9 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,26 @@
"watch": "node build.mjs watch",
"serve": "node build.mjs serve"
},
"devDependencies": {
"dependencies": {
"@chialab/esbuild-plugin-meta-url": "^0.18.0",
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@fontsource/inter": "^5.0.16",
"@mui/icons-material": "^5.15.10",
"@mui/joy": "^5.0.0-beta.28",
"@types/d3": "^7.4.3",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@yowasp/yosys": "release",
"core-js": "^3.46.0",
"d3-wave": "^1.1.5",
"esbuild": "^0.25.0",
"monaco-editor": "^0.46.0",
"pyodide": "^0.25.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@types/d3": "^7.4.3",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"esbuild": "^0.25.0",
"typescript": "^5.9.3"
}
}
107 changes: 90 additions & 17 deletions src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import 'core-js/modules/es.uint8-array.from-base64';
import 'core-js/modules/es.uint8-array.to-base64';

import { createRoot } from 'react-dom/client';

import * as React from 'react';
Expand All @@ -13,6 +16,7 @@ import IconButton from '@mui/joy/IconButton';
import Select from '@mui/joy/Select';
import Option from '@mui/joy/Option';
import Link from '@mui/joy/Link';
import CircularProgress from '@mui/joy/CircularProgress';
import Snackbar from '@mui/joy/Snackbar';
import Alert from '@mui/joy/Alert';
import Tabs from '@mui/joy/Tabs';
Expand All @@ -30,26 +34,90 @@ import * as monaco from 'monaco-editor';
import { EditorState, Editor } from './monaco';
import { Viewer as WaveformViewer } from './d3wave';
import { PythonError, runner } from './runner';
import { compress, decompress } from './zstd';
import data from './config';

import './app.css';

function stealHashQuery() {
interface SharePayload {
av: string;
s: string;
}

const zstdMagic = new Uint8Array([0x28, 0xb5, 0x2f, 0xfd]);

const getSharePayloadDictionary = (() => {
let promise: Promise<Uint8Array> | undefined;
return async () => {
return promise ??=
fetch(new URL('zstd/dictionary.bin', import.meta.url).toString())
.then((response) => response.arrayBuffer())
.then((buffer) => new Uint8Array(buffer));
};
})();

async function createShareFragment(sourceCode: string, amaranthVersion: string): Promise<string> {
let payload = JSON.stringify({
av: amaranthVersion,
s: sourceCode,
} satisfies SharePayload);

let compressed = await compress(
new TextEncoder().encode(payload),
getSharePayloadDictionary(),
);

// Remove the Zstandard magic bytes and add a version field
let result = new Uint8Array(1 + compressed.length - zstdMagic.length);
result[0] = 1; // version
result.set(compressed.subarray(zstdMagic.length), 1);

return result.toBase64();
}

async function decodeShareFragment(hashQuery: string): Promise<SharePayload> {
if (/^[0-9A-Za-z+/=]+$/.test(hashQuery)) {
let bytes = Uint8Array.fromBase64(hashQuery);
// Check the version
if (bytes[0] === 1) {
let zstdPayload = bytes.subarray(1);
if (indexedDB.cmp(bytes.subarray(0, 4), zstdMagic) !== 0) {
zstdPayload = (() => {
let result = new Uint8Array(zstdMagic.length + zstdPayload.length);
result.set(zstdMagic);
result.set(zstdPayload, zstdMagic.length);
return result;
})();
}
bytes = await decompress(
zstdPayload,
getSharePayloadDictionary(),
);
} else if (bytes[0] !== '{'.charCodeAt(0)) {
return;
}
return JSON.parse(new TextDecoder().decode(bytes));
} else {
// Legacy encoding, used 2024-02-16 to 2024-02-24.
return JSON.parse(decodeURIComponent(hashQuery.replace('+', '%20')));
}
}

async function stealHashQuery() {
const { hash } = window.location;
if (hash !== '') {
history.replaceState(null, '', ' '); // remove #... from URL entirely
const hashQuery = hash.substring(1);
try {
return JSON.parse(atob(hashQuery));
} catch {
try {
// Legacy encoding, used 2024-02-16 to 2024-02-24.
return JSON.parse(decodeURIComponent(hashQuery.replace('+', '%20')));
} catch {}
return decodeShareFragment(hashQuery);
} catch (error) {
console.warn('Could not parse the URL fragment, ignoring.', error);
}
}
}

const query: { av?: string, s?: string } | undefined = await stealHashQuery();

interface TerminalChunk {
stream: 'stdout' | 'stderr';
text: string;
Expand All @@ -64,14 +132,14 @@ function AppContent() {
const {mode, setMode} = useColorScheme();
useEffect(() => monaco.editor.setTheme(mode === 'light' ? 'vs' : 'vs-dark'), [mode]);

const query: { av?: string, s?: string } | undefined = stealHashQuery();
const [amaranthVersion, setAmaranthVersion] = useState(
query?.av
?? localStorage.getItem('amaranth-playground.amaranthVersion')
?? data.amaranthVersions[0]);
useEffect(() => localStorage.setItem('amaranth-playground.amaranthVersion', amaranthVersion), [amaranthVersion]);
const [running, setRunning] = useState(false);
const [sharingOpen, setSharingOpen] = useState(false);
const [shareURL, setShareURL] = useState('');
const [tutorialDone, setTutorialDone] = useState(localStorage.getItem('amaranth-playground.tutorialDone') !== null);
useEffect(() => tutorialDone ? localStorage.setItem('amaranth-playground.tutorialDone', '') : void 0, [tutorialDone]);
const [activeTab, setActiveTab] = useState(tutorialDone ? 'amaranth-source' : 'tutorial');
Expand Down Expand Up @@ -160,6 +228,14 @@ function AppContent() {
const runCodeRef = useRef(runCode);
runCodeRef.current = runCode;

async function shareCode() {
setSharingOpen(true);
setShareURL('');
let fragment = await createShareFragment(amaranthSource, amaranthVersion);
let url = new URL('#' + fragment, window.location.href).toString();
setShareURL(url);
}

const amaranthSourceEditorActions = React.useMemo(() => [
{
id: 'amaranth-playground.run',
Expand Down Expand Up @@ -472,7 +548,7 @@ function AppContent() {
color='neutral'
variant='outlined'
endDecorator={<ShareIcon/>}
onClick={() => setSharingOpen(true)}
onClick={() => shareCode()}
>
Share
</Button>
Expand All @@ -482,14 +558,11 @@ function AppContent() {
open={sharingOpen}
onClose={(_event, _reason) => setSharingOpen(false)}
>
<Link href={
// base64 overhead is fixed at 33%, urlencode overhead is variable, typ. 133% (!)
new URL('#' + btoa(JSON.stringify({
av: amaranthVersion, s: amaranthSource
})), window.location.href).toString()
}>
Copy this link to share the source code
</Link>
{shareURL === '' ? <CircularProgress /> : (
<Link href={shareURL}>
Copy this link to share the source code
</Link>
)}
</Snackbar>

<IconButton
Expand Down
4 changes: 3 additions & 1 deletion src/d3wave.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { WaveGraph } from 'd3-wave';
import { RowRendererBits } from 'd3-wave';

export class RowRendererString extends RowRendererBits {
FORMATTERS: { STRING: any };
DEFAULT_FORMAT: any;

constructor(waveGraph: WaveGraph) {
super(waveGraph);
// @ts-ignore
this.FORMATTERS = {
"STRING": (data: { toString(): string }) =>
data.toString()
Expand Down
13 changes: 13 additions & 0 deletions src/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"noEmit": true,
"skipLibCheck": true,
"jsx": "preserve",
"module": "preserve",
"target": "esnext",
"lib": [
"ESNext",
"DOM",
],
},
}
31 changes: 31 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
interface Uint8Array<TArrayBuffer extends ArrayBufferLike> {
/**
* Converts the `Uint8Array` to a base64-encoded string.
* @param options If provided, sets the alphabet and padding behavior used.
* @returns A base64-encoded string.
*/
toBase64(
options?: {
alphabet?: "base64" | "base64url" | undefined;
omitPadding?: boolean | undefined;
},
): string;
}

interface Uint8ArrayConstructor {
/**
* Creates a new `Uint8Array` from a base64-encoded string.
* @param string The base64-encoded string.
* @param options If provided, specifies the alphabet and handling of the last chunk.
* @returns A new `Uint8Array` instance.
* @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last
* chunk is inconsistent with the `lastChunkHandling` option.
*/
fromBase64(
string: string,
options?: {
alphabet?: "base64" | "base64url" | undefined;
lastChunkHandling?: "loose" | "strict" | "stop-before-partial" | undefined;
},
): Uint8Array<ArrayBuffer>;
}
Binary file added src/zstd/dictionary.bin
Binary file not shown.
106 changes: 106 additions & 0 deletions src/zstd/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
let modules = new Map<string, Promise<WebAssembly.Module>>();

async function createWasmInstance(moduleURL: string) {
let instance: WebAssembly.Instance;
if (!modules.has(moduleURL)) {
let promiseWithResolvers = Promise.withResolvers<WebAssembly.Module>();
modules.set(moduleURL, promiseWithResolvers.promise);
let result = await WebAssembly.instantiateStreaming(fetch(moduleURL));
instance = result.instance;
promiseWithResolvers.resolve(result.module);
} else {
instance = await WebAssembly.instantiate(await modules.get(moduleURL));
}
return {
instance,
exports: instance.exports as {
memory: WebAssembly.Memory;

allocate: (size: number) => number;

compress: (
decompressed_data: number, decompressed_size: number,
dictionary_data: number, dictionary_size: number,
) => number;

decompress: (
compressed_data: number, compressed_size: number,
dictionary_data: number, dictionary_size: number,
) => number;

get_result_error: (result: number) => number;
get_result_size: (result: number) => number;
get_result_data: (result: number) => number;
}
};
}

export async function compress(data: Uint8Array, dictionaryPromise: Promise<Uint8Array>) {
let [{ exports }, dictionary] = await Promise.all([
createWasmInstance(new URL('zstd-wrapper.wasm', import.meta.url).toString()),
dictionaryPromise,
]);

let {
memory, allocate, compress,
get_result_error,
get_result_size,
get_result_data,
} = exports;

let dictionaryPtr = allocate(dictionary.byteLength);
new Uint8Array(memory.buffer, dictionaryPtr, dictionary.byteLength).set(dictionary);

let dataPtr = allocate(data.byteLength);
new Uint8Array(memory.buffer, dataPtr, data.byteLength).set(data);

let resultPtr = compress(
dataPtr, data.byteLength,
dictionaryPtr, dictionary.byteLength,
);

let error = get_result_error(resultPtr);
let size = get_result_size(resultPtr);
let compressed_data = get_result_data(resultPtr);

if (error !== 0) {
throw new Error(`Zstandard compression error ${error}`);
}

return new Uint8Array(memory.buffer, compressed_data, size);
}

export async function decompress(data: Uint8Array, dictionaryPromise: Promise<Uint8Array>) {
let [{ exports }, dictionary] = await Promise.all([
createWasmInstance(new URL('zstd-wrapper.wasm', import.meta.url).toString()),
dictionaryPromise,
]);

let {
memory, allocate, decompress,
get_result_error,
get_result_size,
get_result_data,
} = exports;

let dictionaryPtr = allocate(dictionary.byteLength);
new Uint8Array(memory.buffer, dictionaryPtr, dictionary.byteLength).set(dictionary);

let dataPtr = allocate(data.byteLength);
new Uint8Array(memory.buffer, dataPtr, data.byteLength).set(data);

let resultPtr = decompress(
dataPtr, data.byteLength,
dictionaryPtr, dictionary.byteLength,
);

let error = get_result_error(resultPtr);
let size = get_result_size(resultPtr);
let compressed_data = get_result_data(resultPtr);

if (error !== 0) {
throw new Error(`Zstandard decompression error ${error}`);
}

return new Uint8Array(memory.buffer, compressed_data, size);
}
Loading