@wenathlan/saddle 1.8.5 → 1.8.7

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.
@@ -6,12 +6,14 @@
6
6
  (function installglobalbridge(global) {
7
7
  const installedkey = "__saddlecontentbridge";
8
8
  const protocolversion = 1;
9
- const commands = ["snapshot", "readpage", "clickref", "fillref"];
9
+ const commands = ["snapshot", "readpage", "pagefacts", "clickref", "fillref"];
10
+ const pagechannelname = "saddle.pagefacts.v1";
10
11
 
11
- function createbridge(documentref, now = () => Date.now()) {
12
+ function createbridge(documentref, now = () => Date.now(), options = {}) {
12
13
  let snapshotid = null;
13
14
  let sequence = 0;
14
15
  const references = new Map();
16
+ const pagechannel = options.pagechannel ?? (typeof global.addEventListener === "function" && typeof global.postMessage === "function" ? createpagechannel(global, now, options.timeout) : null);
15
17
 
16
18
  function snapshotpage() {
17
19
  const nextid = `snap${++sequence}${now()}`;
@@ -45,6 +47,10 @@
45
47
  const payload = request.payload ?? {};
46
48
  if (request.command === "snapshot") return snapshotpage();
47
49
  if (request.command === "readpage") return readpage();
50
+ if (request.command === "pagefacts") {
51
+ if (!pagechannel) throw failure("page_bridge_unavailable", "page bridge is not available");
52
+ return pagechannel.readpage();
53
+ }
48
54
  const element = resolve(payload.ref, payload.snapshotid);
49
55
  if (request.command === "clickref") { element.click?.(); return { ref: payload.ref, clicked: true, snapshotid }; }
50
56
  if (!isfillable(element)) throw failure("not_fillable", `element is not fillable: ${payload.ref}`);
@@ -52,7 +58,43 @@
52
58
  return { ref: payload.ref, filled: true, snapshotid };
53
59
  }
54
60
 
55
- return { handle, snapshotpage, readpage };
61
+ return { handle, snapshotpage, readpage, pagechannel };
62
+ }
63
+
64
+ function createpagechannel(globalref = global, now = () => Date.now(), timeout = 1500) {
65
+ if (typeof globalref.addEventListener !== "function" || typeof globalref.postMessage !== "function") throw new TypeError("page channel requires window messaging APIs");
66
+ if (!Number.isSafeInteger(timeout) || timeout < 1) throw new TypeError("page channel timeout must be a positive safe integer");
67
+ const pending = new Map();
68
+ let sequence = 0;
69
+ const token = `token${now()}${Math.random().toString(16).slice(2)}`;
70
+
71
+ function listener(event) {
72
+ const response = event?.data;
73
+ if (event?.source && event.source !== globalref) return;
74
+ if (response?.channel !== pagechannelname || response.type !== "response" || response.token !== token) return;
75
+ const request = pending.get(response.requestid);
76
+ if (!request) return;
77
+ pending.delete(response.requestid);
78
+ globalref.clearTimeout?.(request.timer);
79
+ request.resolve(response.payload ?? {});
80
+ }
81
+
82
+ function readpage() {
83
+ const requestid = `page${now()}${++sequence}`;
84
+ return new Promise((resolve, reject) => {
85
+ const timer = globalref.setTimeout(() => {
86
+ pending.delete(requestid);
87
+ const error = new Error("page bridge response timed out");
88
+ error.code = "page_bridge_timeout";
89
+ reject(error);
90
+ }, timeout);
91
+ pending.set(requestid, { resolve, timer });
92
+ globalref.postMessage({ channel: pagechannelname, version: 1, type: "request", requestid, token }, "*");
93
+ });
94
+ }
95
+
96
+ globalref.addEventListener("message", listener);
97
+ return { readpage, dispose() { globalref.removeEventListener?.("message", listener); for (const request of pending.values()) globalref.clearTimeout?.(request.timer); pending.clear(); } };
56
98
  }
57
99
 
58
100
  function install(runtime = global.chrome?.runtime, documentref = global.document) {
@@ -80,6 +122,6 @@
80
122
  function setvalue(element, value) { element.value = String(value ?? ""); element.dispatchEvent?.(new Event("input", { bubbles: true })); element.dispatchEvent?.(new Event("change", { bubbles: true })); }
81
123
  function failure(code, message) { const error = new Error(message); error.code = code; return error; }
82
124
 
83
- global.saddlecontent = { createbridge, install };
125
+ global.saddlecontent = { createbridge, createpagechannel, install };
84
126
  if (global.chrome?.runtime?.onMessage && global.document) install();
85
127
  })(globalThis);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Saddle browser bridge",
4
- "version": "1.1.0",
4
+ "version": "1.8.7",
5
5
  "description": "User initiated page snapshots through the Saddle browser contract.",
6
6
  "minimum_chrome_version": "110",
7
7
  "permissions": ["activeTab", "scripting", "storage"],
@@ -0,0 +1,41 @@
1
+ /**
2
+ * page bridge exposes bounded, read-only page facts to the isolated content world.
3
+ * It never executes page supplied commands and never forwards extension credentials.
4
+ */
5
+
6
+ (function installpagebridge(global) {
7
+ const channel = "saddle.pagefacts.v1";
8
+ const installedkey = "__saddlepagebridge";
9
+
10
+ function createpagebridge(globalref = global) {
11
+ function readpage() {
12
+ const documentref = globalref.document;
13
+ return {
14
+ url: String(documentref?.location?.href ?? ""),
15
+ title: String(documentref?.title ?? "").slice(0, 500),
16
+ text: String(documentref?.body?.innerText ?? "").slice(0, 100000)
17
+ };
18
+ }
19
+
20
+ function listener(event) {
21
+ const request = event?.data;
22
+ if (event?.source && event.source !== globalref) return;
23
+ if (request?.channel !== channel || request.type !== "request" || typeof request.requestid !== "string" || typeof request.token !== "string") return;
24
+ globalref.postMessage({ channel, version: 1, type: "response", requestid: request.requestid, token: request.token, payload: readpage() }, "*");
25
+ }
26
+
27
+ return { channel, listener, readpage };
28
+ }
29
+
30
+ function install(globalref = global) {
31
+ if (typeof globalref?.addEventListener !== "function" || !globalref.document) throw new TypeError("page bridge requires a window and document");
32
+ if (globalref[installedkey]) return globalref[installedkey];
33
+ const bridge = createpagebridge(globalref);
34
+ globalref.addEventListener("message", bridge.listener);
35
+ globalref[installedkey] = { ...bridge, dispose() { globalref.removeEventListener?.("message", bridge.listener); delete globalref[installedkey]; } };
36
+ return globalref[installedkey];
37
+ }
38
+
39
+ global.saddlepagebridge = { channel, createpagebridge, install };
40
+ if (global.document && typeof global.addEventListener === "function") install(global);
41
+ })(globalThis);
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  export const protocolversion = 1;
6
- export const extensioncommands = Object.freeze(["snapshot", "readpage", "clickref", "fillref"]);
6
+ export const extensioncommands = Object.freeze(["snapshot", "readpage", "pagefacts", "clickref", "fillref"]);
7
7
 
8
8
  /** Creates a compact identifier without embedding a host, port or credential. */
9
9
  export function createid(prefix = "msg", source) {
@@ -59,10 +59,13 @@ export function createsnapshot(input = {}) {
59
59
  version: protocolversion,
60
60
  snapshotid: String(input.snapshotid ?? createid("snap")),
61
61
  createdat: Number(input.createdat ?? Date.now()),
62
+ windowid: input.windowid === undefined ? undefined : String(input.windowid),
63
+ tabid: input.tabid === undefined ? undefined : String(input.tabid),
64
+ frameid: input.frameid === undefined ? undefined : String(input.frameid),
62
65
  url: String(input.url ?? ""),
63
66
  title: String(input.title ?? ""),
64
67
  text: String(input.text ?? ""),
65
- elements: Array.isArray(input.elements) ? input.elements.map((element) => ({ ref: String(element.ref), role: String(element.role ?? "generic"), name: String(element.name ?? "") })) : []
68
+ elements: Array.isArray(input.elements) ? input.elements.map((element) => ({ ref: String(element.ref), role: String(element.role ?? "generic"), name: String(element.name ?? ""), value: element.value === undefined ? undefined : String(element.value), disabled: Boolean(element.disabled) })) : []
66
69
  };
67
70
  assertserializable(snapshot);
68
71
  return snapshot;
@@ -71,6 +74,22 @@ export function createsnapshot(input = {}) {
71
74
  /** Returns whether a reference still belongs to the current page snapshot. */
72
75
  export function isfreshsnapshot(snapshotid, currentid) { return Boolean(snapshotid && currentid && snapshotid === currentid); }
73
76
 
77
+ /** Computes bounded additions, removals and changed elements between extension snapshots. */
78
+ export function snapshotdiff(previous, current) {
79
+ const before = createsnapshot(previous);
80
+ const after = createsnapshot(current);
81
+ const oldmap = new Map(before.elements.map((element) => [element.ref, element]));
82
+ const newmap = new Map(after.elements.map((element) => [element.ref, element]));
83
+ return {
84
+ from: before.snapshotid,
85
+ to: after.snapshotid,
86
+ contextchanged: before.windowid !== after.windowid || before.tabid !== after.tabid || before.frameid !== after.frameid,
87
+ added: after.elements.filter((element) => !oldmap.has(element.ref)),
88
+ removed: before.elements.filter((element) => !newmap.has(element.ref)),
89
+ changed: after.elements.filter((element) => oldmap.has(element.ref) && JSON.stringify(oldmap.get(element.ref)) !== JSON.stringify(element))
90
+ };
91
+ }
92
+
74
93
  function assertserializable(value) {
75
94
  try { JSON.stringify(value); } catch (error) { throw new TypeError(`extension message is not serializable: ${error.message}`); }
76
95
  }
@@ -60,7 +60,8 @@ export function createworkerrouter(options = {}) {
60
60
  async function dispatch(request, sender = {}) {
61
61
  const tabid = request.payload?.tabid ?? sender.tab?.id;
62
62
  await ensurecontent(tabid);
63
- return tabs.sendMessage(tabid, request);
63
+ const response = await tabs.sendMessage(tabid, request);
64
+ return decorate(response, sender, tabid);
64
65
  }
65
66
 
66
67
  async function handle(message, sender = {}) {
@@ -70,7 +71,7 @@ export function createworkerrouter(options = {}) {
70
71
  await enqueue(request, { ...sender, tab: { ...sender.tab, id: tabid } });
71
72
  try {
72
73
  const response = await dispatch(request, { ...sender, tab: { ...sender.tab, id: tabid } });
73
- await complete(request.id, response?.type === "response" && response.payload?.snapshotid ? { tabid, snapshotid: response.payload.snapshotid, updatedat: Date.now() } : { updatedat: Date.now() });
74
+ await complete(request.id, response?.type === "response" && response.payload?.snapshotid ? { tabid, snapshotid: response.payload.snapshotid, frameid: response.payload.frameid, windowid: response.payload.windowid, updatedat: Date.now() } : { updatedat: Date.now() });
74
75
  return response;
75
76
  } catch (error) {
76
77
  await markfailure(request.id, error);
@@ -85,8 +86,8 @@ export function createworkerrouter(options = {}) {
85
86
  const pending = state.pending.find((item) => item.requestid === requestid);
86
87
  if (!pending) throw extensionerror("PENDING_NOT_FOUND", `pending command not found: ${requestid}`);
87
88
  const tabid = pending.tabid ?? sender.tab?.id;
88
- const response = await dispatch(pending.message, { ...sender, tab: { ...sender.tab, id: tabid } });
89
- await complete(requestid, response?.type === "response" && response.payload?.snapshotid ? { tabid, snapshotid: response.payload.snapshotid, updatedat: Date.now() } : { updatedat: Date.now() });
89
+ const response = await dispatch(pending.message, { ...sender, frameId: pending.frameid ?? sender.frameId, tab: { ...sender.tab, id: tabid, windowId: pending.windowid ?? sender.tab?.windowId } });
90
+ await complete(requestid, response?.type === "response" && response.payload?.snapshotid ? { tabid, snapshotid: response.payload.snapshotid, frameid: response.payload.frameid, windowid: response.payload.windowid, updatedat: Date.now() } : { updatedat: Date.now() });
90
91
  return response;
91
92
  }
92
93
 
@@ -95,7 +96,13 @@ export function createworkerrouter(options = {}) {
95
96
  return { ensurecontent, readstate, savestate, rehydrate, enqueue, resume, cancel, handle };
96
97
  }
97
98
 
98
- function pendingrecord(request, sender = {}) { return { requestid: request.id, command: request.command, message: request, tabid: request.payload?.tabid ?? sender.tab?.id, attempts: 0, createdat: Date.now(), updatedat: Date.now() }; }
99
+ function pendingrecord(request, sender = {}) { return { requestid: request.id, command: request.command, message: request, tabid: request.payload?.tabid ?? sender.tab?.id, frameid: request.payload?.frameid ?? sender.frameId, windowid: request.payload?.windowid ?? sender.tab?.windowId, attempts: 0, createdat: Date.now(), updatedat: Date.now() }; }
100
+
101
+ function decorate(response, sender, tabid) {
102
+ if (response?.type !== "response" || !response.payload || typeof response.payload !== "object" || !response.payload.snapshotid) return response;
103
+ const payload = { ...response.payload, tabid: response.payload.tabid ?? (tabid === undefined ? undefined : String(tabid)), frameid: response.payload.frameid ?? (sender.frameId === undefined ? undefined : String(sender.frameId)), windowid: response.payload.windowid ?? (sender.tab?.windowId === undefined ? undefined : String(sender.tab.windowId)) };
104
+ return { ...response, payload };
105
+ }
99
106
 
100
107
  function validpending(value) { return Boolean(value && typeof value === "object" && typeof value.requestid === "string" && value.message && value.message.type === "command" && Number.isSafeInteger(value.attempts) && value.attempts >= 0); }
101
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenathlan/saddle",
3
- "version": "1.8.5",
3
+ "version": "1.8.7",
4
4
  "description": "binary computing engine that turns distributed storage into a publishable working set",
5
5
  "type": "module",
6
6
  "private": false,
@@ -11,15 +11,39 @@
11
11
  "module": "./index.js",
12
12
  "browser": "./index.js",
13
13
  "packageManager": "npm@12.0.2",
14
- "publishConfig": { "access": "public" },
15
- "repository": { "type": "git", "url": "https://github.com/wenathlan/saddle.git" },
16
- "bugs": { "url": "https://github.com/wenathlan/saddle/issues" },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/wenathlan/saddle.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/wenathlan/saddle/issues"
23
+ },
17
24
  "homepage": "https://github.com/wenathlan/saddle#readme",
18
- "keywords": ["distributed computing", "virtual memory", "runner", "storage", "automation"],
19
- "engines": { "node": ">=26.7.0", "npm": ">=10.9.2" },
20
- "peerDependencies": { "playwright": "^1.62.1" },
21
- "peerDependenciesMeta": { "playwright": { "optional": true } },
22
- "bin": { "saddle": "./cli/main.js" },
25
+ "keywords": [
26
+ "distributed computing",
27
+ "virtual memory",
28
+ "runner",
29
+ "storage",
30
+ "automation"
31
+ ],
32
+ "engines": {
33
+ "node": ">=26.7.0",
34
+ "npm": ">=10.9.2"
35
+ },
36
+ "peerDependencies": {
37
+ "playwright": "^1.62.1"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "playwright": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "bin": {
45
+ "saddle": "./cli/main.js"
46
+ },
23
47
  "exports": {
24
48
  ".": "./index.js",
25
49
  "./memory": "./memory/bridge.js",
@@ -43,16 +67,136 @@
43
67
  "./queue-persistent": "./queue/persistent.js",
44
68
  "./captcha-evidence": "./captcha/evidence.js",
45
69
  "./hash": "./core/hash.js",
70
+ "./release-assets": "./release/assets.js",
46
71
  "./worker": "./runtime/worker.js"
47
72
  },
48
- "files": ["core", "domain", "memory", "runners", "runtime", "storage", "sessions", "modes", "adapters", "persistence", "queue", "dispatch", "scrape", "crawl", "api", "mcp", "browser", "proxy", "captcha", "ai", "webhook", "surfaces", "library", "errors", "retry", "server", "binary", "format", "packager", "bot", "protocol", "workflow", "cli", "extension", "index.js", "docs", "examples", "README.md", "LICENSE"],
73
+ "files": [
74
+ "core",
75
+ "domain",
76
+ "memory",
77
+ "runners",
78
+ "runtime",
79
+ "storage",
80
+ "sessions",
81
+ "modes",
82
+ "adapters",
83
+ "persistence",
84
+ "queue",
85
+ "dispatch",
86
+ "scrape",
87
+ "crawl",
88
+ "api",
89
+ "mcp",
90
+ "browser",
91
+ "proxy",
92
+ "captcha",
93
+ "ai",
94
+ "webhook",
95
+ "surfaces",
96
+ "library",
97
+ "errors",
98
+ "retry",
99
+ "server",
100
+ "binary",
101
+ "format",
102
+ "packager",
103
+ "bot",
104
+ "protocol",
105
+ "workflow",
106
+ "cli",
107
+ "extension",
108
+ "release",
109
+ "index.js",
110
+ "docs",
111
+ "examples",
112
+ "README.md",
113
+ "LICENSE"
114
+ ],
49
115
  "scripts": {
50
- "check": "node --check index.js && node --check runtime/engine.js && node --check cli/main.js && node --check extension/protocol.js && node --check extension/serviceworker.js && node --check extension/worker.js && node --check extension/content.js && node --check extension/popup.js && node --check extension/permissions.js && node --check extension/build.js && node --check sessions/replay.js && node --check browser/recorder.js && node --check scrape/normalize.js",
116
+ "check": "node --check index.js && node --check runtime/engine.js && node --check cli/main.js && node --check extension/protocol.js && node --check extension/serviceworker.js && node --check extension/worker.js && node --check extension/content.js && node --check extension/pagebridge.js && node --check extension/popup.js && node --check extension/permissions.js && node --check extension/build.js && node --check release/assets.js && node --check sessions/replay.js && node --check browser/recorder.js && node --check scrape/normalize.js",
51
117
  "formatcheck": "node format/check.js",
52
118
  "test": "node --test tests/*.test.js",
53
119
  "run": "node cli/main.js",
54
120
  "extension:build": "node extension/build.js",
121
+ "release:assets": "node release/assets.js",
55
122
  "pack:check": "npm run check && npm run formatcheck && npm test && npm pack --dry-run",
56
- "prepublishOnly": "npm run pack:check"
123
+ "prepublishOnly": "npm run pack:check",
124
+ "web:dev": "vite --config web/vite.config.ts --host",
125
+ "web:check": "tsc --project web/tsconfig.json --noEmit",
126
+ "web:build": "vite build --config web/vite.config.ts",
127
+ "web:build:pages": "vite build --config web/vite.config.ts"
128
+ },
129
+ "devDependencies": {
130
+ "@hookform/resolvers": "^5.2.2",
131
+ "@radix-ui/react-accordion": "^1.2.12",
132
+ "@radix-ui/react-alert-dialog": "^1.1.15",
133
+ "@radix-ui/react-aspect-ratio": "^1.1.7",
134
+ "@radix-ui/react-avatar": "^1.1.10",
135
+ "@radix-ui/react-checkbox": "^1.3.3",
136
+ "@radix-ui/react-collapsible": "^1.1.12",
137
+ "@radix-ui/react-context-menu": "^2.2.16",
138
+ "@radix-ui/react-dialog": "^1.1.15",
139
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
140
+ "@radix-ui/react-hover-card": "^1.1.15",
141
+ "@radix-ui/react-label": "^2.1.7",
142
+ "@radix-ui/react-menubar": "^1.1.16",
143
+ "@radix-ui/react-navigation-menu": "^1.2.14",
144
+ "@radix-ui/react-popover": "^1.1.15",
145
+ "@radix-ui/react-progress": "^1.1.7",
146
+ "@radix-ui/react-radio-group": "^1.3.8",
147
+ "@radix-ui/react-scroll-area": "^1.2.10",
148
+ "@radix-ui/react-select": "^2.2.6",
149
+ "@radix-ui/react-separator": "^1.1.7",
150
+ "@radix-ui/react-slider": "^1.3.6",
151
+ "@radix-ui/react-slot": "^1.2.3",
152
+ "@radix-ui/react-switch": "^1.2.6",
153
+ "@radix-ui/react-tabs": "^1.1.13",
154
+ "@radix-ui/react-toggle": "^1.1.10",
155
+ "@radix-ui/react-toggle-group": "^1.1.11",
156
+ "@radix-ui/react-tooltip": "^1.2.8",
157
+ "axios": "^1.12.0",
158
+ "class-variance-authority": "^0.7.1",
159
+ "clsx": "^2.1.1",
160
+ "cmdk": "^1.1.1",
161
+ "embla-carousel-react": "^8.6.0",
162
+ "express": "^4.21.2",
163
+ "framer-motion": "^12.23.22",
164
+ "input-otp": "^1.4.2",
165
+ "lucide-react": "^0.453.0",
166
+ "nanoid": "^5.1.5",
167
+ "next-themes": "^0.4.6",
168
+ "react": "^19.2.1",
169
+ "react-day-picker": "^9.11.1",
170
+ "react-dom": "^19.2.1",
171
+ "react-hook-form": "^7.64.0",
172
+ "react-resizable-panels": "^3.0.6",
173
+ "recharts": "^2.15.2",
174
+ "sonner": "^2.0.7",
175
+ "streamdown": "^1.4.0",
176
+ "tailwind-merge": "^3.3.1",
177
+ "tailwindcss-animate": "^1.0.7",
178
+ "vaul": "^1.1.2",
179
+ "wouter": "^3.3.5",
180
+ "zod": "^4.1.12",
181
+ "@tailwindcss/typography": "^0.5.15",
182
+ "@tailwindcss/vite": "^4.1.3",
183
+ "@types/express": "4.17.21",
184
+ "@types/google.maps": "^3.58.1",
185
+ "@types/node": "^24.7.0",
186
+ "@types/react": "^19.2.1",
187
+ "@types/react-dom": "^19.2.1",
188
+ "@vitejs/plugin-react": "^5.0.4",
189
+ "add": "^2.0.6",
190
+ "autoprefixer": "^10.4.20",
191
+ "esbuild": "^0.25.0",
192
+ "postcss": "^8.4.47",
193
+ "prettier": "^3.6.2",
194
+ "tailwindcss": "^4.1.14",
195
+ "tsx": "^4.19.1",
196
+ "tw-animate-css": "^1.4.0",
197
+ "typescript": "5.6.3",
198
+ "vite": "^7.1.7",
199
+ "vite-plugin-manus-runtime": "^0.0.58",
200
+ "vitest": "^4.1.10"
57
201
  }
58
202
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * release assets create deterministic checksums, SBOM data and provenance statements.
3
+ * The adapter reads caller-selected artifacts and never publishes or handles credentials.
4
+ */
5
+
6
+ import { createHash } from "node:crypto";
7
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
8
+ import { dirname, join, relative, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const rootpath = resolve(dirname(fileURLToPath(import.meta.url)), "..");
12
+
13
+ /** Builds deterministic release metadata files for caller-selected artifacts. */
14
+ export async function createassets(options = {}) {
15
+ const packagefile = resolve(options.packagefile ?? join(rootpath, "package.json"));
16
+ const lockfile = resolve(options.lockfile ?? join(rootpath, "package-lock.json"));
17
+ const output = resolve(options.output ?? join(rootpath, "build", "release"));
18
+ const artifactroot = resolve(options.artifactroot ?? rootpath);
19
+ const packagejson = JSON.parse(await readFile(packagefile, "utf8"));
20
+ const lockjson = JSON.parse(await readFile(lockfile, "utf8"));
21
+ const artifacts = [...new Set((options.artifacts ?? []).map((artifact) => resolve(String(artifact))))].sort();
22
+ const subjects = await Promise.all(artifacts.map(async (artifact) => ({ name: relative(artifactroot, artifact).replaceAll("\\", "/"), digest: await sha256(artifact) })));
23
+ await mkdir(output, { recursive: true });
24
+ const checksums = `${subjects.map((subject) => `${subject.digest} ${subject.name}`).join("\n")}${subjects.length ? "\n" : ""}`;
25
+ const sbom = createsbom(packagejson, lockjson);
26
+ const provenance = createprovenance(packagejson, subjects, options);
27
+ const files = { checksums: join(output, "SHA256SUMS"), sbom: join(output, "sbom.cdx.json"), provenance: join(output, "provenance.intoto.jsonl") };
28
+ await writeFile(files.checksums, checksums);
29
+ await writeFile(files.sbom, `${JSON.stringify(sbom, null, 2)}\n`);
30
+ await writeFile(files.provenance, `${JSON.stringify(provenance)}\n`);
31
+ return { output, files, subjects, sbom, provenance };
32
+ }
33
+
34
+ /** Creates a compact CycloneDX component list from the root lockfile dependencies. */
35
+ export function createsbom(packagejson, lockjson = {}) {
36
+ const root = lockjson.packages?.[""] ?? {};
37
+ const dependencies = { ...(root.dependencies ?? {}), ...(root.devDependencies ?? {}) };
38
+ const components = Object.keys(dependencies).map((name) => {
39
+ const entry = lockjson.packages?.[`node_modules/${name}`] ?? {};
40
+ const version = String(entry.version ?? dependencies[name]).replace(/^[^0-9]*/, "");
41
+ return { "bom-ref": `pkg:npm/${name}@${version}`, name, version, purl: `pkg:npm/${name}@${version}`, scope: root.devDependencies?.[name] ? "optional" : "required", type: "library" };
42
+ }).sort((left, right) => left.name.localeCompare(right.name));
43
+ return { bomFormat: "CycloneDX", specVersion: "1.5", serialNumber: `urn:uuid:${stableuuid(`${packagejson.name}@${packagejson.version}`)}`, version: 1, metadata: { component: { type: "application", name: String(packagejson.name), version: String(packagejson.version) } }, components };
44
+ }
45
+
46
+ /** Creates an in-toto statement whose subjects are the caller-selected release artifacts. */
47
+ export function createprovenance(packagejson, subjects = [], options = {}) {
48
+ const normalized = subjects.map((subject) => ({ name: String(subject.name), digest: { sha256: String(subject.digest) } })).sort((left, right) => left.name.localeCompare(right.name));
49
+ return { _type: "https://in-toto.io/Statement/v1", subject: normalized, predicateType: "https://slsa.dev/provenance/v1", predicate: { buildDefinition: { buildType: String(options.buildtype ?? "caller-defined"), externalParameters: { package: String(packagejson.name), version: String(options.version ?? packagejson.version) }, internalParameters: {} }, runDetails: { builder: { id: String(options.builder ?? "caller-defined") }, metadata: { invocationId: stableuuid(`${packagejson.name}@${options.version ?? packagejson.version}:${normalized.map((subject) => subject.name).join(",")}`) } } } };
50
+ }
51
+
52
+ async function sha256(path) { const hash = createHash("sha256"); hash.update(await readFile(path)); return hash.digest("hex"); }
53
+
54
+ function stableuuid(value) { const hex = createHash("sha256").update(String(value)).digest("hex").slice(0, 32); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${(8 + (Number.parseInt(hex.slice(16, 17), 16) % 4).toString(16))}${hex.slice(17, 20)}-${hex.slice(20)}`; }
55
+
56
+ function parsearguments(argumentslist) {
57
+ const options = { artifacts: [] };
58
+ for (let index = 0; index < argumentslist.length; index += 1) {
59
+ const argument = argumentslist[index];
60
+ if (argument === "--output") options.output = argumentslist[++index];
61
+ else if (argument === "--version") options.version = argumentslist[++index];
62
+ else if (argument === "--artifact") options.artifacts.push(argumentslist[++index]);
63
+ else if (argument === "--build-type") options.buildtype = argumentslist[++index];
64
+ else if (argument === "--builder") options.builder = argumentslist[++index];
65
+ else throw new TypeError(`unsupported release asset argument: ${argument}`);
66
+ }
67
+ return options;
68
+ }
69
+
70
+ if (process.argv[1] === fileURLToPath(import.meta.url)) createassets(parsearguments(process.argv.slice(2))).then(({ output }) => { console.log(`release assets: ${output}`); }).catch((error) => { console.error(error.message); process.exitCode = 1; });
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
- | --- | --- |