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/AGENTS.md b/AGENTS.md index 7192eb6..5f2682b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,9 @@ oxlint and oxfmt enforce most of the style. Fix what they report; don't silence - Use the `.ts` extension in relative imports (`./scope.ts`). - Put tests next to the code as `*.spec.ts` (Vitest). Every interpreter feature and every rejected escape needs a test. Specs that exercise a whole language chapter through `Maple.run` live in the directory whose behaviour they cover. -- `docs/` is the contract, indexed in `docs/README.md`; change it in the same commit as the behaviour. Contributor - notes such as adding a built-in are in the root `CONTRIBUTING.md`. +- `docs/` is the contract, indexed in `docs/README.md`; change it in the same commit as the behaviour. +- Contributor notes are in the root `CONTRIBUTING.md`: read "Adding a built-in" before adding one, and "Evaluator + layout" before changing anything in `src/evaluator`. ## Playground (`apps/maple-playground`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c24606..3aebb60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,44 @@ The library compiles against `lib: ["ES2024", "ES2025.Collection", "ES2025.Itera It has neither DOM nor Node type definitions, so a host API cannot be used by accident. The declared exceptions are the guarded `performance.now()` behind `durationMs`, and `atob` and `btoa`. +### Evaluator layout + +The evaluator in `packages/maple/src/evaluator` runs a script by walking the syntax tree acorn parsed from it. +`Evaluator` in `evaluator.ts` is only a switchboard: `evaluateStatement` and `evaluateExpression` charge an operation for each node and hand it, by its node type, to the file in `language/` that implements that part of JavaScript. + +| `language/` | JavaScript it implements | +| ------------------ | ------------------------------------------------------------------------ | +| `hoisting.ts` | which names a block or function declares, before its statements run | +| `blocks.ts` | statement lists, hoisting, `var` / `let` / `const` | +| `loops.ts` | `while`, `do…while`, `for`, `for…of`, `for…in` | +| `control-flow.ts` | `if`, `return`, `break`, `continue`, labels, `switch`, `throw`, `try` | +| `literals.ts` | literals, template literals, object and array literals, spread | +| `member-access.ts` | `a.b`, `a[b]`, optional chains, `delete` | +| `references.ts` | reading and assigning variables | +| `patterns.ts` | destructuring into names, as in declarations, parameters and assignments | +| `operators.ts` | unary, binary, logical, conditional and comma operators | +| `assignments.ts` | `=`, compound and logical assignment, `++` / `--` | +| `closures.ts` | creating function values from `function` and arrow syntax | +| `calls.ts` | calls, `new`, tagged templates, running a script function | + +Next to `language/` is what every part of JavaScript runs on: + +- `run-context.ts`: the run's budget, call stack and run state, and how errors are raised: a script error the script can catch, or a `MapleError` that ends the run. +- `host-bridge.ts`: every call into host functions and intrinsics, which is where values are copied and host exceptions are hidden from the script. +- `scope.ts`, `completion.ts`, `budget.ts`, `describe.ts`: scopes, completions (how a `return`, `break` or `continue` travels up), the operation budget, and the text error messages quote from the script. + +Three rules keep the language files apart, so a function can be understood from its own file: + +1. A language file is a set of plain functions that take the `Interpreter` (see `interpreter.ts`) as their first argument. + Whenever it evaluates a piece of syntax it does not handle itself, such as the default in `{ size = 10 }`, it calls the interpreter, which dispatches it. +2. A language file may call plain helpers of the language files below it, as `patterns.ts` uses `resolveMember` from `references.ts`, but never of one above, so the imports form no cycle. +3. A function that takes only a `RunContext`, not the interpreter, cannot evaluate anything. + +Every language file starts with a comment that explains its part of JavaScript with examples, written for a reader who does not know Maple. + +To support a new piece of syntax, add its case to the dispatcher in `evaluator.ts`, implement it as a function in the language file it belongs to, test it through `Maple.run` in the spec in `language/` for that part of the language, such as `control-flow.spec.ts` or `destructuring.spec.ts`, and list it in [language.md](packages/maple/docs/language.md). +Hot paths are measured with `pnpm --filter @netwin/maple-playground measure-run `; compare before and after a change to the evaluator. + ### Adding a built-in 1. Register the method under its name in the prototype of the receiver type as `method(String.prototype.name.length, (receiver, args, context) => value)`, or a static as `new NativeFunction('isArray', Array.isArray.length, (args, context) => value)` in the `members` of the namespace. 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/packages/maple/src/evaluator/describe.ts b/packages/maple/src/evaluator/describe.ts new file mode 100644 index 0000000..d5cb25a --- /dev/null +++ b/packages/maple/src/evaluator/describe.ts @@ -0,0 +1,77 @@ +import type { Expression, Node, Pattern, PrivateIdentifier, Super } from 'acorn'; + +/* + * How error messages name pieces of the script. JavaScript's messages quote the code they are + * about, as in `TypeError: obj.list[0] is not a function`, and Maple's follow them, so these are + * plain functions from AST nodes to text, used wherever the evaluator raises such an error. + */ + +/** + * The keyword that `'…' is not supported` names for a node type, where the AST type itself would + * read badly: a script using a class ends with `'class' is not supported`, not with + * `'ClassDeclaration' is not supported`. Node types not listed here appear under their own name. + */ +export const NODE_NAMES: Partial> = { + ClassDeclaration: 'class', + ClassExpression: 'class', + Super: 'super', + AwaitExpression: 'await', + YieldExpression: 'yield', + ImportDeclaration: 'import', + ImportExpression: 'import', + ExportNamedDeclaration: 'export', + ExportDefaultDeclaration: 'export', + ExportAllDeclaration: 'export', + PrivateIdentifier: '#private' +}; + +/** + * How an error message refers to the expression `node`, the way JavaScript's own messages do: + * + * obj.list[0]() β†’ obj.list[0] is not a function names and member chains as written + * "a".foo() β†’ "a".foo is not a function literals as written in the source + * foo()() β†’ foo(...) is not a function a call as `callee(...)` + * const [x] = {} β†’ {...} is not iterable object and array literals elided + * (1 + 2)() β†’ value is not a function anything else is just `value` + * + * It only reads the syntax and never evaluates anything, so describing an expression has no side + * effects. `ParenthesizedExpression` only exists when acorn runs with `preserveParens`, which + * `parse` does not set; without it, `(a)()` is already the identifier `a`. + */ +export function describe(node: Expression | Super | PrivateIdentifier): string { + switch (node.type) { + case 'Identifier': + return node.name; + case 'Super': + return 'super'; + case 'Literal': + // The literal as written, so a message reads `"a".foo is not a function` + return node.raw ?? String(node.value); + case 'MemberExpression': + return node.computed + ? `${describe(node.object)}[${describe(node.property)}]` + : `${describe(node.object)}.${describe(node.property)}`; + case 'CallExpression': + return `${describe(node.callee)}(...)`; + case 'TaggedTemplateExpression': + return `${describe(node.tag)}(...)`; + case 'ArrayExpression': + return '[...]'; + case 'ObjectExpression': + return '{...}'; + case 'ParenthesizedExpression': + return describe(node.expression); + default: + return 'value'; + } +} + +/** + * The name an anonymous function takes from where it is stored, as JavaScript gives it one: + * in `const add = (a, b) => a + b`, `add.name` is `'add'`. Only a plain identifier gives a name; + * a destructuring pattern (`const [g] = [() => {}]`) or a property (`obj.f = () => {}`) does + * not, so those give `undefined` and the function keeps the empty name. + */ +export function patternName(pattern: Pattern): string | undefined { + return pattern.type === 'Identifier' ? pattern.name : undefined; +} diff --git a/packages/maple/src/evaluator/evaluator.ts b/packages/maple/src/evaluator/evaluator.ts index 591ac78..de381ba 100644 --- a/packages/maple/src/evaluator/evaluator.ts +++ b/packages/maple/src/evaluator/evaluator.ts @@ -1,257 +1,132 @@ -import type { - ArrayExpression, - AssignmentExpression, - CallExpression, - CatchClause, - DoWhileStatement, - Expression, - ForInStatement, - ForOfStatement, - ForStatement, - Identifier, - LogicalExpression, - MemberExpression, - ModuleDeclaration, - NewExpression, - Node, - ObjectExpression, - Pattern, - PrivateIdentifier, - Program, - SpreadElement, - Statement, - Super, - SwitchStatement, - TaggedTemplateExpression, - TemplateLiteral, - TryStatement, - UnaryExpression, - UpdateExpression, - VariableDeclaration, - WhileStatement -} from 'acorn'; -import { fromHost, UnsupportedInputError } from '../boundary/from-host.ts'; -import { type HostFunction, type Intrinsic, isExposed, type Sandbox } from '../boundary/host.ts'; +import type { Expression, ModuleDeclaration, Program, Statement } from 'acorn'; +import { UnplacedError } from '../values/sandbox-error.ts'; +import type { Budget, MapleRunStats } from './budget.ts'; +import { type Completion, NORMAL } from './completion.ts'; +import { HostBridge, type NestedRun } from './host-bridge.ts'; +import type { Interpreter } from './interpreter.ts'; +import { evaluateAssignment, evaluateUpdate } from './language/assignments.ts'; +import { evaluateBlock, evaluateDeclaration, evaluateFunctionBody } from './language/blocks.ts'; import { - asMapleError, - capHostError, - hostErrorText, - MapleError, - MapleErrorCode -} from '../boundary/maple-error.ts'; -import { NestingLimitError, toHost, UnconvertibleValueError } from '../boundary/to-host.ts'; -import { getMember, hasMethod } from '../builtins/method-dispatch.ts'; -import type { MapleRunResult, RunOptions } from '../maple.ts'; -import { assertNever } from '../support/assert-never.ts'; -import { MAX_NESTING } from '../support/nesting-limit.ts'; -import { defineOwn } from '../support/own-property.ts'; -import { createArgumentsObject } from '../values/arguments-object.ts'; -import { Builtin } from '../values/builtin.ts'; -import { Closure, type FunctionNode } from '../values/closure.ts'; -import { toBoolean, toNumber, toString, typeOf } from '../values/coercion.ts'; -import { enumerableKeys, iterate } from '../values/iteration.ts'; + callFunction, + evaluateCall, + evaluateNew, + evaluateTaggedTemplate +} from './language/calls.ts'; +import { createClosure } from './language/closures.ts'; import { - assertAllowedKey, - deleteMember, - type Fail, - hasMember, - type MemberContext, - ownEntries, - setMember, - toPropertyKey -} from '../values/members.ts'; -import { type CallContext, guardHostErrors, NativeFunction } from '../values/native.ts'; -import { RunState } from '../values/run-state.ts'; -import { SandboxError, SandboxThrow, UnplacedError } from '../values/sandbox-error.ts'; -import { RegExpWrapper } from '../values/wrappers/regexp-wrapper.ts'; -import type { Budget, MapleRunStats } from './budget.ts'; -import { BREAK, type Completion, CONTINUE, NORMAL } from './completion.ts'; + evaluateBreak, + evaluateContinue, + evaluateIf, + evaluateLabeled, + evaluateReturn, + evaluateSwitch, + evaluateThrow, + evaluateTry +} from './language/control-flow.ts'; +import { + evaluateArray, + evaluateLiteral, + evaluateObject, + evaluateTemplate +} from './language/literals.ts'; import { - collectFunctionDeclarations, - collectPatternNames, - hoistLexicalDeclarations, - hoistVarDeclarations -} from './hoisting.ts'; -import { applyBinary, applyUnary, COMPOUND_OPERATORS } from './operators.ts'; + evaluateDoWhile, + evaluateFor, + evaluateForIn, + evaluateForOf, + evaluateWhile, + type Labels, + NO_LABELS +} from './language/loops.ts'; +import { evaluateChain, evaluateMember } from './language/member-access.ts'; +import { + evaluateBinary, + evaluateConditional, + evaluateLogical, + evaluateSequence, + evaluateUnary +} from './language/operators.ts'; +import { readIdentifier } from './language/references.ts'; +import { RunContext } from './run-context.ts'; import { Scope } from './scope.ts'; /** - * Host globals that must never resolve; reading one is reported as unsupported, not undefined. - * `arguments` is among them for the script level, which is no function and has none. - */ -const HOST_ONLY_NAMES: ReadonlySet = new Set([ - 'eval', - 'arguments', - 'Function', - 'globalThis', - 'Proxy', - 'Reflect', - 'Symbol', - 'WeakMap', - 'WeakSet', - 'Promise', - 'WeakRef', - 'FinalizationRegistry', - 'SharedArrayBuffer', - 'Atomics', - 'WebAssembly', - 'Intl', - 'require', - 'module', - 'exports', - 'process', - 'global', - 'Buffer', - '__dirname', - '__filename', - 'Deno', - 'Bun', - 'window', - 'self', - 'document', - 'navigator', - 'location', - 'localStorage', - 'sessionStorage', - 'indexedDB', - 'fetch', - 'XMLHttpRequest', - 'WebSocket', - 'Worker', - 'importScripts', - 'postMessage', - 'structuredClone', - 'setTimeout', - 'setInterval', - 'setImmediate', - 'queueMicrotask', - 'requestAnimationFrame' -]); - -/** What the script sees of a host exception that is not marked with `exposed`. */ -const HOST_FAILURE_MESSAGE = 'A host function failed'; - -/** Keywords for the error message where the node type alone would read badly. */ -const NODE_NAMES: Partial> = { - ClassDeclaration: 'class', - ClassExpression: 'class', - Super: 'super', - AwaitExpression: 'await', - YieldExpression: 'yield', - ImportDeclaration: 'import', - ImportExpression: 'import', - ExportNamedDeclaration: 'export', - ExportDefaultDeclaration: 'export', - ExportAllDeclaration: 'export', - PrivateIdentifier: '#private' -}; - -/** - * Travels up an optional chain once a `?.` met null or undefined; `ChainExpression` turns it into - * undefined. + * Runs a script: walks the syntax tree acorn parsed from it (the AST) and computes what each node + * means, the way a JavaScript engine would, without ever handing the script to the host's engine. + * + * This class is the switchboard. `evaluateStatement` and `evaluateExpression` look at a node's type + * and hand it to the file in `language/` that implements that part of JavaScript: + * + * hoisting.ts the names a block or function declares, before its statements run + * blocks.ts statement lists, hoisting, `var` / `let` / `const` + * loops.ts `while`, `do`, `for`, `for...of`, `for...in` + * control-flow.ts `if`, `return`, `break`, `continue`, labels, `switch`, `throw`, `try` + * literals.ts `1`, `'a'`, `/re/`, templates, `{ ... }`, `[ ... ]` + * member-access.ts `a.b`, `a[b]`, `a?.b`, `delete a.b` + * references.ts reading and assigning variables + * patterns.ts destructuring `{ a, b: [c] }` into names + * operators.ts `-a`, `a + b`, `a in b`, `a && b`, `a ? b : c`, `a, b` + * assignments.ts `a = b`, `a += b`, `a++` + * closures.ts creating function values from `function` and `=>` + * calls.ts `f()`, `new Map()`, ``tag`...` ``, running a function's body + * + * The language files call back into evaluation only through this class, as the `Interpreter` they + * receive, which also hands them the run's bookkeeping (`context`, see `run-context.ts`) and the + * way out to host functions (`host`, see `host-bridge.ts`). Anything a script uses that Maple does + * not implement, such as `class` or `async`, ends the run as unsupported syntax when it is + * reached. */ -const SHORT_CIRCUIT: unique symbol = Symbol('short-circuit'); - -type StatementList = ReadonlyArray; - -/** The labels a loop bears, which a labelled `continue` may name. */ -type Labels = ReadonlyArray; - -const NO_LABELS: Labels = []; +export class Evaluator implements Interpreter { + public readonly context: RunContext; -/** How a pattern binds its names: a declaration, a parameter, or an assignment to existing targets. */ -type BindMode = 'initialize' | 'parameter' | 'assign'; - -/** Runs one more program on the current budget; `Maple` supplies it because it owns the globals. */ -type NestedRun = (code: string, data: unknown, options: RunOptions) => MapleRunResult; - -/** A script function on the call stack and the call expression that entered it. */ -interface Frame { - name: string; - calledFrom: Node; -} - -/** A resolved assignment target, read and written in place for compound operators. */ -interface Reference { - get(): unknown; - set(value: unknown): void; -} - -/** Evaluates a parsed program under a budget; anything it does not implement throws when reached. */ -export class Evaluator { - readonly #budget: Budget; - - /** Runs another program on this budget; supplied by `Maple`, which owns the globals. */ - readonly #nestedRun: NestedRun; - - /** The active script calls, outermost first; `Error.stack` is rendered from it. */ - readonly #frames: Array = []; - - /** - * What this run changed on the values every run shares, and its template strings arrays; a nested - * run gets its own for its length. - */ - #runState = new RunState(); - - /** Script throws handed to an intrinsic as `MapleError`s, so they can be restored on the way out. */ - readonly #wrapped = new WeakMap(); + public readonly host: HostBridge; constructor(budget: Budget, nestedRun: NestedRun) { - this.#budget = budget; - this.#nestedRun = nestedRun; + this.context = new RunContext(budget); + this.host = new HostBridge(this.context, nestedRun, (callee, args, node) => + callFunction(this, undefined, callee, args, node) + ); } + /** Operations, call depth and time the run has used so far, which `Maple` reports with it. */ public stats(): MapleRunStats { - return this.#budget.stats(); + return this.context.stats(); } /** - * Evaluates the program as a function body below `globals` and returns what the script returned. - * Like the body of a strict function called plainly, it has `undefined` as `this`. + * Runs a whole script and gives back what it returned with `return`; a script that ends without + * one gives `undefined` (the value of the last expression is not used). The script runs like the + * body of a strict-mode function called plainly: in a scope of its own below `globals` (the + * built-ins, the instance's and the run's globals, and the run's `data`), with `undefined` as + * `this`. */ public run(program: Program, globals: Scope): unknown { const scope = new Scope(globals); scope.bindReceiver(undefined); - const completion = this.evaluateFunctionBody(program.body, scope); + const completion = evaluateFunctionBody(this, program.body, scope); return completion.type === 'return' ? completion.value : undefined; } - /** A function body hoists its `var`s on top of what every block hoists. */ - private evaluateFunctionBody(body: StatementList, scope: Scope): Completion { - hoistVarDeclarations(body, scope); - return this.evaluateBlock(body, scope); - } - - private evaluateBlock(statements: StatementList, scope: Scope): Completion { - this.hoistBlock(statements, scope); - return this.evaluateStatements(statements, scope); - } - - private hoistBlock(statements: StatementList, scope: Scope): void { - hoistLexicalDeclarations(statements, scope); - for (const declaration of collectFunctionDeclarations(statements)) { - scope.define(declaration.id.name, 'var', this.createClosure(declaration, scope)); - } - } - - private evaluateStatements(statements: StatementList, scope: Scope): Completion { - for (const statement of statements) { - const completion = this.evaluateStatement(statement, scope); - if (completion.type !== 'normal') { - return completion; - } - } - return NORMAL; - } - - /** `labels` are the labels directly on this statement, which only a loop has a use for. */ - private evaluateStatement( + /** + * Runs one statement: charges an operation for it, then hands it, by its AST node type, to the + * file in `language/` that implements it (`blocks.ts`, `loops.ts`, `control-flow.ts`), or + * handles it right here when it takes a line or two. A statement type that is not listed, such + * as `class` or `import`, ends the run as unsupported syntax. + * + * `labels` are the labels written directly in front of this statement, which only a loop has a + * use for (see `Labels`). + * + * Some errors are raised by code that knows nothing about the script's positions, such as a + * value conversion deep inside a built-in. It throws an `UnplacedError`, and the nearest + * statement or expression that sees it turns it into a script error at its own position. This + * is the net for those that happen outside any expression, such as a computed key in a pattern. + */ + public evaluateStatement( node: Statement | ModuleDeclaration, scope: Scope, labels: Labels = NO_LABELS ): Completion { - this.tick(node); - // The net for conversions outside any expression, such as a computed key in a pattern + this.context.tick(node); try { switch (node.type) { case 'EmptyStatement': @@ -262,1329 +137,137 @@ export class Evaluator { this.evaluateExpression(node.expression, scope); return NORMAL; case 'BlockStatement': - return this.evaluateBlock(node.body, new Scope(scope)); + return evaluateBlock(this, node.body, new Scope(scope)); case 'VariableDeclaration': - this.evaluateDeclaration(node, scope); + evaluateDeclaration(this, node, scope); return NORMAL; case 'FunctionDeclaration': // Already bound when the enclosing block was entered return NORMAL; case 'ReturnStatement': - return { - type: 'return', - value: node.argument ? this.evaluateExpression(node.argument, scope) : undefined - }; + return evaluateReturn(this, node, scope); case 'IfStatement': - if (toBoolean(this.evaluateExpression(node.test, scope))) { - return this.evaluateStatement(node.consequent, scope); - } - return node.alternate ? this.evaluateStatement(node.alternate, scope) : NORMAL; + return evaluateIf(this, node, scope); case 'WhileStatement': - return this.evaluateWhile(node, scope, labels); + return evaluateWhile(this, node, scope, labels); case 'DoWhileStatement': - return this.evaluateDoWhile(node, scope, labels); + return evaluateDoWhile(this, node, scope, labels); case 'ForStatement': - return this.evaluateFor(node, scope, labels); + return evaluateFor(this, node, scope, labels); case 'ForOfStatement': - return this.evaluateForOf(node, scope, labels); + return evaluateForOf(this, node, scope, labels); case 'ForInStatement': - return this.evaluateForIn(node, scope, labels); - case 'LabeledStatement': { - const name = node.label.name; - const completion = this.evaluateStatement(node.body, scope, [...labels, name]); - // A break to this label ends here; anything else travels on to its own target - return completion.type === 'break' && completion.label === name ? NORMAL : completion; - } + return evaluateForIn(this, node, scope, labels); + case 'LabeledStatement': + return evaluateLabeled(this, node, scope, labels); case 'BreakStatement': - return node.label ? { type: 'break', label: node.label.name } : BREAK; + return evaluateBreak(node); case 'ContinueStatement': - return node.label ? { type: 'continue', label: node.label.name } : CONTINUE; + return evaluateContinue(node); case 'SwitchStatement': - return this.evaluateSwitch(node, scope); - case 'ThrowStatement': { - const value = this.evaluateExpression(node.argument, scope); - throw new SandboxThrow( - value, - node, - value instanceof SandboxError ? '' : this.stackTrace(node) - ); - } + return evaluateSwitch(this, node, scope); + case 'ThrowStatement': + return evaluateThrow(this, node, scope); case 'TryStatement': - return this.evaluateTry(node, scope); + return evaluateTry(this, node, scope); default: - return this.unsupported(node); + return this.context.unsupported(node); } } catch (error) { if (error instanceof UnplacedError) { - return this.throwError(error.name, error.message, node); + return this.context.throwError(error.name, error.message, node); } throw error; } } /** - * Only script throws are caught here. A limit error or an unsupported construct is a - * `MapleError`, which passes through untouched and skips `finally` as well. - */ - private evaluateTry(node: TryStatement, scope: Scope): Completion { - let completion: Completion = NORMAL; - let pending: SandboxThrow | undefined; - try { - completion = this.evaluateStatement(node.block, scope); - } catch (error) { - if (!(error instanceof SandboxThrow)) { - throw error; - } - if (!node.handler) { - pending = error; - } else { - try { - completion = this.evaluateCatch(node.handler, error.value, scope); - } catch (inner) { - if (!(inner instanceof SandboxThrow)) { - throw inner; - } - pending = inner; - } - } - } - if (node.finalizer) { - const final = this.evaluateStatement(node.finalizer, scope); - // A return, break or continue in finally wins over both the result and a pending throw - if (final.type !== 'normal') { - return final; - } - } - if (pending) { - throw pending; - } - return completion; - } - - private evaluateCatch(handler: CatchClause, thrown: unknown, scope: Scope): Completion { - const catchScope = new Scope(scope); - if (handler.param) { - const names: Array = []; - collectPatternNames(handler.param, names); - for (const name of names) { - catchScope.define(name, 'let'); - } - this.bindPattern(handler.param, thrown, catchScope, 'initialize'); - } - return this.evaluateStatement(handler.body, catchScope); - } - - private evaluateDeclaration(node: VariableDeclaration, scope: Scope): void { - const kind = node.kind; - if (kind !== 'var' && kind !== 'let' && kind !== 'const') { - return this.unsupported(node, kind); - } - for (const declarator of node.declarations) { - // A bare `var a;` must not reset a value the hoisted binding already holds - if (!declarator.init && kind === 'var') { - continue; - } - const value = declarator.init - ? this.evaluateNamed(declarator.init, scope, patternName(declarator.id)) - : undefined; - const source = declarator.init ? this.describe(declarator.init) : 'undefined'; - this.bindPattern(declarator.id, value, scope, 'initialize', source); - } - } - - /** - * Binds a pattern's names; for declarations the bindings themselves were created by hoisting. - * `source` names the value in error messages, as in `obj is not iterable`. - */ - private bindPattern( - pattern: Pattern, - value: unknown, - scope: Scope, - mode: BindMode, - source: string = 'value' - ): void { - switch (pattern.type) { - case 'Identifier': - this.bindName(pattern, value, scope, mode); - return; - case 'MemberExpression': - this.resolveMember(pattern, scope).set(value); - return; - case 'AssignmentPattern': { - const bound = - value === undefined - ? this.evaluateNamed(pattern.right, scope, patternName(pattern.left)) - : value; - this.bindPattern(pattern.left, bound, scope, mode); - return; - } - case 'ObjectPattern': - this.bindObjectPattern(pattern, value, scope, mode); - return; - case 'ArrayPattern': - this.bindArrayPattern(pattern, value, scope, mode, source); - return; - case 'RestElement': - throw new Error('A rest element outside of its pattern'); - default: - return assertNever(pattern, 'pattern'); - } - } - - private bindName(node: Identifier, value: unknown, scope: Scope, mode: BindMode): void { - switch (mode) { - case 'initialize': - scope.initialize(node.name, value); - return; - case 'parameter': - scope.define(node.name, 'var', value); - return; - case 'assign': - this.assignIdentifier(node, value, scope); - return; - default: - return assertNever(mode, 'bind mode'); - } - } - - private bindObjectPattern( - pattern: Extract, - value: unknown, - scope: Scope, - mode: BindMode - ): void { - if (value === undefined || value === null) { - return this.throwError( - 'TypeError', - `Cannot destructure '${String(value)}' as it is ${String(value)}.`, - pattern - ); - } - const context = this.memberContext(pattern); - const taken = new Set(); - for (const property of pattern.properties) { - if (property.type === 'RestElement') { - const rest: Record = {}; - for (const [key, entry] of ownEntries(value, this.#runState)) { - this.tick(property); - if (!taken.has(key)) { - defineOwn(rest, key, entry); - } - } - this.bindPattern(property.argument, rest, scope, mode); - continue; - } - const key = this.propertyKey(property.key, property.computed, scope); - taken.add(key); - this.bindPattern(property.value, getMember(value, key, context), scope, mode); - } - } - - private bindArrayPattern( - pattern: Extract, - value: unknown, - scope: Scope, - mode: BindMode, - source: string - ): void { - const iterator = this.iterateOrThrow(value, pattern, source); - for (const element of pattern.elements) { - if (element?.type === 'RestElement') { - const rest: Array = []; - for (let step = iterator.next(); !step.done; step = iterator.next()) { - this.tick(element); - rest.push(step.value); - } - this.bindPattern(element.argument, rest, scope, mode); - return; - } - const step = iterator.next(); - if (element) { - this.bindPattern(element, step.done ? undefined : step.value, scope, mode); - } - } - } - - private evaluateWhile(node: WhileStatement, scope: Scope, labels: Labels): Completion { - while (toBoolean(this.evaluateExpression(node.test, scope))) { - this.tick(node); - const exit = this.evaluateLoopBody(node.body, scope, labels); - if (exit) { - return exit; - } - } - return NORMAL; - } - - private evaluateDoWhile(node: DoWhileStatement, scope: Scope, labels: Labels): Completion { - do { - this.tick(node); - const exit = this.evaluateLoopBody(node.body, scope, labels); - if (exit) { - return exit; - } - } while (toBoolean(this.evaluateExpression(node.test, scope))); - return NORMAL; - } - - /** - * A `let` or `const` in the head gets a fresh binding per iteration, so closures see their own - * `i`. - */ - private evaluateFor(node: ForStatement, scope: Scope, labels: Labels): Completion { - const loopScope = new Scope(scope); - let perIteration = false; - if (node.init?.type === 'VariableDeclaration') { - hoistLexicalDeclarations([node.init], loopScope); - this.evaluateDeclaration(node.init, loopScope); - perIteration = node.init.kind !== 'var'; - } else if (node.init) { - this.evaluateExpression(node.init, loopScope); - } - let iterationScope = perIteration ? loopScope.copy() : loopScope; - for (;;) { - this.tick(node); - if (node.test && !toBoolean(this.evaluateExpression(node.test, iterationScope))) { - break; - } - const exit = this.evaluateLoopBody(node.body, iterationScope, labels); - if (exit) { - return exit; - } - if (perIteration) { - iterationScope = iterationScope.copy(); - } - if (node.update) { - this.evaluateExpression(node.update, iterationScope); - } - } - return NORMAL; - } - - private evaluateForOf(node: ForOfStatement, scope: Scope, labels: Labels): Completion { - if (node.await) { - return this.unsupported(node, 'for await'); - } - const iterable = this.evaluateExpression(node.right, scope); - const iterator = this.iterateOrThrow(iterable, node.right, this.describe(node.right)); - for (let step = iterator.next(); !step.done; step = iterator.next()) { - this.tick(node); - const iterationScope = this.bindLoopTarget(node.left, step.value, scope); - const exit = this.evaluateLoopBody(node.body, iterationScope, labels); - if (exit) { - return exit; - } - } - return NORMAL; - } - - private evaluateForIn(node: ForInStatement, scope: Scope, labels: Labels): Completion { - const keys = enumerableKeys(this.evaluateExpression(node.right, scope), this.#runState); - for (const key of keys) { - this.tick(node); - const iterationScope = this.bindLoopTarget(node.left, key, scope); - const exit = this.evaluateLoopBody(node.body, iterationScope, labels); - if (exit) { - return exit; - } - } - return NORMAL; - } - - /** Binds the head of a `for...of` / `for...in`; `let` and `const` get a scope per iteration. */ - private bindLoopTarget(left: VariableDeclaration | Pattern, value: unknown, scope: Scope): Scope { - if (left.type !== 'VariableDeclaration') { - this.bindPattern(left, value, scope, 'assign'); - return scope; - } - const [declarator] = left.declarations; - if (!declarator) { - throw new Error('A loop declaration without a declarator'); - } - if (left.kind === 'var') { - this.bindPattern(declarator.id, value, scope, 'initialize'); - return scope; - } - if (left.kind !== 'let' && left.kind !== 'const') { - return this.unsupported(left, left.kind); - } - const iterationScope = new Scope(scope); - hoistLexicalDeclarations([left], iterationScope); - this.bindPattern(declarator.id, value, iterationScope, 'initialize'); - return iterationScope; - } - - /** - * Runs one iteration of a loop's body and says how the loop goes on: `undefined` to the next - * iteration, `NORMAL` once a `break` ended it, or the completion itself for the loop to hand - * up: a `return`, a `continue` to an outer loop, or a labelled `break`, which the labelled - * statement it names turns back into a normal completion. + * Evaluates one expression to its value: charges an operation for it, then hands it, by its AST + * node type, to the file in `language/` that implements it (`literals.ts`, `member-access.ts`, + * `assignments.ts`, `operators.ts`, ...), or handles it right here when it takes a line or two. + * An expression type that is not listed, such as `class` or `await`, ends the run as + * unsupported syntax. + * + * Like `evaluateStatement`, it turns an `UnplacedError` from code that knows no positions, such + * as a value conversion, into a script error. Expressions nest, so the error gets the position of + * the innermost expression it surfaces from: in `'Groups: ' + Object.groupBy(list, by)`, the + * object without a prototype cannot be converted to a string, and the TypeError points at the + * `+` expression. */ - private evaluateLoopBody(body: Statement, scope: Scope, labels: Labels): Completion | undefined { - const completion = this.evaluateStatement(body, scope); - switch (completion.type) { - case 'normal': - return undefined; - case 'continue': - return completion.label === undefined || labels.includes(completion.label) - ? undefined - : completion; - case 'break': - return completion.label === undefined ? NORMAL : completion; - case 'return': - return completion; - default: - return assertNever(completion, 'completion'); - } - } - - /** The whole switch body is one block, so a `let` in one case is visible in the next. */ - private evaluateSwitch(node: SwitchStatement, scope: Scope): Completion { - const discriminant = this.evaluateExpression(node.discriminant, scope); - const switchScope = new Scope(scope); - this.hoistBlock( - node.cases.flatMap((switchCase) => switchCase.consequent), - switchScope - ); - let start = node.cases.findIndex((switchCase) => !switchCase.test); - for (const [index, switchCase] of node.cases.entries()) { - if ( - switchCase.test && - this.evaluateExpression(switchCase.test, switchScope) === discriminant - ) { - start = index; - break; - } - } - if (start === -1) { - return NORMAL; - } - for (const switchCase of node.cases.slice(start)) { - const completion = this.evaluateStatements(switchCase.consequent, switchScope); - if (completion.type === 'break' && completion.label === undefined) { - return NORMAL; - } - if (completion.type !== 'normal') { - return completion; - } - } - return NORMAL; - } - - private evaluateExpression(node: Expression, scope: Scope): unknown { - this.tick(node); - // An error from a conversion gets the position of the innermost expression it surfaces from + public evaluateExpression(node: Expression, scope: Scope): unknown { + this.context.tick(node); try { switch (node.type) { case 'Literal': - if (node.regex) { - // Every evaluation creates a new regexp, as the spec requires for literals - const { pattern, flags } = node.regex; - return new RegExpWrapper( - guardHostErrors(this.failAt(node), () => new RegExp(pattern, flags)) - ); - } - if (typeof node.value === 'bigint') { - return this.unsupportedFeature(node, 'bigint literals'); - } - return node.value; + return evaluateLiteral(this, node); case 'Identifier': - return this.readIdentifier(node, scope); + return readIdentifier(this.context, node, scope); case 'ThisExpression': + // The receiver of the nearest non-arrow function call, or undefined at the script level return scope.receiver(); case 'TemplateLiteral': - return this.evaluateTemplate(node, scope); + return evaluateTemplate(this, node, scope); case 'TaggedTemplateExpression': - return this.evaluateTaggedTemplate(node, scope); + return evaluateTaggedTemplate(this, node, scope); case 'ObjectExpression': - return this.evaluateObject(node, scope); + return evaluateObject(this, node, scope); case 'ArrayExpression': - return this.evaluateArray(node, scope); + return evaluateArray(this, node, scope); case 'MemberExpression': - return this.evaluateMember(node, scope); - case 'ChainExpression': { - const value = this.evaluateExpression(node.expression, scope); - return value === SHORT_CIRCUIT ? undefined : value; - } + return evaluateMember(this, node, scope); + case 'ChainExpression': + return evaluateChain(this, node, scope); case 'AssignmentExpression': - return this.evaluateAssignment(node, scope); + return evaluateAssignment(this, node, scope); case 'UpdateExpression': - return this.evaluateUpdate(node, scope); + return evaluateUpdate(this, node, scope); case 'UnaryExpression': - return this.evaluateUnary(node, scope); - case 'BinaryExpression': { - if (node.left.type === 'PrivateIdentifier') { - return this.unsupported(node.left); - } - const left = this.evaluateExpression(node.left, scope); - const right = this.evaluateExpression(node.right, scope); - if (node.operator === 'in') { - const propertyKey = toPropertyKey(left); - return ( - hasMember(right, propertyKey, this.memberContext(node)) || - hasMethod(right, propertyKey) - ); - } - if (node.operator === 'instanceof') { - return this.instanceOf(left, right, node); - } - const operator = node.operator; - return guardHostErrors(this.failAt(node), () => applyBinary(operator, left, right)); - } + return evaluateUnary(this, node, scope); + case 'BinaryExpression': + return evaluateBinary(this, node, scope); case 'LogicalExpression': - return this.evaluateLogical(node, scope); + return evaluateLogical(this, node, scope); case 'ConditionalExpression': - return toBoolean(this.evaluateExpression(node.test, scope)) - ? this.evaluateExpression(node.consequent, scope) - : this.evaluateExpression(node.alternate, scope); - case 'SequenceExpression': { - let result: unknown; - for (const expression of node.expressions) { - result = this.evaluateExpression(expression, scope); - } - return result; - } + return evaluateConditional(this, node, scope); + case 'SequenceExpression': + return evaluateSequence(this, node, scope); case 'FunctionExpression': case 'ArrowFunctionExpression': - return this.createClosure(node, scope); + // Creating a function evaluates nothing inside it yet; its body runs when it is called + return createClosure(this.context, node, scope); case 'CallExpression': - return this.evaluateCall(node, scope); + return evaluateCall(this, node, scope); case 'NewExpression': - return this.evaluateNew(node, scope); + return evaluateNew(this, node, scope); case 'MetaProperty': - return this.unsupported(node, `${node.meta.name}.${node.property.name}`); + // `new.target` and `import.meta` + return this.context.unsupported(node, `${node.meta.name}.${node.property.name}`); default: - return this.unsupported(node); + return this.context.unsupported(node); } } catch (error) { if (error instanceof UnplacedError) { - return this.throwError(error.name, error.message, node); + return this.context.throwError(error.name, error.message, node); } throw error; } } - /** `new` works for the built-in constructors only; a script function cannot be constructed. */ - private evaluateNew(node: NewExpression, scope: Scope): unknown { - const callee = this.evaluateExpression(node.callee, scope); - if (!(callee instanceof Builtin) || !callee.construct) { - return this.unsupported(node, `new ${this.describe(node.callee)}`); - } - return callee.construct(this.evaluateArguments(node.arguments, scope), this.callContext(node)); - } - - private instanceOf(value: unknown, constructor: unknown, node: Node): boolean { - if (!(constructor instanceof Builtin) || !constructor.isInstance) { - return this.throwError( - 'TypeError', - "Right-hand side of 'instanceof' is not a constructor", - node - ); - } - return constructor.isInstance(value); - } - - private evaluateTemplate(node: TemplateLiteral, scope: Scope): string { - const parts: Array = []; - for (const [index, quasi] of node.quasis.entries()) { - parts.push(quasi.value.cooked ?? ''); - const expression = node.expressions[index]; - if (expression) { - parts.push(toString(this.evaluateExpression(expression, scope))); - } - } - return guardHostErrors(this.failAt(node), () => parts.join('')); - } - - /** - * Calls the tag with the site's strings array and the substitutions as values, in JavaScript's - * order: the tag, then the strings, then the substitutions. - */ - private evaluateTaggedTemplate(node: TaggedTemplateExpression, scope: Scope): unknown { - const { receiver, target } = this.resolveCallee(node.tag, scope); - const strings = this.templateObject(node.quasi, node); - const substitutions = node.quasi.expressions.map((expression) => - this.evaluateExpression(expression, scope) - ); - // acorn refuses an optional chain in a tag, so the target cannot be a short circuit here - return this.invoke( - receiver, - target, - [strings, ...substitutions], - node, - this.describe(node.tag) - ); - } - - /** - * The frozen strings array of a template site, with the raw text under a non-enumerable `raw`. - * An invalid escape, which acorn allows in a tagged template, leaves `undefined` in the cooked - * strings, as in JavaScript. - */ - private templateObject(quasi: TemplateLiteral, node: Node): ReadonlyArray { - const cached = this.#runState.getTemplateObject(quasi); - if (cached) { - return cached; - } - - this.charge(quasi.quasis.length, node); - const cooked: Array = quasi.quasis.map( - (element) => element.value.cooked ?? undefined - ); - - const raw: Array = quasi.quasis.map((element) => element.value.raw); - Object.freeze(raw); - - Object.defineProperty(cooked, 'raw', { - value: raw, - writable: false, - enumerable: false, - configurable: false - }); - Object.freeze(cooked); - - this.#runState.setTemplateObject(quasi, cooked); - return cooked; - } - - private evaluateObject(node: ObjectExpression, scope: Scope): Record { - const object: Record = {}; - for (const property of node.properties) { - if (property.type === 'SpreadElement') { - for (const [key, value] of ownEntries( - this.evaluateExpression(property.argument, scope), - this.#runState - )) { - this.tick(property); - defineOwn(object, key, value); - } - continue; - } - if (property.kind !== 'init') { - return this.unsupportedFeature(property, 'getters and setters'); - } - const key = this.propertyKey(property.key, property.computed, scope); - assertAllowedKey(key, this.failAt(property)); - defineOwn(object, key, this.evaluateNamed(property.value, scope, key)); - } - return object; - } - - private evaluateArray(node: ArrayExpression, scope: Scope): Array { - const array: Array = []; - for (const element of node.elements) { - if (element === null) { - // An elision leaves a hole, so `1 in [1, , 3]` is false - array.length++; - } else if (element.type === 'SpreadElement') { - this.spreadInto(array, element, scope); - } else { - array.push(this.evaluateExpression(element, scope)); - } - } - return array; - } - - private spreadInto(target: Array, element: SpreadElement, scope: Scope): void { - const value = this.evaluateExpression(element.argument, scope); - const iterator = this.iterateOrThrow(value, element.argument, this.describe(element.argument)); - for (let step = iterator.next(); !step.done; step = iterator.next()) { - this.tick(element); - target.push(step.value); - } - } - - private evaluateMember(node: MemberExpression, scope: Scope): unknown { - const access = this.accessMember(node, scope); - return access === SHORT_CIRCUIT ? SHORT_CIRCUIT : access.value; - } - - /** A member read that also hands back the object, which a call needs as its receiver. */ - private accessMember( - node: MemberExpression, - scope: Scope - ): { object: unknown; value: unknown } | typeof SHORT_CIRCUIT { - if (node.object.type === 'Super') { - return this.unsupported(node.object); - } - const object = this.evaluateExpression(node.object, scope); - if (object === SHORT_CIRCUIT || (node.optional && (object === null || object === undefined))) { - return SHORT_CIRCUIT; - } - const key = this.propertyKey(node.property, node.computed, scope); - return { object, value: getMember(object, key, this.memberContext(node)) }; - } - - /** The key of a member access or a literal property; computed keys go through ToPropertyKey. */ - private propertyKey( - key: Expression | PrivateIdentifier, - computed: boolean, - scope: Scope - ): string { - if (key.type === 'PrivateIdentifier') { - return this.unsupported(key); - } - if (computed) { - return toPropertyKey(this.evaluateExpression(key, scope)); - } - if (key.type === 'Identifier') { - return key.name; - } - if (key.type === 'Literal' && typeof key.value !== 'bigint' && !key.regex) { - return toString(key.value); - } - return this.unsupported(key); - } - - /** Resolves an assignment target once, so compound operators read and write the same place. */ - private resolveTarget(target: Identifier | MemberExpression, scope: Scope): Reference { - if (target.type === 'Identifier') { - return { - get: () => this.readIdentifier(target, scope), - set: (value) => this.assignIdentifier(target, value, scope) - }; - } - return this.resolveMember(target, scope); - } - - private resolveMember(target: MemberExpression, scope: Scope): Reference { - if (target.object.type === 'Super') { - return this.unsupported(target.object); - } - const object = this.evaluateExpression(target.object, scope); - const key = this.propertyKey(target.property, target.computed, scope); - const context = this.memberContext(target); - return { - get: () => getMember(object, key, context), - set: (value) => setMember(object, key, value, context) - }; - } - - private evaluateAssignment(node: AssignmentExpression, scope: Scope): unknown { - const target = node.left; - if (target.type !== 'Identifier' && target.type !== 'MemberExpression') { - const value = this.evaluateExpression(node.right, scope); - this.bindPattern(target, value, scope, 'assign', this.describe(node.right)); - return value; - } - const reference = this.resolveTarget(target, scope); - const { operator } = node; - if (operator === '=') { - const value = this.evaluateNamed(node.right, scope, patternName(target)); - reference.set(value); - return value; - } - const current = reference.get(); - let value: unknown; - switch (operator) { - case '&&=': - if (!toBoolean(current)) { - return current; - } - value = this.evaluateNamed(node.right, scope, patternName(target)); - break; - case '||=': - if (toBoolean(current)) { - return current; - } - value = this.evaluateNamed(node.right, scope, patternName(target)); - break; - case '??=': - if (current !== null && current !== undefined) { - return current; - } - value = this.evaluateNamed(node.right, scope, patternName(target)); - break; - default: - const right = this.evaluateExpression(node.right, scope); - value = guardHostErrors(this.failAt(node), () => - applyBinary(COMPOUND_OPERATORS[operator], current, right) - ); - } - reference.set(value); - return value; - } - - private evaluateUpdate(node: UpdateExpression, scope: Scope): number { - const target = node.argument; - if (target.type !== 'Identifier' && target.type !== 'MemberExpression') { - return this.unsupported(target); - } - const reference = this.resolveTarget(target, scope); - const previous = toNumber(reference.get()); - const value = node.operator === '++' ? previous + 1 : previous - 1; - reference.set(value); - return node.prefix ? value : previous; - } - - private evaluateUnary(node: UnaryExpression, scope: Scope): unknown { - switch (node.operator) { - case 'delete': { - if (node.argument.type !== 'MemberExpression') { - this.evaluateExpression(node.argument, scope); - return true; - } - if (node.argument.object.type === 'Super') { - return this.unsupported(node.argument.object); - } - const object = this.evaluateExpression(node.argument.object, scope); - const key = this.propertyKey(node.argument.property, node.argument.computed, scope); - return deleteMember(object, key, this.memberContext(node.argument)); - } - case 'typeof': - // `typeof undeclared` is the one read of an unknown name that does not throw - if (node.argument.type === 'Identifier' && !scope.lookup(node.argument.name)) { - return 'undefined'; - } - return typeOf(this.evaluateExpression(node.argument, scope)); - default: - return applyUnary(node.operator, this.evaluateExpression(node.argument, scope)); - } - } - - private evaluateLogical(node: LogicalExpression, scope: Scope): unknown { - const left = this.evaluateExpression(node.left, scope); - switch (node.operator) { - case '&&': - return toBoolean(left) ? this.evaluateExpression(node.right, scope) : left; - case '||': - return toBoolean(left) ? left : this.evaluateExpression(node.right, scope); - case '??': - return left ?? this.evaluateExpression(node.right, scope); - default: - return assertNever(node.operator, 'logical operator'); - } - } - - /** Async and generator functions fail here, at creation, even if they are never called. */ - private createClosure(node: FunctionNode, scope: Scope, name?: string): Closure { - if (node.async) { - return this.unsupported(node, 'async'); - } - if (node.generator) { - return this.unsupportedFeature(node, 'generators'); - } - return new Closure(node, scope, name); - } - /** - * Evaluates the value of a binding, property or default; an anonymous function there takes - * `name` as its own, as `var f = function () {}` names `f` in JavaScript. + * Evaluates a value that is about to be stored under `name`: the initializer of a declaration, + * the value of an object literal property, a default, or the right side of an assignment. An + * anonymous function there takes `name` as its own, as JavaScript names it: + * `const add = (a, b) => a + b` gives `add.name === 'add'`. Any other expression evaluates as + * usual; `name` is `undefined` where nothing gives a name (see `patternName`). */ - private evaluateNamed(node: Expression, scope: Scope, name: string | undefined): unknown { + public evaluateNamed(node: Expression, scope: Scope, name: string | undefined): unknown { if ( name !== undefined && (node.type === 'ArrowFunctionExpression' || (node.type === 'FunctionExpression' && !node.id)) ) { - this.tick(node); - return this.createClosure(node, scope, name); + this.context.tick(node); + return createClosure(this.context, node, scope, name); } return this.evaluateExpression(node, scope); } - - private evaluateCall(node: CallExpression, scope: Scope): unknown { - const { receiver, target } = this.resolveCallee(node.callee, scope); - - if (target === SHORT_CIRCUIT || (node.optional && (target === null || target === undefined))) { - return SHORT_CIRCUIT; - } - - const args = this.evaluateArguments(node.arguments, scope); - return this.invoke(receiver, target, args, node, this.describe(node.callee)); - } - - /** - * What a call or a tagged template invokes and with which `this`: a member callee binds its - * object, any other leaves the receiver `undefined`. The target is `SHORT_CIRCUIT` when an - * optional chain in a member callee short-circuited. - */ - private resolveCallee( - callee: Expression | Super, - scope: Scope - ): { receiver: unknown; target: unknown } { - if (callee.type === 'Super') { - return this.unsupported(callee); - } - - if (callee.type === 'MemberExpression') { - // Not routed through evaluateExpression so a short-circuited object is seen here - this.tick(callee); - const access = this.accessMember(callee, scope); - if (access === SHORT_CIRCUIT) { - return { receiver: undefined, target: SHORT_CIRCUIT }; - } else { - return { receiver: access.object, target: access.value }; - } - } - - if (callee.type === 'ChainExpression' && callee.expression.type === 'MemberExpression') { - // `(a?.b)()` keeps `a` as the receiver; a short circuit ends at the parenthesis - this.tick(callee); - this.tick(callee.expression); - const access = this.accessMember(callee.expression, scope); - if (access === SHORT_CIRCUIT) { - return { receiver: undefined, target: SHORT_CIRCUIT }; - } else { - return { receiver: access.object, target: access.value }; - } - } - return { receiver: undefined, target: this.evaluateExpression(callee, scope) }; - } - - /** What a member operation at `node` needs from the run. */ - private memberContext(node: Node): MemberContext { - return { - fail: this.failAt(node), - charge: (count) => this.charge(count, node), - runState: this.#runState - }; - } - - /** What a built-in gets to reach back into the run; `node` is the call it was invoked from. */ - private callContext(node: Node): CallContext { - return { - ...this.memberContext(node), - call: (receiver, callee, args) => this.call(receiver, callee, args, node), - stackTrace: () => this.stackTrace(node), - invokeHost: (fn, args) => this.invokeHost(fn, args, node), - invokeIntrinsic: (fn, args) => this.invokeIntrinsic(fn, args, node) - }; - } - - /** Arguments go out as copies and the result comes in as a copy, so no reference crosses. */ - private invokeHost(fn: HostFunction, args: ReadonlyArray, node: Node): unknown { - const hostArgs = args.map((arg) => this.toHostArgument(arg, node)); - let result: unknown; - try { - result = fn(...hostArgs); - } catch (error) { - return this.throwHostError(error, node); - } - return this.fromHostResult(result, node, fn.name); - } - - /** - * An intrinsic sees raw sandbox values; a script error it lets through stays catchable by the - * script. - */ - private invokeIntrinsic(fn: Intrinsic, args: ReadonlyArray, node: Node): unknown { - const { handle, revoke } = this.sandboxHandle(node); - try { - return fn(args, handle); - } catch (error) { - if (error instanceof MapleError) { - throw this.#wrapped.get(error) ?? error; - } - return this.throwHostError(error, node); - } finally { - revoke(); - } - } - - /** - * The handle an intrinsic works with, and the switch that revokes it once the intrinsic has - * returned, so a handle it kept cannot run script code outside the call it was made for. - */ - private sandboxHandle(node: Node): { handle: Sandbox; revoke: () => void } { - let revoked = false; - const guard = (action: () => T): T => { - if (revoked) { - throw new Error('The sandbox handle is only usable while the intrinsic runs'); - } - return this.forIntrinsic(action); - }; - return { - handle: { - toHost: (value) => guard(() => this.toHostArgument(value, node)), - fromHost: (value) => guard(() => this.fromHostResult(value, node, 'intrinsic')), - call: (callee, args) => guard(() => this.call(undefined, callee, args, node)), - run: (code, data, options = {}) => guard(() => this.runNested(code, data, options, node)) - }, - revoke: () => { - revoked = true; - } - }; - } - - /** - * Calls a function for a built-in or an intrinsic. A closure counts its own level; any other - * callee counts one here, so built-ins calling each other, as `apply` can, stay under `maxDepth`. - */ - private call( - receiver: unknown, - callee: unknown, - args: ReadonlyArray, - node: Node - ): unknown { - this.tick(node); - if (callee instanceof Closure) { - return this.callClosure(receiver, callee, args, node); - } - this.enterCall(node); - try { - return this.invoke(receiver, callee, args, node, 'callback'); - } finally { - this.#budget.leave(); - } - } - - /** Calls any callable sandbox value; `description` names it in the error when it is not one. */ - private invoke( - receiver: unknown, - callee: unknown, - args: ReadonlyArray, - node: Node, - description: string - ): unknown { - if (callee instanceof Closure) { - return this.callClosure(receiver, callee, args, node); - } - if (callee instanceof NativeFunction) { - return callee.invoke(receiver, args, this.callContext(node)); - } - if (callee instanceof Builtin && callee.call) { - return callee.call(args, this.callContext(node)); - } - return this.throwError('TypeError', `${description} is not a function`, node); - } - - /** - * A nested program is one more call level and one more frame on the same budget. It starts from - * the shared values as they were, like any run, with strings arrays of its own for its template - * sites, and what it changes on the shared values is gone when it returns. - */ - private runNested(code: string, data: unknown, options: RunOptions, node: Node): MapleRunResult { - this.enterCall(node); - this.#frames.push({ name: '', calledFrom: node }); - const outer = this.#runState; - this.#runState = new RunState(); - try { - return this.#nestedRun(code, data, options); - } finally { - this.#runState = outer; - this.#frames.pop(); - this.#budget.leave(); - } - } - - /** Errors reach an intrinsic as `MapleError`s; the original throw is kept to restore it later. */ - private forIntrinsic(action: () => T): T { - try { - return action(); - } catch (error) { - if (error instanceof SandboxThrow) { - const wrapped = asMapleError(error, this.#budget.stats()); - this.#wrapped.set(wrapped, error); - throw wrapped; - } - throw error; - } - } - - private toHostArgument(value: unknown, node: Node): unknown { - try { - return toHost(value, true); - } catch (error) { - if (error instanceof UnconvertibleValueError) { - return this.throwError( - 'TypeError', - `${error.kind === 'iterator' ? 'An' : 'A'} ${error.kind} cannot be passed to the host`, - node - ); - } - if (error instanceof NestingLimitError) { - return this.throwError( - 'RangeError', - `A value nested deeper than ${MAX_NESTING} levels cannot be passed to the host`, - node - ); - } - throw error; - } - } - - /** A host return value the model cannot hold is the host's bug, so it is never catchable. */ - private fromHostResult(value: unknown, node: Node, name: string): unknown { - try { - return fromHost(value, `${name}()`, { freeze: false, functions: false }); - } catch (error) { - if (error instanceof TypeError) { - return this.fail( - MapleErrorCode.INTERNAL_ERROR, - `${name} returned a value the sandbox cannot hold: ${error.message}`, - node, - error - ); - } - throw error; - } - } - - /** - * What the host threw becomes a sandbox error that says only that the host failed, unless the - * host marked it with `exposed`; the host gets it back as `cause`. Maple's own errors pass. - */ - private throwHostError(error: unknown, node: Node): never { - if ( - error instanceof MapleError || - error instanceof SandboxThrow || - error instanceof UnplacedError - ) { - throw error; - } - const sandboxError = isExposed(error) - ? this.createError(hostErrorText(error, 'name'), hostErrorText(error, 'message'), node) - : this.createError('Error', HOST_FAILURE_MESSAGE, node); - sandboxError.hostError = capHostError(error); - throw new SandboxThrow(sandboxError, node); - } - - /** One line per active script frame, innermost first, with the position each frame is at. */ - private stackTrace(node: Node): string { - const lines: Array = []; - let site: Node = node; - for (let index = this.#frames.length - 1; index >= 0; index--) { - const frame = this.#frames[index]; - if (!frame) { - break; - } - lines.push(` at ${frame.name} (${this.position(site)})`); - site = frame.calledFrom; - } - lines.push(` at