@wenathlan/saddle 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +203 -0
- package/README.md +192 -0
- package/adapters/forge.js +16 -0
- package/adapters/forgejo.js +8 -0
- package/adapters/github.js +19 -0
- package/adapters/gitlab.js +10 -0
- package/adapters/huggingface.js +6 -0
- package/adapters/socket.js +14 -0
- package/adapters/transport.js +30 -0
- package/ai/chunk.js +22 -0
- package/ai/llmstxt.js +12 -0
- package/ai/provenance.js +18 -0
- package/ai/rag.js +14 -0
- package/ai/tokens.js +9 -0
- package/api/auth.js +13 -0
- package/api/contracts.js +17 -0
- package/api/control.js +33 -0
- package/api/http.js +12 -0
- package/api/rate.js +31 -0
- package/api/security.js +42 -0
- package/api/service.js +36 -0
- package/binary/build.js +17 -0
- package/bot/adapter.js +8 -0
- package/bot/bot.js +39 -0
- package/bot/commands.js +18 -0
- package/bot/permissions.js +16 -0
- package/browser/actions.js +33 -0
- package/browser/agent.js +9 -0
- package/browser/context.js +52 -0
- package/browser/fingerprint.js +12 -0
- package/browser/index.js +10 -0
- package/browser/recorder.js +15 -0
- package/browser/session.js +19 -0
- package/browser/snapshot.js +57 -0
- package/captcha/contract.js +15 -0
- package/captcha/evidence.js +9 -0
- package/captcha/guard.js +10 -0
- package/cli/main.js +36 -0
- package/core/errors.js +37 -0
- package/core/events.js +21 -0
- package/core/hash.js +73 -0
- package/core/ids.js +15 -0
- package/crawl/crawler.js +29 -0
- package/crawl/frontier.js +34 -0
- package/crawl/normalize.js +14 -0
- package/crawl/persistent.js +13 -0
- package/dispatch/resumable.js +31 -0
- package/dispatch/workflow.js +33 -0
- package/docs/assets/architecture.svg +45 -0
- package/docs/assets/saddlemark.svg +13 -0
- package/docs/comparativeaudit.md +63 -0
- package/docs/ecosystemplan.md +59 -0
- package/docs/enginearchitecture.md +83 -0
- package/docs/featureaudit.md +63 -0
- package/docs/gapmatrix.md +80 -0
- package/docs/libraryapi.md +63 -0
- package/docs/modes.md +27 -0
- package/docs/productindex.md +28 -0
- package/docs/registryresearch.md +56 -0
- package/docs/release.md +28 -0
- package/docs/release17notes.md +24 -0
- package/docs/release181notes.md +15 -0
- package/docs/release18notes.md +15 -0
- package/docs/roadmapp2p3.md +33 -0
- package/docs/toolchains.md +28 -0
- package/docs/usage.md +107 -0
- package/domain/artifacts.js +13 -0
- package/domain/jobs.js +20 -0
- package/domain/providers.js +8 -0
- package/domain/runtime.js +10 -0
- package/domain/sessions.js +34 -0
- package/errors/taxonomy.js +18 -0
- package/examples/localjob.js +15 -0
- package/examples/publicapi.js +7 -0
- package/extension/README.md +23 -0
- package/extension/content.js +85 -0
- package/extension/index.js +5 -0
- package/extension/manifest.json +10 -0
- package/extension/popup.css +13 -0
- package/extension/popup.html +24 -0
- package/extension/popup.js +25 -0
- package/extension/protocol.js +76 -0
- package/extension/serviceworker.js +43 -0
- package/extension/worker.js +20 -0
- package/format/check.js +21 -0
- package/index.js +120 -0
- package/library/public.js +83 -0
- package/license.md +203 -0
- package/license.txt +203 -0
- package/mcp/browser.js +12 -0
- package/mcp/server.js +28 -0
- package/mcp/transport.js +14 -0
- package/memory/bridge.js +16 -0
- package/memory/engine.js +45 -0
- package/memory/modes.js +55 -0
- package/memory/objects.js +18 -0
- package/memory/targets.js +21 -0
- package/memory/transforms.js +15 -0
- package/modes/matrix.js +20 -0
- package/modes/modes.js +16 -0
- package/modes/resolve.js +39 -0
- package/package.json +47 -0
- package/packager/manifest.js +28 -0
- package/packager/publish.js +15 -0
- package/persistence/adapter.js +8 -0
- package/persistence/drizzle.js +10 -0
- package/persistence/memory.js +26 -0
- package/persistence/migrations.js +14 -0
- package/persistence/prisma.js +23 -0
- package/persistence/schema.js +29 -0
- package/persistence/sql.js +30 -0
- package/protocol/blocks.js +18 -0
- package/protocol/json.js +5 -0
- package/protocol/ndjson.js +17 -0
- package/protocol/sse.js +22 -0
- package/proxy/pool.js +12 -0
- package/queue/idempotency.js +12 -0
- package/queue/persistent.js +44 -0
- package/queue/queue.js +50 -0
- package/queue/saga.js +13 -0
- package/readme.txt +163 -0
- package/retry/circuit.js +15 -0
- package/retry/policy.js +12 -0
- package/runners/health.js +23 -0
- package/runners/heartbeat.js +26 -0
- package/runners/inprocess.js +19 -0
- package/runners/scheduler.js +16 -0
- package/runtime/abort.js +10 -0
- package/runtime/compatibility.js +13 -0
- package/runtime/detect.js +14 -0
- package/runtime/engine.js +56 -0
- package/runtime/worker.js +18 -0
- package/scrape/cache.js +14 -0
- package/scrape/extract.js +14 -0
- package/scrape/robots.js +32 -0
- package/scrape/schema.js +21 -0
- package/scrape/scraper.js +40 -0
- package/scrape/semantic.js +22 -0
- package/server/node.js +34 -0
- package/sessions/file.js +13 -0
- package/sessions/replay.js +21 -0
- package/sessions/store.js +13 -0
- package/storage/adapter.js +8 -0
- package/storage/cache.js +54 -0
- package/storage/checksum.js +17 -0
- package/storage/chunked.js +58 -0
- package/storage/content.js +42 -0
- package/storage/filehosting.js +17 -0
- package/storage/githubcontents.js +18 -0
- package/storage/index.js +10 -0
- package/storage/local.js +35 -0
- package/storage/memory.js +28 -0
- package/storage/s3compatible.js +23 -0
- package/storage/sync.js +55 -0
- package/surfaces/adapters.js +48 -0
- package/surfaces/controls.js +37 -0
- package/surfaces/manifest.js +25 -0
- package/surfaces/n8n.js +24 -0
- package/surfaces/operations.js +43 -0
- package/surfaces/targets.js +16 -0
- package/webhook/delivery.js +26 -0
- package/webhook/receiver.js +20 -0
- package/webhook/signature.js +7 -0
- package/workflow/manifest.js +20 -0
- package/workflow/registry.js +16 -0
- package/workflow/templates.js +18 -0
- package/workflow/triggers.js +31 -0
package/readme.txt
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
SADDLE - README
|
|
2
|
+
Version 1.0, August 2026
|
|
3
|
+
|
|
4
|
+
Copyright (C) August 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris
|
|
5
|
+
Everyone is permitted to view this document, but changing it
|
|
6
|
+
is not allowed. This document is part of Project saddle.
|
|
7
|
+
|
|
8
|
+
Preamble
|
|
9
|
+
|
|
10
|
+
Project saddle unifies both README sources.
|
|
11
|
+
|
|
12
|
+
Saddle is a JavaScript ESM engine for jobs that move data between storage,
|
|
13
|
+
a working set, an injected runner and durable artifacts. It includes
|
|
14
|
+
contracts for scraping, crawling, browser agents, queues, persistence,
|
|
15
|
+
MCP transport, webhooks and package delivery.
|
|
16
|
+
|
|
17
|
+
Core thesis: storage bytes and compute-memory bytes are the same bytes.
|
|
18
|
+
A Node.js framework runs on other people's runners, loading storage
|
|
19
|
+
buckets as virtual RAM/GPU via storage->RAM bridge.
|
|
20
|
+
|
|
21
|
+
This README is the single source of truth combining Foundation, Engine,
|
|
22
|
+
and Productization sections from both original READMEs.
|
|
23
|
+
|
|
24
|
+
TERMS AND CONDITIONS
|
|
25
|
+
|
|
26
|
+
0. Overview.
|
|
27
|
+
|
|
28
|
+
See full readme.md for complete documentation, API, execution model,
|
|
29
|
+
CLI, security boundaries, package surfaces, development, and repository
|
|
30
|
+
map.
|
|
31
|
+
|
|
32
|
+
1. What is Included.
|
|
33
|
+
|
|
34
|
+
Jobs, Storage, Working set, Scraping, Crawl, Browser, Operations,
|
|
35
|
+
Protocols, Delivery, Agent Browser, Compute Backends, Storage Backends.
|
|
36
|
+
|
|
37
|
+
2. License.
|
|
38
|
+
|
|
39
|
+
Proprietary - View Only. See license.txt.
|
|
40
|
+
|
|
41
|
+
END OF TERMS AND CONDITIONS
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Saddle
|
|
45
|
+
|
|
46
|
+
<p align="center">
|
|
47
|
+
<img src="docs/assets/saddlemark.svg" alt="Saddle" width="720" />
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
<p align="center">
|
|
51
|
+
<strong>Storage-backed jobs, scraping contracts and portable runners for Node.js.</strong><br/>
|
|
52
|
+
<strong>Binary computing agent, agent browser, computer-use, scraper and packager.</strong><br/>
|
|
53
|
+
<a href="https://github.com/iakadion/saddle/actions/workflows/ci.yml"><img src="https://github.com/iakadion/saddle/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
|
54
|
+
<a href="https://github.com/iakadion/saddle/releases/tag/v1.0.0"><img src="https://img.shields.io/badge/release-v1.0.0-d35d3d" alt="Release 1.0.0" /></a>
|
|
55
|
+
<a href="https://github.com/iakadion/saddle/blob/main/license.md"><img src="https://img.shields.io/badge/license-Proprietary--View--Only-202a2f" alt="Proprietary View Only" /></a>
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
> **Core idea:** storage is the durable side of the working set; the runner is replaceable; the artifact is the boundary. **Storage == Compute** — RAM and disk are the same construct, differing only by usage flag.
|
|
59
|
+
|
|
60
|
+
Saddle is a **JavaScript ESM engine** for jobs that move data between storage, a working set, an injected runner and durable artifacts. It is also a **virtual machine you publish as a package** that runs on other people's computers (GitHub Actions, Forgejo, Gitea, GitLab, Codeberg, free Docker containers) and turns unlimited third-party storage buckets into virtual RAM/GPU/CPU. Nothing runs on the operator's local machine.
|
|
61
|
+
|
|
62
|
+
Ships as a library, CLI, binary, n8n node, CRX extension, Android/iOS and Tauri desktop app. Package `@devthink/saddle` — published to npm, GitHub Packages, Maven, NuGet, RubyGems and GHCR (auto-mirrored to jsDelivr).
|
|
63
|
+
|
|
64
|
+
## Start here
|
|
65
|
+
|
|
66
|
+
Saddle requires **Node.js 22 or newer**.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install @devthink/saddle
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
import { scrapeurl, formatforagent } from "@devthink/saddle";
|
|
74
|
+
|
|
75
|
+
const result = await scrapeurl("https://example.com", { format: "markdown" });
|
|
76
|
+
const context = formatforagent(result, { maxchunksize: 2000, keypoints: 4 });
|
|
77
|
+
|
|
78
|
+
console.log(context.summary);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Deterministic example with no network:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
node examples/publicapi.js
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## What is included
|
|
88
|
+
|
|
89
|
+
| Area | Contract | Result |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| Jobs | `engine`, `scheduler`, `inprocess` | `prepare → process → sync → cleanup` |
|
|
92
|
+
| Storage | local, chunked, S3-compatible, GitHub Contents, file hosting | durable objects and chunks |
|
|
93
|
+
| Working set | memory bridge, modes, objects, transforms | storage-to-compute and compute-to-storage |
|
|
94
|
+
| Scraping | robots, cache, extraction, schema, scraper | text, metadata, links and structured output |
|
|
95
|
+
| Crawl | normalization, BFS crawler, persistent frontier | bounded domain-aware crawling |
|
|
96
|
+
| Browser | fingerprint, session, replay and injected agent | browser actions without vendor lock-in |
|
|
97
|
+
| Operations | queues, idempotency, saga, retry, circuit breaker | controlled execution and recovery |
|
|
98
|
+
| Protocols | JSON, NDJSON, SSE, blocks and MCP | transport-neutral messages |
|
|
99
|
+
| Delivery | manifests, workflow registry, binary/container plans | package and runner surfaces |
|
|
100
|
+
| Agent Browser | capture & replay, stealth, fingerprint | Brave capture, movement replay, session recording |
|
|
101
|
+
| Compute Backends | github-actions, huggingface, gitlab-ci, kaggle, oracle-cloud | free runners chain |
|
|
102
|
+
| Storage Backends | HF, Kaggle, Terabox, R2, Telegram, Discord via rclone | unlimited disk as RAM |
|
|
103
|
+
|
|
104
|
+
## Public API
|
|
105
|
+
|
|
106
|
+
| Export | Purpose |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| `saddleurl` | choose fetch or injected browser path |
|
|
109
|
+
| `scrapeurl` | fetch one URL and extract |
|
|
110
|
+
| `scrapehtml` | extract from HTML without network |
|
|
111
|
+
| `extractcontent` | structured extraction |
|
|
112
|
+
| `serializeresult` | serialize as JSON, Markdown, XML |
|
|
113
|
+
| `formatforagent` | summary, chunks, token count |
|
|
114
|
+
| `batchscrape` | bounded URL groups |
|
|
115
|
+
| `crawlurl` | crawl contract |
|
|
116
|
+
| `browseragent` | navigation, click, type, screenshot |
|
|
117
|
+
| `mcpserver` / `mcptransport` | MCP tools over JSONL/HTTP |
|
|
118
|
+
| `nodeserver` | Web Request/Response handler |
|
|
119
|
+
|
|
120
|
+
Complete API: `docs/libraryapi.md`
|
|
121
|
+
|
|
122
|
+
## The execution model
|
|
123
|
+
|
|
124
|
+
Saddle coordinates contracts instead of hiding providers. A repo + CI runner is a virtual processor:
|
|
125
|
+
|
|
126
|
+
- Repo = Disk (persistent state)
|
|
127
|
+
- CI = CPU (workflow_dispatch = function call)
|
|
128
|
+
- Pages = Bus + CDN
|
|
129
|
+
- Static site = BIOS
|
|
130
|
+
- repository_dispatch = IPC
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
import { engine, eventbus, inprocess, localmemory, localstorage, scheduler } from "@devthink/saddle";
|
|
134
|
+
const events = eventbus();
|
|
135
|
+
const run = engine({
|
|
136
|
+
storage: localstorage("./.saddle-data"),
|
|
137
|
+
memory: localmemory(),
|
|
138
|
+
scheduler: scheduler([inprocess()]),
|
|
139
|
+
events
|
|
140
|
+
});
|
|
141
|
+
const result = await run.run(
|
|
142
|
+
{ name: "example", input: { value: 42 } },
|
|
143
|
+
({ job }) => ({ jobid: job.id, ok: true })
|
|
144
|
+
);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## CLI
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
saddle help
|
|
151
|
+
saddle modes
|
|
152
|
+
saddle runexample
|
|
153
|
+
saddle mcp
|
|
154
|
+
saddle capture --url <url>
|
|
155
|
+
saddle bot --platform github --token $SBOT_TOKEN
|
|
156
|
+
saddle memory --load repo://owner/repo/path/file.json
|
|
157
|
+
saddle deploy --target netlify
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Security boundaries
|
|
161
|
+
|
|
162
|
+
| Boundary | Policy |
|
|
163
|
+
| --- | ---
|
package/retry/circuit.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* circuit breaker protects providers from repeated failure storms.
|
|
3
|
+
*/
|
|
4
|
+
export function circuitbreaker(options = {}) {
|
|
5
|
+
const threshold = options.failurethreshold ?? 5;
|
|
6
|
+
const resettimeout = options.resettimeout ?? 60000;
|
|
7
|
+
let failures = 0;
|
|
8
|
+
let openedat = 0;
|
|
9
|
+
let state = "closed";
|
|
10
|
+
async function execute(handler) {
|
|
11
|
+
if (state === "open") { if (Date.now() - openedat < resettimeout) throw new Error("circuit breaker is open"); state = "halfopen"; }
|
|
12
|
+
try { const result = await handler(); failures = 0; state = "closed"; return result; } catch (error) { failures += 1; if (failures >= threshold) { state = "open"; openedat = Date.now(); } throw error; }
|
|
13
|
+
}
|
|
14
|
+
return { execute, status() { return { state, failures, openedat }; }, reset() { failures = 0; openedat = 0; state = "closed"; } };
|
|
15
|
+
}
|
package/retry/policy.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retry policy handles transient errors and keeps non retryable failures terminal.
|
|
3
|
+
*/
|
|
4
|
+
export function retrypolicy(options = {}) {
|
|
5
|
+
const maxattempts = options.maxattempts ?? 3;
|
|
6
|
+
const base = options.base ?? 1000;
|
|
7
|
+
const factor = options.factor ?? 2;
|
|
8
|
+
const cap = options.cap ?? 30000;
|
|
9
|
+
return { async run(handler) { let last; for (let attempt = 1; attempt <= maxattempts; attempt += 1) { try { return await handler(attempt); } catch (error) { last = error; if (error?.retryable !== true || attempt === maxattempts) throw error; const wait = Math.min(cap, base * factor ** (attempt - 1)) + Math.floor(Math.random() * (options.jitter ?? 0)); options.onretry?.({ attempt, wait, error }); await delay(wait); } } throw last; } };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function delay(milliseconds) { return milliseconds ? new Promise((resolve) => setTimeout(resolve, milliseconds)) : Promise.resolve(); }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runner health checks describe provider capacity without selecting infrastructure.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Checks provider readiness and returns a serializable health report. */
|
|
6
|
+
export async function runnerhealth(provider, job = {}) {
|
|
7
|
+
if (typeof provider?.descriptor !== "function" || typeof provider?.canrun !== "function") throw new TypeError("runner health requires a provider");
|
|
8
|
+
const descriptor = provider.descriptor();
|
|
9
|
+
try {
|
|
10
|
+
const available = await provider.canrun(job);
|
|
11
|
+
return { id: String(descriptor.id), status: available ? descriptor.status ?? "available" : "busy", healthy: Boolean(available), checkedat: Date.now(), capacity: { maxconcurrent: descriptor.maxconcurrent, capabilities: [...(descriptor.capabilities ?? [])] } };
|
|
12
|
+
} catch (error) {
|
|
13
|
+
return { id: String(descriptor.id), status: "offline", healthy: false, checkedat: Date.now(), capacity: { maxconcurrent: descriptor.maxconcurrent, capabilities: [...(descriptor.capabilities ?? [])] }, error: { code: String(error.code ?? "RUNNER_HEALTH_FAILED"), message: String(error.message ?? error) } };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Checks a provider list in stable order and summarizes available capacity. */
|
|
18
|
+
export async function runnerhealthall(providers = [], job = {}) {
|
|
19
|
+
if (!Array.isArray(providers)) throw new TypeError("runner health providers must be an array");
|
|
20
|
+
const reports = [];
|
|
21
|
+
for (const provider of providers) reports.push(await runnerhealth(provider, job));
|
|
22
|
+
return { checkedat: Date.now(), reports, available: reports.filter((report) => report.healthy).length, total: reports.length };
|
|
23
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heartbeat manages cooperative liveness signals for long-running local or remote jobs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Creates a heartbeat controller with manual ticks and optional interval execution. */
|
|
6
|
+
export function heartbeat(options = {}) {
|
|
7
|
+
const interval = Number(options.interval ?? 30000);
|
|
8
|
+
if (!Number.isFinite(interval) || interval < 1) throw new TypeError("heartbeat interval must be positive");
|
|
9
|
+
let timer;
|
|
10
|
+
let sequence = 0;
|
|
11
|
+
let last;
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
|
|
14
|
+
async function tick(input = {}) {
|
|
15
|
+
const signal = { id: String(input.id ?? options.id ?? "job"), sequence: ++sequence, at: Date.now(), status: String(input.status ?? "running"), metadata: { ...(input.metadata ?? {}) } };
|
|
16
|
+
last = signal;
|
|
17
|
+
for (const listener of listeners) await listener({ ...signal, metadata: { ...signal.metadata } });
|
|
18
|
+
return signal;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function on(listener) { if (typeof listener !== "function") throw new TypeError("heartbeat listener must be a function"); listeners.add(listener); return () => listeners.delete(listener); }
|
|
22
|
+
function start(input = {}) { if (timer) return false; const run = () => tick(input).catch(() => undefined); timer = setInterval(run, interval); return true; }
|
|
23
|
+
function stop() { if (!timer) return false; clearInterval(timer); timer = undefined; return true; }
|
|
24
|
+
function status() { return { running: Boolean(timer), interval, sequence, last: last ? { ...last, metadata: { ...last.metadata } } : undefined }; }
|
|
25
|
+
return { tick, on, start, stop, status };
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* the in process runner is the deterministic baseline for local execution.
|
|
3
|
+
*/
|
|
4
|
+
export function inprocess(options = {}) {
|
|
5
|
+
let available = options.status !== "offline";
|
|
6
|
+
const runner = {
|
|
7
|
+
id: options.id ?? "runnerlocal",
|
|
8
|
+
name: options.name ?? "local in process runner",
|
|
9
|
+
priority: options.priority ?? 0,
|
|
10
|
+
maxconcurrent: options.maxconcurrent ?? 1,
|
|
11
|
+
capabilities: options.capabilities ?? ["node", "local"]
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
descriptor() { return { ...runner, status: available ? "available" : "offline" }; },
|
|
15
|
+
setavailable(value) { available = Boolean(value); },
|
|
16
|
+
async canrun() { return available; },
|
|
17
|
+
async execute(context, handler) { return handler(context); }
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduling uses stable priority order and selects the first available runner.
|
|
3
|
+
*/
|
|
4
|
+
import { runnerunavailable } from "../core/errors.js";
|
|
5
|
+
|
|
6
|
+
export function scheduler(providers) {
|
|
7
|
+
if (!Array.isArray(providers) || providers.length === 0) throw new TypeError("scheduler requires providers");
|
|
8
|
+
const ordered = [...providers].sort((left, right) => left.descriptor().priority - right.descriptor().priority);
|
|
9
|
+
return {
|
|
10
|
+
async select(job) {
|
|
11
|
+
for (const provider of ordered) if (provider.descriptor().status === "available" && await provider.canrun(job)) return provider;
|
|
12
|
+
throw runnerunavailable(job.id);
|
|
13
|
+
},
|
|
14
|
+
list() { return [...ordered]; }
|
|
15
|
+
};
|
|
16
|
+
}
|
package/runtime/abort.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* abort helpers unify deadlines without depending on a server framework.
|
|
3
|
+
*/
|
|
4
|
+
export function deadline(milliseconds, parent) {
|
|
5
|
+
if (!Number.isFinite(milliseconds) || milliseconds < 1) throw new TypeError("deadline must be positive");
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const timer = setTimeout(() => controller.abort(new Error("deadline exceeded")), milliseconds);
|
|
8
|
+
if (parent) parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true });
|
|
9
|
+
return { signal: controller.signal, cancel() { clearTimeout(timer); controller.abort(); } };
|
|
10
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compatibility contracts describe core capabilities without importing a runtime-specific adapter.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { runtimefeatures, runtimename } from "./detect.js";
|
|
6
|
+
|
|
7
|
+
export const corecapabilities = Object.freeze(["esm", "fetch", "streams", "textencoding", "webcrypto"]);
|
|
8
|
+
|
|
9
|
+
/** Reports the runtime capabilities expected by the transport-neutral root entry. */
|
|
10
|
+
export function runtimecontract(scope = globalThis) { const features = runtimefeatures(scope); return { runtime: runtimename(scope), core: true, capabilities: { esm: true, fetch: features.fetch, streams: features.streams, textencoding: typeof scope.TextEncoder === "function" && typeof scope.TextDecoder === "function", webcrypto: Boolean(scope.crypto?.subtle) }, nodeonly: { filesystem: features.filesystem, server: Boolean(scope.process?.versions?.node) } }; }
|
|
11
|
+
|
|
12
|
+
/** Returns a structured unsupported-mode error for a missing runtime capability. */
|
|
13
|
+
export function unsupportedruntime(feature, contract = runtimecontract()) { const error = new Error(`runtime capability is unavailable: ${feature}`); error.code = "UNSUPPORTED_RUNTIME"; error.feature = String(feature); error.runtime = contract.runtime; return error; }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime detection uses standard globals and keeps node specific modules outside the core.
|
|
3
|
+
*/
|
|
4
|
+
export function runtimename(scope = globalThis) {
|
|
5
|
+
if (scope.Deno) return "deno";
|
|
6
|
+
if (scope.Bun) return "bun";
|
|
7
|
+
if (scope.process?.versions?.node) return "node";
|
|
8
|
+
if (scope.window?.document) return "browser";
|
|
9
|
+
return "unknown";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function runtimefeatures(scope = globalThis) {
|
|
13
|
+
return { runtime: runtimename(scope), fetch: typeof scope.fetch === "function", streams: typeof scope.ReadableStream === "function" && typeof scope.WritableStream === "function", crypto: Boolean(scope.crypto), websocket: typeof scope.WebSocket === "function", filesystem: Boolean(scope.process?.versions?.node || scope.Deno) };
|
|
14
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* the engine coordinates job intent, working memory, runner execution, and commit.
|
|
3
|
+
*/
|
|
4
|
+
import { aserror } from "../core/errors.js";
|
|
5
|
+
import { eventbus } from "../core/events.js";
|
|
6
|
+
import { idfactory, systemclock } from "../core/ids.js";
|
|
7
|
+
import { createjob } from "../domain/jobs.js";
|
|
8
|
+
|
|
9
|
+
export function engine(options) {
|
|
10
|
+
if (!options?.storage || !options?.memory || !options?.scheduler) throw new TypeError("engine requires storage memory and scheduler");
|
|
11
|
+
const events = options.events ?? eventbus();
|
|
12
|
+
const ids = options.ids ?? idfactory();
|
|
13
|
+
const clock = options.clock ?? systemclock();
|
|
14
|
+
async function emit(job, type, data) { events.emit({ id: ids.next("event"), type, jobid: job.id, at: clock.now(), data }); }
|
|
15
|
+
return {
|
|
16
|
+
events,
|
|
17
|
+
async run(spec, handler) {
|
|
18
|
+
const job = createjob(spec, ids, clock);
|
|
19
|
+
await emit(job, "jobqueued", { name: job.name });
|
|
20
|
+
let set;
|
|
21
|
+
let provider;
|
|
22
|
+
try {
|
|
23
|
+
job.status = "preparing";
|
|
24
|
+
await emit(job, "jobpreparing", { status: job.status });
|
|
25
|
+
provider = await options.scheduler.select(job);
|
|
26
|
+
await emit(job, "runnerselected", { runnerid: provider.descriptor().id });
|
|
27
|
+
set = await options.memory.prepare(job);
|
|
28
|
+
job.status = "running";
|
|
29
|
+
await emit(job, "jobrunning", { status: job.status, location: set.location });
|
|
30
|
+
const output = await provider.execute({ job, workingset: set, signal: new AbortController().signal }, handler);
|
|
31
|
+
const encoded = encodeoutput(output);
|
|
32
|
+
job.status = "syncing";
|
|
33
|
+
await emit(job, "jobsyncing", { status: job.status, bytes: encoded.bytes.byteLength });
|
|
34
|
+
const sync = await options.memory.sync(set, encoded.bytes);
|
|
35
|
+
const artifact = await options.storage.put({ key: spec.outputkey ?? `results/${job.id}${encoded.extension}`, data: encoded.bytes, contenttype: encoded.contenttype, metadata: { jobid: job.id, runnerid: provider.descriptor().id } });
|
|
36
|
+
await emit(job, "storagecommitted", { key: artifact.key, sha256: artifact.sha256 });
|
|
37
|
+
job.status = "completed";
|
|
38
|
+
await emit(job, "jobcompleted", { status: job.status, artifactkey: artifact.key });
|
|
39
|
+
return { job, output, runnerid: provider.descriptor().id, artifact, sync };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
job.status = "failed";
|
|
42
|
+
const failure = aserror(error, job.id);
|
|
43
|
+
await emit(job, "jobfailed", { code: failure.code, retryable: failure.retryable, message: failure.message });
|
|
44
|
+
throw failure;
|
|
45
|
+
} finally {
|
|
46
|
+
if (set) await options.memory.cleanup(set);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function encodeoutput(output) {
|
|
53
|
+
if (output instanceof Uint8Array) return { bytes: output, contenttype: "application/octet-stream", extension: ".bin" };
|
|
54
|
+
if (typeof output === "string") return { bytes: new TextEncoder().encode(output), contenttype: "text/plain;charset=utf-8", extension: ".txt" };
|
|
55
|
+
return { bytes: new TextEncoder().encode(JSON.stringify(output)), contenttype: "application/json", extension: ".json" };
|
|
56
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worker bridge translates message events through an injected dispatcher without owning a worker runtime.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Attaches a bounded message bridge to a caller-owned worker scope. */
|
|
6
|
+
export function workerbridge(options = {}) {
|
|
7
|
+
const scope = options.scope ?? globalThis;
|
|
8
|
+
const dispatch = options.dispatch;
|
|
9
|
+
if (typeof scope.addEventListener !== "function") throw new TypeError("worker scope requires addEventListener");
|
|
10
|
+
if (typeof dispatch !== "function") throw new TypeError("worker bridge requires dispatch");
|
|
11
|
+
const event = String(options.event ?? "message");
|
|
12
|
+
async function listener(message) {
|
|
13
|
+
const input = message?.data ?? message;
|
|
14
|
+
try { scope.postMessage?.({ ok: true, requestid: input?.requestid, data: await dispatch(input) }); } catch (error) { scope.postMessage?.({ ok: false, requestid: input?.requestid, error: { code: String(error?.code ?? "WORKER_DISPATCH_FAILED"), message: String(error?.message ?? error) } }); }
|
|
15
|
+
}
|
|
16
|
+
scope.addEventListener(event, listener);
|
|
17
|
+
return { event, listener, close() { scope.removeEventListener?.(event, listener); } };
|
|
18
|
+
}
|
package/scrape/cache.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ttl cache keeps fetch policy separate from extraction and transport.
|
|
3
|
+
*/
|
|
4
|
+
export function ttlcache(options = {}) {
|
|
5
|
+
const values = new Map();
|
|
6
|
+
const ttl = options.ttl ?? 300000;
|
|
7
|
+
return {
|
|
8
|
+
get(key) { const item = values.get(key); if (!item) return null; if (Date.now() > item.expires) { if (Date.now() > item.stale) values.delete(key); return options.stale ? item.value : null; } return item.value; },
|
|
9
|
+
set(key, value, valueoptions = {}) { const current = Date.now(); values.set(key, { value, expires: current + (valueoptions.ttl ?? ttl), stale: current + (valueoptions.stale ?? ttl * 2) }); return value; },
|
|
10
|
+
delete(key) { values.delete(key); },
|
|
11
|
+
clear() { values.clear(); },
|
|
12
|
+
size() { return values.size; }
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* structured first extraction uses small built in heuristics and returns serializable data.
|
|
3
|
+
*/
|
|
4
|
+
export function extracthtml(html, url) {
|
|
5
|
+
const title = match(html, /<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
6
|
+
const description = match(html, /<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i) ?? match(html, /<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i);
|
|
7
|
+
const links = [...html.matchAll(/<a[^>]+href=["']([^"']+)["'][^>]*>/gi)].map((item) => resolveurl(item[1], url)).filter(Boolean);
|
|
8
|
+
const text = html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
9
|
+
return { url, title: decode(title ?? ""), description: decode(description ?? ""), text, links: [...new Set(links)] };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function match(value, expression) { return value.match(expression)?.[1]?.trim(); }
|
|
13
|
+
function decode(value) { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", '"'); }
|
|
14
|
+
function resolveurl(value, base) { try { return base ? new URL(value, base).href : new URL(value).href; } catch { return null; } }
|
package/scrape/robots.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* robots rules are parsed locally and enforced before a fetch is attempted.
|
|
3
|
+
*/
|
|
4
|
+
export function robotsrules(text = "") {
|
|
5
|
+
const groups = [];
|
|
6
|
+
let current = null;
|
|
7
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
8
|
+
const line = raw.split("#", 1)[0].trim();
|
|
9
|
+
if (!line) continue;
|
|
10
|
+
const separator = line.indexOf(":");
|
|
11
|
+
if (separator < 0) continue;
|
|
12
|
+
const key = line.slice(0, separator).trim().toLowerCase();
|
|
13
|
+
const value = line.slice(separator + 1).trim();
|
|
14
|
+
if (key === "user-agent") { current = { agents: [value.toLowerCase()], disallow: [], allow: [], delay: undefined, sitemaps: [] }; groups.push(current); }
|
|
15
|
+
else if (current && key === "disallow" && value) current.disallow.push(value);
|
|
16
|
+
else if (current && key === "allow" && value) current.allow.push(value);
|
|
17
|
+
else if (current && key === "crawl-delay" && Number.isFinite(Number(value))) current.delay = Number(value);
|
|
18
|
+
else if (key === "sitemap") (current ?? { sitemaps: [] }).sitemaps.push(value);
|
|
19
|
+
}
|
|
20
|
+
return { groups };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function robotsallowed(rules, target, agent = "*") {
|
|
24
|
+
const pathname = new URL(target).pathname || "/";
|
|
25
|
+
const candidates = rules.groups.filter((group) => group.agents.includes(agent.toLowerCase()) || group.agents.includes("*"));
|
|
26
|
+
if (!candidates.length) return true;
|
|
27
|
+
const disallowed = candidates.flatMap((group) => group.disallow).filter((path) => pathname.startsWith(path));
|
|
28
|
+
const allowed = candidates.flatMap((group) => group.allow).filter((path) => pathname.startsWith(path));
|
|
29
|
+
return allowed.some((path) => path.length >= Math.max(...disallowed.map((item) => item.length), 0)) || disallowed.length === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function robotsdelay(rules, agent = "*") { return rules.groups.find((group) => group.agents.includes(agent.toLowerCase()) || group.agents.includes("*"))?.delay ?? 0; }
|
package/scrape/schema.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema extraction accepts safe field descriptors and never evaluates source strings.
|
|
3
|
+
*/
|
|
4
|
+
export function extractwithschema(html, schema = {}, url) {
|
|
5
|
+
const result = {};
|
|
6
|
+
for (const [name, descriptor] of Object.entries(schema)) result[name] = extractfield(html, descriptor, url);
|
|
7
|
+
return result;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function extractfield(html, descriptor, url) {
|
|
11
|
+
if (typeof descriptor === "function") return descriptor({ html, url });
|
|
12
|
+
if (typeof descriptor === "string") return textfromselector(html, descriptor);
|
|
13
|
+
if (!descriptor || typeof descriptor.selector !== "string") throw new TypeError("schema field requires selector or function");
|
|
14
|
+
if (descriptor.selector === "title") return html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? null;
|
|
15
|
+
const escaped = descriptor.selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
16
|
+
const attribute = descriptor.attribute;
|
|
17
|
+
const pattern = attribute ? new RegExp(`<[^>]+${escaped}[^>]*${attribute}=["']([^"']+)["'][^>]*>`, "i") : new RegExp(`<${escaped}[^>]*>([\\s\\S]*?)<\\/${escaped}>`, "i");
|
|
18
|
+
return pattern.exec(html)?.[1]?.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim() ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function textfromselector(html, selector) { return extractfield(html, { selector }); }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scraper composes robots policy cache transport and extraction without browser assumptions.
|
|
3
|
+
*/
|
|
4
|
+
import { transport } from "../adapters/transport.js";
|
|
5
|
+
import { extracthtml } from "./extract.js";
|
|
6
|
+
import { robotsallowed, robotsdelay, robotsrules } from "./robots.js";
|
|
7
|
+
import { ttlcache } from "./cache.js";
|
|
8
|
+
|
|
9
|
+
export function scraper(options = {}) {
|
|
10
|
+
const client = options.transport ?? transport({ fetcher: options.fetcher, timeout: options.timeout, attempts: options.attempts });
|
|
11
|
+
const cache = options.cache ?? ttlcache(options.cacheoptions);
|
|
12
|
+
const agent = options.agent ?? "*";
|
|
13
|
+
const policies = new Map();
|
|
14
|
+
return {
|
|
15
|
+
async robots(origin) {
|
|
16
|
+
if (policies.has(origin)) return policies.get(origin);
|
|
17
|
+
const url = new URL("/robots.txt", origin).href;
|
|
18
|
+
const response = await client.request(url);
|
|
19
|
+
const rules = robotsrules(response.ok ? await response.text() : "");
|
|
20
|
+
policies.set(origin, rules);
|
|
21
|
+
return rules;
|
|
22
|
+
},
|
|
23
|
+
async scrape(url) {
|
|
24
|
+
const target = new URL(url);
|
|
25
|
+
if (!["http:", "https:"].includes(target.protocol)) throw new TypeError("scraper accepts http and https only");
|
|
26
|
+
const rules = await this.robots(target.origin);
|
|
27
|
+
if (!robotsallowed(rules, target.href, agent)) throw new Error("robots policy disallows target");
|
|
28
|
+
const cached = cache.get(target.href);
|
|
29
|
+
if (cached) return cached;
|
|
30
|
+
const wait = robotsdelay(rules, agent);
|
|
31
|
+
if (wait) await new Promise((resolve) => setTimeout(resolve, wait * 1000));
|
|
32
|
+
const response = await client.request(target.href, { headers: options.headers });
|
|
33
|
+
if (!response.ok) throw new Error(`scrape request failed with ${response.status}`);
|
|
34
|
+
const result = extracthtml(await response.text(), target.href);
|
|
35
|
+
cache.set(target.href, result, { ttl: options.ttl });
|
|
36
|
+
return result;
|
|
37
|
+
},
|
|
38
|
+
cache
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* semantic extraction exposes bounded headings, landmarks, controls and links without evaluating page code.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Extracts semantic page facts from HTML using safe built-in parsing heuristics. */
|
|
6
|
+
export function extractsemantic(html, url) {
|
|
7
|
+
const source = String(html ?? "");
|
|
8
|
+
const headings = [...source.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi)].slice(0, 100).map((match) => ({ level: Number(match[1]), text: clean(match[2]) })).filter((item) => item.text);
|
|
9
|
+
const landmarks = [...source.matchAll(/<(main|nav|header|footer|aside|section|article)\b([^>]*)>/gi)].slice(0, 100).map((match) => ({ role: match[1].toLowerCase(), label: attribute(match[2], "aria-label") ?? attribute(match[2], "id") ?? "" }));
|
|
10
|
+
const controls = [...source.matchAll(/<(button|input|textarea|select|a)\b([^>]*)>([\s\S]*?)<\/\1>|<(input)\b([^>]*)\/?\s*>/gi)].slice(0, 200).map((match, index) => {
|
|
11
|
+
const tag = (match[1] ?? match[4]).toLowerCase();
|
|
12
|
+
const attrs = match[2] ?? match[5] ?? "";
|
|
13
|
+
return { ref: `e${index + 1}`, role: attribute(attrs, "role") ?? tag, name: attribute(attrs, "aria-label") ?? attribute(attrs, "placeholder") ?? clean(match[3] ?? ""), type: attribute(attrs, "type") };
|
|
14
|
+
});
|
|
15
|
+
const links = [...source.matchAll(/<a\b([^>]*)href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)].slice(0, 500).map((match) => ({ url: resolve(match[2], url), text: clean(match[3]), rel: attribute(match[1], "rel") })).filter((link) => link.url);
|
|
16
|
+
return { url, title: clean(source.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""), headings, landmarks, controls, links: dedupe(links, (item) => item.url), semantictext: clean(source.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ")).slice(0, 100000) };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clean(value) { return String(value ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").trim(); }
|
|
20
|
+
function attribute(value, name) { return value.match(new RegExp(`${name}=["']([^"']*)["']`, "i"))?.[1] ?? null; }
|
|
21
|
+
function resolve(value, base) { try { return new URL(value, base).href; } catch { return null; } }
|
|
22
|
+
function dedupe(values, key) { const seen = new Set(); return values.filter((value) => { const item = key(value); if (seen.has(item)) return false; seen.add(item); return true; }); }
|
package/server/node.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* node server is an optional adapter around the universal service contract.
|
|
3
|
+
*/
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
|
|
6
|
+
/** Creates a Node HTTP adapter with explicit host, port, and request handler. */
|
|
7
|
+
export function nodeserver(options = {}) {
|
|
8
|
+
if (!options.host || !Number.isInteger(options.port) || options.port < 1) throw new TypeError("node server requires host and port");
|
|
9
|
+
if (typeof options.handle !== "function") throw new TypeError("node server requires handle");
|
|
10
|
+
/* Request translation stays inside the Node adapter. */
|
|
11
|
+
const server = createServer(async (request, response) => {
|
|
12
|
+
try {
|
|
13
|
+
const chunks = [];
|
|
14
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
15
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
16
|
+
const headers = new Headers(request.headers);
|
|
17
|
+
const webrequest = new Request(new URL(request.url ?? "/", `http://${options.host}:${options.port}`), { method: request.method, headers, body: body || undefined });
|
|
18
|
+
const result = await options.handle(webrequest);
|
|
19
|
+
response.statusCode = result.status;
|
|
20
|
+
result.headers.forEach((value, key) => response.setHeader(key, value));
|
|
21
|
+
response.end(Buffer.from(await result.arrayBuffer()));
|
|
22
|
+
} catch (error) {
|
|
23
|
+
response.statusCode = 500;
|
|
24
|
+
response.setHeader("content-type", "application/json");
|
|
25
|
+
response.end(JSON.stringify({ error: error.message }));
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
/* Lifecycle methods keep the server optional for library consumers. */
|
|
29
|
+
return {
|
|
30
|
+
server,
|
|
31
|
+
listen() { return new Promise((resolve, reject) => { server.once("error", reject); server.listen(options.port, options.host, () => resolve({ host: options.host, port: options.port })); }); },
|
|
32
|
+
close() { return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); }
|
|
33
|
+
};
|
|
34
|
+
}
|
package/sessions/file.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file session persistence writes one validated JSON document per session.
|
|
3
|
+
*/
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { validatesession } from "../domain/sessions.js";
|
|
7
|
+
|
|
8
|
+
export function filesessions(root) {
|
|
9
|
+
return {
|
|
10
|
+
async save(session) { const valid = validatesession(session); const path = join(root, `${valid.id}.json`); await mkdir(dirname(path), { recursive: true }); await writeFile(path, JSON.stringify(valid, null, 2)); return valid; },
|
|
11
|
+
async load(id) { const value = JSON.parse(await readFile(join(root, `${id}.json`), "utf8")); return validatesession(value); }
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* replay maps validated session events to an injected browser adapter.
|
|
3
|
+
*/
|
|
4
|
+
export async function replay(session, adapter, options = {}) {
|
|
5
|
+
if (!session?.events || typeof adapter?.move !== "function") throw new TypeError("replay requires session events and browser adapter");
|
|
6
|
+
const speed = options.speed ?? 1;
|
|
7
|
+
let previous = 0;
|
|
8
|
+
for (const event of session.events) {
|
|
9
|
+
const wait = Math.max(0, (event.t - previous) / speed);
|
|
10
|
+
if (wait) await delay(wait);
|
|
11
|
+
previous = event.t;
|
|
12
|
+
if (event.type === "move") await adapter.move(event);
|
|
13
|
+
else if (event.type === "click") await adapter.click(event);
|
|
14
|
+
else if (event.type === "drag") await adapter.drag(event);
|
|
15
|
+
else if (event.type === "scroll") await adapter.scroll(event);
|
|
16
|
+
else if (event.type === "key") await adapter.key(event);
|
|
17
|
+
}
|
|
18
|
+
return { events: session.events.length, duration: previous / speed };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jsonl session storage keeps append only traces independent from browser adapters.
|
|
3
|
+
*/
|
|
4
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { validatesession } from "../domain/sessions.js";
|
|
7
|
+
|
|
8
|
+
export function sessionstore(root) {
|
|
9
|
+
return {
|
|
10
|
+
async append(session) { const valid = validatesession(session); await mkdir(root, { recursive: true }); await appendFile(join(root, `${valid.id}.jsonl`), `${JSON.stringify(valid)}\n`); return valid; },
|
|
11
|
+
async read(id) { const text = await readFile(join(root, `${id}.jsonl`), "utf8"); return text.trim().split("\n").filter(Boolean).map((line) => validatesession(JSON.parse(line))); }
|
|
12
|
+
};
|
|
13
|
+
}
|