diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 6650588..40e44d9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -78,6 +78,35 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Publishes the playground to https://netwin.github.io/maple/. It runs after the release + pages: + name: Deploy playground + needs: [release] + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + permissions: + contents: read + pages: write # upload the site + id-token: write # prove the deployment comes from this workflow + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v7 + with: + ref: master + - uses: pnpm/setup@v3 + with: + cache: true + require-lockfile: true + # Pages serves the site below /maple/, so every URL the app builds must start there + - run: pnpm --filter @netwin/maple-playground build --base-href /maple/ + - uses: actions/upload-pages-artifact@v5 + with: + path: apps/maple-playground/dist/maple-playground/browser + - id: deployment + uses: actions/deploy-pages@v5 + # Brings the release commit and tag back to increment, where pull requests branch off, so their # changelog preview starts from the latest release sync: diff --git a/README.md b/README.md index 1396fc2..d43d261 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Maple is a secure JavaScript interpreter written in TypeScript. It interprets a script itself instead of handing it to `eval`, so the script only ever sees the values you pass in. +Try it in the [playground](https://netwin.github.io/maple/), which runs Maple in your browser. Installation, usage and the API are in the [library's README](packages/maple/README.md), and the full contract is in its [docs](packages/maple/docs/README.md). @@ -17,7 +18,7 @@ This is a pnpm workspace: | Path | Package | Description | | ----------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `packages/maple` | [`@netwin/maple`](packages/maple) | The published library (ESM, Node >= 22, peer dep: `acorn`) | -| `apps/maple-playground` | [`@netwin/maple-playground`](apps/maple-playground) | Private Angular app for trying out the interpreter | +| `apps/maple-playground` | [`@netwin/maple-playground`](apps/maple-playground) | Angular app for trying out the interpreter, [live on GitHub Pages](https://netwin.github.io/maple/) | | `packages/maple-parity` | [`@netwin/maple-parity`](packages/maple-parity) | Private parity tests: Maple adheres to JS standards and (for internal reasons) documents the parity with [JS-Interpreter](https://github.com/NetWin/js-interpreter) | ## Contributing diff --git a/apps/maple-playground/README.md b/apps/maple-playground/README.md index 2d901c6..b13a7eb 100644 --- a/apps/maple-playground/README.md +++ b/apps/maple-playground/README.md @@ -1,8 +1,8 @@ # 🍁 Maple Playground -A private Angular app for trying out Maple. It has editors for code and a JSON context and shows the result, the AST -and the run's statistics live. Maple runs in a web worker, so an endless loop can't freeze the page; a live run is -stopped after 1 s. +An Angular app for trying out Maple. It has editors for a host context and a script and shows the result, the +AST and the run's statistics live. Maple runs in a web worker, so an endless loop can't freeze the page; a live run is +stopped after 1 s. It is live at [netwin.github.io/maple](https://netwin.github.io/maple/). ```sh pnpm dev # from the repository root, serves the playground on :4200 @@ -11,26 +11,54 @@ pnpm dev # from the repository root, serves the playground on :4200 `@netwin/maple` resolves to the library's sources (`packages/maple/src/index.ts`), so library edits show up without a build. +## Host context and script + +The two editors stand for the two sides of a Maple integration: + +- **Host context** is trusted code, a JavaScript module the playground imports natively in the worker, with the + worker's full access, as your application would run. Its two optional named exports are what the script gets: + + ```js + export const INSTANCE_DATA = { upperCase: (text) => text.toUpperCase() }; // new Maple({ globals }) + export const RUN_DATA = { name: 'Steve' }; // maple.run(script, data) + ``` + + `INSTANCE_DATA` holds read-only bindings shared by every run, host functions included; `RUN_DATA` is the data of one + run, which the script gets a copy of and may change. A missing export stands for `{}`. Any other export, a value that + is not an object or a module that fails to load shows up as **Host module failed** in the output, apart from Maple's + own errors. + +- **Script** is the untrusted code Maple runs. It reaches the two exports' keys and nothing else; a key of `RUN_DATA` + shadows the same key of `INSTANCE_DATA`. + +## Welcome dialog and examples + +A first visit opens **What is Maple?**, a dialog on what Maple is, how it works and how to read the playground; the +header button reopens it. **Examples** replaces the host context and the script with one of the scripts in +`src/app/data-access/examples.ts`, each explaining itself in a comment. The first of them is what a first visit and +**Reset** show. The header also links to GitHub and npm and shows the library's version, read from +`packages/maple/package.json` at build time. + ## Settings The **Settings** popover in the header sets the run limits, `maxOps` (default `1000000`) and `maxDepth` (default -`100`). Like code and context they persist in local storage, and **Reset** restores them. +`100`). Like the host context and the script they persist in local storage, and **Reset** restores them. ## Comparing with js-interpreter [`@netwin/js-interpreter`](https://github.com/netwin/JS-Interpreter) is installed as a dev dependency so Maple can be compared with it. Both comparisons call js-interpreter the same way (`src/app/data-access/js-interpreter-engine.ts`): the script is wrapped in a function so that `return` works, and each -top-level key of the context becomes a global. +key of `INSTANCE_DATA` and `RUN_DATA` becomes a global, run data shadowing instance data as in Maple. js-interpreter understands ES5 only, so a script using `let`, `const` or arrow functions shows its parse error in the js-interpreter row while Maple still reports its figures. ### Speed: the Benchmark tab -The **Benchmark** tab in the output pane runs the current code and context in both engines: 3 unmeasured warm-up runs, +The **Benchmark** tab in the output pane runs the current script and host context in both engines: 3 unmeasured warm-up runs, then 20 measured runs each. It shows the median, the min–max range, how much slower js-interpreter is, and a warning -when the two engines return different results. Each run is timed end to end, including parsing, setting up the -context and converting the result. +when the two engines return different results. Each run is timed end to end, including setting up Maple, parsing and +converting the result; the host module is imported once, before the runs. The benchmark has its own worker and a 60 s timeout. js-interpreter has no operation limit, so for an endless loop that timeout is the only stop. diff --git a/apps/maple-playground/angular.json b/apps/maple-playground/angular.json index db67ee7..9bd3be4 100644 --- a/apps/maple-playground/angular.json +++ b/apps/maple-playground/angular.json @@ -73,6 +73,8 @@ }, "defaultConfiguration": "development", "options": { + "host": "0.0.0.0", + "port": 4200, "prebundle": { "exclude": ["monaco-editor"] } diff --git a/apps/maple-playground/src/app/app.html b/apps/maple-playground/src/app/app.html index 93edaca..b8d4548 100644 --- a/apps/maple-playground/src/app/app.html +++ b/apps/maple-playground/src/app/app.html @@ -1,11 +1,25 @@
-
- 🍁 -

Maple

- Playground +
+
+ 🍁 +

Maple

+ + +
+
+ @@ -27,8 +41,31 @@

Maple

- Reset + + +
@@ -39,18 +76,23 @@

Maple

storageKey="maple-playground-panes"> - JSON + title="Host context"> + JS + language="javascript" + [(value)]="hostContext"> - JS + title="Script"> +
+ Sandboxed + JS +
@@ -112,3 +154,5 @@

Maple

+ + diff --git a/apps/maple-playground/src/app/app.ts b/apps/maple-playground/src/app/app.ts index 5a8fdc6..2825063 100644 --- a/apps/maple-playground/src/app/app.ts +++ b/apps/maple-playground/src/app/app.ts @@ -1,16 +1,29 @@ -import { Component, computed, debounced, effect, inject, signal, viewChild } from '@angular/core'; +import { + afterNextRender, + Component, + computed, + debounced, + effect, + inject, + signal, + viewChild +} from '@angular/core'; import { CodeEditor } from './code-editor/code-editor'; import { Benchmark } from './data-access/benchmark'; +import type { Example } from './data-access/examples'; import { Playground } from './data-access/playground'; +import { MAPLE_VERSION, PROJECT_LINKS } from './data-access/project'; import { RUN_TIMEOUT_MS, Runner } from './data-access/runner'; import { Badge } from './ui/badge'; import { BenchmarkPanel } from './ui/benchmark-panel'; +import { ExamplesMenu } from './ui/examples-menu'; import { Pane } from './ui/pane'; import { SettingsMenu } from './ui/settings-menu'; import { Split } from './ui/split'; import { StatusChip } from './ui/status-chip'; import { type TabOption, Tabs } from './ui/tabs'; import { ThemePicker } from './ui/theme-picker'; +import { WelcomeDialog } from './ui/welcome-dialog'; /** Rerun the code execution 300ms after the last edit. */ const RUN_DELAY_MS = 300; @@ -29,12 +42,14 @@ const BENCHMARK_RUNS = 20; Badge, BenchmarkPanel, CodeEditor, + ExamplesMenu, Pane, SettingsMenu, Split, StatusChip, Tabs, - ThemePicker + ThemePicker, + WelcomeDialog ], templateUrl: './app.html', host: { class: 'flex h-full flex-col' } @@ -46,8 +61,16 @@ export class App { readonly #benchmark = inject(Benchmark); + protected readonly VERSION = MAPLE_VERSION; + + protected readonly LINKS = PROJECT_LINKS; + protected readonly split = viewChild.required(Split); + protected readonly welcome = viewChild.required(WelcomeDialog); + + protected readonly welcomeSeen = this.#playground.welcomeSeen; + protected readonly outputTab = signal('result'); protected readonly status = this.#runner.status; @@ -85,7 +108,7 @@ export class App { protected readonly astDocument = computed(() => this.#runner.ast() ?? ''); - protected readonly contextString = this.#playground.contextString; + protected readonly hostContext = this.#playground.hostContext; protected readonly codeString = this.#playground.codeString; @@ -96,12 +119,34 @@ export class App { protected readonly debouncedInput = debounced( () => ({ code: this.codeString(), - context: this.contextString(), + hostContext: this.hostContext(), limits: { maxOps: this.maxOps(), maxDepth: this.maxDepth() } }), RUN_DELAY_MS ); + /** + * What the next live run gets. The debounced resource also notifies when only its status changes, + * so equal input is dropped here: a rerun would advance state the host module keeps between runs. + */ + protected readonly runInput = computed( + () => + this.debouncedInput.hasValue() + ? this.debouncedInput.value() + : { + code: '', + hostContext: '', + limits: { maxOps: this.maxOps(), maxDepth: this.maxDepth() } + }, + { + equal: (previous, next) => + previous.code === next.code && + previous.hostContext === next.hostContext && + previous.limits.maxOps === next.limits.maxOps && + previous.limits.maxDepth === next.limits.maxDepth + } + ); + protected readonly outputTabs: ReadonlyArray> = [ { value: 'result', label: 'Result' }, { value: 'ast', label: 'AST' }, @@ -111,24 +156,33 @@ export class App { constructor() { // Re-runs shortly after the last edit to either editor or to the limits. effect(() => { - const { code, context, limits } = this.debouncedInput.hasValue() - ? this.debouncedInput.value() - : { code: '', context: '', limits: { maxOps: this.maxOps(), maxDepth: this.maxDepth() } }; - this.#runner.run(code, context, limits); + const { code, hostContext, limits } = this.runInput(); + this.#runner.run(code, hostContext, limits); + }); + + // A first visit starts with what Maple is; closing the dialog marks it as seen. + afterNextRender(() => { + if (!this.welcomeSeen()) { + this.welcome().open(); + } }); } + protected load(example: Example): void { + this.#playground.load(example); + } + /** Times the current editors' content, not the debounced one, so the click is what gets measured. */ protected runBenchmark(): void { this.#benchmark.run({ code: this.codeString(), - context: this.contextString(), + hostSource: this.hostContext(), limits: { maxOps: this.maxOps(), maxDepth: this.maxDepth() }, runs: BENCHMARK_RUNS }); } - /** Restores the default context, code and pane sizes. */ + /** Restores the default host context, code and pane sizes. */ protected reset(): void { this.#playground.reset(); this.split().reset(); diff --git a/apps/maple-playground/src/app/data-access/benchmark-protocol.ts b/apps/maple-playground/src/app/data-access/benchmark-protocol.ts index 68601ac..7d73500 100644 --- a/apps/maple-playground/src/app/data-access/benchmark-protocol.ts +++ b/apps/maple-playground/src/app/data-access/benchmark-protocol.ts @@ -5,10 +5,10 @@ export const ENGINES = ['maple', 'js-interpreter'] as const; export type Engine = (typeof ENGINES)[number]; -/** Asks the worker to time `code` against the JSON `context` in both engines. */ +/** Asks the worker to time `code` against the host module at `hostModuleUrl` in both engines. */ export interface BenchmarkRequest { code: string; - context: string; + hostModuleUrl: string; limits: RunLimits; /** Measured runs per engine, after a few unmeasured warm-up runs. */ runs: number; diff --git a/apps/maple-playground/src/app/data-access/benchmark.ts b/apps/maple-playground/src/app/data-access/benchmark.ts index f8fd07b..a3de7cb 100644 --- a/apps/maple-playground/src/app/data-access/benchmark.ts +++ b/apps/maple-playground/src/app/data-access/benchmark.ts @@ -1,5 +1,6 @@ import { Service, signal } from '@angular/core'; import type { BenchmarkRequest, BenchmarkResponse } from './benchmark-protocol'; +import { createModuleUrl } from './host-module'; /** Js-interpreter has no operation limit, so an endless loop is only stopped by this. */ export const BENCHMARK_TIMEOUT_MS = 60_000; @@ -16,6 +17,9 @@ export class Benchmark { #timeout: ReturnType | undefined; + /** The URL of the host module the running benchmark imports, revoked when it stops. */ + #hostModuleUrl: string | undefined; + readonly #status = signal('idle'); readonly #response = signal(undefined); @@ -25,8 +29,14 @@ export class Benchmark { /** The latest finished benchmark; it stays visible while the next one runs. */ public readonly response = this.#response.asReadonly(); - public run(request: BenchmarkRequest): void { + /** Imports the host module written in `hostSource` from a URL of its own, as the live runs do. */ + public run({ + hostSource, + ...request + }: Omit & { hostSource: string }): void { this.stop(); + const hostModuleUrl = createModuleUrl(hostSource); + this.#hostModuleUrl = hostModuleUrl; this.#status.set('running'); const worker = new Worker(new URL('./benchmark.worker', import.meta.url), { type: 'module' }); worker.addEventListener('message', (event: MessageEvent) => { @@ -44,14 +54,19 @@ export class Benchmark { this.stop(); }, BENCHMARK_TIMEOUT_MS); this.#worker = worker; + const message: BenchmarkRequest = { ...request, hostModuleUrl }; // Worker.postMessage has no targetOrigin; the rule only applies to window.postMessage. // oxlint-disable-next-line unicorn/require-post-message-target-origin - worker.postMessage(request); + worker.postMessage(message); } private stop(): void { clearTimeout(this.#timeout); this.#worker?.terminate(); this.#worker = undefined; + if (this.#hostModuleUrl) { + URL.revokeObjectURL(this.#hostModuleUrl); + this.#hostModuleUrl = undefined; + } } } diff --git a/apps/maple-playground/src/app/data-access/benchmark.worker.ts b/apps/maple-playground/src/app/data-access/benchmark.worker.ts index 5b33de8..768f590 100644 --- a/apps/maple-playground/src/app/data-access/benchmark.worker.ts +++ b/apps/maple-playground/src/app/data-access/benchmark.worker.ts @@ -1,46 +1,50 @@ /// import { Maple } from '@netwin/maple'; import type { BenchmarkRequest, BenchmarkResponse, EngineResult } from './benchmark-protocol'; +import { type HostContext, importHostContext } from './host-module'; import { runJsInterpreter } from './js-interpreter-engine'; -import { parseContext } from './maple-worker-protocol'; /** Unmeasured runs first, so neither engine is timed while the JIT is still cold. */ const WARM_UP_RUNS = 3; -addEventListener('message', (event: MessageEvent) => { - const { code, context, limits, runs } = event.data; +addEventListener('message', async (event: MessageEvent) => { + const { code, hostModuleUrl, limits, runs } = event.data; postMessage({ runs, - results: benchmark(code, context, limits, runs) + results: await benchmark(code, hostModuleUrl, limits, runs) } satisfies BenchmarkResponse); }); /** - * Parses the context once, outside the timed runs, since a host passes data it already holds; - * context that is not JSON fails both engines alike. + * Imports the host module once, outside the timed runs, since a host holds its data already; a + * module that fails to load fails both engines alike. */ -function benchmark( +async function benchmark( code: string, - context: string, + hostModuleUrl: string, limits: BenchmarkRequest['limits'], runs: number -): BenchmarkResponse['results'] { - let data: object; +): Promise { + let host: HostContext; try { - data = parseContext(context); + host = await importHostContext(hostModuleUrl); } catch (error) { const failed: EngineResult = { ok: false, error: describe(error) }; return { maple: failed, 'js-interpreter': failed }; } + // js-interpreter has no instances, so both exports become its globals, run data shadowing + // instance data as it does in Maple. + const globals = { ...host.instanceData, ...host.runData }; return { - maple: measure(runs, () => runMaple(code, data, limits)), - 'js-interpreter': measure(runs, () => runJsInterpreter(code, data)) + maple: measure(runs, () => runMaple(code, host, limits)), + 'js-interpreter': measure(runs, () => runJsInterpreter(code, globals)) }; } /** One complete execution as the host would do it: setup, parse, run and result conversion. */ -function runMaple(code: string, data: object, limits: BenchmarkRequest['limits']): string { - return JSON.stringify(new Maple(limits).run(code, data).value) ?? 'undefined'; +function runMaple(code: string, host: HostContext, limits: BenchmarkRequest['limits']): string { + const maple = new Maple({ globals: host.instanceData, ...limits }); + return JSON.stringify(maple.run(code, host.runData).value) ?? 'undefined'; } function measure(runs: number, execute: () => string): EngineResult { diff --git a/apps/maple-playground/src/app/data-access/examples.ts b/apps/maple-playground/src/app/data-access/examples.ts new file mode 100644 index 0000000..e36eb7e --- /dev/null +++ b/apps/maple-playground/src/app/data-access/examples.ts @@ -0,0 +1,186 @@ +/** A host context and a script that show one thing about Maple; the script's comment explains it. */ +export interface Example { + title: string; + /** One line for the Examples menu. */ + summary: string; + hostContext: string; + code: string; +} + +/** For the examples whose script needs nothing from the host. */ +const EMPTY_HOST_CONTEXT = `// Host context: deliberately empty, so the script gets nothing from the host. +`; + +const PRICING_RULE: Example = { + title: 'Pricing rule', + summary: 'A business rule written as a script, with a host function', + hostContext: `// Host context: trusted code that runs natively in the worker, as your application would. +// Maple hands INSTANCE_DATA to every run of the instance (read-only), RUN_DATA to one run. +export const INSTANCE_DATA = { + TAX_RATE: 0.19, + formatPrice: (amount) => amount.toFixed(2) + ' €' +}; + +export const RUN_DATA = { + order: { + coupon: 'SPRING10', + items: [ + { name: 'Maple syrup', price: 12.5, quantity: 2 }, + { name: 'Pancake mix', price: 4.2, quantity: 1 } + ] + } +}; +`, + code: `// Script: untrusted code. It sees TAX_RATE, formatPrice and order, and nothing else. +const subtotal = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0); +const discount = order.coupon === 'SPRING10' ? subtotal * 0.1 : 0; +const total = (subtotal - discount) * (1 + TAX_RATE); + +return { + subtotal: formatPrice(subtotal), + discount: formatPrice(discount), + total: formatPrice(total) +}; +` +}; + +const TRANSFORM_DATA: Example = { + title: 'Transform data', + summary: 'Modern JavaScript over the data of a run', + hostContext: `// Host context: the orders a report script works on. +export const RUN_DATA = { + orders: [ + { id: 1, customer: 'Ada', total: 42.5, status: 'paid' }, + { id: 2, customer: 'Grace', total: 18, status: 'open' }, + { id: 3, customer: 'Ada', total: 7.25, status: 'paid' }, + { id: 4, customer: 'Linus', total: 99.9, status: 'paid' } + ] +}; +`, + code: `// Transform data: arrow functions, destructuring, spread and ?? work as in JavaScript. +const paid = orders.filter(({ status }) => status === 'paid'); + +const revenueByCustomer = paid.reduce( + (totals, { customer, total }) => ({ ...totals, [customer]: (totals[customer] ?? 0) + total }), + {} +); + +return { + paidOrders: paid.map(({ id }) => id), + revenueByCustomer +}; +` +}; + +const NO_HOST_GLOBALS: Example = { + title: 'No host globals', + summary: 'The page, the worker and Node are out of reach', + hostContext: EMPTY_HOST_CONTEXT, + code: `// No host globals: each of these is a global in a browser, a worker or Node, and Maple hands +// none of them to the script. Standard built-ins such as Math and JSON are Maple's own. +return { + globalThis: typeof globalThis, + window: typeof window, + self: typeof self, + document: typeof document, + fetch: typeof fetch, + localStorage: typeof localStorage, + process: typeof process, + require: typeof require, + Math: typeof Math, + JSON: typeof JSON +}; +` +}; + +const PROTOTYPE_ESCAPE: Example = { + title: 'Prototype escape', + summary: 'The classic way out of a sandbox, blocked', + hostContext: EMPTY_HOST_CONTEXT, + code: `// Prototype escape: the constructor of every function is Function, and +// Function('return process')() would run code in the host, outside the sandbox. +// Maple refuses to read \`constructor\` and \`__proto__\`, so the run fails right there. +const HostFunction = ({}).constructor.constructor; +return HostFunction('return process')(); +` +}; + +const NO_EVAL: Example = { + title: 'No eval', + summary: 'Strings never become code', + hostContext: EMPTY_HOST_CONTEXT, + code: `// No eval: eval and Function would turn a string into code that runs outside the sandbox. +// Maple has neither: calling eval ends the run with an error, and the string is never parsed. +const answer = 6 * 7; +return eval('answer'); +` +}; + +const RUNAWAY_LOOP: Example = { + title: 'Runaway loop', + summary: 'An endless loop stops at the operation limit', + hostContext: EMPTY_HOST_CONTEXT, + code: `// Runaway loop: Maple counts the operations of a run and stops it at maxOps (see Settings, +// default 1,000,000), so an endless loop ends with an error instead of hanging the host. +let count = 0; +while (true) { + count++; +} +` +}; + +const INSTANCE_AND_RUN_DATA: Example = { + title: 'Instance and run data', + summary: 'Run data is a copy, instance data is read-only', + hostContext: `// Host context: INSTANCE_DATA is shared by every run and read-only, +// RUN_DATA is copied into each run. +export const INSTANCE_DATA = { + settings: { currency: '€', maxItems: 3 } +}; + +export const RUN_DATA = { + cart: ['Maple syrup', 'Pancake mix'] +}; +`, + code: `// Instance and run data: the script may change its copy of RUN_DATA ... +cart.push('Waffles'); + +// ... but INSTANCE_DATA is read-only, so this line throws. Delete it to see the cart. +settings.maxItems = 10; + +return cart; +` +}; + +const ERRORS: Example = { + title: 'Errors like JavaScript', + summary: 'Real error objects with the messages Node prints', + hostContext: EMPTY_HOST_CONTEXT, + code: `// Errors like JavaScript: a script's errors are TypeError, ReferenceError and RangeError objects +// with the messages Node prints, and try/catch works as usual. +const attempts = [() => null.name, () => missingFunction(), () => new Array(-1)]; + +return attempts.map((attempt) => { + try { + attempt(); + } catch (error) { + return \`\${error.name}: \${error.message}\`; + } +}); +` +}; + +/** In the order of the Examples menu: what Maple is for, then how it keeps scripts in. */ +export const EXAMPLES: ReadonlyArray = [ + PRICING_RULE, + TRANSFORM_DATA, + NO_HOST_GLOBALS, + PROTOTYPE_ESCAPE, + NO_EVAL, + RUNAWAY_LOOP, + INSTANCE_AND_RUN_DATA, + ERRORS +]; + +/** What a first visit and Reset show. */ +export const DEFAULT_EXAMPLE = PRICING_RULE; diff --git a/apps/maple-playground/src/app/data-access/host-module.ts b/apps/maple-playground/src/app/data-access/host-module.ts new file mode 100644 index 0000000..0e3081a --- /dev/null +++ b/apps/maple-playground/src/app/data-access/host-module.ts @@ -0,0 +1,70 @@ +/** + * The host context is a JavaScript module the playground imports natively, as trusted host code. + * Its two optional named exports become what Maple hands to the script: + * + * ```js + * export const INSTANCE_DATA = { upperCase: (text) => text.toUpperCase() }; // new Maple({ globals }) + * export const RUN_DATA = { name: 'Steve' }; // maple.run(code, data) + * ``` + */ +export interface HostContext { + /** Read-only bindings every run of the instance sees, host functions included. */ + instanceData: Record; + /** The data of one run; the script gets a copy it may change. */ + runData: Record; +} + +const EXPORTS: ReadonlyArray = ['INSTANCE_DATA', 'RUN_DATA']; + +/** The host module failed to load or exports something else than an object for each name. */ +export class HostModuleError extends Error { + public override readonly name = 'HostModuleError'; +} + +/** Puts `source` behind a URL a worker can `import()`; revoke it once no worker needs it. */ +export function createModuleUrl(source: string): string { + return URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); +} + +/** Imports the module at `url` and checks its exports. */ +export async function importHostContext(url: string): Promise { + let namespace: object; + try { + namespace = await import(/* @vite-ignore */ url); + } catch (error) { + throw new HostModuleError(describe(error), { cause: error }); + } + + const exports = new Map(Object.entries(namespace)); + const unknown = [...exports.keys()].filter((name) => !EXPORTS.includes(name)); + if (unknown.length > 0) { + const names = unknown.map((name) => `'${name}'`).join(', '); + const noun = unknown.length === 1 ? 'export' : 'exports'; + throw new HostModuleError( + `Unexpected ${noun} ${names}; export INSTANCE_DATA and RUN_DATA only` + ); + } + return { + instanceData: readObject(exports, 'INSTANCE_DATA'), + runData: readObject(exports, 'RUN_DATA') + }; +} + +/** + * Copies the export's own enumerable keys, the ones Maple reads, into a record. A missing export + * stands for an empty object. + */ +function readObject(exports: Map, name: string): Record { + const value = exports.get(name); + if (value === undefined) { + return {}; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new HostModuleError(`${name} must be an object`); + } + return Object.fromEntries(Object.entries(value)); +} + +function describe(error: unknown): string { + return error instanceof Error ? `${error.name}: ${error.message}` : String(error); +} diff --git a/apps/maple-playground/src/app/data-access/js-interpreter-engine.ts b/apps/maple-playground/src/app/data-access/js-interpreter-engine.ts index 368a905..17abc36 100644 --- a/apps/maple-playground/src/app/data-access/js-interpreter-engine.ts +++ b/apps/maple-playground/src/app/data-access/js-interpreter-engine.ts @@ -7,7 +7,7 @@ interface InterpreterInternals { } /** - * Runs `code` against the parsed `data` in js-interpreter and returns the result as JSON text. + * Runs `code` against `data` in js-interpreter and returns the result as JSON text. * The script is wrapped in a function to allow `return`, and each data key becomes a global; * js-interpreter understands ES5 only and has no operation limit. The result is read and * serialized on the host, as Maple's is, so the data cannot shadow anything this relies on. diff --git a/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts b/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts index 59d9269..b02f75f 100644 --- a/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts +++ b/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts @@ -4,26 +4,17 @@ export interface RunLimits { maxDepth: number; } -/** Asks the worker to parse and run `code` against the JSON `context` within `limits`. */ +/** + * Asks the worker to parse `code` and run it within `limits` against the host module at + * `hostModuleUrl`. + */ export interface RunRequest { id: number; code: string; - context: string; + hostModuleUrl: string; limits: RunLimits; } -/** - * Turns the JSON `context` of a request back into the data a run takes. An array is refused here, - * as Maple would, so that both engines of the Benchmark tab see the same input error. - */ -export function parseContext(context: string): object { - const data: unknown = JSON.parse(context); - if (typeof data !== 'object' || data === null || Array.isArray(data)) { - throw new TypeError('context must be a JSON object'); - } - return data; -} - /** An error, reduced to what survives `postMessage`. */ export interface RunError { message: string; diff --git a/apps/maple-playground/src/app/data-access/maple.worker.ts b/apps/maple-playground/src/app/data-access/maple.worker.ts index 7d395ff..2f1a319 100644 --- a/apps/maple-playground/src/app/data-access/maple.worker.ts +++ b/apps/maple-playground/src/app/data-access/maple.worker.ts @@ -1,7 +1,7 @@ /// import { Maple, MapleError, parse } from '@netwin/maple'; +import { type HostContext, HostModuleError, importHostContext } from './host-module'; import { - parseContext, type RunError, type RunLimits, type RunRequest, @@ -9,10 +9,16 @@ import { type StepOutcome } from './maple-worker-protocol'; +/** The host module of the latest run, imported once per edit, so its state lasts between runs. */ +let host: { url: string; context: Promise } | undefined; + +/** One Maple per host module and limits, reused by every run as a host application would. */ +let instance: { url: string; limits: RunLimits; maple: Maple } | undefined; + // Runs off the main thread, so an endless loop in the interpreter can be stopped by terminating // the worker instead of freezing the page. -addEventListener('message', (event: MessageEvent) => { - const { id, code, context, limits } = event.data; +addEventListener('message', async (event: MessageEvent) => { + const { id, code, hostModuleUrl, limits } = event.data; postMessage({ id, @@ -23,20 +29,39 @@ addEventListener('message', (event: MessageEvent) => { postMessage({ id, step: 'result', - outcome: runStep(code, context, limits) + outcome: await runStep(code, hostModuleUrl, limits) } satisfies RunResponse); }); /** Runs the code within the limits and reports the value as JSON text next to the run's stats. */ -function runStep(code: string, context: string, limits: RunLimits): StepOutcome { +async function runStep( + code: string, + hostModuleUrl: string, + limits: RunLimits +): Promise { try { - const { value, stats } = new Maple(limits).run(code, parseContext(context)); + if (host?.url !== hostModuleUrl) { + host = { url: hostModuleUrl, context: importHostContext(hostModuleUrl) }; + } + const { instanceData, runData } = await host.context; + const { value, stats } = getMaple(hostModuleUrl, instanceData, limits).run(code, runData); return { ok: true, value: JSON.stringify(value, null, 2) ?? String(value), stats }; } catch (error) { return { ok: false, error: describeError(error) }; } } +function getMaple(url: string, globals: Record, limits: RunLimits): Maple { + if ( + instance?.url !== url || + instance.limits.maxOps !== limits.maxOps || + instance.limits.maxDepth !== limits.maxDepth + ) { + instance = { url, limits, maple: new Maple({ globals, ...limits }) }; + } + return instance.maple; +} + /** Runs `produce` and serializes its value as JSON text, or captures the error it throws. */ function attempt(produce: () => unknown): StepOutcome { try { @@ -57,6 +82,9 @@ function describeError(error: unknown): RunError { stack: error.stack }; } + if (error instanceof HostModuleError) { + return { message: `Host module failed: ${error.message}`, stack: error.stack }; + } if (error instanceof Error) { return { message: `${error.name}: ${error.message}`, stack: error.stack }; } diff --git a/apps/maple-playground/src/app/data-access/playground.ts b/apps/maple-playground/src/app/data-access/playground.ts index e08edd7..2ab7e07 100644 --- a/apps/maple-playground/src/app/data-access/playground.ts +++ b/apps/maple-playground/src/app/data-access/playground.ts @@ -1,19 +1,15 @@ import { effect, Service, signal, type WritableSignal } from '@angular/core'; - -/** Default JSON context for the Maple code execution. */ -const DEFAULT_CONTEXT = JSON.stringify({ name: 'Maple' }, null, 2); - -/** Default code to be loaded into the editor. */ -const DEFAULT_CODE = ['const greeting = "Hello, " + name;', 'return greeting;'].join('\n'); +import { DEFAULT_EXAMPLE, type Example } from './examples'; /** Maple's own defaults, repeated here so the settings form can show and restore them. */ export const DEFAULT_MAX_OPS = 1_000_000; export const DEFAULT_MAX_DEPTH = 100; -const CONTEXT_STORAGE_KEY = 'maple-playground-context'; +const HOST_CONTEXT_STORAGE_KEY = 'maple-playground-host-context'; const CODE_STORAGE_KEY = 'maple-playground-code'; const MAX_OPS_STORAGE_KEY = 'maple-playground-max-ops'; const MAX_DEPTH_STORAGE_KEY = 'maple-playground-max-depth'; +const WELCOME_SEEN_STORAGE_KEY = 'maple-playground-welcome-seen'; /** How a persisted value is written to and read back from storage. */ interface Codec { @@ -26,6 +22,11 @@ const TEXT: Codec = { format: (value) => value }; +const FLAG: Codec = { + parse: (text) => text === 'true', + format: (value) => String(value) +}; + const POSITIVE_INTEGER: Codec = { parse: (text) => { const value = Number(text); @@ -35,14 +36,19 @@ const POSITIVE_INTEGER: Codec = { }; /** - * The playground's context, code and run limits, persisted across reloads. A value equal to its - * default isn't stored, so a later change to the defaults reaches everyone who never edited it. + * The playground's host context, code and run limits, persisted across reloads. A value equal to + * its default isn't stored, so a later change to the defaults reaches everyone who never edited + * it. */ @Service() export class Playground { - public readonly contextString = persistedSignal(CONTEXT_STORAGE_KEY, DEFAULT_CONTEXT, TEXT); + public readonly hostContext = persistedSignal( + HOST_CONTEXT_STORAGE_KEY, + DEFAULT_EXAMPLE.hostContext, + TEXT + ); - public readonly codeString = persistedSignal(CODE_STORAGE_KEY, DEFAULT_CODE, TEXT); + public readonly codeString = persistedSignal(CODE_STORAGE_KEY, DEFAULT_EXAMPLE.code, TEXT); public readonly maxOps = persistedSignal(MAX_OPS_STORAGE_KEY, DEFAULT_MAX_OPS, POSITIVE_INTEGER); @@ -52,10 +58,18 @@ export class Playground { POSITIVE_INTEGER ); - /** Restores every default, which also clears the values from storage. */ + /** Whether the visitor has closed the welcome dialog once; Reset leaves it alone. */ + public readonly welcomeSeen = persistedSignal(WELCOME_SEEN_STORAGE_KEY, false, FLAG); + + /** Replaces the host context and the script with an example's. */ + public load(example: Example): void { + this.hostContext.set(example.hostContext); + this.codeString.set(example.code); + } + + /** Restores every default but the welcome flag, which also clears the values from storage. */ public reset(): void { - this.contextString.set(DEFAULT_CONTEXT); - this.codeString.set(DEFAULT_CODE); + this.load(DEFAULT_EXAMPLE); this.maxOps.set(DEFAULT_MAX_OPS); this.maxDepth.set(DEFAULT_MAX_DEPTH); } diff --git a/apps/maple-playground/src/app/data-access/project.ts b/apps/maple-playground/src/app/data-access/project.ts new file mode 100644 index 0000000..ede43c9 --- /dev/null +++ b/apps/maple-playground/src/app/data-access/project.ts @@ -0,0 +1,11 @@ +import { version } from '../../../../../packages/maple/package.json'; + +/** The version of the library the playground runs, read from its package.json at build time. */ +export const MAPLE_VERSION = version; + +/** Where Maple lives, for the header and the welcome dialog. */ +export const PROJECT_LINKS = { + github: 'https://github.com/NetWin/maple', + npm: 'https://www.npmjs.com/package/@netwin/maple', + docs: 'https://github.com/NetWin/maple/blob/master/packages/maple/docs/README.md' +} as const; diff --git a/apps/maple-playground/src/app/data-access/runner.ts b/apps/maple-playground/src/app/data-access/runner.ts index e55c03c..d450b24 100644 --- a/apps/maple-playground/src/app/data-access/runner.ts +++ b/apps/maple-playground/src/app/data-access/runner.ts @@ -1,4 +1,5 @@ import { Service, signal } from '@angular/core'; +import { createModuleUrl } from './host-module'; import type { RunError, RunLimits, @@ -32,6 +33,9 @@ export class Runner { #timeout: ReturnType | undefined; + /** The host module of the latest run; an edit replaces its URL and revokes the old one. */ + #hostModule: { source: string; url: string } | undefined; + readonly #status = signal('running'); readonly #ast = signal(undefined); @@ -57,10 +61,10 @@ export class Runner { public readonly stats = this.#stats.asReadonly(); /** - * Runs `code` against the JSON `context` within `limits`. The previous output stays visible - * until the new run replaces it, so fast runs don't flicker. + * Runs `code` within `limits` against the host module written in `hostSource`. The previous + * output stays visible until the new run replaces it, so fast runs don't flicker. */ - public run(code: string, context: string, limits: RunLimits): void { + public run(code: string, hostSource: string, limits: RunLimits): void { if (this.#inFlight) { this.stopWorker(); } @@ -71,12 +75,31 @@ export class Runner { this.#status.set('running'); this.#timeout = setTimeout(() => this.onTimeout(id), RUN_TIMEOUT_MS); - const request: RunRequest = { id, code, context, limits }; + const request: RunRequest = { + id, + code, + hostModuleUrl: this.getHostModuleUrl(hostSource), + limits + }; // Worker.postMessage has no targetOrigin; the rule only applies to window.postMessage. // oxlint-disable-next-line unicorn/require-post-message-target-origin this.getWorker().postMessage(request); } + /** + * No worker needs the old URL any more: a run in flight was stopped, an idle worker imports the + * new one. + */ + private getHostModuleUrl(source: string): string { + if (this.#hostModule?.source !== source) { + if (this.#hostModule) { + URL.revokeObjectURL(this.#hostModule.url); + } + this.#hostModule = { source, url: createModuleUrl(source) }; + } + return this.#hostModule.url; + } + private getWorker(): Worker { if (!this.#worker) { const worker = new Worker(new URL('./maple.worker', import.meta.url), { type: 'module' }); diff --git a/apps/maple-playground/src/app/ui/benchmark-panel.html b/apps/maple-playground/src/app/ui/benchmark-panel.html index 6512508..439f459 100644 --- a/apps/maple-playground/src/app/ui/benchmark-panel.html +++ b/apps/maple-playground/src/app/ui/benchmark-panel.html @@ -7,7 +7,7 @@ {{ status() === 'running' ? 'Running…' : 'Run benchmark' }}

- Same code and context in both engines, each timed end to end + Same script and host context in both engines, each timed end to end @if (response(); as last) { Β· median of {{ last.runs }} runs after warm-up } diff --git a/apps/maple-playground/src/app/ui/examples-menu.html b/apps/maple-playground/src/app/ui/examples-menu.html new file mode 100644 index 0000000..cf0d02c --- /dev/null +++ b/apps/maple-playground/src/app/ui/examples-menu.html @@ -0,0 +1,47 @@ + + +

+

+ Examples +

+
    + @for (example of EXAMPLES; track example.title) { +
  • + +
  • + } +
+

+ Replaces the host context and the script. +

+
diff --git a/apps/maple-playground/src/app/ui/examples-menu.ts b/apps/maple-playground/src/app/ui/examples-menu.ts new file mode 100644 index 0000000..7331dfc --- /dev/null +++ b/apps/maple-playground/src/app/ui/examples-menu.ts @@ -0,0 +1,21 @@ +import { Component, output, viewChild, type ElementRef } from '@angular/core'; +import { EXAMPLES, type Example } from '../data-access/examples'; + +/** The examples behind a native popover anchored below its button; picking one closes it. */ +@Component({ + selector: 'maple-examples-menu', + templateUrl: './examples-menu.html', + host: { class: 'contents' } +}) +export class ExamplesMenu { + protected readonly EXAMPLES = EXAMPLES; + + public readonly pick = output(); + + protected readonly popover = viewChild.required>('popover'); + + protected choose(example: Example): void { + this.pick.emit(example); + this.popover().nativeElement.hidePopover(); + } +} diff --git a/apps/maple-playground/src/app/ui/settings-menu.html b/apps/maple-playground/src/app/ui/settings-menu.html index 0efe232..03d8676 100644 --- a/apps/maple-playground/src/app/ui/settings-menu.html +++ b/apps/maple-playground/src/app/ui/settings-menu.html @@ -19,7 +19,7 @@ cy="12" r="3" /> - Settings +
+ +
+
+

+ 🍁 + Maple {{ VERSION }} +

+

+ A secure JavaScript interpreter +

+

+ Maple runs a script itself instead of handing it to eval, so + the script only ever sees the values you pass in. Use it to let users write rules, formulas + or automations that run inside your application without reaching anything else. +

+
+ +
+
+

+ How it works +

+
    +
  1. + 1 +

    + Parse. + acorn turns the script into a syntax tree, which the AST tab shows. +

    +
  2. +
  3. + 2 +

    + Interpret. + Maple walks that tree node by node. Nothing is ever handed to + eval or Function. +

    +
  4. +
  5. + 3 +

    + Isolate. + The only names a script can reach are the ones the host passes in. Everything else, + from Math to Array, is + Maple's own. +

    +
  6. +
+ +

+ What a script can't do +

+
    +
  • + + Reach host globals such as window, + fetch or process +
  • +
  • + + Turn strings into code with eval or + Function +
  • +
  • + + Climb prototypes through constructor or + __proto__ +
  • +
  • + + Touch the DOM, storage, cookies or the network +
  • +
+
+ +
+

+ Reading the playground +

+
+
+
Host context
+
+ Trusted code standing in for your application. It runs natively and exports + INSTANCE_DATA, read-only and shared by every run, and + RUN_DATA, copied into each run. +
+
+
+
Script
+
+ The untrusted code Maple runs. It sees the keys of those two exports and nothing else, + and hands back its result with return. +
+
+
+
Output
+
+ The result or the error, the syntax tree, and a benchmark against another interpreter. + It updates as you type. +
+
+
+ +

+ Use it in your project +

+
{{ INSTALL }}
+

+ + GitHub + + + npm + + + Documentation + +

+
+
+ +
+

+ Pick another script under Examples, or edit either + side. +

+ +
+
+ diff --git a/apps/maple-playground/src/app/ui/welcome-dialog.ts b/apps/maple-playground/src/app/ui/welcome-dialog.ts new file mode 100644 index 0000000..7636d04 --- /dev/null +++ b/apps/maple-playground/src/app/ui/welcome-dialog.ts @@ -0,0 +1,39 @@ +import { Component, output, viewChild, type ElementRef } from '@angular/core'; +import { MAPLE_VERSION, PROJECT_LINKS } from '../data-access/project'; + +/** What Maple is and how to read the playground, in a native modal dialog. */ +@Component({ + selector: 'maple-welcome-dialog', + templateUrl: './welcome-dialog.html', + host: { class: 'contents' } +}) +export class WelcomeDialog { + protected readonly VERSION = MAPLE_VERSION; + + protected readonly LINKS = PROJECT_LINKS; + + protected readonly INSTALL = 'pnpm add @netwin/maple acorn'; + + /** Emitted however the dialog was closed: the button, Escape or a click on the backdrop. */ + public readonly closed = output(); + + protected readonly dialog = viewChild.required>('dialog'); + + protected readonly content = viewChild.required>('content'); + + public open(): void { + this.dialog().nativeElement.showModal(); + this.content().nativeElement.scrollTop = 0; + } + + protected close(): void { + this.dialog().nativeElement.close(); + } + + /** A click on the backdrop lands on the dialog element itself, never on its content. */ + protected closeOnBackdrop(event: MouseEvent): void { + if (event.target === this.dialog().nativeElement) { + this.close(); + } + } +} diff --git a/packages/maple/README.md b/packages/maple/README.md index 130b4d9..15973bf 100644 --- a/packages/maple/README.md +++ b/packages/maple/README.md @@ -8,6 +8,8 @@ A secure JavaScript interpreter written in TypeScript. Maple parses a string of JavaScript using [acorn](https://github.com/acornjs/acorn) and interprets it itself instead of handing it to `eval`, so a script only ever sees the values you pass in. +Try it in the [playground](https://netwin.github.io/maple/), which runs Maple in your browser. + Secure means that a script cannot escape: it cannot reach host globals, `require` or the file system on Node, or `localStorage`, cookies or the DOM in a browser. It does not mean that a run's CPU time or memory is capped; run untrusted code in a worker you can terminate if you need that. diff --git a/release.config.js b/release.config.js index c14d85a..155552d 100644 --- a/release.config.js +++ b/release.config.js @@ -17,6 +17,26 @@ const publish = [ '@semantic-release/github' ]; +// Every commit type the release notes list, in the order of their sections +const types = [ + { type: 'feat', section: 'πŸš€ Features' }, + { type: 'fix', section: '🩹 Bug Fixes' }, + { type: 'perf', section: '⚑ Performance Improvements' }, + { type: 'revert', section: '↩️ Reverts' }, + { type: 'docs', section: 'πŸ“– Documentation' }, + { type: 'style', section: '🎨 Code Style' }, + { type: 'chore', section: 'πŸ”§ Miscellaneous Chores' }, + { type: 'refactor', section: 'πŸ› οΈ Code Refactoring' }, + { type: 'test', section: 'πŸ§ͺ Tests' }, + { type: 'build', scope: 'deps', section: 'πŸ— Dependency Updates' }, + { type: 'build', section: 'πŸ“¦ Build System' }, + { type: 'ci', section: 'πŸ”„οΈ Continuous Integration' } +]; + +// The playground isn't published in the library, so its commits neither release nor appear in the release notes. +// The preset uses the first entry that matches a commit, so these go before `types`. +const playground = types.map(({ type }) => ({ type, scope: 'playground', hidden: true })); + export default { branches: ['master'], // Plain versions (1.2.3), without the default "v" prefix @@ -27,6 +47,7 @@ export default { { preset: 'conventionalcommits', releaseRules: [ + { scope: 'playground', release: false }, // While Maple is on 0.x, a breaking change bumps the minor version { breaking: true, release: 'minor' }, // Dependency updates and refactorings change what ships, so they release a patch @@ -40,20 +61,7 @@ export default { { preset: 'conventionalcommits', presetConfig: { - types: [ - { type: 'feat', section: 'πŸš€ Features' }, - { type: 'fix', section: '🩹 Bug Fixes' }, - { type: 'perf', section: '⚑ Performance Improvements' }, - { type: 'revert', section: '↩️ Reverts' }, - { type: 'docs', section: 'πŸ“– Documentation' }, - { type: 'style', section: '🎨 Code Style' }, - { type: 'chore', section: 'πŸ”§ Miscellaneous Chores' }, - { type: 'refactor', section: 'πŸ› οΈ Code Refactoring' }, - { type: 'test', section: 'πŸ§ͺ Tests' }, - { type: 'build', scope: 'deps', section: 'πŸ— Dependency Updates' }, - { type: 'build', section: 'πŸ“¦ Build System' }, - { type: 'ci', section: 'πŸ”„οΈ Continuous Integration' } - ] + types: [...playground, ...types] } } ],