@wenathlan/saddle 1.8.6 → 1.8.8
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/README.md +115 -104
- package/api/service.js +1 -1
- package/core/errors.js +20 -0
- package/docs/actionsincident.md +4 -0
- package/docs/enginearchitecture.md +5 -4
- package/docs/libraryapi.md +3 -3
- package/docs/release.md +5 -5
- package/docs/releaseassets.md +1 -1
- package/docs/reorganization-1.8.8.md +31 -0
- package/docs/securityaudit-1.8.7.md +21 -0
- package/extension/manifest.json +1 -1
- package/index.js +2 -7
- package/library/public.js +1 -1
- package/mcp/server.js +1 -1
- package/package.json +3 -7
- package/runtime/retry.js +27 -0
- package/scrape/crawl.js +104 -0
- package/crawl/crawler.js +0 -29
- package/crawl/frontier.js +0 -34
- package/crawl/normalize.js +0 -14
- package/crawl/persistent.js +0 -13
- package/errors/taxonomy.js +0 -18
- package/readme.txt +0 -163
- package/retry/circuit.js +0 -15
- package/retry/policy.js +0 -12
- package/scrape/package-lock.json +0 -9397
- package/scrape/package.json +0 -1420
package/runtime/retry.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retry context groups transient retry policy and circuit protection for runners,
|
|
3
|
+
* storage adapters and network-facing surfaces.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Creates bounded exponential retry behavior for retryable failures. */
|
|
7
|
+
export function retrypolicy(options = {}) {
|
|
8
|
+
const maxattempts = options.maxattempts ?? 3;
|
|
9
|
+
const base = options.base ?? 1000;
|
|
10
|
+
const factor = options.factor ?? 2;
|
|
11
|
+
const cap = options.cap ?? 30000;
|
|
12
|
+
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; } };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Creates a circuit breaker that opens after repeated handler failures. */
|
|
16
|
+
export function circuitbreaker(options = {}) {
|
|
17
|
+
const threshold = options.failurethreshold ?? 5;
|
|
18
|
+
const resettimeout = options.resettimeout ?? 60000;
|
|
19
|
+
let failures = 0;
|
|
20
|
+
let openedat = 0;
|
|
21
|
+
let state = "closed";
|
|
22
|
+
async function execute(handler) { if (state === "open") { if (Date.now() - openedat < resettimeout) throw new Error("circuit breaker is open"); state = "halfopen"; } 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; } }
|
|
23
|
+
return { execute, status() { return { state, failures, openedat }; }, reset() { failures = 0; openedat = 0; state = "closed"; } };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Waits between retry attempts without introducing an external timer dependency. */
|
|
27
|
+
function delay(milliseconds) { return milliseconds ? new Promise((resolve) => setTimeout(resolve, milliseconds)) : Promise.resolve(); }
|
package/scrape/crawl.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scrape crawl context owns URL normalization traversal frontier and durable crawl state.
|
|
3
|
+
*
|
|
4
|
+
* The context keeps single page acquisition injectable while grouping the correlated
|
|
5
|
+
* crawl responsibilities that previously lived in four separate top level files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Removes fragments and tracking parameters before crawl deduplication. */
|
|
9
|
+
export function normalizeurl(value) {
|
|
10
|
+
const url = new URL(value);
|
|
11
|
+
url.hash = "";
|
|
12
|
+
for (const key of [...url.searchParams.keys()]) if (/^(utm_|fbclid$|msclkid$|gclid$|gclsrc$|dclid$|gbraid$|wbraid$|twclid$|campaign$|content$|term$|source$|medium$|ref$|share_id$)/i.test(key)) url.searchParams.delete(key);
|
|
13
|
+
if (url.pathname.length > 1) url.pathname = url.pathname.replace(/\/+$/, "");
|
|
14
|
+
return url.href;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Compares two crawl targets by origin. */
|
|
18
|
+
export function sameorigin(left, right) { return new URL(left).origin === new URL(right).origin; }
|
|
19
|
+
|
|
20
|
+
/** Creates a bounded priority frontier for crawler and queue adapters. */
|
|
21
|
+
export function crawlfrontier(options = {}) {
|
|
22
|
+
const maxpages = Number(options.maxpages ?? 20);
|
|
23
|
+
const maxperdomain = Number(options.maxperdomain ?? maxpages);
|
|
24
|
+
const queue = [];
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const completed = new Set();
|
|
27
|
+
const domains = new Map();
|
|
28
|
+
|
|
29
|
+
/** Adds a URL once while respecting the global page budget. */
|
|
30
|
+
function add(input = {}) {
|
|
31
|
+
const url = String(input.url ?? "");
|
|
32
|
+
if (!url || seen.has(url) || seen.size >= maxpages) return false;
|
|
33
|
+
seen.add(url);
|
|
34
|
+
queue.push({ url, depth: Number(input.depth ?? 0), priority: Number(input.priority ?? 0), discoveredat: Number(input.discoveredat ?? Date.now()) });
|
|
35
|
+
queue.sort((left, right) => right.priority - left.priority || left.discoveredat - right.discoveredat);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Removes the next URL that still fits its per-domain budget. */
|
|
40
|
+
function next() {
|
|
41
|
+
while (queue.length) {
|
|
42
|
+
const item = queue.shift();
|
|
43
|
+
const domain = new URL(item.url).hostname;
|
|
44
|
+
if ((domains.get(domain) ?? 0) >= maxperdomain) continue;
|
|
45
|
+
domains.set(domain, (domains.get(domain) ?? 0) + 1);
|
|
46
|
+
return { ...item };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Records a completed URL for diagnostics and persistence-friendly state. */
|
|
52
|
+
function complete(url) { completed.add(String(url)); }
|
|
53
|
+
|
|
54
|
+
/** Returns stable frontier diagnostics without exposing mutable collections. */
|
|
55
|
+
function state() { return { maxpages, maxperdomain, queued: queue.length, discovered: seen.size, completed: completed.size, domains: Object.fromEntries(domains) }; }
|
|
56
|
+
|
|
57
|
+
return { add, next, complete, state, list() { return queue.map((item) => ({ ...item })); } };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Creates a durable crawl queue around a caller-owned store. */
|
|
61
|
+
export function persistentqueue(options = {}) {
|
|
62
|
+
const store = options.store;
|
|
63
|
+
const values = [];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
|
|
66
|
+
/** Restores unfinished crawl records from the injected store. */
|
|
67
|
+
async function restore() { if (typeof store?.list !== "function") return; for (const item of await store.list()) if (!seen.has(item.url) && item.status !== "done") { seen.add(item.url); values.push(item); } }
|
|
68
|
+
|
|
69
|
+
/** Adds a crawl record and persists it when the store supports writes. */
|
|
70
|
+
async function add(item) { if (!item?.url || seen.has(item.url)) return false; const value = { url: item.url, depth: item.depth ?? 0, status: "queued", createdat: Date.now(), metadata: item.metadata ?? {} }; seen.add(value.url); values.push(value); if (typeof store?.save === "function") await store.save(value); return true; }
|
|
71
|
+
|
|
72
|
+
/** Claims the next queued crawl record. */
|
|
73
|
+
async function next() { const item = values.find((value) => value.status === "queued"); if (!item) return null; item.status = "running"; if (typeof store?.update === "function") await store.update(item.url, item); return item; }
|
|
74
|
+
|
|
75
|
+
/** Completes a crawl record and persists the resulting status. */
|
|
76
|
+
async function complete(url, patch = {}) { const item = values.find((value) => value.url === url); if (!item) return null; Object.assign(item, patch, { status: patch.status ?? "done", processedat: Date.now() }); if (typeof store?.update === "function") await store.update(url, item); return item; }
|
|
77
|
+
|
|
78
|
+
return { restore, add, next, complete, list() { return values.map((value) => ({ ...value })); } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Runs bounded breadth first traversal through the injected single page scrape contract. */
|
|
82
|
+
export async function crawl(start, options = {}) {
|
|
83
|
+
const maxdepth = options.maxdepth ?? 1;
|
|
84
|
+
const maxpages = options.maxpages ?? 20;
|
|
85
|
+
const sameDomain = options.samedomain ?? true;
|
|
86
|
+
const frontier = crawlfrontier({ maxpages, maxperdomain: options.maxperdomain ?? maxpages });
|
|
87
|
+
frontier.add({ url: normalizeurl(start), depth: 0, priority: options.startpriority ?? 0 });
|
|
88
|
+
const results = [];
|
|
89
|
+
while (frontier.state().queued && results.length < maxpages) {
|
|
90
|
+
const current = frontier.next();
|
|
91
|
+
if (!current || current.depth > maxdepth) continue;
|
|
92
|
+
const result = await options.scrape(current.url);
|
|
93
|
+
results.push({ ...result, depth: current.depth });
|
|
94
|
+
frontier.complete(current.url);
|
|
95
|
+
if (current.depth >= maxdepth) continue;
|
|
96
|
+
for (const link of result.links ?? []) {
|
|
97
|
+
let url;
|
|
98
|
+
try { url = normalizeurl(link); } catch { continue; }
|
|
99
|
+
if (sameDomain && !sameorigin(start, url)) continue;
|
|
100
|
+
frontier.add({ url, depth: current.depth + 1, priority: Number(options.priority?.(url, result) ?? 0) });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { results, stats: { ...frontier.state(), completed: results.length, maxdepth, maxpages } };
|
|
104
|
+
}
|
package/crawl/crawler.js
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* crawler performs bounded breadth first traversal through the scraper contract.
|
|
3
|
-
*/
|
|
4
|
-
import { normalizeurl, sameorigin } from "./normalize.js";
|
|
5
|
-
import { crawlfrontier } from "./frontier.js";
|
|
6
|
-
|
|
7
|
-
export async function crawl(start, options = {}) {
|
|
8
|
-
const maxdepth = options.maxdepth ?? 1;
|
|
9
|
-
const maxpages = options.maxpages ?? 20;
|
|
10
|
-
const sameDomain = options.samedomain ?? true;
|
|
11
|
-
const frontier = crawlfrontier({ maxpages, maxperdomain: options.maxperdomain ?? maxpages });
|
|
12
|
-
frontier.add({ url: normalizeurl(start), depth: 0, priority: options.startpriority ?? 0 });
|
|
13
|
-
const results = [];
|
|
14
|
-
while (frontier.state().queued && results.length < maxpages) {
|
|
15
|
-
const current = frontier.next();
|
|
16
|
-
if (!current || current.depth > maxdepth) continue;
|
|
17
|
-
const result = await options.scrape(current.url);
|
|
18
|
-
results.push({ ...result, depth: current.depth });
|
|
19
|
-
frontier.complete(current.url);
|
|
20
|
-
if (current.depth >= maxdepth) continue;
|
|
21
|
-
for (const link of result.links ?? []) {
|
|
22
|
-
let url;
|
|
23
|
-
try { url = normalizeurl(link); } catch { continue; }
|
|
24
|
-
if (sameDomain && !sameorigin(start, url)) continue;
|
|
25
|
-
frontier.add({ url, depth: current.depth + 1, priority: Number(options.priority?.(url, result) ?? 0) });
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return { results, stats: { ...frontier.state(), completed: results.length, maxdepth, maxpages } };
|
|
29
|
-
}
|
package/crawl/frontier.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* crawl frontier provides priorities, per-domain budgets and persistent-friendly queue state.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/** Creates a bounded priority frontier for crawler and queue adapters. */
|
|
6
|
-
export function crawlfrontier(options = {}) {
|
|
7
|
-
const maxpages = Number(options.maxpages ?? 20);
|
|
8
|
-
const maxperdomain = Number(options.maxperdomain ?? maxpages);
|
|
9
|
-
const queue = [];
|
|
10
|
-
const seen = new Set();
|
|
11
|
-
const completed = new Set();
|
|
12
|
-
const domains = new Map();
|
|
13
|
-
function add(input = {}) {
|
|
14
|
-
const url = String(input.url ?? "");
|
|
15
|
-
if (!url || seen.has(url) || seen.size >= maxpages) return false;
|
|
16
|
-
seen.add(url);
|
|
17
|
-
queue.push({ url, depth: Number(input.depth ?? 0), priority: Number(input.priority ?? 0), discoveredat: Number(input.discoveredat ?? Date.now()) });
|
|
18
|
-
queue.sort((left, right) => right.priority - left.priority || left.discoveredat - right.discoveredat);
|
|
19
|
-
return true;
|
|
20
|
-
}
|
|
21
|
-
function next() {
|
|
22
|
-
while (queue.length) {
|
|
23
|
-
const item = queue.shift();
|
|
24
|
-
const domain = new URL(item.url).hostname;
|
|
25
|
-
if ((domains.get(domain) ?? 0) >= maxperdomain) continue;
|
|
26
|
-
domains.set(domain, (domains.get(domain) ?? 0) + 1);
|
|
27
|
-
return { ...item };
|
|
28
|
-
}
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
function complete(url) { completed.add(String(url)); }
|
|
32
|
-
function state() { return { maxpages, maxperdomain, queued: queue.length, discovered: seen.size, completed: completed.size, domains: Object.fromEntries(domains) }; }
|
|
33
|
-
return { add, next, complete, state, list() { return queue.map((item) => ({ ...item })); } };
|
|
34
|
-
}
|
package/crawl/normalize.js
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* url normalization removes tracking noise before frontier deduplication.
|
|
3
|
-
*/
|
|
4
|
-
const tracking = /^(utm_|fbclid$|msclkid$|gclid$|gclsrc$|dclid$|gbraid$|wbraid$|twclid$|campaign$|content$|term$|source$|medium$|ref$|share_id$)/i;
|
|
5
|
-
|
|
6
|
-
export function normalizeurl(value) {
|
|
7
|
-
const url = new URL(value);
|
|
8
|
-
url.hash = "";
|
|
9
|
-
for (const key of [...url.searchParams.keys()]) if (tracking.test(key)) url.searchParams.delete(key);
|
|
10
|
-
if (url.pathname.length > 1) url.pathname = url.pathname.replace(/\/+$/, "");
|
|
11
|
-
return url.href;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function sameorigin(left, right) { return new URL(left).origin === new URL(right).origin; }
|
package/crawl/persistent.js
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* persistent crawl queue uses an injected store and falls back to memory when no store exists.
|
|
3
|
-
*/
|
|
4
|
-
export function persistentqueue(options = {}) {
|
|
5
|
-
const store = options.store;
|
|
6
|
-
const values = [];
|
|
7
|
-
const seen = new Set();
|
|
8
|
-
async function restore() { if (typeof store?.list !== "function") return; for (const item of await store.list()) if (!seen.has(item.url) && item.status !== "done") { seen.add(item.url); values.push(item); } }
|
|
9
|
-
async function add(item) { if (!item?.url || seen.has(item.url)) return false; const value = { url: item.url, depth: item.depth ?? 0, status: "queued", createdat: Date.now(), metadata: item.metadata ?? {} }; seen.add(value.url); values.push(value); if (typeof store?.save === "function") await store.save(value); return true; }
|
|
10
|
-
async function next() { const item = values.find((value) => value.status === "queued"); if (!item) return null; item.status = "running"; if (typeof store?.update === "function") await store.update(item.url, item); return item; }
|
|
11
|
-
async function complete(url, patch = {}) { const item = values.find((value) => value.url === url); if (!item) return null; Object.assign(item, patch, { status: patch.status ?? "done", processedat: Date.now() }); if (typeof store?.update === "function") await store.update(url, item); return item; }
|
|
12
|
-
return { restore, add, next, complete, list() { return values.map((value) => ({ ...value })); } };
|
|
13
|
-
}
|
package/errors/taxonomy.js
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* scrape errors carry a stable code status retry flag severity and recovery hint.
|
|
3
|
-
*/
|
|
4
|
-
export const errorcatalog = Object.freeze({
|
|
5
|
-
timeout: { code: "E1001", statuscode: 504, retryable: true, recovery: "WAIT_AND_RETRY" },
|
|
6
|
-
connectionrefused: { code: "E1002", statuscode: 503, retryable: true, recovery: "WAIT_AND_RETRY" },
|
|
7
|
-
dns: { code: "E1003", statuscode: 503, retryable: true, recovery: "ROTATE_PROXY" },
|
|
8
|
-
ratelimited: { code: "E2001", statuscode: 429, retryable: true, recovery: "WAIT_AND_RETRY" },
|
|
9
|
-
forbidden: { code: "E2002", statuscode: 403, retryable: false, recovery: "REVIEW_ROBOTS_TXT" },
|
|
10
|
-
notfound: { code: "E2003", statuscode: 404, retryable: false, recovery: "STOP_CRAWLING" },
|
|
11
|
-
parse: { code: "E4002", statuscode: 422, retryable: false, recovery: "STOP_CRAWLING" },
|
|
12
|
-
captcha: { code: "E4003", statuscode: 403, retryable: false, recovery: "REVIEW_ROBOTS_TXT" },
|
|
13
|
-
session: { code: "E5001", statuscode: 401, retryable: true, recovery: "ROTATE_USER_AGENT" },
|
|
14
|
-
config: { code: "E6001", statuscode: 400, retryable: false, recovery: "STOP_CRAWLING" }
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
export function webscrapeerror(kind, message, options = {}) { const preset = errorcatalog[kind] ?? errorcatalog.config; const error = new Error(message, { cause: options.cause }); error.name = "webscrapeerror"; error.code = options.code ?? preset.code; error.statuscode = options.statuscode ?? preset.statuscode; error.retryable = options.retryable ?? preset.retryable; error.recovery = options.recovery ?? preset.recovery; error.severity = options.severity ?? (error.statuscode >= 500 ? "high" : "medium"); error.details = options.details ?? {}; return error; }
|
|
18
|
-
export function classifyerror(error) { if (error?.name === "webscrapeerror") return error; const message = String(error?.message ?? error); if (/timeout|aborted/i.test(message)) return webscrapeerror("timeout", message, { cause: error }); if (/dns|enotfound/i.test(message)) return webscrapeerror("dns", message, { cause: error }); return webscrapeerror("config", message, { cause: error }); }
|
package/readme.txt
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
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/wenathlan/saddle/actions/workflows/ci.yml"><img src="https://github.com/wenathlan/saddle/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
|
54
|
-
<a href="https://github.com/wenathlan/saddle/releases/tag/v1.8.2"><img src="https://img.shields.io/badge/release-v1.8.2-d35d3d" alt="Release 1.8.2" /></a>
|
|
55
|
-
<a href="https://github.com/wenathlan/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. The canonical JavaScript package is `@wenathlan/saddle`; GitHub Packages npm, Maven and GHCR use the transferred `wenathlan` owner namespace, while NuGet and RubyGems retain their unscoped ecosystem package names.
|
|
63
|
-
|
|
64
|
-
## Start here
|
|
65
|
-
|
|
66
|
-
Saddle requires **Node.js 22 or newer**.
|
|
67
|
-
|
|
68
|
-
```bash
|
|
69
|
-
npm install @wenathlan/saddle
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
```js
|
|
73
|
-
import { scrapeurl, formatforagent } from "@wenathlan/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 "@wenathlan/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
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
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(); }
|