diff --git a/applications/viewer/.dockerignore b/applications/viewer/.dockerignore index 89d5fdb4..4cda9105 100644 --- a/applications/viewer/.dockerignore +++ b/applications/viewer/.dockerignore @@ -8,3 +8,4 @@ deno.lock .env .env.* *.log +static/julc diff --git a/applications/viewer/.gitignore b/applications/viewer/.gitignore index f4a1c100..a81230f6 100644 --- a/applications/viewer/.gitignore +++ b/applications/viewer/.gitignore @@ -9,3 +9,6 @@ node_modules vite.config.js.timestamp-* vite.config.ts.timestamp-* .idea +/static/julc +/test-results +/playwright-report diff --git a/applications/viewer/README.md b/applications/viewer/README.md index a7768fcc..a90e9dbd 100644 --- a/applications/viewer/README.md +++ b/applications/viewer/README.md @@ -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. @@ -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: @@ -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-.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--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= 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`. diff --git a/applications/viewer/julc.lock.json b/applications/viewer/julc.lock.json new file mode 100644 index 00000000..ab4f9c9b --- /dev/null +++ b/applications/viewer/julc.lock.json @@ -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--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" +} diff --git a/applications/viewer/package.json b/applications/viewer/package.json index 5365cb37..3895fe3b 100644 --- a/applications/viewer/package.json +++ b/applications/viewer/package.json @@ -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", diff --git a/applications/viewer/playwright.config.ts b/applications/viewer/playwright.config.ts index 1027493e..34f7ed61 100644 --- a/applications/viewer/playwright.config.ts +++ b/applications/viewer/playwright.config.ts @@ -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 } } ] diff --git a/applications/viewer/scripts/fetch-julc.mjs b/applications/viewer/scripts/fetch-julc.mjs new file mode 100644 index 00000000..3a557f6e --- /dev/null +++ b/applications/viewer/scripts/fetch-julc.mjs @@ -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= 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`); + } +} diff --git a/applications/viewer/scripts/lib/untar.mjs b/applications/viewer/scripts/lib/untar.mjs new file mode 100644 index 00000000..3f9dd6f3 --- /dev/null +++ b/applications/viewer/scripts/lib/untar.mjs @@ -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} */ +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; +} diff --git a/applications/viewer/scripts/lib/untar.test.ts b/applications/viewer/scripts/lib/untar.test.ts new file mode 100644 index 00000000..b42591ba --- /dev/null +++ b/applications/viewer/scripts/lib/untar.test.ts @@ -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/); + }); +}); diff --git a/applications/viewer/src/lib/assets/script.ts b/applications/viewer/src/lib/assets/script.ts index 5540af59..0790e95c 100644 --- a/applications/viewer/src/lib/assets/script.ts +++ b/applications/viewer/src/lib/assets/script.ts @@ -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; +} diff --git a/applications/viewer/src/lib/julc/CodeView.svelte b/applications/viewer/src/lib/julc/CodeView.svelte new file mode 100644 index 00000000..233bfcdc --- /dev/null +++ b/applications/viewer/src/lib/julc/CodeView.svelte @@ -0,0 +1,138 @@ + + +
+
+ + {#if needle.length >= 2} + {hits.length ? `${current + 1} / ${hits.length}${hits.length >= 5000 ? '+' : ''}` : 'No matches'} + + + {/if} + {lines.length.toLocaleString()} lines + + + +
+ + {#if wrap} + +
{text}
+ {:else} +
(scrollTop = e.currentTarget.scrollTop)} + role="region" + aria-label={label} + tabindex="-1" + > +
+
+ {#each visible as line (line.n)} +
+ {line.n + 1} + {#each line.tokens as t}{#if needle.length >= 2 && hitSet.has(line.n)}{#each splitMatches(t.text, needle) as part}{part.text}{/each}{:else}{t.text}{/if}{/each} +
+ {/each} +
+
+
+ {/if} +
diff --git a/applications/viewer/src/lib/julc/ScriptCode.svelte b/applications/viewer/src/lib/julc/ScriptCode.svelte new file mode 100644 index 00000000..2175cecb --- /dev/null +++ b/applications/viewer/src/lib/julc/ScriptCode.svelte @@ -0,0 +1,164 @@ + + + + {#snippet icon()}{/snippet} + {#snippet actions()} + {#if view === 'cbor'}{/if} + {/snippet} + +
+ + {#if view === 'cbor'} + {cbor} + {:else if installed === false} + + {:else if !supported} + + {:else if !started} +
+ +

Read this script

+

+ Decode the compiled code into readable UPLC, see which builtins it uses and check its hash. It runs in your browser with the julc engine, + a few megabytes downloaded once. +

+ + +
+ {:else if failure} +
+ +
+

{julc.status === 'unsupported' ? 'This browser can’t run the julc engine' : 'Couldn’t read this script'}

+

{failure}

+ {#if julc.status !== 'unsupported'} + + {/if} +
+
+ {:else if !decoded} +
+ + {julc.status === 'loading' ? 'Starting the julc engine… (first time only)' : 'Decoding…'} +
+ + {:else if info} +
+
Language
Plutus {info.language ?? '—'}
+
Plutus Core
{info.programVersion ?? '—'}
+
Size
{num(info.flatBytes)} bytes
+
Terms
{num(info.termCount)}
+
Parameters applied
{info.paramsApplied}
+
+
Hash
+ {#if verified} +
Verified
+ {:else if verified === false} +
Mismatch
+ {:else} +
—
+ {/if} +
+
+ + {#if verified === false} +

+ This code hashes to {info.scriptHash}, not to this script's hash. The store may hold the wrong body, or the language was misread. +

+ {/if} + {#if info.warnings?.length} +
    + {#each info.warnings as w}
  • {w}
  • {/each} +
+ {/if} + {#if info.builtins?.length} +
+
Builtins used ({info.builtins.length})
+
+ {#each info.builtins as b} + + {/each} +
+
+ {/if} + + {/if} +
diff --git a/applications/viewer/src/lib/julc/engine.svelte.ts b/applications/viewer/src/lib/julc/engine.svelte.ts new file mode 100644 index 00000000..41f88f14 --- /dev/null +++ b/applications/viewer/src/lib/julc/engine.svelte.ts @@ -0,0 +1,142 @@ +import { browser } from '$app/environment'; +import { env } from '$env/dynamic/public'; +import type { JulcClient, JulcError, Manifest } from './types'; + +/* + * The julc engine (Script Lab), loaded on first use and shared by every page. It runs in its own worker, so a long + * evaluation never blocks the page. Nothing is downloaded until a feature asks for the client; installs without the + * engine (JULC_SKIP) report 'absent' and the features hide themselves. + */ + +export type EngineStatus = 'idle' | 'checking' | 'absent' | 'unsupported' | 'loading' | 'ready' | 'error'; + +const AUTO_KEY = 'viewer:julc:auto'; + +/* + * julc is a GraalVM Web Image: it needs WasmGC and the final Wasm exception handling (exnref, try_table). Both are + * checked with tiny modules before anything is downloaded. Older browsers (e.g. Chrome before 2025) lack exnref. + */ +const WASM_GC = [0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 95, 1, 120, 0]; +const WASM_EXNREF = [0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 10, 8, 1, 6, 0, 31, 64, 0, 11, 11]; + +export function wasmSupported(): boolean { + try { + return typeof WebAssembly === 'object' && WebAssembly.validate(new Uint8Array(WASM_GC)) && WebAssembly.validate(new Uint8Array(WASM_EXNREF)); + } catch { + return false; + } +} + +export function engineBaseUrl(): URL { + const configured = env.PUBLIC_JULC_BASE_URL; + const raw = (configured || '/julc/').replace(/\/?$/, '/'); + return new URL(raw, browser ? location.href : 'http://localhost/'); +} + +/** Workers must be same-origin; for an engine on another host, start a tiny worker that imports julc's. */ +function workerFactory(url: URL): Worker { + if (url.origin === location.origin) return new Worker(url); + const shim = new Blob([`importScripts(${JSON.stringify(url.href)});`], { type: 'text/javascript' }); + return new Worker(URL.createObjectURL(shim)); +} + +class JulcEngine { + status = $state('idle'); + error = $state(null); + manifest = $state(null); + /** How long the last engine start took, in ms. */ + loadMs = $state(null); + + #manifest: Promise | null = null; + #client: Promise | null = null; + + /** Is an engine installed with this viewer? Reads engine.json (a few hundred bytes). */ + available(): Promise { + if (!browser) return Promise.resolve(null); + this.#manifest ??= (async () => { + if (this.status === 'idle') this.status = 'checking'; + try { + const res = await fetch(new URL('engine.json', engineBaseUrl()), { cache: 'no-cache' }); + const manifest = res.ok ? ((await res.json()) as Manifest) : null; + this.manifest = manifest; + if (this.status === 'checking') this.status = manifest ? 'idle' : 'absent'; + return manifest; + } catch { + if (this.status === 'checking') this.status = 'absent'; + return null; + } + })(); + return this.#manifest; + } + + /** The engine client, started on first call. A failed start can be retried. */ + client(): Promise { + this.#client ??= this.#start().catch((e) => { + this.#client = null; + throw e; + }); + return this.#client; + } + + /** Run a call against the engine. When the worker dies (a timeout disposes it), the next call starts a new one. */ + async run(fn: (client: JulcClient) => Promise): Promise { + const client = await this.client(); + try { + return await fn(client); + } catch (e) { + const code = (e as JulcError)?.code; + if (code === 'timeout' || /closed|worker/i.test((e as Error)?.message ?? '')) { + this.#client = null; + this.status = 'idle'; + } + throw e; + } + } + + get auto(): boolean { + try { + return browser && localStorage.getItem(AUTO_KEY) === '1'; + } catch { + return false; + } + } + + set auto(on: boolean) { + try { + if (on) localStorage.setItem(AUTO_KEY, '1'); + else localStorage.removeItem(AUTO_KEY); + } catch { + /* storage blocked: the choice lasts for this page only */ + } + } + + async #start(): Promise { + if (!browser) throw new Error('The julc engine runs in the browser only.'); + const manifest = await this.available(); + if (!manifest) { + this.status = 'absent'; + throw new Error('Script Lab is not installed with this viewer.'); + } + if (!wasmSupported()) { + this.status = 'unsupported'; + throw new Error('The julc engine needs WebAssembly GC and exception handling, which this browser does not support. Use a current Chrome, Edge, Firefox or Safari.'); + } + this.status = 'loading'; + this.error = null; + const started = performance.now(); + try { + const baseUrl = engineBaseUrl(); + const mod = await import(/* @vite-ignore */ new URL(`julc-wasm.js?engine=${encodeURIComponent(manifest.version)}`, baseUrl).href); + const client = (await mod.createJulc({ baseUrl, manifest, workerFactory, timeoutMs: 60_000 })) as JulcClient; + this.loadMs = Math.round(performance.now() - started); + this.status = 'ready'; + return client; + } catch (e) { + this.status = 'error'; + this.error = (e as Error)?.message || 'The julc engine failed to start.'; + throw e; + } + } +} + +export const julc = new JulcEngine(); diff --git a/applications/viewer/src/lib/julc/highlight.test.ts b/applications/viewer/src/lib/julc/highlight.test.ts new file mode 100644 index 00000000..76861007 --- /dev/null +++ b/applications/viewer/src/lib/julc/highlight.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { splitMatches, tokenize } from './highlight'; + +// Adjacent tokens of the same kind (names and spaces) are merged into one span. +const kinds = (line: string, lang: 'uplc') => tokenize(line, lang).filter((t) => t.text.trim()).map((t) => [t.text.trim(), t.kind]); + +describe('tokenize', () => { + it('highlights UPLC keywords, builtins, types and constants', () => { + expect(kinds('[(builtin addInteger) (con integer -5)]', 'uplc')).toEqual([ + ['[(', 'punct'], + ['builtin', 'kw'], + ['addInteger', 'builtin'], + [')', 'punct'], + ['(', 'punct'], + ['con', 'kw'], + ['integer', 'type'], + ['-5', 'num'], + [')]', 'punct'] + ]); + expect(kinds('(con bytestring #c0ffee)', 'uplc')).toContainEqual(['#c0ffee', 'bytes']); + expect(kinds('(con string "a \\"b\\"")', 'uplc')).toContainEqual(['"a \\"b\\""', 'str']); + expect(kinds('(lam i_1 i_1)', 'uplc')).toEqual([ + ['(', 'punct'], + ['lam', 'kw'], + ['i_1 i_1', 'plain'], + [')', 'punct'] + ]); + }); + + it('keeps every character', () => { + const line = ' [(force (builtin ifThenElse)) (con bool True) #ff "q" ~]'; + expect(tokenize(line, 'uplc').map((t) => t.text).join('')).toBe(line); + }); +}); + +describe('splitMatches', () => { + it('marks case-insensitive hits', () => { + expect(splitMatches('addInteger AddInteger', 'addinteger')).toEqual([ + { text: 'addInteger', hit: true }, + { text: ' ', hit: false }, + { text: 'AddInteger', hit: true } + ]); + expect(splitMatches('abc', '')).toEqual([{ text: 'abc', hit: false }]); + }); +}); diff --git a/applications/viewer/src/lib/julc/highlight.ts b/applications/viewer/src/lib/julc/highlight.ts new file mode 100644 index 00000000..3b89dfbe --- /dev/null +++ b/applications/viewer/src/lib/julc/highlight.ts @@ -0,0 +1,59 @@ +/* + * Line-by-line syntax highlighting for UPLC. Only the lines on screen are tokenised, so even very large scripts stay + * fast. Tokens are rendered as text, never as HTML. + */ + +export type TokenKind = 'kw' | 'builtin' | 'type' | 'num' | 'str' | 'bytes' | 'punct' | 'plain'; +export interface Token { + text: string; + kind: TokenKind; +} +export type CodeLanguage = 'uplc' | 'plain'; + +const UPLC_KW = new Set(['program', 'lam', 'delay', 'force', 'builtin', 'con', 'error', 'constr', 'case']); +const UPLC_TYPES = new Set(['integer', 'bytestring', 'string', 'bool', 'unit', 'data', 'list', 'pair', 'array', 'value', 'bls12_381_G1_element', 'bls12_381_G2_element', 'bls12_381_MlResult']); + +const UPLC_RE = /("(?:[^"\\]|\\.)*")|(#[0-9a-fA-F]*)|(-?\d+)|([A-Za-z_][\w']*)|([()[\]{},.<>])|(\s+)|(.)/g; + +export function tokenize(line: string, lang: CodeLanguage): Token[] { + if (lang === 'plain') return [{ text: line, kind: 'plain' }]; + const out: Token[] = []; + const push = (text: string, kind: TokenKind) => { + const last = out[out.length - 1]; + if (last && last.kind === kind) last.text += text; + else out.push({ text, kind }); + }; + let prevWord = ''; + for (const m of line.matchAll(UPLC_RE)) { + const [text, str, bytes, num, word, punct] = m; + if (str) push(text, 'str'); + else if (bytes) push(text, 'bytes'); + else if (num) push(text, 'num'); + else if (word) { + if (UPLC_KW.has(word)) push(text, 'kw'); + else if (prevWord === 'builtin') push(text, 'builtin'); + else if (UPLC_TYPES.has(word) || prevWord === 'con') push(text, 'type'); + else if (word === 'True' || word === 'False' || word === 'Constr' || word === 'Map' || word === 'List' || word === 'I' || word === 'B') push(text, 'num'); + else push(text, 'plain'); + prevWord = word; + } else if (punct) push(text, 'punct'); + else push(text, 'plain'); + } + return out; +} + +/** Split a line into plain and matched parts, for highlighting search hits (case-insensitive). */ +export function splitMatches(text: string, query: string): { text: string; hit: boolean }[] { + if (!query) return [{ text, hit: false }]; + const parts: { text: string; hit: boolean }[] = []; + const lower = text.toLowerCase(); + const q = query.toLowerCase(); + let at = 0; + for (let i = lower.indexOf(q); i !== -1; i = lower.indexOf(q, i + q.length)) { + if (i > at) parts.push({ text: text.slice(at, i), hit: false }); + parts.push({ text: text.slice(i, i + q.length), hit: true }); + at = i + q.length; + } + if (at < text.length) parts.push({ text: text.slice(at), hit: false }); + return parts; +} diff --git a/applications/viewer/src/lib/julc/types.ts b/applications/viewer/src/lib/julc/types.ts new file mode 100644 index 00000000..cfaa7608 --- /dev/null +++ b/applications/viewer/src/lib/julc/types.ts @@ -0,0 +1,71 @@ +/* + * The parts of the julc WebAssembly API (julc-wasm.d.ts, API version 0) that the viewer uses. The engine itself is + * downloaded at build time (scripts/fetch-julc.mjs), so its own typings aren't available to the type checker. + * Long and BigInteger fields are bigint. + */ + +export type PlutusVersion = 'V1' | 'V2' | 'V3'; + +export interface ScriptInput { + script: string; + language?: PlutusVersion; + params?: DataInput[]; +} + +export type DataInput = { format: 'cbor' | 'json' | 'uplc' | 'auto'; value: string }; + +export interface ScriptInfo { + inputFormat: string | null; + wrapping: string | null; + programVersion: string | null; + language: string | null; + languageSource: string | null; + scriptHash: string | null; + flatBytes: number; + termCount: number; + builtins: string[] | null; + paramsApplied: number; + warnings: string[] | null; +} + +export interface DecodeResponse { + ok: boolean; + error: string | null; + info: ScriptInfo | null; + uplcText: string | null; +} + +export type FeatureGroup = 'vm' | 'debug' | 'compiler' | 'uplc' | 'sourceDebug' | 'sourceDebugLocals'; + +export interface Features { + api: 0; + variant: 'full' | 'vm'; + groups: FeatureGroup[]; + defaultProtocol: number; +} + +export interface Manifest { + api: 0; + version: string; + launcher: string; + variant: 'full' | 'vm'; + groups?: FeatureGroup[]; + julcVersion: string; +} + +export interface JulcClient { + version(): string; + features(): Features; + dispose(): void; + vm: { + decode(request: { script: ScriptInput }): Promise; + hash(request: { script: ScriptInput }): Promise<{ scriptHash: string }>; + prettyPrint(request: { script: ScriptInput; width?: number }): Promise<{ uplcText: string }>; + }; +} + +/** Errors thrown by the engine for bad requests and timeouts (a failing script is a result, not an error). */ +export interface JulcError extends Error { + code?: string; + status?: number; +} diff --git a/applications/viewer/src/routes/scripts/[hash]/+page.svelte b/applications/viewer/src/routes/scripts/[hash]/+page.svelte index d6478e10..5f546787 100644 --- a/applications/viewer/src/routes/scripts/[hash]/+page.svelte +++ b/applications/viewer/src/routes/scripts/[hash]/+page.svelte @@ -11,7 +11,8 @@ import CopyButton from '$lib/ui/CopyButton.svelte'; import Empty from '$lib/ui/Empty.svelte'; import NativeScriptTree from '$lib/assets/NativeScriptTree.svelte'; - import { isPlutus, scriptTypeLabel } from '$lib/assets/script'; + import { isPlutus, plutusVersionOf, scriptTypeLabel } from '$lib/assets/script'; + import ScriptCode from '$lib/julc/ScriptCode.svelte'; import FileCode from '@lucide/svelte/icons/file-code'; import Coins from '@lucide/svelte/icons/coins'; import Wallet from '@lucide/svelte/icons/wallet'; @@ -20,6 +21,7 @@ const label = $derived(scriptTypeLabel(data.type)); const plutus = $derived(isPlutus(data.type)); + const plutusVersion = $derived(plutusVersionOf(data.type)); const bytes = $derived(data.size ?? (data.cbor ? data.cbor.length / 2 : null)); let showCbor = $state(false); @@ -81,14 +83,18 @@ {/if} - - {#snippet actions()}{#if data.cbor}{/if}{/snippet} - {#if data.cbor} - {data.cbor} - {#if data.cbor.length > 600} - +{#if plutus && data.cbor} + +{:else} + + {#snippet actions()}{#if data.cbor}{/if}{/snippet} + {#if data.cbor} + {data.cbor} + {#if data.cbor.length > 600} + + {/if} + {:else} + {/if} - {:else} - - {/if} - + +{/if} diff --git a/applications/viewer/tests/script-lab.spec.ts b/applications/viewer/tests/script-lab.spec.ts new file mode 100644 index 00000000..21d86317 --- /dev/null +++ b/applications/viewer/tests/script-lab.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; + +/* + * Script Lab (julc engine) on a script page. Needs a Plutus script on the configured store: + * + * SCRIPT_LAB_HASH=<56-hex script hash> PW_CHANNEL=chrome npx playwright test script-lab + * + * The julc engine needs WebAssembly GC and exnref, which Playwright's bundled Chromium 128 lacks, so use a current + * browser channel (PW_CHANNEL=chrome). Without support the test checks the "can't run" message instead. + */ +const hash = process.env.SCRIPT_LAB_HASH; + +test.skip(!hash, 'Set SCRIPT_LAB_HASH to a Plutus script hash on the configured store'); + +const supported = () => + WebAssembly.validate(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 95, 1, 120, 0])) && + WebAssembly.validate(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 10, 8, 1, 6, 0, 31, 64, 0, 11, 11])); + +test('decodes a script into searchable UPLC and verifies its hash', async ({ page }) => { + await page.goto(`/scripts/${hash}`); + if (!(await page.evaluate(supported))) { + await expect(page.getByText('This browser can’t run the julc engine')).toBeVisible(); + return; + } + await page.getByRole('button', { name: 'Decode script' }).click(); + const code = page.getByRole('region', { name: 'UPLC' }); + await expect(code).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText('Verified')).toBeVisible(); + await expect(page.getByText(/Builtins used \(\d+\)/)).toBeVisible(); + await expect(code.getByText('(program', { exact: false }).first()).toBeVisible(); + + // Search: typing finds matches, Enter moves to the next one. + await page.getByPlaceholder('Search uplc').fill('builtin'); + await expect(page.getByText(/^1 \/ \d+\+?$/)).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page.getByText(/^2 \/ \d+\+?$/)).toBeVisible(); + + // Only the lines on screen are rendered. + expect(await code.locator('.sticky').count()).toBeLessThan(200); + + await page.getByRole('tab', { name: 'CBOR' }).click(); + await expect(page).toHaveURL(/view=cbor/); +}); diff --git a/applications/viewer/vite.config.js b/applications/viewer/vite.config.js index e0f76d00..e524313b 100644 --- a/applications/viewer/vite.config.js +++ b/applications/viewer/vite.config.js @@ -8,6 +8,6 @@ export default { exclude: ['@lucide/svelte'] }, test: { - include: ['src/**/*.{test,spec}.{js,ts}'] + include: ['src/**/*.{test,spec}.{js,ts}', 'scripts/**/*.test.ts'] } };