Skip to content
Open
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
1 change: 1 addition & 0 deletions applications/viewer/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ deno.lock
.env
.env.*
*.log
static/julc
3 changes: 3 additions & 0 deletions applications/viewer/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ node_modules
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.idea
/static/julc
/test-results
/playwright-report
20 changes: 19 additions & 1 deletion applications/viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ A live Cardano explorer for your Yaci DevKit devnet, or any [Yaci Store](https:/
transaction, address, stake address, pool, DRep, policy, asset, fingerprint, script, datum or epoch.
- **Transactions you can read:** inputs flow into outputs, scripts show their execution budget, redeemers and datums
are decoded from CBOR, plus mints, metadata, collateral, reference inputs and the raw JSON.
- **Script Lab:** decode any Plutus script into searchable UPLC, see the builtins it uses and verify its hash. It runs
in your browser with the [julc](https://github.com/bloxbean/julc) WebAssembly engine.
- Addresses, stake accounts, assets, epochs and protocol parameters, pools, rewards, certificates, Ada pots and
Conway governance.
- Light and dark themes; works on a phone.
Expand All @@ -34,6 +36,7 @@ Set these in the environment, or in a `.env` file in the directory you start Yac
| `PUBLIC_INDEXER_CLIENT_BASE_URL` | same as above | Yaci Store as the browser sees it, when that differs (e.g. Docker) |
| `PUBLIC_INDEXER_WS_URL` | `ws://localhost:8080/ws/liveblocks` | Live updates. Leave empty for a store without the live module; the viewer polls instead |
| `PUBLIC_MAINTENANCE_MSG` | | Optional banner shown on every page |
| `PUBLIC_JULC_BASE_URL` | `/julc/` | Where the browser loads the julc engine from, if not served by Yaci Viewer itself |
| `PORT`, `HOST` | `3000`, `0.0.0.0` | Where Yaci Viewer listens |

The base and WebSocket URLs can also be passed on the command line:
Expand All @@ -53,6 +56,21 @@ npm test # Playwright smoke tests (needs a running Yaci Store)
npm run build # production build in build/, run with `node build`
```

### Script Lab and the julc engine

Script Lab uses the julc WebAssembly engine. It is not built here: `npm run dev` and `npm run build` run
`scripts/fetch-julc.mjs`, which downloads the julc release pinned in `julc.lock.json`, checks its SHA-256 and unpacks it
into `static/julc/` (git-ignored). The engine is only downloaded by the browser when a user decodes a script (about
5.5 MB with compression, then cached).

- A production build fails if the engine can't be installed. Set `JULC_SKIP=1` to build without Script Lab (its
panels then say so), or `JULC_ARCHIVE=/path/to/julc-wasm-<version>.tar.gz` to install from a local copy.
- To upgrade julc, change `tag`, `version`, `file` and `sha256` in `julc.lock.json` together; the checksum is in the
release's `julc-wasm-<version>-SHA256SUMS.txt`.
- The engine needs WebAssembly GC and exception handling (exnref): current Chrome, Edge, Firefox and Safari. Other
browsers get a message instead. Playwright's bundled Chromium is too old, so run the Script Lab test with Chrome:
`SCRIPT_LAB_HASH=<plutus script hash> PW_CHANNEL=chrome npx playwright test script-lab`.

SvelteKit 2, Svelte 5 and Tailwind. Shared code lives in `src/lib`: `api/` (Yaci Store client), `live/` (WebSocket
with a polling fallback), `ui/` (components), `shell/` (layout, navigation, search) and `format.ts`, `cbor.ts`,
with a polling fallback), `ui/` (components), `shell/` (layout, navigation, search), `julc/` (Script Lab) and `format.ts`, `cbor.ts`,
`bech32.ts`.
8 changes: 8 additions & 0 deletions applications/viewer/julc.lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"comment": "julc WebAssembly engine used by Script Lab. Downloaded from the GitHub release by scripts/fetch-julc.mjs and checked against sha256. To upgrade, change tag, version, file and sha256 together (sha256 from the release's julc-wasm-<version>-SHA256SUMS.txt).",
"repo": "bloxbean/julc",
"tag": "v0.1.0-pre18",
"version": "0.1.0-pre18",
"file": "julc-wasm-0.1.0-pre18.tar.gz",
"sha256": "475cb97719f882ae889f24a341b6f2713e3a7ac0164d32f25e048792936ffa28"
}
2 changes: 2 additions & 0 deletions applications/viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
"homepage": "https://devkit.yaci.xyz/",
"repository": "github:bloxbean/yaci-devkit",
"scripts": {
"predev": "node scripts/fetch-julc.mjs --optional",
"dev": "vite dev --host 0.0.0.0",
"prebuild": "node scripts/fetch-julc.mjs",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0",
"test": "playwright test",
Expand Down
4 changes: 3 additions & 1 deletion applications/viewer/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ const config: PlaywrightTestConfig = {
{
name: 'chromium',
use: {
browserName: 'chromium'
browserName: 'chromium',
// e.g. PW_CHANNEL=chrome for features the bundled Chromium lacks (the julc engine needs Wasm exnref)
channel: process.env.PW_CHANNEL || undefined
}
}
]
Expand Down
84 changes: 84 additions & 0 deletions applications/viewer/scripts/fetch-julc.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env node
/*
* Installs the julc WebAssembly engine (Script Lab) into static/julc/ from the julc GitHub release pinned in
* julc.lock.json. julc is never built here: the release archive is downloaded, checked against the pinned sha256
* and unpacked (plain files only).
*
* node scripts/fetch-julc.mjs required: fails the build if the engine can't be installed
* node scripts/fetch-julc.mjs --optional dev: warns and carries on; Script Lab hides itself
*
* JULC_SKIP=1 skip entirely (Script Lab hidden)
* JULC_ARCHIVE=<path> use a local copy of the release archive (offline builds); it is still checked
*/
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gunzipSync } from 'node:zlib';
import { untar } from './lib/untar.mjs';

const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const optional = process.argv.includes('--optional');
const lock = JSON.parse(readFileSync(join(root, 'julc.lock.json'), 'utf8'));
const target = join(root, 'static', 'julc');
const marker = join(root, 'node_modules', '.cache', 'julc', 'installed');
const cacheFile = join(root, 'node_modules', '.cache', 'julc', lock.file);
const url = `https://github.com/${lock.repo}/releases/download/${lock.tag}/${lock.file}`;

const log = (msg) => console.log(`[julc] ${msg}`);
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');

async function archive() {
if (process.env.JULC_ARCHIVE) return readFileSync(process.env.JULC_ARCHIVE);
if (existsSync(cacheFile)) {
const cached = readFileSync(cacheFile);
if (sha256(cached) === lock.sha256) return cached;
}
log(`downloading ${url}`);
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
mkdirSync(dirname(cacheFile), { recursive: true });
writeFileSync(cacheFile, buf);
return buf;
}

async function install() {
if (existsSync(marker) && existsSync(join(target, 'engine.json')) && readFileSync(marker, 'utf8').trim() === lock.sha256) {
log(`engine ${lock.version} already installed`);
return;
}
const buf = await archive();
const actual = sha256(buf);
if (actual !== lock.sha256) throw new Error(`checksum mismatch for ${lock.file}: expected ${lock.sha256}, got ${actual}`);

const files = untar(gunzipSync(buf));
const engine = files.get('engine.json');
if (!engine) throw new Error('archive has no engine.json');
const manifest = JSON.parse(engine.toString('utf8'));
if (manifest.julcVersion !== lock.version) throw new Error(`archive is julc ${manifest.julcVersion}, lock says ${lock.version}`);
if (!files.has(manifest.launcher) || !files.has(`${manifest.launcher}.wasm`)) throw new Error('archive is missing the engine launcher');

rmSync(target, { recursive: true, force: true });
mkdirSync(target, { recursive: true });
// Type declarations aren't needed at runtime (the viewer has its own, src/lib/julc/types.ts).
for (const [name, body] of files) if (!name.endsWith('.d.ts')) writeFileSync(join(target, name), body);
mkdirSync(dirname(marker), { recursive: true });
writeFileSync(marker, lock.sha256 + '\n');
log(`installed engine ${lock.version} (${manifest.variant}, ${manifest.version}) into static/julc`);
}

if (process.env.JULC_SKIP === '1') {
log('JULC_SKIP=1: skipping; Script Lab will be hidden');
} else {
try {
await install();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!optional) {
console.error(`[julc] ${msg}\n[julc] Set JULC_ARCHIVE to a local copy of ${lock.file}, or JULC_SKIP=1 to build without Script Lab.`);
process.exit(1);
}
log(`${msg}; continuing without Script Lab`);
}
}
32 changes: 32 additions & 0 deletions applications/viewer/scripts/lib/untar.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Minimal reader for the flat tar archives julc releases ship. Only regular files at the top level are returned;
* directories, links, pax headers and anything with a nested or unusual name are skipped, so an archive can't
* write outside the target folder.
*/
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;

function field(block, start, length) {
const end = block.indexOf(0, start);
return block.toString('utf8', start, end === -1 || end > start + length ? start + length : end);
}

/** @param {Buffer} tar @returns {Map<string, Buffer>} */
export function untar(tar) {
const files = new Map();
let offset = 0;
while (offset + 512 <= tar.length) {
const header = tar.subarray(offset, offset + 512);
if (header.every((b) => b === 0)) break;
const size = parseInt(field(header, 124, 12).trim() || '0', 8);
if (!Number.isFinite(size) || size < 0) throw new Error('corrupt tar header');
const type = String.fromCharCode(header[156] || 48);
const prefix = field(header, 345, 155);
const name = (prefix ? `${prefix}/` : '') + field(header, 0, 100);
const body = tar.subarray(offset + 512, offset + 512 + size);
if (body.length !== size) throw new Error('truncated tar archive');
const base = name.replace(/^\.\//, '');
if (type === '0' && SAFE_NAME.test(base)) files.set(base, Buffer.from(body));
offset += 512 + Math.ceil(size / 512) * 512;
}
return files;
}
48 changes: 48 additions & 0 deletions applications/viewer/scripts/lib/untar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { untar } from './untar.mjs';

/* Build a tar archive in memory: entries of [name, body, typeflag]. */
function tar(entries: [string, string, string?][]): Buffer {
const blocks: Buffer[] = [];
for (const [name, body, type = '0'] of entries) {
const header = Buffer.alloc(512);
header.write(name, 0, 100, 'utf8');
header.write(body.length.toString(8).padStart(11, '0') + '\0', 124, 12, 'utf8');
header.write(type, 156, 1, 'utf8');
blocks.push(header);
const data = Buffer.alloc(Math.ceil(body.length / 512) * 512);
data.write(body, 0, 'utf8');
blocks.push(data);
}
blocks.push(Buffer.alloc(1024));
return Buffer.concat(blocks);
}

describe('untar', () => {
it('returns top-level regular files', () => {
const files = untar(tar([['./', '', '5'], ['./engine.json', '{"a":1}'], ['julc-x.js', 'x'.repeat(700)]]));
expect([...files.keys()]).toEqual(['engine.json', 'julc-x.js']);
expect(files.get('engine.json')!.toString()).toBe('{"a":1}');
expect(files.get('julc-x.js')!.length).toBe(700);
});

it('skips paths that could escape the target folder, links and pax headers', () => {
const files = untar(
tar([
['../evil.js', 'x'],
['/etc/passwd', 'x'],
['sub/dir.js', 'x'],
['.hidden', 'x'],
['link.js', '', '2'],
['./PaxHeaders/x', 'path=a', 'x'],
['ok.js', 'y']
])
);
expect([...files.keys()]).toEqual(['ok.js']);
});

it('rejects a truncated archive', () => {
const t = tar([['a.js', 'x'.repeat(600)]]);
expect(() => untar(t.subarray(0, 700))).toThrow(/truncated/);
});
});
6 changes: 6 additions & 0 deletions applications/viewer/src/lib/assets/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ export function scriptTypeLabel(type: string | null | undefined): string {
}

export const isPlutus = (type: string | null | undefined) => String(type ?? '').toLowerCase().includes('plutus');

/** 'V1' | 'V2' | 'V3' for Plutus script types (plutusV2, PLUTUS_V3, PlutusScriptV1), else null. */
export function plutusVersionOf(type: string | null | undefined): 'V1' | 'V2' | 'V3' | null {
const m = String(type ?? '').match(/plutus\D*v?_?([123])/i);
return m ? (`V${m[1]}` as 'V1' | 'V2' | 'V3') : null;
}
138 changes: 138 additions & 0 deletions applications/viewer/src/lib/julc/CodeView.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<script lang="ts">
import { untrack } from 'svelte';
import { tokenize, splitMatches, type CodeLanguage, type TokenKind } from './highlight';
import { copyText } from '$lib/ui/toast.svelte';
import Search from '@lucide/svelte/icons/search';
import ChevronUp from '@lucide/svelte/icons/chevron-up';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Copy from '@lucide/svelte/icons/copy';
import Download from '@lucide/svelte/icons/download';
import WrapText from '@lucide/svelte/icons/wrap-text';

/*
* A read-only code viewer for large generated code (UPLC of a 16 KB script is tens of thousands of lines).
* Only the lines on screen are rendered and highlighted. Search jumps between matching lines.
*/
let {
text,
language = 'plain',
filename = 'code.txt',
height = 520,
label = 'Code',
query = $bindable('')
}: { text: string; language?: CodeLanguage; filename?: string; height?: number; label?: string; query?: string } = $props();

const LINE = 20;
const OVERSCAN = 30;

const lines = $derived(text.split('\n'));
const widest = $derived(Math.min(4000, lines.reduce((m, l) => Math.max(m, l.length), 0)));
const gutter = $derived(String(lines.length).length);
const viewHeight = $derived(Math.min(height, lines.length * LINE + 16));

let viewport = $state<HTMLDivElement>();
let scrollTop = $state(0);
let wrap = $state(false);

const first = $derived(Math.max(0, Math.floor(scrollTop / LINE) - OVERSCAN));
const last = $derived(Math.min(lines.length, Math.ceil((scrollTop + viewHeight) / LINE) + OVERSCAN));
const visible = $derived(lines.slice(first, last).map((line, i) => ({ n: first + i, tokens: tokenize(line, language) })));

let current = $state(0);
const needle = $derived(query.trim().toLowerCase());
const hits = $derived.by(() => {
if (needle.length < 2) return [] as number[];
const out: number[] = [];
for (let i = 0; i < lines.length && out.length < 5000; i++) if (lines[i].toLowerCase().includes(needle)) out.push(i);
return out;
});
const hitSet = $derived(new Set(hits));

function jump(index: number) {
if (!hits.length || !viewport) return;
current = (index + hits.length) % hits.length;
viewport.scrollTop = Math.max(0, hits[current] * LINE - viewHeight / 3);
}

$effect(() => {
// A new search starts at the first hit. Only `hits` is tracked: jump() reads `current`.
if (hits.length) untrack(() => jump(0));
});

function onKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
jump(current + (e.shiftKey ? -1 : 1));
} else if (e.key === 'Escape') {
query = '';
}
}

function download() {
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}

const tone: Record<TokenKind, string> = {
kw: 'text-violet',
builtin: 'text-cyan',
type: 'text-amber',
num: 'text-mint',
bytes: 'text-mint',
str: 'text-pink',
punct: 'text-faint',
plain: ''
};
</script>

<div class="overflow-hidden rounded-xl border border-line bg-bg-2">
<div class="flex flex-wrap items-center gap-2 border-b border-line bg-surface px-3 py-2">
<label class="relative flex min-w-[180px] flex-1 items-center sm:max-w-xs">
<Search class="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-faint" />
<input
class="y-input h-8 pl-8 text-xs"
placeholder="Search {label.toLowerCase()}"
aria-label="Search {label.toLowerCase()}"
bind:value={query}
onkeydown={onKey}
/>
</label>
{#if needle.length >= 2}
<span class="text-xs tabular-nums text-muted">{hits.length ? `${current + 1} / ${hits.length}${hits.length >= 5000 ? '+' : ''}` : 'No matches'}</span>
<button class="y-icon-btn h-8 w-8" aria-label="Previous match" disabled={!hits.length} onclick={() => jump(current - 1)}><ChevronUp class="h-4 w-4" /></button>
<button class="y-icon-btn h-8 w-8" aria-label="Next match" disabled={!hits.length} onclick={() => jump(current + 1)}><ChevronDown class="h-4 w-4" /></button>
{/if}
<span class="ml-auto text-xs tabular-nums text-faint">{lines.length.toLocaleString()} lines</span>
<button class="y-icon-btn h-8 w-8 {wrap ? 'text-brand' : ''}" aria-pressed={wrap} aria-label="Wrap long lines" title="Wrap long lines" onclick={() => (wrap = !wrap)}><WrapText class="h-4 w-4" /></button>
<button class="y-icon-btn h-8 w-8" aria-label="Copy {label}" title="Copy" onclick={() => copyText(text, `${label} copied`)}><Copy class="h-4 w-4" /></button>
<button class="y-icon-btn h-8 w-8" aria-label="Download {label}" title="Download" onclick={download}><Download class="h-4 w-4" /></button>
</div>

{#if wrap}
<!-- Wrapped lines have no fixed height, so this view renders everything; fine for reading, not for huge scripts. -->
<pre class="mono overflow-auto p-3 text-[12.5px] leading-5 whitespace-pre-wrap break-all text-ink" style="max-height:{height}px">{text}</pre>
{:else}
<div
bind:this={viewport}
class="mono relative overflow-auto text-[12.5px] leading-5"
style="height:{viewHeight}px"
onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)}
role="region"
aria-label={label}
tabindex="-1"
>
<div style="height:{lines.length * LINE + 16}px; min-width:calc({widest + gutter + 4}ch + 2rem)">
<div style="transform:translateY({first * LINE + 8}px)">
{#each visible as line (line.n)}
<div class="flex h-5 {hitSet.has(line.n) ? (hits[current] === line.n ? 'bg-amber/20' : 'bg-amber/[0.07]') : ''}">
<span class="sticky left-0 shrink-0 select-none border-r border-line bg-bg-2 pl-3 pr-3 text-right text-faint" style="width:calc({gutter}ch + 1.5rem)">{line.n + 1}</span>
<span class="whitespace-pre pl-3 pr-4 text-ink">{#each line.tokens as t}{#if needle.length >= 2 && hitSet.has(line.n)}{#each splitMatches(t.text, needle) as part}<span class="{tone[t.kind]} {part.hit ? 'rounded-sm bg-amber/40 text-ink' : ''}">{part.text}</span>{/each}{:else}<span class={tone[t.kind]}>{t.text}</span>{/if}{/each}</span>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
Loading