Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand Down
44 changes: 36 additions & 8 deletions apps/maple-playground/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions apps/maple-playground/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@
},
"defaultConfiguration": "development",
"options": {
"host": "0.0.0.0",
"port": 4200,
"prebundle": {
"exclude": ["monaco-editor"]
}
Expand Down
66 changes: 55 additions & 11 deletions apps/maple-playground/src/app/app.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
<header
class="flex h-12 shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 dark:border-neutral-800 dark:bg-neutral-900">
<div class="flex items-baseline gap-2">
<span class="text-lg leading-none">🍁</span>
<h1 class="text-sm font-semibold tracking-tight">Maple</h1>
<span class="text-sm text-neutral-400 dark:text-neutral-500">Playground</span>
<div class="flex items-center gap-3">
<div class="flex items-baseline gap-2">
<span class="text-lg leading-none">🍁</span>
<h1 class="text-sm font-semibold tracking-tight">Maple</h1>
<span class="hidden text-sm text-neutral-400 lg:inline dark:text-neutral-500"
>Playground</span
>
<span class="hidden font-mono text-xs text-neutral-400 sm:inline dark:text-neutral-500">
v{{ VERSION }}
</span>
</div>
<button
type="button"
class="flex h-7 items-center gap-1 rounded-full bg-amber-100 px-2.5 text-xs font-medium whitespace-nowrap text-amber-800 transition-colors hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-300 dark:hover:bg-amber-900/70"
(click)="welcome().open()">
What is Maple?
</button>
Comment thread
NetWin marked this conversation as resolved.
Resolved
</div>
<div class="flex items-center gap-2">
<maple-examples-menu (pick)="load($event)" />
<maple-settings-menu
[(maxOps)]="maxOps"
[(maxDepth)]="maxDepth">
Expand All @@ -27,8 +41,31 @@ <h1 class="text-sm font-semibold tracking-tight">Maple</h1>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
Reset
<span class="hidden md:inline">Reset</span>
</button>
<a
[href]="LINKS.github"
target="_blank"
rel="noopener"
title="Maple on GitHub"
class="hidden size-7 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-800 sm:flex dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100">
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
class="size-4">
<path
d="M12 .5C5.65.5.5 5.65.5 12a11.5 11.5 0 0 0 7.86 10.92c.58.1.79-.25.79-.56v-2c-3.2.7-3.87-1.37-3.87-1.37-.53-1.33-1.28-1.69-1.28-1.69-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.19-3.1-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.77 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.11 3.05.74.81 1.19 1.84 1.19 3.1 0 4.42-2.7 5.4-5.26 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
</svg>
</a>
<a
[href]="LINKS.npm"
target="_blank"
rel="noopener"
title="@netwin/maple on npm"
class="hidden size-7 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-800 sm:flex dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100">
<span class="font-mono text-[11px] font-bold tracking-tight">npm</span>
</a>
<maple-theme-picker />
</div>
</header>
Expand All @@ -39,18 +76,23 @@ <h1 class="text-sm font-semibold tracking-tight">Maple</h1>
storageKey="maple-playground-panes">
<maple-pane
slot="pane"
title="Context">
<maple-badge slot="badge">JSON</maple-badge>
title="Host context">
<maple-badge slot="badge">JS</maple-badge>
<maple-code-editor
language="json"
[(value)]="contextString">
language="javascript"
[(value)]="hostContext">
</maple-code-editor>
</maple-pane>

<maple-pane
slot="pane"
title="Code">
<maple-badge slot="badge">JS</maple-badge>
title="Script">
<div
slot="badge"
class="flex items-center gap-1.5">
<maple-badge>Sandboxed</maple-badge>
<maple-badge>JS</maple-badge>
</div>
<maple-code-editor
language="javascript"
[(value)]="codeString">
Expand Down Expand Up @@ -112,3 +154,5 @@ <h1 class="text-sm font-semibold tracking-tight">Maple</h1>
</maple-pane>
</maple-split>
</main>

<maple-welcome-dialog (closed)="welcomeSeen.set(true)" />
74 changes: 64 additions & 10 deletions apps/maple-playground/src/app/app.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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' }
Expand All @@ -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<OutputTab>('result');

protected readonly status = this.#runner.status;
Expand Down Expand Up @@ -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;

Expand All @@ -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<TabOption<OutputTab>> = [
{ value: 'result', label: 'Result' },
{ value: 'ast', label: 'AST' },
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading