diff --git a/.changeset/ocapn-op-flush.md b/.changeset/ocapn-op-flush.md new file mode 100644 index 0000000000..052c3da10a --- /dev/null +++ b/.changeset/ocapn-op-flush.md @@ -0,0 +1,8 @@ +--- +'@endo/ocapn': minor +--- + +- Add the OCapN `op:flush` and `op:flush-done` operations and wire them into the message dispatcher. +- On receiving `op:flush` for an exported promise position, the receiver mints a fresh local promise, swaps it in at the same export position (preserving the slot's refcount) and replies with `op:flush-done`. Subsequent deliveries that target the position queue on the new promise so per-reference FIFO order is preserved during promise shortening. +- Expose `_debug.flushExport(remoteValue)` to send `op:flush` and obtain a promise that resolves when `op:flush-done` is received. +- Add `replaceExportValue` on the pairwise table to support in-place value replacement under flush. diff --git a/.changeset/ocapn-simulator-deposit-gift-promise.md b/.changeset/ocapn-simulator-deposit-gift-promise.md new file mode 100644 index 0000000000..08b9560a28 --- /dev/null +++ b/.changeset/ocapn-simulator-deposit-gift-promise.md @@ -0,0 +1,8 @@ +--- +'@endo/ocapn': minor +'@endo/ocapn-simulator': minor +--- + +- Add a new private `@endo/ocapn-simulator` package: a browser-based playground that runs N OCapN clients in web workers, brokered by a `MessagePort`-based netlayer with simulated latency. Useful for hands-on experimentation with the `op:flush` / `op:flush-done` proposal. +- Re-export `encodeSwissnum` and `locationToLocationId` from `@endo/ocapn` so external clients (e.g. the simulator) can build sturdyrefs and refer to peer sessions without reaching into internal subpaths. +- Allow `bootstrap.deposit-gift` to accept a promise as the gift value, in addition to a remotable. This unblocks third-party promise handoffs of unresolved answer-promises across the network — the natural shape of forwarder chains that return `[answerPromise]` to keep the chain visible as a `desc:import-promise`. Previously such gifts rejected with "Gift must be remotable" the first time a hop tried to ship its answer to a third party. diff --git a/packages/ocapn-simulator/.eslintrc.cjs b/packages/ocapn-simulator/.eslintrc.cjs new file mode 100644 index 0000000000..64d3f28bae --- /dev/null +++ b/packages/ocapn-simulator/.eslintrc.cjs @@ -0,0 +1,16 @@ +// The simulator is a browser/worker-only experimental package; the +// strict SES library lint preset doesn't apply. We use a minimal config +// that knows about browser and worker globals. +module.exports = { + root: true, + env: { + browser: true, + worker: true, + es2022: true, + }, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + rules: {}, +}; diff --git a/packages/ocapn-simulator/README.md b/packages/ocapn-simulator/README.md new file mode 100644 index 0000000000..9a60336d6b --- /dev/null +++ b/packages/ocapn-simulator/README.md @@ -0,0 +1,113 @@ +# @endo/ocapn-simulator + +A browser-based playground for experimenting with promise-shortening +scenarios on top of `@endo/ocapn`. Each "client" runs in its own web +worker, hosting a real `@endo/ocapn` client wired to a custom +`MessagePort`-based netlayer with simulated latency. + +This package is **private** to the workspace and is not published to +npm. It exists to make the `op:flush` / `op:flush-done` proposal +concrete: you can watch the export tables, kick off forwarder chains, +and manually trigger flushes that mirror what an automatic shortening +implementation would do. + +## Quick start + +From the workspace root: + +```sh +yarn install +yarn workspace @endo/ocapn-simulator dev +``` + +Vite will print a local URL (typically `http://localhost:5173`). + +To produce a production bundle: + +```sh +yarn workspace @endo/ocapn-simulator build:bundle +``` + +The package's plain `build` script is intentionally a no-op (Vite 7 +requires Node 20.19+; the workspace-wide `yarn build` in CI runs on +Node 18 too). + +## Layout + +``` +packages/ocapn-simulator/ + index.html + package.json + vite.config.js + src/ + main.js page bootstrap + sim-controller.js worker pool, viz state, UI wiring + bridge.js brokers MessagePort pairs between workers + visualization.js SVG rendering of clients/sessions/flow + sim-netlayer.js custom netlayer (transport: 'simworker') + worker.js per-worker entry: SES, ocapn client, Forwarder + styles.css + shims/ + node-crypto.js browser shim for `node:crypto` +``` + +## Wire protocol with the worker + +Each worker hosts one `@endo/ocapn` client. The main thread brokers +`MessagePort` pairs in response to the worker's outgoing connections: + +``` +worker → main : { type: 'sim/connect', toDesignator } +main → worker (peer A) : { type: 'sim/outgoing-port', toDesignator, port } +main → worker (peer B) : { type: 'sim/incoming-port', peerDesignator, port } +``` + +After that, the two ports carry application bytes +(`{ kind: 'data', bytes }`) directly, with each side's netlayer +applying the user-configured per-write latency. + +## Visualization + +- One circle per worker, labelled with the first hex bytes of its + designator. +- A solid edge between two circles means an active OCapN session + (the workers have completed handshake; the export tables on each + side hold at least the bootstrap plus one additional reference once + any forward traffic begins). +- Dashed = idle session, solid blue = recent forward traffic. +- The blue ring on a node = currently busy serving or initiating + a `forward(N)`. +- Small dots animate from sender to receiver each time a `forward` + call is dispatched. + +## Controls + +- **Restart** — tear down all workers and bring up a fresh ring. +- **Kick off random chain** — pick a random worker and call its + local `forward()`. +- **Flush a random imported promise** — pick a random worker that + has at least one `desc:import-promise` open and invoke the debug + `flushExport()` on it. The exporter swaps in a fresh local promise + at that slot and replies with `op:flush-done`. This is the same + primitive that a future shortening implementation would invoke + automatically as part of a 3PH; here it's exposed manually so you + can watch the table replacement happen. + +## Browser compatibility + +`@endo/ocapn`'s cryptography module imports `randomBytes` from +`node:crypto`. The simulator aliases that to `src/shims/node-crypto.js` +through Vite's `resolve.alias` so the package can run unmodified in the +browser. + +## Known limitations + +- The `flushExport` API is part of the prototype branch's debug + surface; the simulator probes positions `p-0` through `p-63` to find + candidates rather than iterating the export table, since the public + `OcapnTable` doesn't expose iteration. +- The "exports beyond bootstrap" indicator is approximated from the + active-session reports a worker emits on a 500 ms timer; visually + it lags real-time table changes. +- This is a prototype to make the proposal concrete; it is not a + conformance test for the spec. diff --git a/packages/ocapn-simulator/index.html b/packages/ocapn-simulator/index.html new file mode 100644 index 0000000000..262f1992c6 --- /dev/null +++ b/packages/ocapn-simulator/index.html @@ -0,0 +1,108 @@ + + + + + + OCapN promise-shortening simulator + + + +
+

OCapN promise-shortening simulator

+

+ Browser-only experiment. N web workers each host an + @endo/ocapn client. They reach each other through a + custom MessagePort netlayer with simulated latency. +

+
+ +
+
+
+ Topology + + + +
+
+ Forward chain + + +
+
+ +
+
+ +
+
+ Graph messages + + + + + + +
+ +
+ +
+
+
+

Event log

+
+
+
+

Active sessions

+

+          
+
+
+
+ + + + diff --git a/packages/ocapn-simulator/package.json b/packages/ocapn-simulator/package.json new file mode 100644 index 0000000000..5d1f165578 --- /dev/null +++ b/packages/ocapn-simulator/package.json @@ -0,0 +1,53 @@ +{ + "name": "@endo/ocapn-simulator", + "version": "0.1.0", + "private": true, + "description": "Browser-based simulator for OCapN promise-shortening experiments using @endo/ocapn from the op:flush branch.", + "keywords": [ + "ocapn", + "captp", + "endo", + "simulator", + "promise-shortening" + ], + "author": "Endo contributors", + "license": "Apache-2.0", + "homepage": "https://github.com/endojs/endo/tree/master/packages/ocapn-simulator#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/endojs/endo.git", + "directory": "packages/ocapn-simulator" + }, + "bugs": { + "url": "https://github.com/endojs/endo/issues" + }, + "type": "module", + "scripts": { + "build": "exit 0", + "build:bundle": "vite build", + "dev": "vite", + "start": "vite", + "preview": "vite preview", + "lint": "yarn lint:eslint", + "lint:eslint": "eslint '**/*.js'", + "lint-fix": "yarn lint:eslint --fix", + "test": "exit 0", + "test:c8": "exit 0", + "test:xs": "exit 0" + }, + "dependencies": { + "@endo/eventual-send": "workspace:^", + "@endo/harden": "workspace:^", + "@endo/init": "workspace:^", + "@endo/marshal": "workspace:^", + "@endo/ocapn": "workspace:^", + "@endo/pass-style": "workspace:^" + }, + "devDependencies": { + "eslint": "catalog:dev", + "vite": "^7.1.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/ocapn-simulator/src/bridge.js b/packages/ocapn-simulator/src/bridge.js new file mode 100644 index 0000000000..c63606ab8d --- /dev/null +++ b/packages/ocapn-simulator/src/bridge.js @@ -0,0 +1,83 @@ +// Main-thread bridge: brokers MessagePort pairs between workers in +// response to their `sim/connect` requests. +// +// One Bridge instance per simulation. The controller adds workers to +// the bridge with their stable designator; the bridge wires each +// worker's outgoing-message stream to a single dispatch handler. + +export class Bridge { + constructor() { + /** @type {Map} */ + this.workers = new Map(); // designator -> Worker + /** @type {(msg: any) => void} */ + this.onUnknownMessage = () => {}; + } + + /** + * @param {string} designator + * @param {Worker} worker + */ + add(designator, worker) { + this.workers.set(designator, worker); + worker.addEventListener('message', ev => { + const msg = ev.data; + if (!msg || typeof msg.type !== 'string') return; + if (msg.type === 'sim/connect') { + this.broker(designator, msg.toDesignator); + } else { + this.onUnknownMessage({ from: designator, msg }); + } + }); + } + + /** + * Create a MessageChannel and hand each end to the appropriate + * worker. The requester gets `sim/outgoing-port`; the responder gets + * `sim/incoming-port`. + * + * @param {string} fromDesignator + * @param {string} toDesignator + */ + broker(fromDesignator, toDesignator) { + const fromWorker = this.workers.get(fromDesignator); + const toWorker = this.workers.get(toDesignator); + if (!fromWorker) return; + if (!toWorker) { + fromWorker.postMessage({ + type: 'sim/connect-failed', + toDesignator, + reason: `no such peer ${toDesignator}`, + }); + return; + } + const channel = new MessageChannel(); + fromWorker.postMessage( + { + type: 'sim/outgoing-port', + toDesignator, + port: channel.port1, + }, + [channel.port1], + ); + toWorker.postMessage( + { + type: 'sim/incoming-port', + peerDesignator: fromDesignator, + port: channel.port2, + }, + [channel.port2], + ); + } + + shutdown() { + for (const worker of this.workers.values()) { + try { + worker.postMessage({ type: 'sim/shutdown' }); + } catch {} + try { + worker.terminate(); + } catch {} + } + this.workers.clear(); + } +} diff --git a/packages/ocapn-simulator/src/main.js b/packages/ocapn-simulator/src/main.js new file mode 100644 index 0000000000..0cd10ee5c8 --- /dev/null +++ b/packages/ocapn-simulator/src/main.js @@ -0,0 +1,11 @@ +// Thin entry: bootstrap, then hand off to the DOM UI module. + +import '@endo/init/debug.js'; + +import { + createSimulatorController, + wireSimulatorControls, +} from './sim-ui-dom.js'; + +const controller = createSimulatorController(document); +wireSimulatorControls(controller, document); diff --git a/packages/ocapn-simulator/src/shims/node-crypto.js b/packages/ocapn-simulator/src/shims/node-crypto.js new file mode 100644 index 0000000000..44ae9dca72 --- /dev/null +++ b/packages/ocapn-simulator/src/shims/node-crypto.js @@ -0,0 +1,12 @@ +// Browser-compatible replacement for the small slice of `node:crypto` +// that `@endo/ocapn` uses. The simulator's vite config aliases +// `node:crypto` to this file. +// +// Only `randomBytes` is exported; the full surface of the Node module +// is not implemented. + +export const randomBytes = n => { + const out = new Uint8Array(n); + globalThis.crypto.getRandomValues(out); + return out; +}; diff --git a/packages/ocapn-simulator/src/sim-controller.js b/packages/ocapn-simulator/src/sim-controller.js new file mode 100644 index 0000000000..f2ff6c089a --- /dev/null +++ b/packages/ocapn-simulator/src/sim-controller.js @@ -0,0 +1,462 @@ +// Top-level orchestration. Owns the worker pool, the bridge, and world state +// derived from worker messages. Emits view updates via `emit` — no DOM. + +import { Bridge } from './bridge.js'; + +/** + * @typedef {( + * | { + * type: 'worldView'; + * designators: string[]; + * sessions: string[]; + * active: string[]; + * busyDesignators: string[]; + * sessionSummaryText: string; + * } + * | { + * type: 'log'; + * category: string; + * text: string; + * ooo?: boolean; + * } + * | { type: 'logClear' } + * | { type: 'vizClearFlights' } + * | { + * type: 'vizPulse'; + * from: string; + * peer: string; + * flightMs: number; + * pulseClass: string; + * labelText?: string; + * } + * )} SimViewEvent + */ + +/** + * Excel-style labels: A..Z, AA, AB, ... + * @param {number} index 0-based + * @returns {string} + */ +const alphabetDesignator = index => { + let n = index; + let s = ''; + do { + s = String.fromCharCode(65 + (n % 26)) + s; + n = Math.floor(n / 26) - 1; + } while (n >= 0); + return s; +}; + +/** @param {number} count */ +const makeDesignators = count => + Array.from({ length: count }, (_, i) => alphabetDesignator(i)); + +const trim = (str, max) => (str.length <= max ? str : `${str.slice(0, max)}…`); + +export class SimController { + /** + * @param {object} opts + * @param {(e: SimViewEvent) => void} [opts.emit] + */ + constructor({ emit } = {}) { + /** @type {(e: SimViewEvent) => void} */ + this._emit = emit ?? (() => {}); + this.bridge = null; + this.workers = []; // [{ designator, worker, ready }] + this.designators = []; + /** @type {Map>} */ + this.peersOfWorker = new Map(); // designator -> Set from snapshots + /** @type {Map} */ + this.activityScore = new Map(); // edgeKey -> last activity timestamp + this.busyDesignators = new Set(); + this.traceCounter = 0; + /** @type {Map} directed `${sender}|${receiver}` -> next expected noop seq */ + this.noopExpected = new Map(); + /** @type {{ noop: boolean, forward: boolean, flush: boolean, handoff: boolean, handshake: boolean, abort: boolean }} */ + this.graphMessageFilters = { + noop: true, + forward: true, + flush: true, + handoff: true, + handshake: true, + abort: true, + }; + /** Simulated one-way latency per worker (ms); matches sim-netlayer write + read delays. */ + this.latencyMs = 500; + this.enableFlush = true; + } + + /** + * @param {'noop' | 'forward' | 'flush' | 'handoff' | 'handshake' | 'abort'} cat + * @param {boolean} visible + */ + setGraphMessageFilter(cat, visible) { + if (this.graphMessageFilters[cat] === undefined) return; + this.graphMessageFilters[cat] = visible; + } + + edgeKey(a, b) { + return a < b ? `${a}|${b}` : `${b}|${a}`; + } + + /** + * @param {'noop' | 'forward' | 'flush' | 'handoff' | 'handshake' | 'abort' | 'misc'} category + * @param {string} text + * @param {object} [opts] + * @param {boolean} [opts.ooo] + */ + appendLog(category, text, opts = {}) { + if ( + ![ + 'noop', + 'forward', + 'flush', + 'handoff', + 'handshake', + 'abort', + 'misc', + ].includes(category) + ) { + category = 'misc'; + } + const { ooo = false } = opts; + this._emit({ type: 'log', category, text, ooo }); + } + + log(line) { + this.appendLog('misc', line); + } + + /** + * @param {string} from designator of worker that emitted the event + * @param {object} detail + */ + applyVizOp(from, detail) { + const { kind, direction, peer, seq, n, msgType, method, position, reason } = + detail; + const isSend = direction === 'send'; + /** One logical hop applies latency on send and again on receive (see sim-netlayer). */ + const flightMs = Math.max(1, 2 * this.latencyMs); + const maybePulse = (pulseClass, filterKey, labelText) => { + if (!isSend) return; + if (!this.graphMessageFilters[filterKey]) return; + this._emit({ + type: 'vizPulse', + from, + peer, + flightMs, + pulseClass, + labelText, + }); + }; + + if (kind === 'forward') { + maybePulse('flow-pulse-forward', 'forward'); + if (!isSend) { + this.busyDesignators.add(from); + setTimeout(() => { + this.busyDesignators.delete(from); + this.emitWorldView(); + }, 1500); + } else { + this.activityScore.set(this.edgeKey(from, peer), Date.now()); + this.busyDesignators.add(from); + this.busyDesignators.add(peer); + setTimeout(() => { + this.busyDesignators.delete(from); + this.busyDesignators.delete(peer); + this.emitWorldView(); + }, 1500); + } + const nLabel = n === undefined || Number.isNaN(n) ? '?' : String(n); + this.appendLog( + 'forward', + `${direction} forward(n=${nLabel}) peer ${trim(peer, 6)}`, + ); + return; + } + + if (kind === 'noop') { + const noopLabel = + seq !== undefined && !Number.isNaN(seq) ? String(seq) : undefined; + maybePulse('flow-pulse-noop', 'noop', noopLabel); + if (seq === undefined || Number.isNaN(seq)) return; + if (isSend) { + this.appendLog('noop', `noop send #${seq} → ${trim(peer, 6)}`); + } else { + const key = `${peer}|${from}`; + const exp = this.noopExpected.get(key) ?? 1; + if (seq !== exp) { + console.error('[ocapn-simulator] noop recv out of order', { + from, + peer, + seq, + expected: exp, + }); + this.appendLog( + 'noop', + `OUT OF ORDER noop #${seq} (expected ${exp}) ← ${trim(peer, 6)}`, + { ooo: true }, + ); + } else { + this.appendLog('noop', `noop recv #${seq} ← ${trim(peer, 6)}`); + } + this.noopExpected.set(key, exp + 1); + } + return; + } + + if (kind === 'handshake') { + maybePulse('flow-pulse-handshake', 'handshake'); + this.appendLog( + 'handshake', + `${direction} op:start-session peer ${trim(peer, 6)}`, + ); + return; + } + + if (kind === 'abort') { + maybePulse('flow-pulse-abort', 'abort'); + const r = + reason !== undefined && reason !== null && reason !== '' + ? trim(String(reason), 48) + : ''; + this.appendLog( + 'abort', + `${direction} op:abort${r ? ` (${r})` : ''} peer ${trim(peer, 6)}`, + ); + return; + } + + if (kind === 'flush') { + const pos = position !== undefined ? String(position) : ''; + maybePulse('flow-pulse-flush', 'flush'); + this.appendLog( + 'flush', + `${direction} ${msgType}${pos ? ` pos ${pos}` : ''} peer ${trim(peer, 6)}`, + ); + return; + } + + if (kind === 'handoff') { + maybePulse('flow-pulse-handoff', 'handoff'); + const m = method || 'handoff'; + this.appendLog('handoff', `${direction} ${m} peer ${trim(peer, 6)}`); + } + } + + emitWorldView() { + const sessions = new Set(); + for (const [from, peers] of this.peersOfWorker) { + for (const peer of peers) { + sessions.add(this.edgeKey(from, peer)); + } + } + const active = new Set(); + const now = Date.now(); + for (const [key, ts] of this.activityScore) { + if (now - ts < 3000) active.add(key); + } + const lines = []; + for (const designator of this.designators) { + const peers = Array.from(this.peersOfWorker.get(designator) ?? []).map( + p => trim(p, 6), + ); + lines.push(`${trim(designator, 6)} : [${peers.join(', ')}]`); + } + this._emit({ + type: 'worldView', + designators: [...this.designators], + sessions: [...sessions], + active: [...active], + busyDesignators: [...this.busyDesignators], + sessionSummaryText: lines.join('\n'), + }); + } + + /** + * @param {object} opts + * @param {number} opts.clientCount + * @param {number} opts.latencyMs + * @param {boolean} [opts.enableFlush] + * @param {number} opts.chainLength + * @param {boolean} [opts.chainUniqueInPath=true] - when true, each hop targets a distinct node; length is capped by peer count. When false, nodes may repeat in the chain but never on two successive hops (always enforced). + */ + async restart({ + clientCount, + latencyMs, + enableFlush = true, + chainLength, + chainUniqueInPath = true, + }) { + this.enableFlush = enableFlush; + this.latencyMs = Math.max(0, Number(latencyMs) || 0); + + for (const { worker } of this.workers) { + try { + worker.postMessage({ type: 'sim/stop-noop-ticker' }); + } catch { + /* worker may already be dead */ + } + } + + this._emit({ type: 'logClear' }); + this._emit({ type: 'vizClearFlights' }); + + if (this.bridge) { + this.bridge.shutdown(); + this.bridge = null; + } + this.workers = []; + this.peersOfWorker = new Map(); + this.activityScore = new Map(); + this.busyDesignators = new Set(); + this.noopExpected = new Map(); + this.designators = makeDesignators(clientCount); + this.bridge = new Bridge(); + this.bridge.onUnknownMessage = ({ from, msg }) => + this.handleWorkerMessage(from, msg); + + const readyPromises = []; + for (const designator of this.designators) { + const worker = new Worker(new URL('./worker.js', import.meta.url), { + type: 'module', + }); + this.bridge.add(designator, worker); + this.workers.push({ designator, worker }); + readyPromises.push( + new Promise(resolve => { + const onMsg = ev => { + if (ev.data?.type === 'sim/ready') { + worker.removeEventListener('message', onMsg); + resolve(); + } + }; + worker.addEventListener('message', onMsg); + }), + ); + worker.postMessage({ + type: 'sim/init', + designator, + peerDesignators: this.designators, + latencyMs, + enableFlush, + }); + } + + this.appendLog( + 'misc', + `Spawning ${clientCount} workers (latency=${latencyMs}ms, enableFlush=${enableFlush}, chainLength=${Math.min(20, Math.max(1, Number(chainLength) || 1))}, uniqueInChain=${chainUniqueInPath})…`, + ); + await Promise.all(readyPromises); + this.appendLog('misc', 'All workers ready.'); + this.kickoff({ + chainLength: Math.min(20, Math.max(1, Number(chainLength) || 1)), + chainUniqueInPath, + }); + this.emitWorldView(); + } + + handleWorkerMessage(from, msg) { + switch (msg.type) { + case 'sim/snapshot': { + const set = new Set(msg.peers.map(p => p.designator)); + this.peersOfWorker.set(from, set); + this.emitWorldView(); + break; + } + case 'sim/event': { + const { event, detail } = msg; + if (event === 'viz-op') { + this.applyVizOp(from, detail); + } + break; + } + case 'sim/log': { + const parts = msg.args; + const body = Array.isArray(parts) + ? parts.map(p => String(p)).join(' ') + : String(parts ?? ''); + this.appendLog('misc', `[${trim(from, 6)}] ${body}`); + break; + } + case 'sim/kickoff-result': { + if (msg.error) { + this.appendLog( + 'misc', + `kickoff-result (${trim(from, 6)}): error ${msg.error}`, + ); + } else { + const r = msg.result; + const text = typeof r === 'string' ? r : JSON.stringify(r); + this.appendLog('misc', `kickoff-result (${trim(from, 6)}): ${text}`); + } + break; + } + default: + break; + } + } + + /** + * Hop path as `simworker://` URLs (all clients except initiator A). + * Successive hops never target the same node. Optional `uniqueInChain` (default true) + * also forbids repeating a node later in the path; effective length ≤ peer count. + * @param {number} chainLength + * @param {boolean} [uniqueInChain=true] + * @returns {string[]} + */ + pickChainPath(chainLength, uniqueInChain = true) { + const [, ...peers] = this.designators; + const want = Math.min( + 20, + Math.max(0, Math.floor(Number(chainLength) || 0)), + ); + if (want === 0 || peers.length === 0) { + return []; + } + if (peers.length === 1) { + return [`simworker://${peers[0]}`]; + } + if (uniqueInChain) { + const cap = Math.min(want, peers.length); + return peers.slice(0, cap).map(d => `simworker://${d}`); + } + const out = []; + for (let i = 0; i < want; i++) { + out.push(`simworker://${peers[i % peers.length]}`); + } + return out; + } + + kickoff({ chainLength, chainUniqueInPath = true }) { + if (this.workers.length === 0) { + this.appendLog('misc', 'no workers; press Restart first'); + return; + } + const initiator = this.designators[0]; + const target = this.workers.find(w => w.designator === initiator); + if (!target) { + this.appendLog('misc', 'initiator client missing'); + return; + } + const traceId = ++this.traceCounter; + const chainPath = this.pickChainPath(chainLength, chainUniqueInPath); + const pathLabel = chainPath + .map(s => s.replace(/^simworker:\/\//, '')) + .join('→'); + this.appendLog( + 'misc', + `kickoff #${traceId}: ${initiator} buildChain(${pathLabel})`, + ); + for (const { worker } of this.workers) { + worker.postMessage({ type: 'sim/stop-noop-ticker' }); + } + this.noopExpected.clear(); + target.worker.postMessage({ + type: 'sim/kickoff', + chainPath, + traceId, + }); + } +} diff --git a/packages/ocapn-simulator/src/sim-netlayer.js b/packages/ocapn-simulator/src/sim-netlayer.js new file mode 100644 index 0000000000..b89a991c82 --- /dev/null +++ b/packages/ocapn-simulator/src/sim-netlayer.js @@ -0,0 +1,335 @@ +// A browser-only netlayer that ferries OCapN bytes between web workers +// through MessagePorts brokered by the main thread. +// +// One netlayer instance per worker. The transport name is `simworker`. +// Each worker picks a deterministic designator chosen by the main thread +// (so all workers know each other's location ahead of time). +// +// Wire protocol with the main thread (via `globalThis` postMessage): +// +// worker -> main : { type: 'sim/connect', toDesignator } +// main -> worker: { type: 'sim/incoming-port', port, peerDesignator } +// main -> worker: { type: 'sim/outgoing-port', toDesignator, port } +// main -> worker: { type: 'sim/connect-failed', toDesignator, reason } +// +// Once both sides hold their MessagePort, application bytes flow +// directly: each side `port.postMessage({ kind: 'data', bytes })` with +// the bytes' ArrayBuffer transferred. Closing the connection sends +// `{ kind: 'close' }`. + +import harden from '@endo/harden'; + +import { locationToLocationId } from '@endo/ocapn'; +import { readOcapnHandshakeMessage } from '../../ocapn/src/codecs/operations.js'; +import { makeSyrupReader } from '../../ocapn/src/syrup/decode.js'; + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +/** + * If bytes are a pre-session frame (op:start-session / op:abort), notify for viz. + * @param {Uint8Array} bytes + * @param {'send' | 'receive'} direction + * @param {string} peerDesignator + * @param {(d: { kind: string, direction: string, peer: string, reason?: string }) => void} [report] + */ +const tryReportPreSessionFrame = (bytes, direction, peerDesignator, report) => { + if (!report) return; + try { + const reader = makeSyrupReader(bytes); + if (reader.index >= bytes.length) return; + const msg = readOcapnHandshakeMessage(reader); + if (msg.type === 'op:start-session') { + report({ kind: 'handshake', direction, peer: peerDesignator }); + } else if (msg.type === 'op:abort') { + report({ + kind: 'abort', + direction, + peer: peerDesignator, + reason: msg.reason, + }); + } + } catch { + // Bytes are a post-handshake session frame or incomplete. + } +}; + +/** + * @typedef {object} SimNetlayerOptions + * @property {string} designator - This worker's stable identifier. + * @property {() => number} getLatencyMs - Per-write delay; read each call so the UI can tune live. + * @property {object} mainBridge - The object that knows how to talk to the main thread. + * @property {(msg: any, transfer?: Transferable[]) => void} mainBridge.postToMain + * @property {(handler: (msg: any) => void) => void} mainBridge.onMainMessage + * @property {(detail: { kind: string, direction: 'send' | 'receive', peer: string, reason?: string }) => void} [reportPreSessionWire] + */ + +/** + * @param {SimNetlayerOptions} options + * @returns {(handlers: import('@endo/ocapn').NetlayerHandlers, logger: any) => any} + */ +export const makeSimNetlayerFactory = ({ + designator, + getLatencyMs, + mainBridge, + reportPreSessionWire, +}) => { + // Build the location at construction time so the main thread can + // refer to us by it. + const location = harden({ + type: 'ocapn-peer', + transport: 'simworker', + designator, + hints: harden({}), + }); + + return (handlers, logger) => { + /** @type {Map} */ + const outgoing = new Map(); + + /** + * Wrap a MessagePort as the netlayer's "socket" side. The connection + * may be created before the port has been brokered; until that + * happens we buffer writes locally. + * + * @param {string | undefined} outgoingKey - outgoing map key; undefined for incoming-only sockets + * @param {() => MessagePort | undefined} getPort + * @param {string} wirePeerDesignator - remote peer designator (viz / pre-session tap) + * @returns {import('@endo/ocapn').SocketOperations} + */ + const makeSocketOps = (outgoingKey, getPort, wirePeerDesignator) => { + let closed = false; + return { + write(bytes) { + if (closed) return; + const send = port => { + tryReportPreSessionFrame( + bytes, + 'send', + wirePeerDesignator, + reportPreSessionWire, + ); + const copy = new Uint8Array(bytes); + try { + port.postMessage({ kind: 'data', bytes: copy }, [copy.buffer]); + } catch (err) { + logger?.error?.('sim-netlayer write failed', err); + } + }; + const port = getPort(); + const latency = Math.max(0, getLatencyMs() | 0); + const dispatch = () => { + if (closed) return; + const livePort = getPort(); + if (livePort) { + send(livePort); + } else if (port === undefined && outgoingKey !== undefined) { + // Buffer until the port arrives. + const slot = outgoing.get(outgoingKey); + if (slot) slot.pending.push(bytes); + } + }; + if (latency > 0) { + sleep(latency).then(dispatch); + } else { + dispatch(); + } + }, + end() { + if (closed) return; + closed = true; + const port = getPort(); + if (port) { + try { + port.postMessage({ kind: 'close' }); + } catch {} + try { + port.close(); + } catch {} + } + }, + }; + }; + + /** + * @param {string} peerDesignator + * @param {MessagePort} port + */ + const wireIncomingPort = (peerDesignator, port) => { + let connection; + const socketOps = makeSocketOps(undefined, () => port, peerDesignator); + // eslint-disable-next-line no-use-before-define + connection = handlers.makeConnection(netlayer, false, socketOps); + port.onmessage = ev => { + const data = ev.data; + if (data?.kind === 'data') { + const latency = Math.max(0, getLatencyMs() | 0); + const bytes = + data.bytes instanceof Uint8Array + ? data.bytes + : new Uint8Array(data.bytes); + const deliver = () => { + if (!connection.isDestroyed) { + tryReportPreSessionFrame( + bytes, + 'receive', + peerDesignator, + reportPreSessionWire, + ); + try { + handlers.handleMessageData(connection, bytes); + } catch (err) { + logger?.error?.('handleMessageData (incoming) threw', err); + } + } + }; + if (latency > 0) sleep(latency).then(deliver); + else deliver(); + } else if (data?.kind === 'close') { + handlers.handleConnectionClose(connection); + } + }; + port.start(); + }; + + /** + * @param {string} toDesignator + * @param {MessagePort} port + */ + const wireOutgoingPort = (toDesignator, port) => { + const slot = outgoing.get(toDesignator); + if (!slot) { + try { + port.close(); + } catch {} + return; + } + slot.port = port; + port.onmessage = ev => { + const data = ev.data; + if (data?.kind === 'data') { + const latency = Math.max(0, getLatencyMs() | 0); + const bytes = + data.bytes instanceof Uint8Array + ? data.bytes + : new Uint8Array(data.bytes); + const deliver = () => { + if (!slot.connection.isDestroyed) { + tryReportPreSessionFrame( + bytes, + 'receive', + toDesignator, + reportPreSessionWire, + ); + try { + handlers.handleMessageData(slot.connection, bytes); + } catch (err) { + logger?.error?.('handleMessageData (outgoing) threw', err); + } + } + }; + if (latency > 0) sleep(latency).then(deliver); + else deliver(); + } else if (data?.kind === 'close') { + handlers.handleConnectionClose(slot.connection); + outgoing.delete(toDesignator); + } + }; + port.start(); + // Flush any buffered writes that accumulated before the port + // arrived. The latency was already paid when we deferred them. + const pending = slot.pending; + slot.pending = []; + for (const bytes of pending) { + tryReportPreSessionFrame( + bytes, + 'send', + toDesignator, + reportPreSessionWire, + ); + const copy = new Uint8Array(bytes); + try { + port.postMessage({ kind: 'data', bytes: copy }, [copy.buffer]); + } catch (err) { + logger?.error?.('sim-netlayer flush failed', err); + } + } + }; + + /** + * @param {string} toDesignator + * @param {string} reason + */ + const failOutgoing = (toDesignator, reason) => { + const slot = outgoing.get(toDesignator); + if (!slot) return; + outgoing.delete(toDesignator); + handlers.handleConnectionClose( + slot.connection, + Error(`connect failed: ${reason}`), + ); + }; + + mainBridge.onMainMessage(msg => { + if (msg?.type === 'sim/incoming-port') { + wireIncomingPort(msg.peerDesignator, msg.port); + } else if (msg?.type === 'sim/outgoing-port') { + wireOutgoingPort(msg.toDesignator, msg.port); + } else if (msg?.type === 'sim/connect-failed') { + failOutgoing(msg.toDesignator, msg.reason); + } + }); + + /** + * @param {import('@endo/ocapn').OcapnLocation} target + * @returns {import('@endo/ocapn').Connection} + */ + const connect = target => { + if (target.transport !== 'simworker') { + throw Error( + `sim-netlayer cannot connect to transport: ${target.transport}`, + ); + } + const key = target.designator; + const existing = outgoing.get(key); + if (existing && !existing.connection.isDestroyed) { + return existing.connection; + } + if (existing) { + outgoing.delete(key); + } + // Allocate a connection up front; the port arrives async. + const slot = { connection: undefined, port: undefined, pending: [] }; + const socketOps = makeSocketOps(key, () => slot.port, key); + const connection = handlers.makeConnection(netlayer, true, socketOps); + slot.connection = connection; + outgoing.set(key, slot); + mainBridge.postToMain({ type: 'sim/connect', toDesignator: key }); + return connection; + }; + + const shutdown = () => { + for (const slot of outgoing.values()) { + if (slot.port) { + try { + slot.port.postMessage({ kind: 'close' }); + } catch {} + try { + slot.port.close(); + } catch {} + } + try { + slot.connection.end(); + } catch {} + } + outgoing.clear(); + }; + + const netlayer = harden({ + location, + locationId: locationToLocationId(location), + connect, + shutdown, + }); + return netlayer; + }; +}; diff --git a/packages/ocapn-simulator/src/sim-ui-dom.js b/packages/ocapn-simulator/src/sim-ui-dom.js new file mode 100644 index 0000000000..6ac8826e9d --- /dev/null +++ b/packages/ocapn-simulator/src/sim-ui-dom.js @@ -0,0 +1,131 @@ +// Browser UI: wires SimController emissions to the SVG viz and log panels. + +import { SimController } from './sim-controller.js'; +import { makeViz } from './visualization.js'; + +const LOG_MAX_LINES = 220; + +const nowStr = () => { + const d = new Date(); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; +}; + +/** + * @param {Document} doc + * @returns {SimController} + */ +export function createSimulatorController(doc = document) { + const $ = id => doc.getElementById(id); + const svg = $('viz-svg'); + const eventLog = $('log-events'); + const sessionLog = $('log-sessions'); + + const appendEventLog = (category, text, ooo = false) => { + const line = doc.createElement('div'); + line.className = `log-line cat-${category}${ooo ? ' log-ooo' : ''}`; + const timeEl = doc.createElement('span'); + timeEl.className = 'log-time'; + timeEl.textContent = `[${nowStr()}] `; + const bodyEl = doc.createElement('span'); + bodyEl.className = 'log-body'; + bodyEl.textContent = text; + line.append(timeEl, bodyEl); + eventLog.prepend(line); + while (eventLog.childElementCount > LOG_MAX_LINES) { + eventLog.lastElementChild?.remove(); + } + }; + + const viz = makeViz(svg); + + return new SimController({ + emit: ev => { + switch (ev.type) { + case 'worldView': + viz.render({ + designators: ev.designators, + sessions: new Set(ev.sessions), + active: new Set(ev.active), + busyDesignators: new Set(ev.busyDesignators), + }); + sessionLog.textContent = ev.sessionSummaryText; + break; + case 'log': + appendEventLog(ev.category, ev.text, ev.ooo); + break; + case 'logClear': + eventLog.replaceChildren(); + sessionLog.textContent = ''; + break; + case 'vizClearFlights': + viz.clearFlights(); + break; + case 'vizPulse': + viz.pulse(ev.from, ev.peer, ev.flightMs, ev.pulseClass, ev.labelText); + break; + default: + break; + } + }, + }); +} + +/** + * Binds form controls to the controller and auto-starts the simulation. + * @param {SimController} controller + * @param {Document} [doc] + */ +export function wireSimulatorControls(controller, doc = document) { + const $ = id => doc.getElementById(id); + + const numericInput = (id, fallback) => { + const el = $(id); + const v = parseInt(el.value, 10); + return Number.isFinite(v) ? v : fallback; + }; + + const readEnableFlush = () => $('control-enable-flush').checked; + + const readChainUniqueInPath = () => $('control-chain-unique-in-path').checked; + + const runRestart = () => + controller.restart({ + clientCount: Math.min( + 12, + Math.max(2, numericInput('control-client-count', 8)), + ), + latencyMs: Math.min( + 2000, + Math.max(0, numericInput('control-latency', 500)), + ), + enableFlush: readEnableFlush(), + chainLength: Math.min( + 20, + Math.max(1, numericInput('control-chain-length', 4)), + ), + chainUniqueInPath: readChainUniqueInPath(), + }); + + $('control-restart').addEventListener('click', () => { + runRestart().catch(err => console.error(err)); + }); + + const bindVizToggle = (checkboxId, category) => { + const el = $(checkboxId); + el.addEventListener('change', () => { + controller.setGraphMessageFilter(category, el.checked); + }); + }; + + bindVizToggle('toggle-viz-forward', 'forward'); + bindVizToggle('toggle-viz-noop', 'noop'); + bindVizToggle('toggle-viz-flush', 'flush'); + bindVizToggle('toggle-viz-handoff', 'handoff'); + bindVizToggle('toggle-viz-handshake', 'handshake'); + bindVizToggle('toggle-viz-abort', 'abort'); + + runRestart().catch(err => console.error(err)); +} diff --git a/packages/ocapn-simulator/src/styles.css b/packages/ocapn-simulator/src/styles.css new file mode 100644 index 0000000000..c6b47fc595 --- /dev/null +++ b/packages/ocapn-simulator/src/styles.css @@ -0,0 +1,433 @@ +:root { + color-scheme: light dark; + --bg: #fdfdfd; + --fg: #111; + --accent: #3a6ea5; + --muted: #888; + --edge: #888; + --edge-active: #3a6ea5; + --node: #fff8d8; + --node-stroke: #444; + --flush-tone: #b35900; + --handoff-tone: #1e8f5e; + --noop-tone: #1e40af; + --forward-tone: #ffffff; + --handshake-tone: #c2189a; + --abort-tone: #b71c1c; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1a1a1a; + --fg: #eee; + --accent: #84b6f4; + --muted: #aaa; + --edge: #555; + --edge-active: #84b6f4; + --node: #2a2a3a; + --node-stroke: #ccc; + --flush-tone: #ffb86b; + --handoff-tone: #6dffc4; + --noop-tone: #82b1ff; + --forward-tone: #ffffff; + --handshake-tone: #f48fb1; + --abort-tone: #ef5350; + } +} + +html { + height: 100%; +} + +html, +body { + background: var(--bg); + color: var(--fg); + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + margin: 0; + padding: 0; +} + +body { + min-height: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +header { + padding: 1rem 1.5rem 0.5rem; + flex-shrink: 0; +} +header h1 { + margin: 0; + font-size: 1.25rem; +} +header .subtitle { + margin: 0.25rem 0 0; + color: var(--muted); + font-size: 0.9rem; +} + +.app-main { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.controls { + display: flex; + gap: 1rem; + flex-wrap: wrap; + padding: 0.5rem 1.5rem; + flex-shrink: 0; +} +.controls fieldset { + border: 1px solid var(--muted); + border-radius: 6px; + padding: 0.5rem 0.75rem; + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; +} +.controls legend { + padding: 0 0.25rem; + color: var(--muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.controls label { + display: inline-flex; + flex-direction: column; + font-size: 0.8rem; + color: var(--muted); +} +.controls label.toggle-inline { + flex-direction: row; + align-items: center; + gap: 0.35rem; +} +.controls input[type='checkbox'] { + width: auto; +} +.controls input { + width: 5rem; + padding: 0.25rem; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--muted); + border-radius: 3px; +} +.controls button { + padding: 0.4rem 0.75rem; + background: var(--accent); + color: white; + border: 0; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; +} +.controls button:hover { + filter: brightness(1.1); +} + +.controls-run { + display: flex; + align-items: center; + flex: 1 1 auto; + justify-content: flex-end; + min-width: 7rem; + padding: 0.25rem 0; +} +.controls-run button { + padding: 0.45rem 1.1rem; + background: var(--accent); + color: white; + border: 0; + border-radius: 4px; + cursor: pointer; + font-size: 0.95rem; + font-weight: 600; +} +.controls-run button:hover { + filter: brightness(1.1); +} + +.viz { + flex: 1 1 auto; + min-height: 120px; + display: flex; + flex-direction: column; + padding: 0 1rem; + box-sizing: border-box; + min-width: 0; +} + +.graph-toggles { + border: 1px solid var(--muted); + border-radius: 6px; + padding: 0.4rem 0.65rem; + margin: 0 0 0.4rem; + display: flex; + flex-wrap: wrap; + gap: 0.65rem 1rem; + align-items: center; + flex-shrink: 0; +} +.graph-toggles legend { + padding: 0 0.25rem; + color: var(--muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.graph-toggles .toggle-inline { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.8rem; + color: var(--muted); +} + +.graph-toggles .viz-key-forward::before, +.graph-toggles .viz-key-noop::before, +.graph-toggles .viz-key-flush::before, +.graph-toggles .viz-key-handoff::before, +.graph-toggles .viz-key-handshake::before, +.graph-toggles .viz-key-abort::before { + content: ''; + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + flex-shrink: 0; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 28%, transparent); +} +.graph-toggles .viz-key-forward::before { + background: var(--forward-tone); +} +.graph-toggles .viz-key-noop::before { + background: var(--noop-tone); +} +.graph-toggles .viz-key-flush::before { + background: var(--flush-tone); +} +.graph-toggles .viz-key-handoff::before { + background: var(--handoff-tone); +} +.graph-toggles .viz-key-handshake::before { + background: var(--handshake-tone); +} +.graph-toggles .viz-key-abort::before { + background: var(--abort-tone); +} + +#viz-svg { + flex: 1 1 auto; + width: 100%; + min-height: 120px; + display: block; +} + +.logs-panel { + flex: 0 0 auto; + display: flex; + flex-direction: column; + padding: 0 1rem 0.75rem; + box-sizing: border-box; + height: 240px; + max-height: 40vh; +} + +.log-split { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: row; + gap: 1rem; +} + +.log-column { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +.log-column h2 { + font-size: 0.9rem; + margin: 0 0 0.25rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; + flex-shrink: 0; +} + +.log-view { + flex: 1 1 auto; + min-height: 0; + height: 0; + overflow-x: hidden; + overflow-y: auto; + background: rgba(127, 127, 127, 0.08); + border: 1px solid rgba(127, 127, 127, 0.2); + border-radius: 4px; + padding: 0.5rem; + font-size: 0.78rem; + box-sizing: border-box; +} + +.log-view-mono { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + white-space: pre-wrap; + word-break: break-word; +} + +.log-line { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + line-height: 1.35; + margin-bottom: 0.12rem; + color: var(--fg); +} +.log-line .log-time { + color: var(--muted); + font-weight: 400; +} +.log-line.cat-forward .log-body { + color: var(--forward-tone); +} +@media (prefers-color-scheme: light) { + .log-line.cat-forward .log-body { + text-shadow: + 0 0 2px rgba(0, 0, 0, 0.75), + 0 0 4px rgba(0, 0, 0, 0.45), + 0 1px 1px rgba(0, 0, 0, 0.35); + } +} +.log-line.cat-flush .log-body { + color: var(--flush-tone); +} +.log-line.cat-handoff .log-body { + color: var(--handoff-tone); +} +.log-line.cat-noop .log-body { + color: var(--noop-tone); +} +.log-line.cat-handshake .log-body { + color: var(--handshake-tone); +} +.log-line.cat-abort .log-body { + color: var(--abort-tone); +} +.log-line.cat-misc .log-body { + color: var(--fg); + opacity: 0.88; +} +.log-line.log-ooo .log-body { + color: #d22; + font-weight: 600; +} +@media (prefers-color-scheme: dark) { + .log-line.log-ooo .log-body { + color: #ff6b6b; + } +} + +.node-circle { + fill: var(--node); + stroke: var(--node-stroke); + stroke-width: 2; +} +.node-circle.busy { + stroke: var(--accent); + stroke-width: 4; +} +.node-label { + fill: var(--fg); + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + text-anchor: middle; + dominant-baseline: central; + pointer-events: none; +} +.session-edge { + stroke: var(--edge); + stroke-width: 1.5; + fill: none; + stroke-dasharray: 4 3; +} +.session-edge.active { + stroke: var(--edge-active); + stroke-width: 2.5; + stroke-dasharray: none; +} +.flow-pulse { + fill: var(--accent); + stroke: var(--fg); + stroke-width: 1.25; + opacity: 0.95; +} + +.flow-pulse-noop { + fill: var(--noop-tone); + stroke: color-mix(in srgb, var(--noop-tone) 40%, #0a0a2a); + stroke-width: 1.35; + opacity: 0.98; +} + +.flow-pulse-noop-label { + fill: #f8fafc; + font-size: 8px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-weight: 700; + pointer-events: none; + paint-order: stroke fill; + stroke: rgba(0, 0, 0, 0.45); + stroke-width: 0.4px; +} + +.flow-pulse-forward { + fill: var(--forward-tone); + stroke: var(--fg); + stroke-width: 1.6; + opacity: 1; +} + +.flow-pulse-flush { + fill: var(--flush-tone); + stroke: var(--fg); + stroke-width: 1.15; + opacity: 0.94; +} + +.flow-pulse-handoff { + fill: var(--handoff-tone); + stroke: var(--fg); + stroke-width: 1.15; + opacity: 0.94; +} + +.flow-pulse-handshake { + fill: var(--handshake-tone); + stroke: color-mix(in srgb, var(--handshake-tone) 35%, var(--fg)); + stroke-width: 1.35; + opacity: 0.97; +} + +.flow-pulse-abort { + fill: var(--abort-tone); + stroke: color-mix(in srgb, var(--abort-tone) 30%, #1a0000); + stroke-width: 1.4; + opacity: 0.98; +} + +@media (max-width: 720px) { + .log-split { + flex-direction: column; + } +} diff --git a/packages/ocapn-simulator/src/visualization.js b/packages/ocapn-simulator/src/visualization.js new file mode 100644 index 0000000000..e0d609884d --- /dev/null +++ b/packages/ocapn-simulator/src/visualization.js @@ -0,0 +1,201 @@ +// SVG visualization of the simulator state. Pure DOM; `sim-ui-dom.js` +// calls `render(state)` when the controller emits a worldView update. + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const RADIUS = 220; +const NODE_RADIUS = 28; + +/** + * @param {SVGSVGElement} svg + */ +export const makeViz = svg => { + const layer = document.createElementNS(SVG_NS, 'g'); + layer.setAttribute('id', 'viz-layer'); + svg.appendChild(layer); + + /** @type {Map} */ + const nodes = new Map(); + /** @type {Map} */ + const edges = new Map(); + /** @type {SVGGElement} */ + const flowLayer = document.createElementNS(SVG_NS, 'g'); + flowLayer.setAttribute('id', 'flow-layer'); + layer.appendChild(flowLayer); + + const layout = designators => { + nodes.clear(); + edges.clear(); + layer.innerHTML = ''; + layer.appendChild(flowLayer); + flowLayer.innerHTML = ''; + const n = designators.length; + designators.forEach((designator, i) => { + const angle = (i / n) * Math.PI * 2 - Math.PI / 2; + const x = Math.cos(angle) * RADIUS; + const y = Math.sin(angle) * RADIUS; + const dot = document.createElementNS(SVG_NS, 'circle'); + dot.setAttribute('cx', String(x)); + dot.setAttribute('cy', String(y)); + dot.setAttribute('r', String(NODE_RADIUS)); + dot.setAttribute('class', 'node-circle'); + layer.appendChild(dot); + const label = document.createElementNS(SVG_NS, 'text'); + label.setAttribute('x', String(x)); + label.setAttribute('y', String(y)); + label.setAttribute('class', 'node-label'); + label.textContent = designator; + layer.appendChild(label); + nodes.set(designator, { dot, label, x, y }); + }); + }; + + /** + * @param {string} a + * @param {string} b + * @returns {string} + */ + const edgeKey = (a, b) => (a < b ? `${a}|${b}` : `${b}|${a}`); + + /** + * @param {{ + * designators: string[], + * sessions: Set, // edgeKey of active sessions + * active: Set, // edgeKey of "have non-bootstrap exports" or recent activity + * busyDesignators: Set, + * }} state + */ + const render = state => { + // (Re)layout if the node set changed. + const currentDesignators = Array.from(nodes.keys()); + const desiredDesignators = state.designators; + const sameSet = + currentDesignators.length === desiredDesignators.length && + currentDesignators.every(d => desiredDesignators.includes(d)); + if (!sameSet) { + layout(desiredDesignators); + } + // Edges + const wantKeys = new Set(); + for (const key of state.sessions) { + wantKeys.add(key); + let line = edges.get(key); + if (!line) { + const [a, b] = key.split('|'); + const na = nodes.get(a); + const nb = nodes.get(b); + if (!na || !nb) continue; + line = document.createElementNS(SVG_NS, 'line'); + line.setAttribute('x1', String(na.x)); + line.setAttribute('y1', String(na.y)); + line.setAttribute('x2', String(nb.x)); + line.setAttribute('y2', String(nb.y)); + line.setAttribute('class', 'session-edge'); + // Insert below dots. + layer.insertBefore(line, layer.firstChild); + edges.set(key, line); + } + if (state.active.has(key)) { + line.classList.add('active'); + } else { + line.classList.remove('active'); + } + } + for (const [key, line] of edges) { + if (!wantKeys.has(key)) { + line.remove(); + edges.delete(key); + } + } + // Busy ring on nodes + for (const [designator, n] of nodes) { + if (state.busyDesignators.has(designator)) { + n.dot.classList.add('busy'); + } else { + n.dot.classList.remove('busy'); + } + } + // Keep animated pulses above nodes and edges (edges use insertBefore firstChild). + layer.appendChild(flowLayer); + }; + + /** Remove in-flight packet animations (e.g. on simulator reset). */ + const clearFlights = () => { + flowLayer.innerHTML = ''; + }; + + /** + * Animate a small dot from one node to another. Used to show + * forward(N) traffic and noop chain hops. + * @param {string} fromDesignator + * @param {string} toDesignator + * @param {number} durationMs + * @param {string} [pulseClass] - defaults to 'flow-pulse' + * @param {string} [labelText] - e.g. noop sequence number (centered on packet) + */ + const pulse = ( + fromDesignator, + toDesignator, + durationMs, + pulseClass, + labelText, + ) => { + const from = nodes.get(fromDesignator); + const to = nodes.get(toDesignator); + if (!from || !to) return; + const isSmall = + pulseClass === 'flow-pulse-noop' || + pulseClass === 'flow-pulse-handshake' || + pulseClass === 'flow-pulse-abort'; + const r = isSmall ? '6' : '7'; + const isNoopLabel = + pulseClass === 'flow-pulse-noop' && + labelText !== undefined && + labelText !== ''; + + const root = isNoopLabel ? document.createElementNS(SVG_NS, 'g') : null; + const dot = document.createElementNS(SVG_NS, 'circle'); + dot.setAttribute('cx', '0'); + dot.setAttribute('cy', '0'); + dot.setAttribute('r', r); + dot.setAttribute('class', pulseClass || 'flow-pulse'); + + if (isNoopLabel && root) { + root.appendChild(dot); + const seqText = document.createElementNS(SVG_NS, 'text'); + seqText.setAttribute('x', '0'); + seqText.setAttribute('y', '0'); + seqText.setAttribute('text-anchor', 'middle'); + seqText.setAttribute('dominant-baseline', 'central'); + seqText.setAttribute('class', 'flow-pulse-noop-label'); + seqText.textContent = labelText; + root.appendChild(seqText); + flowLayer.appendChild(root); + } else { + dot.setAttribute('cx', String(from.x)); + dot.setAttribute('cy', String(from.y)); + flowLayer.appendChild(dot); + } + + const start = performance.now(); + const tick = now => { + const dur = Math.max(1, durationMs); + const t = Math.min(1, (now - start) / dur); + const x = from.x + (to.x - from.x) * t; + const y = from.y + (to.y - from.y) * t; + if (root) { + root.setAttribute('transform', `translate(${x} ${y})`); + } else { + dot.setAttribute('cx', String(x)); + dot.setAttribute('cy', String(y)); + } + if (t < 1) { + requestAnimationFrame(tick); + } else { + (root ?? dot).remove(); + } + }; + requestAnimationFrame(tick); + }; + + return { render, pulse, edgeKey, clearFlights }; +}; diff --git a/packages/ocapn-simulator/src/worker.js b/packages/ocapn-simulator/src/worker.js new file mode 100644 index 0000000000..45f2627497 --- /dev/null +++ b/packages/ocapn-simulator/src/worker.js @@ -0,0 +1,520 @@ +// Worker entrypoint. One worker hosts one OCapN client. +// +// Lifecycle: +// 1. Main thread postMessages { type: 'sim/init', designator, peerDesignators, latencyMs }. +// 2. Worker registers netlayer + one sturdy ref: DemoController (buildChain, demoMessage). +// 3. Worker postMessages { type: 'sim/ready', location }. +// 4. Main thread: +// - sim/stop-noop-ticker stop demoMessage ticker and clear chain root +// - sim/start-noop-ticker start ticker only if noopRootPromise is set (chain root exists) +// - sim/kickoff { chainPath: string[], traceId } URLs like simworker://B +// - sim/shutdown +// 5. Client locators in buildChain are netlayer URL strings: simworker:// +// +// The netlayer brokers MessagePorts through the main thread; see sim-netlayer.js. + +// Must be first: installs HandledPromise shim, imports SES, and runs lockdown. +import '@endo/init/debug.js'; + +import harden from '@endo/harden'; +import { Far } from '@endo/marshal'; +import { E } from '@endo/eventual-send'; +import { makeClient, encodeSwissnum, locationToLocationId } from '@endo/ocapn'; +import { getSelectorName } from '../../ocapn/src/selector.js'; +import { nameForPassableSymbol } from '@endo/pass-style'; + +import { makeSimNetlayerFactory } from './sim-netlayer.js'; + +/** Single app sturdy ref; same swiss string used in registerSturdyRef + fetch. */ +const SWISSNUM_DEMO = 'DemoController'; + +/** @type {string} */ +let myDesignator; +/** @type {string[]} */ +let peerDesignators = []; +let latencyMs = 500; +/** @type {boolean} */ +let enableFlushFeature = true; +/** @type {ReturnType | null} */ +let client; +let netlayer; +/** @type {ReturnType | null} */ +let demoController = null; + +const log = (...parts) => { + postMessage({ + type: 'sim/log', + args: parts.map(p => String(p)), + }); +}; +const reportEvent = (event, detail = {}) => { + postMessage({ type: 'sim/event', from: myDesignator, event, detail }); +}; + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +/** @returns {number} 0..999 ms */ +const randomDelay = () => Math.floor(Math.random() * 1000); + +/** + * Client locator URL for this transport (see sim-netlayer location shape). + * @param {string} url + */ +const parseLocator = url => { + const m = /^simworker:\/\/([^/]+)$/.exec(String(url).trim()); + if (!m) { + throw Error( + `invalid client locator (expected simworker://): ${url}`, + ); + } + return harden({ + type: /** @type {'ocapn-peer'} */ ('ocapn-peer'), + transport: /** @type {'simworker'} */ ('simworker'), + designator: m[1], + hints: harden({}), + }); +}; + +/** @type {Promise} */ +let noopWaveChain = Promise.resolve(); + +/** Only `E(demoController).buildChain(path)` on A (until stop/shutdown error). Steady demoMessage pipelines on this; no fallback enliven. */ +let promiseChainEntryPromise = null; + +/** Monotonic seq passed only as demoMessage(seq) (wire + viz). */ +let demoSeqCounter = 0; + +/** @type {WeakSet} */ +const wireTappedOcapn = new WeakSet(); + +let noopTimer; + +/** + * @param {unknown} v + * @returns {number} + */ +const toNum = v => + typeof v === 'bigint' ? Number(v) : Number(/** @type {any} */ (v)); + +const countCapSlots = table => { + let n = 0; + for (let pos = 0n; pos < 64n; pos += 1n) { + for (const typ of ['o', 'p', 'a']) { + const locSlot = `${typ}+${pos}`; + const remSlot = `${typ}-${pos}`; + if (table.getValueForSlot(locSlot) !== undefined) { + if (typ === 'o' && pos === 0n) continue; + n += 1; + } + if (table.getValueForSlot(remSlot) !== undefined) { + n += 1; + } + } + } + return n; +}; + +const argHasHandoffDesc = (v, depth = 0) => { + if (depth > 10 || v === null || v === undefined || typeof v !== 'object') { + return false; + } + const inner = v.object !== undefined ? v.object : v; + if ( + inner && + typeof inner === 'object' && + (inner.type === 'desc:handoff-give' || + inner.type === 'desc:handoff-receive') + ) { + return true; + } + for (const k of Object.keys(v)) { + if (argHasHandoffDesc(v[k], depth + 1)) return true; + } + return false; +}; + +const deliverMethodName = firstArg => { + if (typeof firstArg === 'string') return firstArg; + if (typeof firstArg === 'symbol') { + const name = nameForPassableSymbol(firstArg); + if (typeof name === 'string' && !name.startsWith('@@')) { + return name; + } + try { + return getSelectorName(firstArg); + } catch { + return null; + } + } + return null; +}; + +/** + * @param {'send' | 'receive'} direction + * @param {string} peerDesignator + * @param {any} message + * @returns {object | null} + */ +const classifyVizMessage = (direction, peerDesignator, message) => { + if (!message || typeof message !== 'object') return null; + const t = message.type; + + if (t === 'op:flush' || t === 'op:flush-done') { + return { + kind: 'flush', + direction, + peer: peerDesignator, + msgType: t, + position: + message.position !== undefined ? toNum(message.position) : undefined, + }; + } + + if (t === 'op:abort') { + return { + kind: 'abort', + direction, + peer: peerDesignator, + reason: + message.reason !== undefined && message.reason !== null + ? String(message.reason) + : undefined, + }; + } + + if ( + t === 'op:deliver' && + Array.isArray(message.args) && + message.args.length > 0 + ) { + const method = deliverMethodName(message.args[0]); + const rest = message.args.slice(1); + + if (method === 'demoMessage' && rest.length >= 1) { + return { + kind: 'noop', + direction, + peer: peerDesignator, + seq: toNum(rest[0]), + }; + } + + if (method === 'buildChain' && rest.length >= 1 && Array.isArray(rest[0])) { + return { + kind: 'forward', + direction, + peer: peerDesignator, + n: rest[0].length, + }; + } + + if (method === 'deposit-gift' || method === 'withdraw-gift') { + return { kind: 'handoff', direction, peer: peerDesignator, method }; + } + + if (method && message.args.some(a => argHasHandoffDesc(a))) { + return { + kind: 'handoff', + direction, + peer: peerDesignator, + method, + }; + } + } + + return null; +}; + +/** + * @param {{ _debug?: { subscribeMessages: Function } }} ocapn + * @param {string} peerDesignator + */ +const attachWireTap = (ocapn, peerDesignator) => { + const dbg = ocapn._debug; + if (!dbg || wireTappedOcapn.has(ocapn)) return; + wireTappedOcapn.add(ocapn); + dbg.subscribeMessages((direction, message) => { + const viz = classifyVizMessage(direction, peerDesignator, message); + if (viz) { + reportEvent('viz-op', viz); + } + }); +}; + +const ensureWireTaps = () => { + if (!client?._debug) return; + for (const peer of peerDesignators) { + const peerLocation = harden({ + type: /** @type {'ocapn-peer'} */ ('ocapn-peer'), + transport: /** @type {'simworker'} */ ('simworker'), + designator: peer, + hints: harden({}), + }); + const session = client._debug.sessionManager.getActiveSession( + locationToLocationId(peerLocation), + ); + if (session?.ocapn) { + attachWireTap(session.ocapn, peer); + } + } +}; + +const createDemoController = () => + Far('DemoController', { + /** + * @param {string[]} locators Net URLs: simworker://<designator> + * @returns {Promise} + */ + buildChain: locators => { + if (!client?._debug) { + throw Error('no client debug api'); + } + const list = Array.isArray(locators) ? [...locators] : []; + // If end of chain, return a Destination object. + if (list.length === 0) { + log('buildChain: empty path -> local Destination'); + // await sleep(randomDelay()); + // await sleep(randomDelay()); + return Far('Destination', { + demoMessage: async seq => { + void seq; + }, + }); + } + const [head, ...rest] = list; + // const beforeTime = randomDelay(); + // const afterTime = randomDelay(); + // await sleep(beforeTime); + const loc = parseLocator(head); + const sessionP = client.provideSession(loc); + const bootstrapP = E(sessionP).getBootstrap(); + const demoControllerP = E(bootstrapP).fetch( + encodeSwissnum(SWISSNUM_DEMO), + ); + return E(demoControllerP).buildChain(rest); + }, + }); + +const makeMainBridge = () => + harden({ + postToMain: (msg, transfer) => { + if (transfer) { + postMessage(msg, transfer); + } else { + postMessage(msg); + } + }, + onMainMessage: handler => { + addEventListener('message', ev => { + const msg = ev.data; + if ( + msg && + typeof msg.type === 'string' && + msg.type.startsWith('sim/') + ) { + if ( + msg.type === 'sim/incoming-port' || + msg.type === 'sim/outgoing-port' || + msg.type === 'sim/connect-failed' + ) { + handler(msg); + } + } + }); + }, + }); + +const setupClient = async () => { + client = makeClient({ + debugLabel: `worker-${myDesignator.slice(0, 6)}`, + verbose: false, + debugMode: true, + enableFlush: enableFlushFeature, + }); + demoController = createDemoController(); + client.registerSturdyRef(SWISSNUM_DEMO, demoController); + netlayer = await client.registerNetlayer( + makeSimNetlayerFactory({ + designator: myDesignator, + getLatencyMs: () => latencyMs, + mainBridge: makeMainBridge(), + reportPreSessionWire: detail => reportEvent('viz-op', detail), + }), + ); +}; + +const reportSnapshot = () => { + ensureWireTaps(); + const peers = []; + if (client?._debug) { + for (const peer of peerDesignators) { + const peerLocation = harden({ + type: /** @type {'ocapn-peer'} */ ('ocapn-peer'), + transport: /** @type {'simworker'} */ ('simworker'), + designator: peer, + hints: harden({}), + }); + const session = client._debug.sessionManager.getActiveSession( + locationToLocationId(peerLocation), + ); + if ( + session?.ocapn?._debug && + countCapSlots(session.ocapn._debug.ocapnTable) > 0 + ) { + peers.push({ designator: peer }); + } + } + } + postMessage({ type: 'sim/snapshot', from: myDesignator, peers }); +}; + +let snapshotTimer; + +const startSnapshotLoop = () => { + stopSnapshotLoop(); + snapshotTimer = setInterval(reportSnapshot, 500); +}; +const stopSnapshotLoop = () => { + if (snapshotTimer !== undefined) { + clearInterval(snapshotTimer); + snapshotTimer = undefined; + } +}; + +const stopNoopLoop = () => { + if (noopTimer !== undefined) { + clearInterval(noopTimer); + noopTimer = undefined; + } + noopWaveChain = Promise.resolve(); +}; + +const resumeSteadyDemoMessages = () => { + if (peerDesignators.length === 0) { + log('resumeSteadyDemoMessages: skip - no peers'); + return; + } + if (promiseChainEntryPromise == null) { + log( + 'steady demo: no chain root — ticker off (Restart or a successful kickoff required)', + ); + return; + } + log('steady demo: starting ticker on buildChain promise root'); + startNoopLoop(); +}; + +const startNoopLoop = () => { + stopNoopLoop(); + noopTimer = setInterval(() => { + if (promiseChainEntryPromise === null) return; + demoSeqCounter += 1; + const seq = demoSeqCounter; + const run = () => { + try { + // E.sendOnly(noopRootPromise).demoMessage(seq); + E(promiseChainEntryPromise) + .demoMessage(seq) + .catch(err => { + log('demoMessage tick failed', `seq=${seq}`, String(err)); + }); + } catch (err) { + log('demoMessage tick failed', `seq=${seq}`, String(err)); + } + }; + noopWaveChain = noopWaveChain.then(run).catch(err => { + log('demoMessage queue chain failed', String(err)); + }); + }, 200); +}; + +addEventListener('message', async ev => { + const msg = ev.data; + if (!msg || typeof msg.type !== 'string') return; + try { + if (msg.type === 'sim/init') { + myDesignator = msg.designator; + peerDesignators = (msg.peerDesignators || []).filter( + d => d !== myDesignator, + ); + latencyMs = msg.latencyMs ?? 500; + enableFlushFeature = msg.enableFlush !== false; + promiseChainEntryPromise = null; + demoSeqCounter = 0; + stopNoopLoop(); + demoController = null; + await setupClient(); + startSnapshotLoop(); + postMessage({ + type: 'sim/ready', + from: myDesignator, + location: { ...netlayer.location }, + }); + } else if (msg.type === 'sim/update-peers') { + peerDesignators = (msg.peerDesignators || []).filter( + d => d !== myDesignator, + ); + latencyMs = msg.latencyMs ?? latencyMs; + } else if (msg.type === 'sim/stop-noop-ticker') { + stopNoopLoop(); + promiseChainEntryPromise = null; + } else if (msg.type === 'sim/start-noop-ticker') { + demoSeqCounter = 0; + resumeSteadyDemoMessages(); + } else if (msg.type === 'sim/kickoff') { + const { chainPath, traceId } = msg; + const path = Array.isArray(chainPath) ? chainPath : []; + reportEvent('kickoff', { chainLength: path.length }); + if (!demoController) { + postMessage({ + type: 'sim/kickoff-result', + from: myDesignator, + traceId, + error: 'DemoController not initialized', + }); + return; + } + try { + const startPromise = /** @type {Promise} */ ( + /** @type {any} */ (E(demoController)).buildChain(path) + ); + promiseChainEntryPromise = startPromise; + demoSeqCounter = 0; + startNoopLoop(); + log( + 'kickoff: demo ticker on buildChain promise', + `hops=${path.length}`, + ); + const result = await startPromise; + postMessage({ + type: 'sim/kickoff-result', + from: myDesignator, + traceId, + result: + result && typeof result === 'object' + ? '[Destination]' + : String(result), + }); + } catch (err) { + stopNoopLoop(); + promiseChainEntryPromise = null; + postMessage({ + type: 'sim/kickoff-result', + from: myDesignator, + traceId, + error: String(err), + }); + } finally { + resumeSteadyDemoMessages(); + } + } else if (msg.type === 'sim/shutdown') { + stopSnapshotLoop(); + stopNoopLoop(); + promiseChainEntryPromise = null; + try { + client?.shutdown(); + } catch {} + } + } catch (err) { + log('worker top-level error', String(err)); + } +}); diff --git a/packages/ocapn-simulator/vite.config.js b/packages/ocapn-simulator/vite.config.js new file mode 100644 index 0000000000..d239de0e33 --- /dev/null +++ b/packages/ocapn-simulator/vite.config.js @@ -0,0 +1,42 @@ +import { defineConfig } from 'vite'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '..', '..'); + +// `@endo/ocapn` imports `randomBytes` from `node:crypto`. In the +// browser we redirect that to a tiny WebCrypto-based shim. +const cryptoShim = resolve(here, 'src/shims/node-crypto.js'); + +export default defineConfig({ + root: here, + resolve: { + alias: { + 'node:crypto': cryptoShim, + crypto: cryptoShim, + }, + }, + optimizeDeps: { + // Pre-bundle to avoid a refresh storm in dev as the many small files + // in @endo/ocapn are first crawled. + include: [ + '@endo/eventual-send', + '@endo/harden', + '@endo/init/debug.js', + '@endo/marshal', + '@endo/ocapn', + ], + }, + worker: { + format: 'es', + }, + server: { + fs: { + // Allow vite to serve files from the workspace (yarn pnpm linker + // resolves @endo/* into ../../node_modules and the package + // sources live in ../../packages/*). + allow: [root], + }, + }, +}); diff --git a/packages/ocapn/index.js b/packages/ocapn/index.js index 33b7de52e9..394b486837 100644 --- a/packages/ocapn/index.js +++ b/packages/ocapn/index.js @@ -22,4 +22,9 @@ */ export { makeClient } from './src/client/index.js'; -export { swissnumFromBytes, swissnumToBytes } from './src/client/util.js'; +export { + encodeSwissnum, + locationToLocationId, + swissnumFromBytes, + swissnumToBytes, +} from './src/client/util.js'; diff --git a/packages/ocapn/src/captp/pairwise.js b/packages/ocapn/src/captp/pairwise.js index f4632111b0..7085d9978f 100644 --- a/packages/ocapn/src/captp/pairwise.js +++ b/packages/ocapn/src/captp/pairwise.js @@ -16,6 +16,7 @@ import { makeRefCounter } from './refcount.js'; * @property {(value: object) => Slot | undefined} getSlotForValue - Lookup the slot for a value. Does NOT increment refcount. * @property {(slot: Slot) => object | undefined} getValueForSlot - Lookup the value for a slot. Does NOT increment refcount. * @property {(slot: Slot, value: object) => void} registerSlot - Register a slot and value. Does NOT increment refcount. + * @property {(slot: Slot, value: object) => void} replaceExportValue - Replace the value held at an existing local (export) slot, preserving its refcount. Used by op:flush to swap a resolver in place. * @property {(slot: Slot, refcount: number) => void} dropSlot - Decrements refcount by the given amount, Drops the slot and value if the refcount reaches 0. * @property {(slot: Slot) => number} getRefCount - Get the committed refcount for the slot. * @property {(slot: Slot) => void} recordSentSlot - Increments refcount for the slot. @@ -167,6 +168,32 @@ export const makePairwiseTable = ({ } }; + /** + * Replace the value at an existing local (export) slot, keeping its + * refcount intact. The previous value's reverse mapping is dropped and + * the new value's mapping is installed so that lookups by value continue + * to work. The export hook is fired again so observers can re-record the + * binding. + * + * @param {Slot} slot + * @param {object} newValue + */ + const replaceExportValue = (slot, newValue) => { + if (!isSlotLocal(slot)) { + throw new Error('replaceExportValue: only local slots are supported'); + } + if (!exportTable.has(slot)) { + throw new Error(`replaceExportValue: no export at slot ${slot}`); + } + const oldValue = exportTable.get(slot); + if (oldValue !== undefined) { + valueToSlot.delete(oldValue); + } + exportTable.set(slot, newValue); + valueToSlot.set(newValue, slot); + exportHook(newValue, slot); + }; + /** * @param {Slot} slot * @param {number} refcount @@ -234,6 +261,7 @@ export const makePairwiseTable = ({ getSlotForValue, getValueForSlot, registerSlot, + replaceExportValue, dropSlot, getRefCount, recordSentSlot, diff --git a/packages/ocapn/src/client/index.js b/packages/ocapn/src/client/index.js index ecb1158706..a910d3ed5e 100644 --- a/packages/ocapn/src/client/index.js +++ b/packages/ocapn/src/client/index.js @@ -176,6 +176,7 @@ const makeSessionManager = () => { * @param {string} [options.captpVersion] - For testing: override the CapTP version sent in handshakes * @param {boolean} [options.enableImportCollection] - If true, imports are tracked with WeakRefs and GC'd when unreachable. Default: true. * @param {boolean} [options.debugMode] - **EXPERIMENTAL**: If true, exposes `_debug` object on Ocapn instances with internal APIs for testing. Default: false. + * @param {boolean} [options.enableFlush] - If true, enables `flushExport` and `op:flush` / `op:flush-done`. Default: false. * @param {Logger} [options.logger] - If provided, overrides the default console-based logger. The same logger is also handed to netlayer factories registered via `registerNetlayer`. When omitted, defaults to a console-based logger labelled with `debugLabel`; `info` is suppressed unless `verbose` is true. * @returns {Client} */ @@ -187,6 +188,7 @@ export const makeClient = ({ captpVersion = '1.0', enableImportCollection = true, debugMode = false, + enableFlush = false, logger: providedLogger, } = {}) => { /** @type {Map} */ @@ -316,6 +318,7 @@ export const makeClient = ({ debugLabel, enableImportCollection, debugMode, + enableFlush, ); }; diff --git a/packages/ocapn/src/client/ocapn.js b/packages/ocapn/src/client/ocapn.js index d78ee96c0d..4ec19ca5c2 100644 --- a/packages/ocapn/src/client/ocapn.js +++ b/packages/ocapn/src/client/ocapn.js @@ -529,8 +529,10 @@ const makeBootstrapObject = ( */ 'deposit-gift': (giftId, gift) => { const passStyle = ocapnPassStyleOf(gift); - if (passStyle !== 'remotable') { - throw Error(`${label}: Bootstrap deposit-gift: Gift must be remotable`); + if (passStyle !== 'remotable' && passStyle !== 'promise') { + throw Error( + `${label}: Bootstrap deposit-gift: Gift must be remotable or a promise (got pass-style ${passStyle})`, + ); } const { isLocal } = referenceKit.getInfoForVal(gift); if (!isLocal) { @@ -666,6 +668,7 @@ const makeBootstrapObject = ( * @property {OcapnTable} ocapnTable * @property {(message: object) => void} sendMessage * @property {(observer: MessageObserver) => () => void} subscribeMessages + * @property {(remoteValue: object) => Promise} flushExport */ /** @@ -693,6 +696,7 @@ const makeBootstrapObject = ( * @param {string} [ourIdLabel] * @param {boolean} [enableImportCollection] - If true, imports are tracked with WeakRefs and GC'd when unreachable. Default: true. * @param {boolean} [debugMode] - **EXPERIMENTAL**: If true, exposes `_debug` object with internal APIs for testing. Default: false. + * @param {boolean} [enableFlush] - If true, `flushExport` and `op:flush` / `op:flush-done` are enabled. Default: false. * @returns {Ocapn} */ export const makeOcapn = ( @@ -710,6 +714,7 @@ export const makeOcapn = ( ourIdLabel = 'OCapN', enableImportCollection = true, debugMode = false, + enableFlush = false, ) => { const onReject = reason => { logger.info(`onReject`, reason); @@ -731,6 +736,19 @@ export const makeOcapn = ( connection.end(); // eslint-disable-next-line no-use-before-define ocapnTable.destroy(disconnectError); + // Reject any flush operations that haven't completed. + // eslint-disable-next-line no-use-before-define + for (const promiseKit of pendingFlushSettlers.values()) { + promiseKit.reject(disconnectError); + } + // eslint-disable-next-line no-use-before-define + pendingFlushSettlers.clear(); + // eslint-disable-next-line no-use-before-define + for (const promiseKit of flushedPromiseSettlers.values()) { + promiseKit.reject(disconnectError); + } + // eslint-disable-next-line no-use-before-define + flushedPromiseSettlers.clear(); // Notify the session manager to immediately end the session. // This prevents race conditions where a new connection arrives // before the socket 'close' event fires. @@ -959,12 +977,142 @@ export const makeOcapn = ( ocapnTable.dropSlot(slot, 1); } }, + 'op:flush': message => { + if (!enableFlush) { + throw Error( + 'OCapN: op:flush received but flush is disabled (pass enableFlush: true to makeClient)', + ); + } + const { position } = message; + logger.info(`flush`, { position }); + // The flush targets the export-table position of a local promise. The + // promise-shortening proposal calls for the local resolver `r` to be + // fulfilled with a fresh promise `p'` and for the export at the same + // position to be replaced with the resolver `r'` of `p'`. Subsequent + // messages from the peer that target this position then naturally + // buffer on `p'` (an unresolved HandledPromise) until something + // (typically the third-party handoff completing) resolves it. + const slot = makeSlot('p', true, position); + // eslint-disable-next-line no-use-before-define + const oldValue = ocapnTable.getValueForSlot(slot); + if (oldValue === undefined) { + throw Error( + `OCapN: op:flush: No local promise at position ${position}`, + ); + } + // Build a fresh promise / settler pair; the settler is held in + // flushedPromiseSettlers so a subsequent shortening step (e.g. a + // handoff) can resolve it. Until then any deliveries targeting this + // position queue up locally on the unresolved promise. + const rPrimeKit = makePromiseKit(); + const pPrime = rPrimeKit.promise; + // Silence the unhandled rejection warning, but don't affect handlers. + pPrime.catch(sink); + // eslint-disable-next-line no-use-before-define + ocapnTable.replaceExportValue(slot, pPrime); + // eslint-disable-next-line no-use-before-define + flushedPromiseSettlers.set(position, rPrimeKit); + // Acknowledge the flush. Because messages on this connection are FIFO, + // by the time this op:flush-done reaches the peer it has already + // received every message Alice had sent that targeted the prior + // promise. + // eslint-disable-next-line no-use-before-define + send({ + type: 'op:flush-done', + position, + }); + }, + 'op:flush-done': message => { + const { position } = message; + logger.info(`flush-done`, { position }); + // eslint-disable-next-line no-use-before-define + const pendingKit = pendingFlushSettlers.get(position); + if (pendingKit === undefined) { + throw Error( + `OCapN: op:flush-done: No pending flush at position ${position}`, + ); + } + // eslint-disable-next-line no-use-before-define + pendingFlushSettlers.delete(position); + pendingKit.resolve(undefined); + }, 'op:abort': message => { const { reason } = message; abort(reason); }, }); + /** + * Promise-kits for `op:flush` that we have sent and are awaiting an + * `op:flush-done` for. Keyed by the export-position we asked the peer to + * flush. + * @type {Map>} + */ + const pendingFlushSettlers = new Map(); + /** + * Promise-kits for fresh promises we created when the peer asked us to + * flush one of our exported promises. The settler can be used to fulfill + * the new promise (and thus deliver any buffered messages) once the + * shortened reference is available. + * @type {Map>} + */ + const flushedPromiseSettlers = new Map(); + + /** + * Send an op:flush for the given remote promise (i.e. an imported + * `desc:import-promise`) and return a promise that resolves when the + * peer's op:flush-done has been received. After that point all messages + * that the peer had previously sent for this promise have been received, + * which is the precondition the proposal needs before initiating the + * third-party handoff that shortens the path. + * + * @param {object} remoteValue + * @returns {Promise} + */ + const flushExport = remoteValue => { + if (!enableFlush) { + throw Error( + 'OCapN: flushExport requires flush support (pass enableFlush: true to makeClient)', + ); + } + // eslint-disable-next-line no-use-before-define + if (didUnplug()) { + return /** @type {Promise} */ ( + /** @type {unknown} */ ( + // eslint-disable-next-line no-use-before-define + quietReject(didUnplug()) + ) + ); + } + // eslint-disable-next-line no-use-before-define + const slot = ocapnTable.getSlotForValue(remoteValue); + if (slot === undefined) { + throw Error('flushExport: value is not tracked in this session'); + } + const { type, isLocal, position } = parseSlot(slot); + if (isLocal) { + throw Error( + `flushExport: value must be a remote (peer-exported) reference, got slot ${slot}`, + ); + } + if (type !== 'p') { + throw Error(`flushExport: value must be a promise, got slot ${slot}`); + } + if (pendingFlushSettlers.has(position)) { + throw Error( + `flushExport: a flush is already pending at position ${position}`, + ); + } + const promiseKit = makePromiseKit(); + pendingFlushSettlers.set(position, promiseKit); + // eslint-disable-next-line no-use-before-define + send({ + type: 'op:flush', + position, + }); + return /** @type {Promise} */ (promiseKit.promise); + }; + /** * @param {Record} message */ @@ -1276,6 +1424,7 @@ export const makeOcapn = ( ocapnTable, sendMessage: send, subscribeMessages, + flushExport, }; } return harden(ocapn); diff --git a/packages/ocapn/src/codecs/operations.js b/packages/ocapn/src/codecs/operations.js index cda6fb3245..d22d697eb8 100644 --- a/packages/ocapn/src/codecs/operations.js +++ b/packages/ocapn/src/codecs/operations.js @@ -60,6 +60,27 @@ const OpGcAnswersCodec = makeOcapnRecordCodecFromDefinition( }, ); +// op:flush is sent by Bob to Alice when Bob is about to shorten a promise +// exported by Alice (typically as part of a third-party handoff). It targets +// the export-table position of Alice's resolver for the promise. On receipt +// Alice swaps the value at that position for a fresh local promise so any +// further messages from Bob targeting the position buffer locally on Alice's +// side until the shortened path resolves, then replies with op:flush-done. +const OpFlushCodec = makeOcapnRecordCodecFromDefinition('OpFlush', 'op:flush', { + position: NonNegativeIntegerCodec, +}); + +// op:flush-done is Alice's acknowledgement that the swap has happened. After +// receiving it, Bob knows it has received every message Alice had previously +// sent that targeted the original promise, and may proceed with the handoff. +const OpFlushDoneCodec = makeOcapnRecordCodecFromDefinition( + 'OpFlushDone', + 'op:flush-done', + { + position: NonNegativeIntegerCodec, + }, +); + export const OcapnPreSessionOperationsCodecs = makeRecordUnionCodec( 'OcapnPreSessionOperations', { @@ -204,6 +225,8 @@ export const makeOcapnOperationsCodecs = (descCodecs, passableCodecs) => { OpListenCodec, OpGcExportsCodec, OpGcAnswersCodec, + OpFlushCodec, + OpFlushDoneCodec, }); /** diff --git a/packages/ocapn/test/codecs/operations.test.js b/packages/ocapn/test/codecs/operations.test.js index c82230a49d..1655f4a08e 100644 --- a/packages/ocapn/test/codecs/operations.test.js +++ b/packages/ocapn/test/codecs/operations.test.js @@ -161,6 +161,22 @@ export const table = [ answerPositions: [1n], }, }, + { + // ; non-negative integer (export position to flush) + name: 'op:flush', + value: { + type: 'op:flush', + position: 7n, + }, + }, + { + // ; non-negative integer (matches op:flush position) + name: 'op:flush-done', + value: { + type: 'op:flush-done', + position: 7n, + }, + }, // Below are messages observed in the ocapn python test suite. { name: 'python op:deliver fetch 1', diff --git a/packages/ocapn/test/codecs/snapshots/operations.test.js.md b/packages/ocapn/test/codecs/snapshots/operations.test.js.md index 6ad6a9135e..154b2570cc 100644 Binary files a/packages/ocapn/test/codecs/snapshots/operations.test.js.md and b/packages/ocapn/test/codecs/snapshots/operations.test.js.md differ diff --git a/packages/ocapn/test/codecs/snapshots/operations.test.js.snap b/packages/ocapn/test/codecs/snapshots/operations.test.js.snap index 20cc91c225..23d86b2ebe 100644 Binary files a/packages/ocapn/test/codecs/snapshots/operations.test.js.snap and b/packages/ocapn/test/codecs/snapshots/operations.test.js.snap differ diff --git a/packages/ocapn/test/flush.test.js b/packages/ocapn/test/flush.test.js new file mode 100644 index 0000000000..43c9272f86 --- /dev/null +++ b/packages/ocapn/test/flush.test.js @@ -0,0 +1,211 @@ +// @ts-check + +import harden from '@endo/harden'; +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; +import { makePromiseKit } from '@endo/promise-kit'; +import { + testWithErrorUnwrapping, + makeTestClientPair, + getOcapnDebug, + waitUntilTrue, +} from './_util.js'; +import { encodeSwissnum } from '../src/client/util.js'; +import { makeSlot, parseSlot } from '../src/captp/pairwise.js'; + +testWithErrorUnwrapping( + 'op:flush swaps the export and acknowledges', + async t => { + // B exports a Promise (via PromiseProvider.get()). A then sends + // op:flush targeting the export-position. B should swap the value + // at that position for a fresh local promise and reply with + // op:flush-done. A's flushExport() promise resolves on receipt. + const promiseKit = makePromiseKit(); + const testObjectTable = new Map(); + testObjectTable.set( + 'PromiseProvider', + Far('PromiseProvider', { + // Wrapping the promise in an array forces it to be serialized as a + // desc:import-promise immediately rather than awaited; otherwise an + // unresolved promise return value blocks the fulfill message until + // it settles. + get: () => harden([promiseKit.promise]), + }), + ); + + const { establishSession, shutdownBoth } = await makeTestClientPair({ + makeDefaultSwissnumTable: () => testObjectTable, + clientAOptions: { enableImportCollection: false }, + clientBOptions: { enableImportCollection: false }, + }); + + try { + const { sessionA, sessionB } = await establishSession(); + const debugA = getOcapnDebug(sessionA.ocapn); + const debugB = getOcapnDebug(sessionB.ocapn); + + const bootstrapB = sessionA.ocapn.getRemoteBootstrap(); + const provider = await E(bootstrapB).fetch( + encodeSwissnum('PromiseProvider'), + ); + // Trigger the Promise to be exported by B. + const answerPromise = E(provider).get(); + // Drop the floating promise; the call's mere act of being sent + // ensures B exports its promise. + answerPromise.catch(() => {}); + + // Wait until B has registered an export slot for promiseKit.promise. + await waitUntilTrue( + () => + debugB.ocapnTable.getSlotForValue(promiseKit.promise) !== undefined, + ); + const bExportSlot = debugB.ocapnTable.getSlotForValue(promiseKit.promise); + if (!bExportSlot) { + t.fail('B did not export its promise'); + return; + } + const { type, isLocal, position } = parseSlot(bExportSlot); + t.is(type, 'p', 'B exported the value as a promise slot'); + t.true(isLocal, 'slot is local on B'); + + // Now wait until A has imported the promise at the matching slot. + const aImportSlot = makeSlot('p', false, position); + await waitUntilTrue( + () => debugA.ocapnTable.getValueForSlot(aImportSlot) !== undefined, + ); + const aImportValue = debugA.ocapnTable.getValueForSlot(aImportSlot); + if (!aImportValue) { + t.fail('A does not have an import for the promise position'); + return; + } + + // Verify that B holds promiseKit.promise at the export position. + const beforeValue = debugB.ocapnTable.getValueForSlot(bExportSlot); + t.is( + beforeValue, + promiseKit.promise, + 'before flush, B exports its original promise at the position', + ); + + // Issue the flush from A. + const flushDone = debugA.flushExport(aImportValue); + await flushDone; + + // After op:flush-done has been received on A, B's export at the + // same position must be a *different* (still pending) promise. + const afterValue = debugB.ocapnTable.getValueForSlot(bExportSlot); + t.not( + afterValue, + promiseKit.promise, + 'after flush, B has swapped the export for a fresh promise', + ); + t.true( + afterValue instanceof Promise, + 'after flush, the swapped export is still a promise', + ); + } finally { + shutdownBoth(); + } + }, +); + +testWithErrorUnwrapping( + 'flushExport: a second flush at the same position errors', + async t => { + const promiseKit = makePromiseKit(); + const testObjectTable = new Map(); + testObjectTable.set( + 'PromiseProvider', + Far('PromiseProvider', { + get: () => harden([promiseKit.promise]), + }), + ); + + const { establishSession, shutdownBoth } = await makeTestClientPair({ + makeDefaultSwissnumTable: () => testObjectTable, + clientAOptions: { enableImportCollection: false }, + clientBOptions: { enableImportCollection: false }, + }); + + try { + const { sessionA, sessionB } = await establishSession(); + const debugA = getOcapnDebug(sessionA.ocapn); + const debugB = getOcapnDebug(sessionB.ocapn); + + const provider = await E(sessionA.ocapn.getRemoteBootstrap()).fetch( + encodeSwissnum('PromiseProvider'), + ); + E(provider) + .get() + .catch(() => {}); + + await waitUntilTrue( + () => + debugB.ocapnTable.getSlotForValue(promiseKit.promise) !== undefined, + ); + const bSlot = debugB.ocapnTable.getSlotForValue(promiseKit.promise); + if (!bSlot) { + t.fail('B did not export the promise'); + return; + } + const { position } = parseSlot(bSlot); + const aSlot = makeSlot('p', false, position); + await waitUntilTrue( + () => debugA.ocapnTable.getValueForSlot(aSlot) !== undefined, + ); + const aValue = debugA.ocapnTable.getValueForSlot(aSlot); + if (!aValue) { + t.fail('A does not have an import for the promise position'); + return; + } + + // Start a flush but do not await it. + const firstFlush = debugA.flushExport(aValue); + // Calling again before the first completes must throw. + t.throws(() => debugA.flushExport(aValue), { + message: /already pending/, + }); + await firstFlush; + } finally { + shutdownBoth(); + } + }, +); + +testWithErrorUnwrapping( + 'flushExport rejects for non-promise references', + async t => { + const testObjectTable = new Map(); + testObjectTable.set('Greeter', Far('Greeter', { hi: () => 'hello' })); + const { establishSession, shutdownBoth } = await makeTestClientPair({ + makeDefaultSwissnumTable: () => testObjectTable, + }); + try { + const { sessionA } = await establishSession(); + const debugA = getOcapnDebug(sessionA.ocapn); + const greeter = await E(sessionA.ocapn.getRemoteBootstrap()).fetch( + encodeSwissnum('Greeter'), + ); + // Greeter is an imported *object*, not a promise. flushExport + // should refuse it. + t.throws(() => debugA.flushExport(greeter), { + message: /must be a promise/, + }); + } finally { + shutdownBoth(); + } + }, +); + +testWithErrorUnwrapping('flushExport rejects for unknown values', async t => { + const { establishSession, shutdownBoth } = await makeTestClientPair({}); + try { + const { sessionA } = await establishSession(); + const debugA = getOcapnDebug(sessionA.ocapn); + t.throws(() => debugA.flushExport(Far('NotTracked', {})), { + message: /not tracked/, + }); + } finally { + shutdownBoth(); + } +}); diff --git a/tsconfig.json b/tsconfig.json index 4638be8762..4f106b493b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "api-docs/**", "**/node_modules", "packages/eslint-plugin", + "packages/ocapn-simulator", "packages/ses-integration-test", "packages/module-source", "packages/test262-runner", diff --git a/typedoc.json b/typedoc.json index 8c27fac0e6..b6f6f14829 100644 --- a/typedoc.json +++ b/typedoc.json @@ -31,6 +31,7 @@ "packages/memoize", "packages/module-source", "packages/netstring", + "packages/ocapn-simulator", "packages/skel", "packages/stream-types-test", "packages/syrup", diff --git a/yarn.lock b/yarn.lock index 5c3aad278d..9e8513b75c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1027,6 +1027,21 @@ __metadata: languageName: unknown linkType: soft +"@endo/ocapn-simulator@workspace:packages/ocapn-simulator": + version: 0.0.0-use.local + resolution: "@endo/ocapn-simulator@workspace:packages/ocapn-simulator" + dependencies: + "@endo/eventual-send": "workspace:^" + "@endo/harden": "workspace:^" + "@endo/init": "workspace:^" + "@endo/marshal": "workspace:^" + "@endo/ocapn": "workspace:^" + "@endo/pass-style": "workspace:^" + eslint: "catalog:dev" + vite: "npm:^7.1.0" + languageName: unknown + linkType: soft + "@endo/ocapn@workspace:^, @endo/ocapn@workspace:packages/ocapn": version: 0.0.0-use.local resolution: "@endo/ocapn@workspace:packages/ocapn" @@ -1282,6 +1297,188 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/aix-ppc64@npm:0.27.7" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/android-arm64@npm:0.27.7" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/android-arm@npm:0.27.7" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/android-x64@npm:0.27.7" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/darwin-arm64@npm:0.27.7" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/darwin-x64@npm:0.27.7" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/freebsd-arm64@npm:0.27.7" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/freebsd-x64@npm:0.27.7" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-arm64@npm:0.27.7" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-arm@npm:0.27.7" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-ia32@npm:0.27.7" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-loong64@npm:0.27.7" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-mips64el@npm:0.27.7" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-ppc64@npm:0.27.7" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-riscv64@npm:0.27.7" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-s390x@npm:0.27.7" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-x64@npm:0.27.7" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/netbsd-arm64@npm:0.27.7" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/netbsd-x64@npm:0.27.7" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/openbsd-arm64@npm:0.27.7" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/openbsd-x64@npm:0.27.7" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/openharmony-arm64@npm:0.27.7" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/sunos-x64@npm:0.27.7" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/win32-arm64@npm:0.27.7" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/win32-ia32@npm:0.27.7" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/win32-x64@npm:0.27.7" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.7.0": version: 4.7.0 resolution: "@eslint-community/eslint-utils@npm:4.7.0" @@ -2291,6 +2488,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm-eabi@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.60.3" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@rollup/rollup-android-arm64@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-android-arm64@npm:4.45.1" @@ -2298,6 +2502,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm64@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-android-arm64@npm:4.60.3" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-arm64@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-darwin-arm64@npm:4.45.1" @@ -2305,6 +2516,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-arm64@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-darwin-arm64@npm:4.60.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-x64@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-darwin-x64@npm:4.45.1" @@ -2312,6 +2530,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-x64@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-darwin-x64@npm:4.60.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-freebsd-arm64@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-freebsd-arm64@npm:4.45.1" @@ -2319,6 +2544,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-freebsd-arm64@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.60.3" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-freebsd-x64@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-freebsd-x64@npm:4.45.1" @@ -2326,6 +2558,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-freebsd-x64@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-freebsd-x64@npm:4.60.3" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-gnueabihf@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.45.1" @@ -2333,6 +2572,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-gnueabihf@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.60.3" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-musleabihf@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.45.1" @@ -2340,6 +2586,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-musleabihf@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.60.3" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-gnu@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.45.1" @@ -2347,6 +2600,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-gnu@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.60.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-musl@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-linux-arm64-musl@npm:4.45.1" @@ -2354,6 +2614,27 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-musl@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.60.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-gnu@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.60.3" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-musl@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.60.3" + conditions: os=linux & cpu=loong64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-loongarch64-gnu@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.45.1" @@ -2368,6 +2649,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-ppc64-gnu@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.60.3" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-musl@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.60.3" + conditions: os=linux & cpu=ppc64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-gnu@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.45.1" @@ -2375,6 +2670,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-gnu@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.60.3" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-musl@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.45.1" @@ -2382,6 +2684,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-musl@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.60.3" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-s390x-gnu@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.45.1" @@ -2389,6 +2698,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-s390x-gnu@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.60.3" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-gnu@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.45.1" @@ -2396,6 +2712,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-gnu@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.60.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-musl@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-linux-x64-musl@npm:4.45.1" @@ -2403,6 +2726,27 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-musl@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.60.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-openbsd-x64@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-openbsd-x64@npm:4.60.3" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-openharmony-arm64@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.60.3" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-arm64-msvc@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.45.1" @@ -2410,6 +2754,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-arm64-msvc@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.60.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-ia32-msvc@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.45.1" @@ -2417,6 +2768,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-ia32-msvc@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.60.3" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-gnu@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.60.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-win32-x64-msvc@npm:4.45.1": version: 4.45.1 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.45.1" @@ -2424,6 +2789,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-x64-msvc@npm:4.60.3": + version: 4.60.3 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.60.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rtsao/scc@npm:^1.1.0": version: 1.1.0 resolution: "@rtsao/scc@npm:1.1.0" @@ -5217,6 +5589,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.27.0": + version: 0.27.7 + resolution: "esbuild@npm:0.27.7" + dependencies: + "@esbuild/aix-ppc64": "npm:0.27.7" + "@esbuild/android-arm": "npm:0.27.7" + "@esbuild/android-arm64": "npm:0.27.7" + "@esbuild/android-x64": "npm:0.27.7" + "@esbuild/darwin-arm64": "npm:0.27.7" + "@esbuild/darwin-x64": "npm:0.27.7" + "@esbuild/freebsd-arm64": "npm:0.27.7" + "@esbuild/freebsd-x64": "npm:0.27.7" + "@esbuild/linux-arm": "npm:0.27.7" + "@esbuild/linux-arm64": "npm:0.27.7" + "@esbuild/linux-ia32": "npm:0.27.7" + "@esbuild/linux-loong64": "npm:0.27.7" + "@esbuild/linux-mips64el": "npm:0.27.7" + "@esbuild/linux-ppc64": "npm:0.27.7" + "@esbuild/linux-riscv64": "npm:0.27.7" + "@esbuild/linux-s390x": "npm:0.27.7" + "@esbuild/linux-x64": "npm:0.27.7" + "@esbuild/netbsd-arm64": "npm:0.27.7" + "@esbuild/netbsd-x64": "npm:0.27.7" + "@esbuild/openbsd-arm64": "npm:0.27.7" + "@esbuild/openbsd-x64": "npm:0.27.7" + "@esbuild/openharmony-arm64": "npm:0.27.7" + "@esbuild/sunos-x64": "npm:0.27.7" + "@esbuild/win32-arm64": "npm:0.27.7" + "@esbuild/win32-ia32": "npm:0.27.7" + "@esbuild/win32-x64": "npm:0.27.7" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/ccd51f0555708bc9ff4ec9dc3ac92d3daacd45ecaac949ca8645984c5c323bf8cefe98c2df307418685e0b4ce37f9a3bdbfe8e3651fe632a0059a436195a17d4 + languageName: node + linkType: hard + "escalade@npm:^3.1.1": version: 3.1.1 resolution: "escalade@npm:3.1.1" @@ -5818,6 +6279,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + "figures@npm:3.2.0, figures@npm:^3.0.0": version: 3.2.0 resolution: "figures@npm:3.2.0" @@ -6075,6 +6548,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.2 resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" @@ -6084,6 +6567,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "function-bind@npm:^1.1.2": version: 1.1.2 resolution: "function-bind@npm:1.1.2" @@ -8647,6 +9139,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.11": + version: 3.3.12 + resolution: "nanoid@npm:3.3.12" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/ba142b7b39e11e80c16dd74b0365d407880c87c1cf7e1480956981ae940ee36060fa5b6f092cd1e315184dd19244c657bd017d03327bd3c62247d691c5e8edfb + languageName: node + linkType: hard + "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -9713,6 +10214,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 + languageName: node + linkType: hard + "pify@npm:5.0.0": version: 5.0.0 resolution: "pify@npm:5.0.0" @@ -9792,6 +10300,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.5.6": + version: 8.5.14 + resolution: "postcss@npm:8.5.14" + dependencies: + nanoid: "npm:^3.3.11" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/48138207cf5ef5581be1bfe2cb65ccfe0ac75e43888ba045afc8ed6043d7b56aeb3b9a9fe5b353ff554be943cd0cc15d826ccb991525159175971e5ee8ab0237 + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -10407,6 +10926,96 @@ __metadata: languageName: node linkType: hard +"rollup@npm:^4.43.0": + version: 4.60.3 + resolution: "rollup@npm:4.60.3" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.60.3" + "@rollup/rollup-android-arm64": "npm:4.60.3" + "@rollup/rollup-darwin-arm64": "npm:4.60.3" + "@rollup/rollup-darwin-x64": "npm:4.60.3" + "@rollup/rollup-freebsd-arm64": "npm:4.60.3" + "@rollup/rollup-freebsd-x64": "npm:4.60.3" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.60.3" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.60.3" + "@rollup/rollup-linux-arm64-gnu": "npm:4.60.3" + "@rollup/rollup-linux-arm64-musl": "npm:4.60.3" + "@rollup/rollup-linux-loong64-gnu": "npm:4.60.3" + "@rollup/rollup-linux-loong64-musl": "npm:4.60.3" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.60.3" + "@rollup/rollup-linux-ppc64-musl": "npm:4.60.3" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.60.3" + "@rollup/rollup-linux-riscv64-musl": "npm:4.60.3" + "@rollup/rollup-linux-s390x-gnu": "npm:4.60.3" + "@rollup/rollup-linux-x64-gnu": "npm:4.60.3" + "@rollup/rollup-linux-x64-musl": "npm:4.60.3" + "@rollup/rollup-openbsd-x64": "npm:4.60.3" + "@rollup/rollup-openharmony-arm64": "npm:4.60.3" + "@rollup/rollup-win32-arm64-msvc": "npm:4.60.3" + "@rollup/rollup-win32-ia32-msvc": "npm:4.60.3" + "@rollup/rollup-win32-x64-gnu": "npm:4.60.3" + "@rollup/rollup-win32-x64-msvc": "npm:4.60.3" + "@types/estree": "npm:1.0.8" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loong64-gnu": + optional: true + "@rollup/rollup-linux-loong64-musl": + optional: true + "@rollup/rollup-linux-ppc64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-musl": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-openbsd-x64": + optional: true + "@rollup/rollup-openharmony-arm64": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/72c9c768f3fabeaeff228b6364e6600c169d6c231a4324c47c34880fd8961aebacd974cf905ecc2db75e56c6491bdd676409a06aecf589791bf419cd41d06e76 + languageName: node + linkType: hard + "root-workspace-0b6124@workspace:.": version: 0.0.0-use.local resolution: "root-workspace-0b6124@workspace:." @@ -10953,6 +11562,13 @@ __metadata: languageName: node linkType: hard +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + "source-map-support@npm:~0.5.20": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" @@ -11569,6 +12185,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.15": + version: 0.2.16 + resolution: "tinyglobby@npm:0.2.16" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/f2e09fd93dd95c41e522113b686ff6f7c13020962f8698a864a257f3d7737599afc47722b7ab726e12f8a813f779906187911ff8ee6701ede65072671a7e934b + languageName: node + linkType: hard + "titleize@npm:^3.0.0": version: 3.0.0 resolution: "titleize@npm:3.0.0" @@ -12187,6 +12813,61 @@ __metadata: languageName: node linkType: hard +"vite@npm:^7.1.0": + version: 7.3.3 + resolution: "vite@npm:7.3.3" + dependencies: + esbuild: "npm:^0.27.0" + fdir: "npm:^6.5.0" + fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.6" + rollup: "npm:^4.43.0" + tinyglobby: "npm:^0.2.15" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + jiti: ">=1.21.0" + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/44fed2591d5d0a9d1f6313e0a4330659b7f1eec57e542558f12a924c53b450a84b9fad6d57ac28ec739eca1cf5ff0f62e41b965e3806c47eefdbbe13b74ec9ae + languageName: node + linkType: hard + "walk-up-path@npm:^3.0.1": version: 3.0.1 resolution: "walk-up-path@npm:3.0.1"