Skip to content

Commit c7d9c8e

Browse files
fix(examples): use jsDelivr and typed pipeline records
1 parent 43ca9b7 commit c7d9c8e

20 files changed

Lines changed: 372 additions & 139 deletions

File tree

‎examples/components/script-only-pipeline/README.md‎

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Use Node.js 24 or newer. This example uses the workspace version of Jco, which i
2828
`pnpm run all` follows the other examples: generate types, check TypeScript, build the components,
2929
transpile the engine and dataset initializer, run the Node.js demo, and run Node.js and Puppeteer tests.
3030
The QuickJS engine uses a synchronous ABI and stubbed WASI because these processors only operate on
31-
in-memory JSON. Neither host needs JSPI flags, filesystem access from the guest, or WASI shims.
31+
in-memory records. Neither host needs JSPI flags, filesystem access from the guest, or WASI shims.
3232

3333
## Try the browser demo
3434

@@ -42,6 +42,10 @@ Open `http://localhost:5173/demo/` after `pnpm run demo`, or use `pnpm run demo:
4242
5. Set the risk step's options to `{"minScore":70}` and rerun. Three logs remain, with scores 70, 70, and 90.
4343
6. Move the page counter to the beginning. Its report now counts all 12 input logs, before filtering.
4444

45+
Choose **Use one sample log** to call `process-single` with one `access-log` record. You can also paste
46+
a JSON object into the input; arrays call `process-batch` with `list<access-log>`. A single-record filter
47+
can return no record, which stops subsequent steps. **Restore sample** switches back to the full batch.
48+
4549
The JSON input is editable. Every run starts from that input, so a previous run's filters, scores, and
4650
reports never leak into the next run. **Restore sample** resets the input; **Clear pipeline** removes
4751
the steps. Invalid uploads or options show an error on the page and can be corrected without a reload.
@@ -71,8 +75,10 @@ It reads that Wasm back through the same loader as an upload and validates `desc
7175
the step. Code runs inside the shared QuickJS engine. The JavaScript engine is reused as you build
7276
and load new components.
7377

74-
The editor and compiler load from versioned CDN URLs, so browser authoring needs internet access.
75-
Your source stays in the browser; no compilation requests are sent to a server. If the editor CDN is
78+
The editor and compiler load from versioned [jsDelivr](https://www.jsdelivr.com/) CDN URLs, so browser
79+
authoring needs internet access. An import map pins CodeMirror's shared dependencies to one version
80+
each so its extension identity checks work. Your source stays in the browser; no compilation requests
81+
are sent to a server. If the editor CDN is
7682
unavailable, the page keeps a plain source text area. Build errors appear beside the editor and leave
7783
the pipeline intact. Browser builds validate syntax and exports; full TypeScript checking for the
7884
checked-in components and editor template is provided by `pnpm run check`.
@@ -118,18 +124,29 @@ export is checked against its generated interface, for example:
118124

119125
```ts
120126
import type { processor as ProcessorInterface } from 'example:log-pipeline/pipeline';
127+
import { getDataset } from '../shared/state.ts';
121128

122129
export const processor: typeof ProcessorInterface = {
123130
describe() {
124-
/* name, description, defaultOptions */
131+
return { name: 'My processor', description: 'Process access logs.', defaultOptions: '{}' };
132+
},
133+
processBatch(logs, options) {
134+
const data = getDataset();
135+
data.logs = logs;
136+
/* parse options and update shared data */
137+
return { logs: data.logs, summary: `Processed ${logs.length} logs` };
125138
},
126-
process(options) {
127-
/* update shared data and return a summary */
139+
processSingle(log, options) {
140+
const { logs, summary } = processor.processBatch([log], options);
141+
return { log: logs[0], summary };
128142
},
129143
};
130144
```
131145

132-
The generated `AccessLog` type also describes the shared records. These declarations are checked in
146+
The shared WIT `types` interface defines `access-log`. The dataset and processor interfaces both use
147+
it: `reset` accepts a list, `snapshot` returns typed logs, `process-batch` accepts and returns a list,
148+
and `process-single` accepts one record and returns an optional record. The generated `AccessLog`
149+
TypeScript type also describes the shared records. These declarations are checked in
133150
for editor support and regenerated by `build`; Jco bundles TypeScript directly, so there is no separate
134151
JavaScript compilation step.
135152

@@ -172,12 +189,20 @@ The shared [`createPipeline`](./demo/shared/pipeline.js) runner instantiates the
172189
platform adapter is a function that reads bytes from a URL: Node.js supplies `readFile`, and the browser
173190
supplies `fetch`. Both entry points use the same script reader and execution code.
174191

175-
For each run, the runner loads the dataset script and calls `dataset.reset(input)`. For each step, it
176-
loads that step's source with `scriptLoader.load`, calls `processor.process(options)`, then reloads the
177-
dataset script to read a snapshot. Loading scripts replaces the active exports, but retains the
178-
engine's JavaScript realm. The working dataset lives on that realm's `globalThis`, shared through
179-
[`components/shared/state.ts`](./components/shared/state.ts); it is not passed back and forth between
180-
processors as JSON. Only input, options, summaries, and output snapshots cross the host boundary.
192+
For each run, the host parses the JSON input into records. The runner loads the dataset script and
193+
calls `dataset.reset(logs)`. `runBatch(logs, stages)` calls each processor's `processBatch(logs, options)`
194+
and passes its returned records to the next step. `runSingle(log, stages)` instead calls
195+
`processSingle(log, options)` and stops if a filter returns no record. The checked-in single-record
196+
implementations reuse the batch algorithm with a one-element list. Batch statistics, such as repeated
197+
IP counts, therefore apply only to the records in that call.
198+
199+
Logs cross the WIT boundary as records, including optional country and risk-score annotations.
200+
Options and extensible reports remain JSON strings; log records are never encoded as JSON inside the
201+
engine. After each step, the runner reloads the dataset script to read a typed snapshot. Loading
202+
scripts replaces the active exports, but retains the engine's JavaScript realm. The dataset and
203+
reports live on that realm's `globalThis`, shared through
204+
[`components/shared/state.ts`](./components/shared/state.ts). Reports survive switching processors
205+
and reset at the beginning of each run. Both hosts format the final records as JSON for display.
181206

182207
> [!NOTE]
183208
> Scripts in one engine share a realm and can access each other's data. Use trusted scripts together;
@@ -202,15 +227,19 @@ Choose the Node.js stages and their execution order with positional arguments or
202227
pnpm run run top-pages geoip
203228
PIPELINE_STAGES=geoip,top-pages pnpm run run
204229
PIPELINE_STAGES= pnpm run run
230+
pnpm run run --single risk-score top-pages
205231
```
206232

207233
Arguments take precedence over the environment variable. With neither specified, the demo runs
208234
`geoip`, `risk-score`, then `top-pages`. An empty environment value runs an empty pipeline. Stage names
209235
are the filenames in `dist/components/` without `.wasm`; unknown names report the available stages and
210236
exit with a nonzero status. Each selected stage uses its own default options.
237+
`--single` processes the first sample record through `process-single`; the default processes the
238+
whole sample array through `process-batch`.
211239

212-
The entry point exports an async `main()` and calls it only when executed directly, so importing
213-
`demo/node.mjs` does not run the demo.
240+
The entry point exports an async `main({ stageNames, mode })` and calls it only when executed directly,
241+
so importing `demo/node.mjs` does not run the demo. CLI and environment parsing happen in the
242+
direct-execution block at the bottom; `main` receives named options.
214243

215244
### Add a processor
216245

@@ -227,8 +256,9 @@ pnpm run test:node
227256
pnpm run test:browser
228257
```
229258

230-
The tests exercise the built Wasm components, shared data across script loads, ordering and options,
231-
repeat runs, empty input, invalid uploads, and recovery from errors. Node.js tests also cover CLI and
259+
The tests exercise typed batches and single records through the built Wasm components, optional
260+
filter results, shared data across script loads, ordering and options, repeat runs, empty input,
261+
invalid uploads, and recovery from errors. Node.js tests also cover CLI and
232262
environment stage selection, precedence, unknown names, and the direct-execution guard. The Puppeteer
233263
test uploads actual `.wasm` files, edits the highlighted TypeScript editor, builds custom components,
234264
loads the downloadable bytes in Node.js, uploads them again, and checks recovery from syntax errors.
Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,11 @@
11
import type { dataset as DatasetInterface } from 'example:log-pipeline/pipeline';
2-
import type { AccessLog } from 'example:log-pipeline/dataset';
32
import { getDataset, resetDataset } from '../shared/state.ts';
43

54
export const dataset: typeof DatasetInterface = {
6-
reset(json) {
7-
let rows: unknown;
8-
try {
9-
rows = JSON.parse(json);
10-
} catch {
11-
throw 'Input must be valid JSON';
12-
}
13-
if (!Array.isArray(rows)) {
14-
throw 'Input must be a JSON array of access logs';
15-
}
16-
const logs: AccessLog[] = rows.map((row, index) => {
17-
if (
18-
!row ||
19-
typeof row !== 'object' ||
20-
!['timestamp', 'ip', 'method', 'path'].every((key) => typeof row[key] === 'string') ||
21-
!Number.isInteger(row.status) ||
22-
row.status < 100 ||
23-
row.status > 599
24-
) {
25-
throw `Log ${index + 1} needs timestamp, ip, method, path, and an HTTP status (100–599)`;
5+
reset(rows) {
6+
const logs = rows.map((row, index) => {
7+
if (row.status < 100 || row.status > 599) {
8+
throw `Log ${index + 1} needs an HTTP status (100–599)`;
269
}
2710
// Start each run from raw logs; annotations belong to the processors.
2811
return {
@@ -38,6 +21,7 @@ export const dataset: typeof DatasetInterface = {
3821
resetDataset(logs);
3922
},
4023
snapshot() {
41-
return JSON.stringify(getDataset());
24+
const { logs, reports } = getDataset();
25+
return { logs, reportsJson: JSON.stringify(reports) };
4226
},
4327
};

‎examples/components/script-only-pipeline/components/geoip/geoip.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,24 @@ export const processor: typeof ProcessorInterface = {
1717
defaultOptions: JSON.stringify({ country: 'DE' }),
1818
};
1919
},
20-
process(json) {
20+
processBatch(logs, json) {
2121
const { country = 'DE' } = parseOptions(json);
2222
if (typeof country !== 'string' || !['all', 'US', 'DE', 'JP', 'unknown'].includes(country)) {
2323
throw 'country must be all, US, DE, JP, or unknown';
2424
}
2525
const data = getDataset();
26+
data.logs = logs;
2627
const before = data.logs.length;
2728
data.logs = data.logs
2829
.map((log) => ({
2930
...log,
3031
country: countries.get(log.ip.split('.').slice(0, 3).join('.')) ?? 'unknown',
3132
}))
3233
.filter((log) => country === 'all' || log.country === country);
33-
return `GeoIP: ${before} → ${data.logs.length} logs (${country})`;
34+
return { logs: data.logs, summary: `GeoIP: ${before} → ${data.logs.length} logs (${country})` };
35+
},
36+
processSingle(log, options) {
37+
const { logs, summary } = processor.processBatch([log], options);
38+
return { log: logs[0], summary };
3439
},
3540
};

‎examples/components/script-only-pipeline/components/risk-score/risk-score.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ export const processor: typeof ProcessorInterface = {
99
defaultOptions: JSON.stringify({ minScore: 0 }),
1010
};
1111
},
12-
process(json) {
12+
processBatch(logs, json) {
1313
const { minScore = 0 } = parseOptions(json);
1414
if (typeof minScore !== 'number' || !Number.isInteger(minScore) || minScore < 0 || minScore > 100) {
1515
throw 'minScore must be an integer from 0 to 100';
1616
}
1717
const data = getDataset();
18+
data.logs = logs;
1819
const requests = new Map<string, number>();
1920
for (const log of data.logs) {
2021
requests.set(log.ip, (requests.get(log.ip) ?? 0) + 1);
@@ -30,6 +31,10 @@ export const processor: typeof ProcessorInterface = {
3031
),
3132
}))
3233
.filter((log) => log.riskScore >= minScore);
33-
return `Risk: scored logs and kept ${data.logs.length} with score ≥ ${minScore}`;
34+
return { logs: data.logs, summary: `Risk: scored logs and kept ${data.logs.length} with score ≥ ${minScore}` };
35+
},
36+
processSingle(log, options) {
37+
const { logs, summary } = processor.processBatch([log], options);
38+
return { log: logs[0], summary };
3439
},
3540
};

‎examples/components/script-only-pipeline/components/shared/state.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AccessLog } from 'example:log-pipeline/dataset';
1+
import type { AccessLog } from 'example:log-pipeline/types';
22

33
export interface Dataset {
44
logs: AccessLog[];

‎examples/components/script-only-pipeline/components/top-pages/top-pages.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ export const processor: typeof ProcessorInterface = {
99
defaultOptions: JSON.stringify({ limit: 5 }),
1010
};
1111
},
12-
process(json) {
12+
processBatch(logs, json) {
1313
const { limit = 5 } = parseOptions(json);
1414
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 100) {
1515
throw 'limit must be an integer from 1 to 100';
1616
}
1717
const data = getDataset();
18+
data.logs = logs;
1819
const counts = new Map<string, number>();
1920
for (const log of data.logs) {
2021
counts.set(log.path, (counts.get(log.path) ?? 0) + 1);
@@ -24,6 +25,10 @@ export const processor: typeof ProcessorInterface = {
2425
.sort((a, b) => b.visits - a.visits || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
2526
.slice(0, limit);
2627
data.reports.topPages = pages;
27-
return `Pages: counted ${data.logs.length} logs across ${counts.size} paths`;
28+
return { logs: data.logs, summary: `Pages: counted ${data.logs.length} logs across ${counts.size} paths` };
29+
},
30+
processSingle(log, options) {
31+
const { logs, summary } = processor.processBatch([log], options);
32+
return { log: logs[0], summary };
2833
},
2934
};

‎examples/components/script-only-pipeline/demo/browser.js‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,16 @@ $('#components').addEventListener('change', async (event) => {
117117
$('#run').addEventListener('click', () => {
118118
invalidate();
119119
try {
120-
const result = pipeline.run(
121-
$('#input').value,
122-
stages.map((stage) => ({
123-
script: stage.script,
124-
options: JSON.parse(stage.options),
125-
})),
126-
);
120+
const input = JSON.parse($('#input').value);
121+
const configuredStages = stages.map((stage) => ({
122+
script: stage.script,
123+
options: JSON.parse(stage.options),
124+
}));
125+
const result = Array.isArray(input)
126+
? pipeline.runBatch(input, configuredStages)
127+
: pipeline.runSingle(input, configuredStages);
127128
$('#output').textContent = JSON.stringify({ logs: result.logs, reports: result.reports }, null, 2);
128-
$('#count').textContent = `${result.logs.length} logs / ${stages.length} steps`;
129+
$('#count').textContent = `${result.logs.length} logs / ${result.steps.length} steps`;
129130
$('#output-note').textContent = 'Every run starts with fresh input. Steps share data inside the engine.';
130131
for (const step of result.steps) {
131132
const row = document.createElement('li');
@@ -145,6 +146,10 @@ $('#clear').addEventListener('click', () => {
145146
status('Pipeline cleared · upload a component to start again');
146147
});
147148
$('#restore').addEventListener('click', restoreSample);
149+
$('#single-input').addEventListener('click', () => {
150+
$('#input').value = JSON.stringify(sample[1], null, 2);
151+
invalidate();
152+
});
148153
$('#input').addEventListener('input', invalidate);
149154
function restoreSample() {
150155
$('#input').value = JSON.stringify(sample, null, 2);

‎examples/components/script-only-pipeline/demo/custom-component.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export async function createCustomComponent(name, source) {
5858
});
5959
const exports = Object.values(result.metafile.outputs).flatMap((output) => output.exports);
6060
if (exports.length !== 1 || exports[0] !== 'processor') {
61-
throw new Error('Export one processor object with describe() and process(options) methods');
61+
throw new Error('Export one processor object with describe(), processBatch(), and processSingle() methods');
6262
}
6363
await $init;
6464
return scripts.createComponent({

‎examples/components/script-only-pipeline/demo/custom-template.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@ export const processor: typeof ProcessorInterface = {
99
defaultOptions: JSON.stringify({ minStatus: 400 }),
1010
};
1111
},
12-
process(json) {
12+
processBatch(logs, json) {
1313
const { minStatus = 400 } = parseOptions(json);
1414
if (typeof minStatus !== 'number' || minStatus < 100 || minStatus > 599) {
1515
throw 'minStatus must be a number from 100 to 599';
1616
}
1717
const data = getDataset();
18+
data.logs = logs;
1819
data.logs = data.logs.filter((log) => log.status >= minStatus);
19-
return `HTTP status: kept ${data.logs.length} logs with status >= ${minStatus}`;
20+
return { logs: data.logs, summary: `HTTP status: kept ${data.logs.length} logs with status >= ${minStatus}` };
21+
},
22+
processSingle(log, options) {
23+
const { logs, summary } = processor.processBatch([log], options);
24+
return { log: logs[0], summary };
2025
},
2126
};

‎examples/components/script-only-pipeline/demo/editor.js‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
const CODEMIRROR_URL = 'https://esm.sh/codemirror@6.0.2';
2-
const JAVASCRIPT_URL = 'https://esm.sh/@codemirror/lang-javascript@6.2.4';
3-
41
/** Enhance the source textarea without making the rest of the demo depend on a CDN. */
52
export async function mountEditor(textarea, parent) {
6-
const [{ EditorView, basicSetup }, { javascript }] = await Promise.all([
7-
import(/* @vite-ignore */ CODEMIRROR_URL),
8-
import(/* @vite-ignore */ JAVASCRIPT_URL),
9-
]);
3+
const modules = ['codemirror', '@codemirror/lang-javascript'];
4+
const [{ EditorView, basicSetup }, { javascript }] = await Promise.all(
5+
modules.map((name) => import(/* @vite-ignore */ name)),
6+
);
107
const view = new EditorView({
118
parent,
129
doc: textarea.value,

0 commit comments

Comments
 (0)