@wenathlan/saddle 1.8.1 → 1.8.4
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 +1 -1
- package/README.md +16 -13
- package/browser/recorder.js +8 -2
- package/docs/ecosystemplan.md +5 -5
- package/docs/gapmatrix.md +7 -7
- package/docs/libraryapi.md +2 -1
- package/docs/modes.md +1 -1
- package/docs/platformpipelineaudit.md +18 -0
- package/docs/platformpipelines.md +13 -0
- package/docs/productindex.md +1 -1
- package/docs/registryresearch.md +7 -3
- package/docs/release.md +8 -7
- package/docs/release181notes.md +1 -1
- package/docs/release182notes.md +27 -0
- package/docs/release184notes.md +7 -0
- package/docs/usage.md +1 -1
- package/domain/sessions.js +13 -1
- package/extension/README.md +6 -2
- package/extension/build.js +46 -0
- package/extension/index.js +1 -0
- package/extension/permissions.js +22 -0
- package/extension/serviceworker.js +63 -4
- package/extension/worker.js +3 -1
- package/index.js +1 -0
- package/library/public.js +4 -2
- package/license.md +1 -1
- package/license.txt +1 -1
- package/package.json +6 -5
- package/packager/manifest.js +1 -1
- package/readme.txt +8 -8
- package/scrape/normalize.js +86 -0
- package/sessions/replay.js +41 -1
- package/workflow/templates.js +4 -4
package/LICENSE
CHANGED
|
@@ -198,6 +198,6 @@
|
|
|
198
198
|
|
|
199
199
|
You should have received a copy of the Proprietary Source-Available
|
|
200
200
|
License along with this program. If not, see
|
|
201
|
-
<https://github.com/
|
|
201
|
+
<https://github.com/wenathlan/saddle/LICENSE>
|
|
202
202
|
|
|
203
203
|
Also add information on how to contact you by electronic and paper mail.
|
package/README.md
CHANGED
|
@@ -7,16 +7,16 @@
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<strong>Storage-backed jobs, scraping contracts and portable runners for Node.js.</strong><br/>
|
|
9
9
|
<strong>Binary computing agent, agent browser, computer-use, scraper and packager.</strong><br/>
|
|
10
|
-
<a href="https://github.com/
|
|
11
|
-
<a href="https://github.com/
|
|
12
|
-
<a href="https://github.com/
|
|
10
|
+
<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>
|
|
11
|
+
<a href="https://github.com/wenathlan/saddle/releases/tag/v1.8.4"><img src="https://img.shields.io/badge/release-v1.8.4-d35d3d" alt="Release 1.8.4" /></a>
|
|
12
|
+
<a href="https://github.com/wenathlan/saddle/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0-202a2f" alt="GPL 3.0 license" /></a>
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
15
|
> **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.
|
|
16
16
|
|
|
17
17
|
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.
|
|
18
18
|
|
|
19
|
-
Ships as a library, CLI, binary, n8n node, CRX extension, Android/iOS and Tauri desktop app.
|
|
19
|
+
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.
|
|
20
20
|
|
|
21
21
|
## Start here
|
|
22
22
|
|
|
@@ -109,14 +109,17 @@ The caller still chooses how to provide `fetcher`, browser transport, persistenc
|
|
|
109
109
|
|
|
110
110
|
## Browser extension
|
|
111
111
|
|
|
112
|
-
Version 1.
|
|
112
|
+
Version 1.8.4 includes a pure JavaScript Manifest V3 reference surface in [`extension/`](extension/). It is deliberately narrow: the user invokes the action, the popup sends a versioned command, the service worker routes it, and an isolated content bridge returns bounded page metadata, visible text or a user initiated action result. The exported `permissionpolicy` keeps base permissions minimal and makes optional escalation caller-owned.
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
115
|
# load the unpacked extension from chrome://extensions
|
|
116
116
|
ls extension/manifest.json extension/worker.js extension/content.js extension/popup.html
|
|
117
|
+
|
|
118
|
+
# build an isolated unpacked artifact using the package version
|
|
119
|
+
npm run extension:build -- --output build/extension
|
|
117
120
|
```
|
|
118
121
|
|
|
119
|
-
The extension requests `activeTab`, `scripting` and `storage`; it does not request broad host permissions, cookies, `webRequest`, debugger access or arbitrary page code execution. Its public contracts are available from `@wenathlan/saddle/extension`. See [`extension/README.md`](extension/README.md) for the unpacked
|
|
122
|
+
The extension requests `activeTab`, `scripting` and `storage`; it does not request broad host permissions, cookies, `webRequest`, debugger access or arbitrary page code execution. Its public contracts are available from `@wenathlan/saddle/extension`. Published releases attach `saddle-extension-<version>.zip`; the source manifest remains a stable unpacked reference. See [`extension/README.md`](extension/README.md) for the unpacked and release artifact flows.
|
|
120
123
|
|
|
121
124
|
## CLI
|
|
122
125
|
|
|
@@ -142,12 +145,12 @@ saddle mcp
|
|
|
142
145
|
|
|
143
146
|
| Registry | Artifact | Workflow | Status |
|
|
144
147
|
| --- | --- | --- | --- |
|
|
145
|
-
| GitHub npm | `@
|
|
146
|
-
| GHCR | `ghcr.io/
|
|
147
|
-
| Maven | `io.
|
|
148
|
-
| NuGet | `Saddle.1.8.
|
|
149
|
-
| RubyGems | `saddle 1.8.
|
|
150
|
-
| npmjs | `@wenathlan/saddle@1.8.
|
|
148
|
+
| GitHub npm | `@wenathlan/saddle@1.8.4` | publishgithubnpm.yml | pending release |
|
|
149
|
+
| GHCR | `ghcr.io/wenathlan/saddle:1.8.4` and `latest` | publishghcr.yml | pending release |
|
|
150
|
+
| Maven | `io.wenathlan:saddle:1.8.4` | publishmaven.yml | pending release |
|
|
151
|
+
| NuGet | `Saddle.1.8.4.nupkg` | publishnuget.yml | pending release |
|
|
152
|
+
| RubyGems | `saddle 1.8.4` | publishrubygems.yml | pending release |
|
|
153
|
+
| npmjs | `@wenathlan/saddle@1.8.4` | publishnpmjs.yml | pending release |
|
|
151
154
|
|
|
152
155
|
## Development
|
|
153
156
|
|
|
@@ -185,7 +188,7 @@ Root-based JavaScript ESM layout, no src/ directory, no TypeScript build require
|
|
|
185
188
|
|
|
186
189
|
## Current scope
|
|
187
190
|
|
|
188
|
-
Version 1.8 establishes the engine contracts, browser snapshot foundation, storage sync primitives, runner recovery contracts, scraping context provenance, API/MCP security contracts, bot integration lifecycle, the
|
|
191
|
+
Version 1.8.4 establishes the engine contracts, browser snapshot foundation, storage sync primitives, runner recovery contracts, scraping context provenance and normalization, API/MCP security contracts, bot integration lifecycle, the tested extension bridge and permission policy, deterministic extension packaging, context-aware replay, Node.js 26.7.0 cross-forge gates, desktop/mobile/n8n surface contracts, a framework-neutral operator control boundary and the first cross-runtime import boundary. Native runtimes, n8n host registration, provider credentials, persistent databases and production deployment remain caller-selected adapters. The next improvements should extend these contracts without coupling the core to one forge, registry, browser or storage vendor.
|
|
189
192
|
|
|
190
193
|
## License
|
|
191
194
|
|
package/browser/recorder.js
CHANGED
|
@@ -7,9 +7,15 @@ export function actionrecorder(options = {}) {
|
|
|
7
7
|
const startedat = Number(options.startedat ?? Date.now());
|
|
8
8
|
const events = [];
|
|
9
9
|
let lastsnapshotid;
|
|
10
|
-
function snapshot(snapshot) { lastsnapshotid = snapshot?.snapshotid; events.push({ type: "snapshot", t: Date.now() - startedat, snapshotid: lastsnapshotid, tabid: snapshot?.tabid, frameid: snapshot?.frameid }); return snapshot; }
|
|
11
|
-
function action(input = {}) { const event = { type: "action", t: Date.now() - startedat, action: String(input.action), snapshotid: input.snapshotid ?? lastsnapshotid, tabid: input.tabid, frameid: input.frameid, payload: input.payload ?? {} }; events.push(event); return { ...event }; }
|
|
10
|
+
function snapshot(snapshot) { lastsnapshotid = snapshot?.snapshotid; const context = recordercontext(snapshot); events.push({ type: "snapshot", t: Date.now() - startedat, snapshotid: lastsnapshotid, tabid: snapshot?.tabid, frameid: snapshot?.frameid, context }); return snapshot; }
|
|
11
|
+
function action(input = {}) { const context = recordercontext(input.context ?? input); const event = { type: "action", t: Date.now() - startedat, action: String(input.action), snapshotid: input.snapshotid ?? lastsnapshotid, tabid: input.tabid, frameid: input.frameid, windowid: input.windowid, context, payload: input.payload ?? {} }; events.push(event); return { ...event }; }
|
|
12
12
|
function list() { return events.map((event) => ({ ...event, payload: { ...event.payload } })); }
|
|
13
13
|
function manifest() { return { version: 1, startedat, eventcount: events.length, lastsnapshotid, events: list() }; }
|
|
14
14
|
return { snapshot, action, list, manifest };
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
function recordercontext(value = {}) {
|
|
18
|
+
const context = {};
|
|
19
|
+
for (const name of ["windowid", "tabid", "frameid"]) if (value[name] !== undefined) context[name] = String(value[name]);
|
|
20
|
+
return Object.keys(context).length ? context : undefined;
|
|
21
|
+
}
|
package/docs/ecosystemplan.md
CHANGED
|
@@ -25,16 +25,16 @@ The engine owns **contracts, validation, orchestration, recovery and auditabilit
|
|
|
25
25
|
| Block | Scope | Current state | Exit evidence |
|
|
26
26
|
| --- | --- | --- | --- |
|
|
27
27
|
| 1 | audit and governance | active | gap matrix, sources, claims reconciled |
|
|
28
|
-
| 2 | browser agent foundation | complete | snapshots, refs, stale errors, tabs, frames, action results and replay provenance tests |
|
|
28
|
+
| 2 | browser agent foundation | complete | snapshots, refs, stale errors, tabs, frames, action results and context-aware replay provenance tests |
|
|
29
29
|
| 3 | extension runtime | first slice complete | MV3 unpacked surface, protocol, worker, content bridge and tests |
|
|
30
30
|
| 4 | working set and storage | complete | range chunks, content dedupe, tiered cache, conflict sync and memory capabilities |
|
|
31
31
|
| 5 | runners and execution | complete | provider health, triggers, cancellation, heartbeat and resumable runs |
|
|
32
|
-
| 6 | scraping and context | complete | semantic extraction, crawl budgets, RAG lineage and low-cardinality metrics |
|
|
32
|
+
| 6 | scraping and context | complete | semantic extraction, content-type normalization, crawl budgets, RAG lineage and low-cardinality metrics |
|
|
33
33
|
| 7 | API, MCP and security | complete | request identity, optional auth, secure headers, browser MCP tools and redirect/DNS checks |
|
|
34
34
|
| 8 | bots and integrations | complete | app lifecycle, command scopes, idempotency, delivery retries and dead letters |
|
|
35
|
-
| 9 | packaging and distribution |
|
|
35
|
+
| 9 | packaging and distribution | extension zip and registry slice prepared for 1.8.2 | desktop, mobile, n8n and binary artifacts remain caller-owned |
|
|
36
36
|
| 10 | product surfaces and operations | first slice complete | desktop, mobile, n8n and operator control contracts; observability, retention and threat model remain |
|
|
37
|
-
| 11 | cross-runtime compatibility |
|
|
37
|
+
| 11 | cross-runtime compatibility | transport-neutral graph audit complete | Node, Bun and Deno root probe, browser worker bridge, extension permission/build checks and package graph audit |
|
|
38
38
|
| 12 | release gates | active | deterministic checks, docs, clean diffs and claim/code parity |
|
|
39
39
|
|
|
40
40
|
## execution method
|
|
@@ -52,7 +52,7 @@ Each block follows the same loop:
|
|
|
52
52
|
|
|
53
53
|
## current implementation
|
|
54
54
|
|
|
55
|
-
Version 1.
|
|
55
|
+
Version 1.8.2 carries the public npm identity migration through the transferred `wenathlan` repository owner, aligns GitHub Packages npm, Maven and GHCR owner metadata, and includes the minimal extension permission policy, deterministic extension zip workflow, context-aware replay, transport-neutral graph audit and bounded content normalization. Registry publication is triggered only after the release tag and independent target checks. The first product surface slice adds desktop, mobile, n8n, operator control, operational policy and framework-neutral HTTP contracts. The first cross-runtime slice validates the root on Node, Bun and Deno, while extension-context and browser bundler checks remain caller-owned.
|
|
56
56
|
|
|
57
57
|
## evidence sources
|
|
58
58
|
|
package/docs/gapmatrix.md
CHANGED
|
@@ -18,20 +18,20 @@ This matrix turns the supplied README and conclusions into implementation decisi
|
|
|
18
18
|
| Root library | Implemented ESM entry point with broad exports | No extension subpath or extension files are shipped | P0 | Add a small `extension/` package surface and export only serializable contracts |
|
|
19
19
|
| Browser agent | Implemented injected action adapter for navigate, click, type, screenshot, DOM, title, scroll and command batches | Vendor-neutral action results and bounded action batches are now public | P1 | Keep the adapter boundary; add vendor adapters without moving browser ownership into the core |
|
|
20
20
|
| Browser snapshots | Implemented public contract | Snapshot ids, bounded elements, stable refs, stale checks and diffs are covered by deterministic tests | P0 | Reuse the contract from MCP and extension transport |
|
|
21
|
-
| Session replay |
|
|
21
|
+
| Session replay | Implemented | Replay restores caller-owned window, tab and frame context before actions; context identifiers are validated and counted | P1 | Keep browser selection and restoration in injected adapters |
|
|
22
22
|
| Extension runtime | Surface is only declared in `surfaces/manifest.js` and `surfaces/targets.js` | No Manifest V3 manifest, service worker, content bridge, popup or build artifact | P0 | Implement a pure JavaScript MV3 reference surface with minimal permissions |
|
|
23
23
|
| Extension messaging | Not implemented | No versioned envelope, correlation id, timeout, sender metadata or error response contract | P0 | Add transport-neutral message contracts and Chrome runtime adapter |
|
|
24
|
-
| Service worker resilience |
|
|
25
|
-
| Permissions |
|
|
24
|
+
| Service worker resilience | Implemented contract slice | Pending command envelopes, attempt metadata and snapshot summaries persist through injected storage; resume remains explicit and user-owned | P0 | Rehydrate metadata on startup and never replay a command without an explicit caller action |
|
|
25
|
+
| Permissions | Contract slice | `permissionpolicy` keeps the base permissions minimal and optional escalation caller-owned | P0 | Start with `storage` and no broad host permissions; make host access caller-configured |
|
|
26
26
|
| Content isolation | Not implemented | No isolated-world DOM bridge or page-to-extension boundary | P0 | Add a narrow content script that reports page facts through the message contract |
|
|
27
27
|
| Task agent | Partial | Jobs, workflows and bot commands exist, but no browser task planner or tool registry | P1 | Reuse workflow, trigger and bot contracts; add browser task commands only after snapshots |
|
|
28
28
|
| MCP | Implemented scrape, crawl, batch, extract and serialize tools with JSON-RPC handling | No browser snapshot or browser action MCP tools | P1 | Add browser tools as an optional adapter over the same snapshot/action contracts |
|
|
29
29
|
| API security | Implemented URL protocol and private hostname/IP checks | Request envelopes, optional authorization, security headers, redirect bounds and injected DNS resolution checks are now available | P0 | Keep credentials caller-owned and reject private or rebinding targets before transport |
|
|
30
30
|
| Apps and bots | Implemented platform adapter, commands, bot and webhook signature contracts | App install/suspend/revoke, command scope checks, idempotency and delivery retry/dead-letter records are now available | P1 | Keep platform tokens and OAuth lifecycle caller-owned |
|
|
31
31
|
| Storage | Implemented local, chunked, S3-compatible, GitHub Contents and file hosting adapters | Range reads, content dedupe, tiered cache, capabilities and conflict-aware sync were missing | P1 | Use the new neutral storage helpers and keep extension storage injected |
|
|
32
|
-
| Queue | Implemented in-memory and
|
|
32
|
+
| Queue | Implemented in-memory, persistent and extension-resumable command contracts | Browser host still owns long-lived queue workers and cancellation policy | P1 | Keep command records serializable and resume explicit through the extension adapter |
|
|
33
33
|
| Remote execution | Implemented provider, scheduler, health, triggers, heartbeat and resumable run contracts | No permissioned extension-to-runner bridge or forge-specific status adapters for every provider | P1 | Require explicit caller-provided endpoint and auth; no default remote host |
|
|
34
|
-
| Scraping | Implemented robots, cache, HTML extraction, schema extraction and
|
|
34
|
+
| Scraping | Implemented robots, cache, HTML extraction, schema extraction, scraper and content normalization | Rich document parsers and binary format adapters remain caller-owned | P1 | Keep extraction safe and bounded; add adapters for richer document types |
|
|
35
35
|
| Crawl | Implemented normalized BFS and persistent frontier | Priority and per-domain budget frontier now exist; sitemap refresh remains absent | P1 | Keep frontier state serializable and caller-persistable |
|
|
36
36
|
| RAG context | Implemented chunk hashes and vector record metadata | Retrieval provenance and merge records now exist; embeddings and indexes remain injected | P1 | Preserve source, document, chunk and score lineage |
|
|
37
37
|
| Observability | Contract slice | Low-cardinality counters and durations are bound to the standard operational metric vocabulary; export and tracing remain caller-owned | P1 | Keep metrics vendor-neutral and bounded |
|
|
@@ -40,10 +40,10 @@ This matrix turns the supplied README and conclusions into implementation decisi
|
|
|
40
40
|
| Auth profiles | Session file and replay contracts exist | No extension profile or consent model | P1 | Defer cookie/profile export; support explicit user-owned session references only |
|
|
41
41
|
| CAPTCHA | Contract, guard and evidence exist | No automatic solver integration | deferred | Keep external/manual solver boundary; do not promise bypass in the extension |
|
|
42
42
|
| Stealth | Fingerprint contract exists | No automatic stealth patching | deferred | Keep opt-in fingerprint metadata; no hidden anti-detection behavior |
|
|
43
|
-
| Packaging | npm, GHCR, Maven, NuGet and
|
|
43
|
+
| Packaging | npm, GHCR, Maven, NuGet, RubyGems and extension zip workflows are live | Desktop, mobile, n8n and binary release artifacts remain caller-owned | P1 | Keep non-JavaScript artifacts in explicit adapters and release jobs |
|
|
44
44
|
| Mobile and desktop apps | Contract slice | Desktop/mobile manifests and caller-owned adapter contracts exist; no native project is bundled | P1 | Keep native projects caller-owned and add runtime conformance tests incrementally |
|
|
45
45
|
| n8n surface | Contract slice | Node metadata, trigger matching and declared action execution exist; no n8n host package is bundled | P1 | Keep node registration and credentials caller-owned |
|
|
46
|
-
| Cross-browser | Target profile declares browser and extension |
|
|
46
|
+
| Cross-browser | Target profile declares browser and extension | Firefox, Edge or Safari manifests remain unbundled; transport-neutral export graph is statically audited for Node-only imports | P2 | Keep WebExtension-compatible contracts and add browser adapters incrementally |
|
|
47
47
|
| Storage equals compute | Memory bridge and engine implement storage-to-working-set-to-artifact; sync and capability negotiation now exist | Remote storage is not physical VRAM and has latency | deferred | Document as a working-set model, never as literal remote VRAM |
|
|
48
48
|
| Site/database deployment | Persistence schemas and adapters exist | No hosted site or database is part of the package | deferred | Keep deploy targets caller-owned and outside the library core |
|
|
49
49
|
|
package/docs/libraryapi.md
CHANGED
|
@@ -6,6 +6,7 @@ The public API is designed around injected transports. Consumers can use the sam
|
|
|
6
6
|
|---|---|
|
|
7
7
|
| `saddleurl` | choose fetch or an injected browser agent |
|
|
8
8
|
| `scrapeurl` | fetch a URL and return extracted content |
|
|
9
|
+
| `detectcontenttype` / `normalizeresult` / `normalizeresponse` | classify and bound JSON, XML, Markdown, text, HTML and binary response content |
|
|
9
10
|
| `scrapehtml` | extract content from an HTML string |
|
|
10
11
|
| `extractcontent` | expose structured extraction directly |
|
|
11
12
|
| `serializeresult` | serialize as JSON, Markdown, text, XML, or Redis payload |
|
|
@@ -40,7 +41,7 @@ The public API is designed around injected transports. Consumers can use the sam
|
|
|
40
41
|
| `nodeserver` | expose a Web Request/Response handler through Node HTTP |
|
|
41
42
|
|
|
42
43
|
```js
|
|
43
|
-
import { scrapeurl, formatforagent } from "@
|
|
44
|
+
import { scrapeurl, formatforagent } from "@wenathlan/saddle";
|
|
44
45
|
|
|
45
46
|
const result = await scrapeurl("https://example.com", {
|
|
46
47
|
format: "markdown",
|
package/docs/modes.md
CHANGED
|
@@ -13,7 +13,7 @@ The mode resolver keeps execution open. It returns a profile and capability map
|
|
|
13
13
|
| pair | without, with |
|
|
14
14
|
|
|
15
15
|
```js
|
|
16
|
-
import { resolvemode } from "@
|
|
16
|
+
import { resolvemode } from "@wenathlan/saddle/modes";
|
|
17
17
|
|
|
18
18
|
const profile = resolvemode({
|
|
19
19
|
execution: "binary",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Platform pipeline audit
|
|
2
|
+
|
|
3
|
+
This document records the platform facts used by the 1.8.4 pipeline work. The workflows remain caller-configured: repository owners provide runner availability, deployment secrets and target repository names.
|
|
4
|
+
|
|
5
|
+
## verified platform facts
|
|
6
|
+
|
|
7
|
+
| Platform | Verified contract | Source |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| GitHub Pages | A custom workflow uses `actions/configure-pages@v5`, uploads a static artifact with `actions/upload-pages-artifact@v4`, then deploys with `actions/deploy-pages@v4`. The deployment job needs `pages: write` and `id-token: write`, depends on the build job and uses the `github-pages` environment. | [GitHub Pages custom workflows](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages) |
|
|
10
|
+
| Codeberg Pages | Forgejo Actions can deploy with `https://codeberg.org/git-pages/action@v2`, using `site`, `source` and the injected `${{ forge.token }}`. Publishing the repository subdomain should be restricted to the default branch. Codeberg is migrating its legacy Pages v2 flow; custom domains still have separate constraints. | [Codeberg Pages via Forgejo Actions](https://docs.codeberg.org/codeberg-pages/forgejo-actions/), [Codeberg Pages output](https://docs.codeberg.org/codeberg-pages/pushing-output/) |
|
|
11
|
+
| GitLab Pages | A job with `pages: true` publishes the default `public` directory; the job can also use a `pages` hash for a `path_prefix`. Static HTML, CSS and JavaScript are supported through GitLab CI/CD. | [GitLab Pages](https://docs.gitlab.com/user/project/pages/) |
|
|
12
|
+
| Woodpecker CI | A workflow is a serial list of container steps with `image` and `commands`; branch/event filters belong in `when`, and secrets are injected through the pipeline environment rather than committed YAML. | [Woodpecker workflow syntax](https://woodpecker-ci.org/docs/usage/workflow-syntax) |
|
|
13
|
+
| Forgejo Actions | Fully qualified actions are recommended; the official checkout reference is `https://data.forgejo.org/actions/checkout@v6`. Short `actions/checkout@v6` resolves through the instance `DEFAULT_ACTIONS_URL`, which can vary. | [Forgejo Actions: Using Actions](https://forgejo.org/docs/v15.0/user/actions/actions/) |
|
|
14
|
+
| Gitea Actions | Gitea Actions is mostly compatible with GitHub Actions and requires a separately installed Gitea Runner. The official documentation lists `actions/checkout@v4` as a supported action reference and warns that runner trust matters for public instances. | [Gitea Actions overview](https://docs.gitea.com/usage/actions/overview) |
|
|
15
|
+
|
|
16
|
+
## boundary
|
|
17
|
+
|
|
18
|
+
GitHub Pages has a first-party artifact/deploy contract. Codeberg Pages has a git-pages Forgejo Action contract. Forgejo, Gitea and GitLab workflow files can build the same static output, but their hosting destination and token names are instance-specific. Woodpecker can build and publish artifacts, but a Pages deploy step requires the target host's configured service or a caller-owned token. No workflow in this release hardcodes credentials, hostnames or a private deployment target.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Cross-forge pipelines
|
|
2
|
+
|
|
3
|
+
The library keeps GitHub Actions as the release-authoritative workflow because its six registry publishers already share the release-tag action and validation action. Forgejo, Gitea, GitLab and Woodpecker now execute the same deterministic gates with Node.js 26.7.0, but they do not assume a shared package registry, release API or secret name.
|
|
4
|
+
|
|
5
|
+
| Forge | File | Current role | Required caller configuration |
|
|
6
|
+
| --- | --- | --- | --- |
|
|
7
|
+
| GitLab | `.gitlab-ci.yml` | verify and package dry-run | runner, optional package registry credentials and artifact retention |
|
|
8
|
+
| Forgejo | `.forgejo/workflows/saddle.yml` | verify and package dry-run | trusted Forgejo runner and action source policy |
|
|
9
|
+
| Gitea | `.gitea/workflows/saddle.yml` | verify and package dry-run | Gitea Runner and instance action policy |
|
|
10
|
+
| Codeberg | Forgejo workflow files | verify plus optional Codeberg Pages action | Codeberg repository, `forge.token` and default-branch policy |
|
|
11
|
+
| Woodpecker | `.woodpecker/deploy.yml` | verify and package dry-run | trusted agent, image pull policy and optional artifact plugin |
|
|
12
|
+
|
|
13
|
+
These files intentionally do not invent a cross-forge release token or hardcode a host. A caller can add a publish step for the target forge after configuring its own secret and package endpoint. The public site has its own platform workflow set in the `saddle-pages` repository.
|
package/docs/productindex.md
CHANGED
|
@@ -14,7 +14,7 @@ Saddle is the contract layer for a family of caller-owned surfaces. The library
|
|
|
14
14
|
| cross runtime | `runtimecontract`, `memorystorage` and root ESM import | runtime-specific APIs and package loader behavior |
|
|
15
15
|
| browser worker | `workerbridge` and root-safe contracts | worker lifecycle, message transport and extension permissions |
|
|
16
16
|
| browser | browser agent and snapshot contracts | browser vendor adapter, profile and session ownership |
|
|
17
|
-
| extension | Manifest V3 reference files
|
|
17
|
+
| extension | Manifest V3 reference files, serializable protocol and `permissionpolicy` | browser permission grant, signing and store submission |
|
|
18
18
|
| web control | API, service, `controlsurface` and `controlservice` contracts | operator UI, authentication, database and hosting |
|
|
19
19
|
|
|
20
20
|
## operating boundary
|
package/docs/registryresearch.md
CHANGED
|
@@ -20,13 +20,13 @@ The RubyGems workflow must pass the host without a trailing slash, matching the
|
|
|
20
20
|
|
|
21
21
|
## workflow decisions
|
|
22
22
|
|
|
23
|
-
The repository now uses one release-triggered workflow per destination. `publishgithubnpm.yml`
|
|
23
|
+
The repository now uses one release-triggered workflow per destination. After the repository transfer, `publishgithubnpm.yml` derives the GitHub Packages npm scope from `github.repository_owner`, so the v1.8.2 run targets `@wenathlan/saddle` with `GITHUB_TOKEN`; the public npm workflow publishes the same canonical package with `NPM_TOKEN`. `publishghcr.yml` derives `ghcr.io/wenathlan/saddle`; `publishmaven.yml` publishes `io.wenathlan:saddle`; NuGet and RubyGems retain their ecosystem-compatible unscoped package identities. Every GitHub Packages job grants only `contents: read` and `packages: write`.
|
|
24
24
|
|
|
25
25
|
`publishnpmjs.yml` is intentionally separate from GitHub Packages. It uses Node 26.7.0, disables package-manager caching for the release job, and runs `npm publish --access public` against `https://registry.npmjs.org` with `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}`. The secret name is public by design, but its value must remain owner-managed and absent from logs and source files.
|
|
26
26
|
|
|
27
27
|
The public npm package must be created under the owner account before the first successful publication. The current path uses the owner-managed `NPM_TOKEN` secret for that bootstrap and subsequent releases. The exposed credential from the prior conversation must never be used.
|
|
28
28
|
|
|
29
|
-
The GitHub Packages npm workflow
|
|
29
|
+
The GitHub Packages npm workflow uses the repository owner scope at runtime. The repository is now owned by `wenathlan`, while the authenticated `iakadion` account retains administrator permission, so the committed package metadata and the GitHub Packages workspace both resolve to `@wenathlan/saddle`. Public npm and GitHub Packages therefore share the canonical JavaScript identity for v1.8.2.
|
|
30
30
|
|
|
31
31
|
The GHCR package is linked automatically by publishing from this repository and includes the OCI source label in `dockerfile.saddle`. GitHub creates a first container package as private by default, so the owner must change its package visibility to public if public pulls are required. The same visibility review applies to the Maven, NuGet, RubyGems, and GitHub npm packages after their first publication.
|
|
32
32
|
|
|
@@ -44,7 +44,11 @@ The cross-runtime workflow `31552266171` and its manual rerun `31552272176` comp
|
|
|
44
44
|
|
|
45
45
|
Release `v1.8.0` was created from commit `9ddfd6c`. GitHub npm run `31556461901`, GHCR run `31556461887`, NuGet run `31556461883` and RubyGems run `31556461991` completed successfully for `1.8.0`. Maven run `31556461885` initially failed because setup-java rejects `latest`; after changing the workflow to JDK 26, manual run `31556549154` completed successfully. Public npmjs run `31556461909` produced the `@devthink/saddle@1.8.0` tarball but the registry rejected the PUT with HTTP 404 `Scope not found`; no public npmjs publication is claimed.
|
|
46
46
|
|
|
47
|
-
The active package identity on main is now `@wenathlan/saddle`. The
|
|
47
|
+
The active package identity on main is now `@wenathlan/saddle`. The repository has since transferred to `wenathlan`; the authenticated `iakadion` account retains administrator permission. Release `v1.8.2` is the first release prepared against the transferred repository owner for GitHub Packages npm, Maven and GHCR.
|
|
48
|
+
|
|
49
|
+
Release `v1.8.1` was created from the identity migration commit `efcbb02`. The npmjs workflow `31557618590` authenticated as `wenathlan`, generated `@wenathlan/saddle@1.8.1`, and ended with npm's `+ @wenathlan/saddle@1.8.1` success line. A later independent `npm view` returned `1.8.1`, and the direct registry metadata endpoint returned HTTP 200 with the same version record, confirming public visibility. The other v1.8.1 registry workflows also completed successfully: GitHub npm `31557618600`, GHCR `31557618571`, Maven `31557618597`, NuGet `31557618573` and RubyGems `31557618575`.
|
|
50
|
+
|
|
51
|
+
Release `v1.8.2` was created from commit `ac7c355` after the repository transfer to `wenathlan`. All release jobs succeeded: GitHub npm `31559770092` logged the owner-derived `@wenathlan/saddle` package, npmjs `31559770096` published the same public package, GHCR `31559770016` pushed `ghcr.io/wenathlan/saddle:1.8.2` and `latest`, Maven `31559770216` deployed `io.wenathlan:saddle:1.8.2`, NuGet `31559770142` created and pushed `Saddle.1.8.2.nupkg`, RubyGems `31559770023` registered `saddle (1.8.2)`, and extension build `31559769740` attached `saddle-extension-1.8.2.zip`. Release validation `31559770059` also succeeded. Independent checks confirmed npmjs `@wenathlan/saddle@1.8.2` and the GitHub release asset. The GitHub organization package-list API returned 403 for the available integration, while unauthenticated GHCR, Maven and NuGet endpoints returned 401; these protected visibility boundaries do not contradict the successful authenticated workflow logs.
|
|
48
52
|
|
|
49
53
|
## sources
|
|
50
54
|
|
package/docs/release.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# saddle release 1.8.
|
|
1
|
+
# saddle release 1.8.4
|
|
2
2
|
|
|
3
3
|
## release path
|
|
4
4
|
|
|
@@ -6,12 +6,13 @@ The release path is intentionally split into source validation, package validati
|
|
|
6
6
|
|
|
7
7
|
| step | owner | condition |
|
|
8
8
|
|---|---|---|
|
|
9
|
-
| package version | repository | `package.json` is `1.8.
|
|
9
|
+
| package version | repository | `package.json` is `1.8.4` |
|
|
10
10
|
| quality gate | GitHub Actions | `npm run pack:check` passes |
|
|
11
|
-
| tag | repository owner | tag `v1.8.
|
|
12
|
-
| GitHub release | repository owner | release `v1.8.
|
|
11
|
+
| tag | repository owner | tag `v1.8.4` points to the validated release commit |
|
|
12
|
+
| GitHub release | repository owner | release `v1.8.4` is created from the validated tag |
|
|
13
13
|
| GitHub Packages | GitHub Actions | `publishgithubnpm.yml`, `publishghcr.yml`, `publishmaven.yml`, `publishnuget.yml`, and `publishrubygems.yml` use `GITHUB_TOKEN` |
|
|
14
14
|
| public npmjs | owner-managed GitHub Actions secret | `publishnpmjs.yml` uses `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` and derives the version from the release tag or latest release in manual maintenance runs |
|
|
15
|
+
| extension zip | GitHub Actions | `buildextension.yml` derives the version from the release tag, validates the unpacked artifact and attaches `saddle-extension-<version>.zip` |
|
|
15
16
|
|
|
16
17
|
## credential rule
|
|
17
18
|
|
|
@@ -21,8 +22,8 @@ The npm token previously sent in chat is compromised and must not be used. GitHu
|
|
|
21
22
|
|
|
22
23
|
```text
|
|
23
24
|
npm run pack:check
|
|
24
|
-
git tag v1.8.
|
|
25
|
-
git push origin v1.8.
|
|
25
|
+
git tag v1.8.2
|
|
26
|
+
git push origin v1.8.2
|
|
26
27
|
```
|
|
27
28
|
|
|
28
|
-
The release-created event is the publication trigger for the
|
|
29
|
+
The release-created event is the publication and extension-asset trigger for the release workflows. A dry-run verifies package shape and local tests, but cannot verify registry ownership, Trusted Publisher configuration, package scope authorization, package visibility or browser-store submission; those remain settings controlled by the owner.
|
package/docs/release181notes.md
CHANGED
|
@@ -12,4 +12,4 @@ The candidate must pass `npm run check`, `npm run formatcheck`, `npm test`, `npm
|
|
|
12
12
|
|
|
13
13
|
## publication boundary
|
|
14
14
|
|
|
15
|
-
The release workflows remain separate at the registry job level because each destination requires a different protocol and credential. Shared checkout, Node setup, version resolution and package validation use local actions; incompatible publish commands remain isolated by registry.
|
|
15
|
+
The release workflows remain separate at the registry job level because each destination requires a different protocol and credential. Shared checkout, Node setup, version resolution and package validation use local actions; incompatible publish commands remain isolated by registry. The npmjs job authenticated as `wenathlan` and reported `+ @wenathlan/saddle@1.8.1`; a later independent `npm view` and direct registry metadata lookup both returned `1.8.1`, confirming public visibility.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Saddle 1.8.2
|
|
2
|
+
|
|
3
|
+
Saddle 1.8.2 is the first release prepared after the repository transfer to `wenathlan`. The authenticated `iakadion` account still has administrator permission on the repository, while `wenathlan` is the repository owner and the canonical public npm owner.
|
|
4
|
+
|
|
5
|
+
## canonical identities
|
|
6
|
+
|
|
7
|
+
The JavaScript package is `@wenathlan/saddle` on public npm and GitHub Packages npm. GHCR resolves to `ghcr.io/wenathlan/saddle`, and Maven uses the owner-aligned coordinate `io.wenathlan:saddle`. NuGet keeps the unscoped package id `Saddle`, and RubyGems keeps the unscoped gem name `saddle`, because those registries do not use npm-style owner scopes; their authorship and repository metadata now point to `wenathlan`.
|
|
8
|
+
|
|
9
|
+
| Destination | 1.8.2 identity | Owner resolution |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| public npm | `@wenathlan/saddle@1.8.2` | owner-managed `NPM_TOKEN` |
|
|
12
|
+
| GitHub Packages npm | `@wenathlan/saddle@1.8.2` | `${{ github.repository_owner }}` and `GITHUB_TOKEN` |
|
|
13
|
+
| GHCR | `ghcr.io/wenathlan/saddle:1.8.2` | `${{ github.repository_owner }}` and `GITHUB_TOKEN` |
|
|
14
|
+
| Maven | `io.wenathlan:saddle:1.8.2` | transferred GitHub Packages owner path |
|
|
15
|
+
| NuGet | `Saddle 1.8.2` | unscoped NuGet package id with `wenathlan` repository metadata |
|
|
16
|
+
| RubyGems | `saddle 1.8.2` | unscoped gem name with `wenathlan` metadata |
|
|
17
|
+
| extension | `saddle-extension-1.8.2.zip` | release-tag-derived asset |
|
|
18
|
+
|
|
19
|
+
## included engine changes
|
|
20
|
+
|
|
21
|
+
This release includes the minimal extension permission policy, deterministic extension packaging, context-aware window/tab/frame replay, the transport-neutral export graph audit, and bounded content-type normalization for JSON, XML, Markdown, text, HTML and binary results. The package remains root-based JavaScript ESM and keeps Node-only adapters in explicit subpaths.
|
|
22
|
+
|
|
23
|
+
## validation boundary
|
|
24
|
+
|
|
25
|
+
The release must pass `npm run check`, `npm run formatcheck`, `npm test`, `npm run pack:check` and `git diff --check` before the tag is created. Each registry workflow remains isolated because its package protocol and credential boundary differ. Publication evidence is reported only after the workflow succeeds and the target registry can be queried independently.
|
|
26
|
+
|
|
27
|
+
The `v1.8.2` release and all publication workflows completed successfully. Public npm and the extension asset were independently visible. GitHub Package registries returned their expected protected-access responses to unauthenticated probes, so their workflow logs remain the authoritative evidence available to the authenticated publisher context.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Saddle 1.8.4
|
|
2
|
+
|
|
3
|
+
Saddle 1.8.4 updates every active library and forge pipeline to Node.js 26.7.0 and adds deterministic package gates to GitLab, Forgejo, Gitea and Woodpecker workflows. GitHub Actions remains the authoritative multi-registry release path; the other forge files are caller-configured validation and artifact paths because their runners, secrets and package registries differ.
|
|
4
|
+
|
|
5
|
+
The legacy public Maven package `io.devthink.saddle` was identified under the `wenathlan` organization. Its deletion request was attempted through the GitHub Packages API, but the authenticated GitHub App integration returned HTTP 403 because it lacks package-management deletion permission. The new owner-aligned Maven coordinate is `io.wenathlan:saddle` and must not be removed.
|
|
6
|
+
|
|
7
|
+
The companion Saddle Pages repository now contains a GitHub Pages workflow using `actions/configure-pages@v5`, `actions/upload-pages-artifact@v4` and `actions/deploy-pages@v4`, plus caller-configured GitLab Pages, Forgejo, Codeberg Pages, Gitea and Woodpecker pipelines. The site builds only `dist/public` with `VITE_BASE_PATH=/saddle-pages/` and uses Node.js 26.7.0.
|
package/docs/usage.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## install
|
|
4
4
|
|
|
5
|
-
The canonical
|
|
5
|
+
The canonical package is `@wenathlan/saddle` on both public npm and GitHub Packages npm after the repository transfer. Consumers of GitHub Packages must configure the `wenathlan` registry scope and authenticate with a token authorized for that package.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install @wenathlan/saddle
|
package/domain/sessions.js
CHANGED
|
@@ -30,5 +30,17 @@ function validateevent(value, index) {
|
|
|
30
30
|
if (value.key !== undefined && typeof value.key !== "string") throw validationerror(`session event ${index} key is invalid`);
|
|
31
31
|
if (value.target !== undefined && typeof value.target !== "string") throw validationerror(`session event ${index} target is invalid`);
|
|
32
32
|
if (value.button !== undefined && !["left", "right"].includes(value.button)) throw validationerror(`session event ${index} button is invalid`);
|
|
33
|
-
|
|
33
|
+
const context = validatecontext(value.context ?? value, index);
|
|
34
|
+
return context ? { ...value, context } : { ...value };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validatecontext(value, index) {
|
|
38
|
+
if (value.context !== undefined && (value.context === null || typeof value.context !== "object" || Array.isArray(value.context))) throw validationerror(`session event ${index} context is invalid`);
|
|
39
|
+
const source = value.context ?? value;
|
|
40
|
+
const context = {};
|
|
41
|
+
for (const name of ["windowid", "tabid", "frameid"]) if (source[name] !== undefined) {
|
|
42
|
+
if (typeof source[name] !== "string" || !source[name]) throw validationerror(`session event ${index} ${name} is invalid`);
|
|
43
|
+
context[name] = source[name];
|
|
44
|
+
}
|
|
45
|
+
return Object.keys(context).length ? context : undefined;
|
|
34
46
|
}
|
package/extension/README.md
CHANGED
|
@@ -14,10 +14,14 @@ The manifest requests only `activeTab`, `scripting` and `storage`. It does not r
|
|
|
14
14
|
|
|
15
15
|
## boundaries
|
|
16
16
|
|
|
17
|
-
The content bridge runs in Chrome's isolated world. It exposes bounded page metadata, visible text, stable references and user initiated click or fill commands. The service worker forwards versioned messages and stores
|
|
17
|
+
The content bridge runs in Chrome's isolated world. It exposes bounded page metadata, visible text, stable references and user initiated click or fill commands. The service worker forwards versioned messages, persists bounded pending command records and stores the latest snapshot metadata in session storage. Rehydration is explicit; startup never replays a command without a caller action. No endpoint, credential, remote script or browser profile is embedded.
|
|
18
18
|
|
|
19
19
|
`protocol.js` and `serviceworker.js` are reusable ESM contracts. `content.js` is intentionally a classic injected file because programmatic Chrome content scripts are loaded as files; it exposes a small global bridge and avoids arbitrary page JavaScript evaluation.
|
|
20
20
|
|
|
21
|
+
## deterministic release artifact
|
|
22
|
+
|
|
23
|
+
The Node-only build adapter creates an isolated unpacked artifact with the release version in its manifest. A caller can run `npm run extension:build -- --version 1.8.2 --output build/extension` and package that directory with the archive tool available in the host environment. The release workflow derives the version from the published tag and attaches `saddle-extension-<version>.zip` without changing the source manifest.
|
|
24
|
+
|
|
21
25
|
## next slices
|
|
22
26
|
|
|
23
|
-
The next extension slices should add snapshot diffing, tab and frame
|
|
27
|
+
The next extension slices should add snapshot diffing, richer tab and frame metadata, optional host permission escalation and browser action results. Browser providers, login profiles, captcha solvers and remote runners remain caller owned adapters.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extension build adapter creates a versioned, unpacked Manifest V3 artifact for release packaging.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const rootpath = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const entries = ["manifest.json", "worker.js", "serviceworker.js", "content.js", "popup.js", "popup.html", "popup.css", "protocol.js", "permissions.js"];
|
|
11
|
+
|
|
12
|
+
function parsearguments(argumentslist) {
|
|
13
|
+
const options = {};
|
|
14
|
+
for (let index = 0; index < argumentslist.length; index += 1) {
|
|
15
|
+
const argument = argumentslist[index];
|
|
16
|
+
if (argument === "--version") options.version = argumentslist[++index];
|
|
17
|
+
else if (argument === "--output") options.output = argumentslist[++index];
|
|
18
|
+
else throw new TypeError(`unsupported extension build argument: ${argument}`);
|
|
19
|
+
}
|
|
20
|
+
return options;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validversion(version) {
|
|
24
|
+
return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version ?? ""));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Builds the extension into an isolated directory and returns its manifest. */
|
|
28
|
+
export async function buildextension(options = {}) {
|
|
29
|
+
const packagefile = JSON.parse(await readFile(resolve(rootpath, "package.json"), "utf8"));
|
|
30
|
+
const version = String(options.version ?? packagefile.version);
|
|
31
|
+
if (!validversion(version)) throw new TypeError(`invalid extension version: ${version}`);
|
|
32
|
+
const output = resolve(process.cwd(), options.output ?? "build/extension");
|
|
33
|
+
await rm(output, { force: true, recursive: true });
|
|
34
|
+
await mkdir(output, { recursive: true });
|
|
35
|
+
const manifest = JSON.parse(await readFile(resolve(rootpath, "extension/manifest.json"), "utf8"));
|
|
36
|
+
manifest.version = version;
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const source = resolve(rootpath, "extension", entry);
|
|
39
|
+
const destination = resolve(output, entry);
|
|
40
|
+
if (entry === "manifest.json") await writeFile(destination, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
41
|
+
else await cp(source, destination);
|
|
42
|
+
}
|
|
43
|
+
return { output, manifest };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) buildextension(parsearguments(process.argv.slice(2))).then(({ output }) => { console.log(`extension artifact: ${output}`); }).catch((error) => { console.error(error.message); process.exitCode = 1; });
|
package/extension/index.js
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extension permission policies keep the browser capability boundary explicit and caller-owned.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const extensionpermissions = Object.freeze(["activeTab", "scripting", "storage"]);
|
|
6
|
+
|
|
7
|
+
/** Creates a minimal permission policy without requesting broad host access. */
|
|
8
|
+
export function permissionpolicy(options = {}) {
|
|
9
|
+
const requested = [...new Set((options.requested ?? extensionpermissions).map(String))];
|
|
10
|
+
const optional = [...new Set((options.optional ?? []).map(String))];
|
|
11
|
+
const unknown = requested.concat(optional).filter((permission) => !extensionpermissions.includes(permission));
|
|
12
|
+
if (unknown.length) throw new TypeError(`unsupported extension permission: ${unknown[0]}`);
|
|
13
|
+
return { version: 1, requested, optional, hostpermissions: [], allows(permission) { return requested.includes(String(permission)); }, missing(permissions = []) { return [...new Set(permissions.map(String))].filter((permission) => !requested.includes(permission)); } };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Requests an optional capability through an injected browser permission function. */
|
|
17
|
+
export async function requestpermission(policy, permission, request) {
|
|
18
|
+
const name = String(permission ?? "");
|
|
19
|
+
if (!policy?.optional?.includes(name)) throw new TypeError(`permission is not optional: ${name}`);
|
|
20
|
+
if (typeof request !== "function") throw new TypeError("permission request function is required");
|
|
21
|
+
try { return { permission: name, granted: Boolean(await request(name)) }; } catch (error) { return { permission: name, granted: false, code: String(error?.code ?? "PERMISSION_REQUEST_FAILED"), message: String(error?.message ?? error) }; }
|
|
22
|
+
}
|
|
@@ -11,7 +11,9 @@ export function createworkerrouter(options = {}) {
|
|
|
11
11
|
const storage = options.storage;
|
|
12
12
|
const contentfile = options.contentfile ?? "content.js";
|
|
13
13
|
const statekey = options.statekey ?? "saddleextensionstate";
|
|
14
|
+
const maxpending = options.maxpending ?? 32;
|
|
14
15
|
if (typeof tabs?.sendMessage !== "function") throw new TypeError("extension router requires tabs.sendMessage");
|
|
16
|
+
if (!Number.isSafeInteger(maxpending) || maxpending < 1) throw new TypeError("extension router maxpending must be a positive safe integer");
|
|
15
17
|
|
|
16
18
|
async function ensurecontent(tabid) {
|
|
17
19
|
if (!Number.isInteger(tabid)) throw new TypeError("extension command requires a tab id");
|
|
@@ -29,15 +31,72 @@ export function createworkerrouter(options = {}) {
|
|
|
29
31
|
if (typeof storage?.set === "function") await storage.set({ [statekey]: value });
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
async function pendingstate() {
|
|
35
|
+
const value = await readstate();
|
|
36
|
+
return { ...value, pending: Array.isArray(value.pending) ? value.pending.filter(validpending) : [] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function enqueue(request, sender) {
|
|
40
|
+
const state = await pendingstate();
|
|
41
|
+
const record = pendingrecord(request, sender);
|
|
42
|
+
const pending = state.pending.filter((item) => item.requestid !== record.requestid);
|
|
43
|
+
if (pending.length >= maxpending && !state.pending.some((item) => item.requestid === record.requestid)) throw extensionerror("PENDING_LIMIT", "extension pending command limit reached");
|
|
44
|
+
pending.push(record);
|
|
45
|
+
await savestate({ ...state, pending });
|
|
46
|
+
return record;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function complete(requestid, patch = {}) {
|
|
50
|
+
const state = await pendingstate();
|
|
51
|
+
await savestate({ ...state, ...patch, pending: state.pending.filter((item) => item.requestid !== requestid) });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function markfailure(requestid, error) {
|
|
55
|
+
const state = await pendingstate();
|
|
56
|
+
const pending = state.pending.map((item) => item.requestid === requestid ? { ...item, attempts: item.attempts + 1, lasterror: { code: String(error?.code ?? "extension_error"), message: String(error?.message ?? error) }, updatedat: Date.now() } : item);
|
|
57
|
+
await savestate({ ...state, pending });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function dispatch(request, sender = {}) {
|
|
61
|
+
const tabid = request.payload?.tabid ?? sender.tab?.id;
|
|
62
|
+
await ensurecontent(tabid);
|
|
63
|
+
return tabs.sendMessage(tabid, request);
|
|
64
|
+
}
|
|
65
|
+
|
|
32
66
|
async function handle(message, sender = {}) {
|
|
33
67
|
const request = assertmessage(message);
|
|
34
68
|
if (request.type !== "command") throw new TypeError("extension router accepts commands only");
|
|
35
69
|
const tabid = request.payload?.tabid ?? sender.tab?.id;
|
|
36
|
-
await
|
|
37
|
-
|
|
38
|
-
|
|
70
|
+
await enqueue(request, { ...sender, tab: { ...sender.tab, id: tabid } });
|
|
71
|
+
try {
|
|
72
|
+
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
|
+
return response;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
await markfailure(request.id, error);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function rehydrate() { return pendingstate(); }
|
|
82
|
+
|
|
83
|
+
async function resume(requestid, sender = {}) {
|
|
84
|
+
const state = await pendingstate();
|
|
85
|
+
const pending = state.pending.find((item) => item.requestid === requestid);
|
|
86
|
+
if (!pending) throw extensionerror("PENDING_NOT_FOUND", `pending command not found: ${requestid}`);
|
|
87
|
+
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() });
|
|
39
90
|
return response;
|
|
40
91
|
}
|
|
41
92
|
|
|
42
|
-
|
|
93
|
+
async function cancel(requestid) { const state = await pendingstate(); await savestate({ ...state, pending: state.pending.filter((item) => item.requestid !== requestid) }); }
|
|
94
|
+
|
|
95
|
+
return { ensurecontent, readstate, savestate, rehydrate, enqueue, resume, cancel, handle };
|
|
43
96
|
}
|
|
97
|
+
|
|
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
|
+
|
|
100
|
+
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
|
+
|
|
102
|
+
function extensionerror(code, message) { const error = new Error(message); error.code = code; return error; }
|
package/extension/worker.js
CHANGED
|
@@ -13,8 +13,10 @@ export function startworker(chromeapi = globalThis.chrome) {
|
|
|
13
13
|
router.handle(message, sender).then(sendresponse).catch((error) => sendresponse(createerror(message, error)));
|
|
14
14
|
return true;
|
|
15
15
|
};
|
|
16
|
+
const startup = () => router.rehydrate().catch(() => undefined);
|
|
16
17
|
chromeapi.runtime.onMessage.addListener(listener);
|
|
17
|
-
|
|
18
|
+
chromeapi.runtime.onStartup?.addListener(startup);
|
|
19
|
+
return { router, dispose() { chromeapi.runtime.onMessage.removeListener?.(listener); chromeapi.runtime.onStartup?.removeListener?.(startup); } };
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
if (globalThis.chrome?.runtime?.onMessage) startworker();
|
package/index.js
CHANGED
|
@@ -77,6 +77,7 @@ export * from "./crawl/normalize.js";
|
|
|
77
77
|
export * from "./crawl/crawler.js";
|
|
78
78
|
export * from "./crawl/persistent.js";
|
|
79
79
|
export * from "./scrape/schema.js";
|
|
80
|
+
export * from "./scrape/normalize.js";
|
|
80
81
|
export * from "./mcp/server.js";
|
|
81
82
|
export * from "./runtime/detect.js";
|
|
82
83
|
export * from "./runtime/abort.js";
|
package/library/public.js
CHANGED
|
@@ -5,6 +5,7 @@ import { crawl } from "../crawl/crawler.js";
|
|
|
5
5
|
import { chunkmarkdown } from "../ai/chunk.js";
|
|
6
6
|
import { estimatetokens } from "../ai/tokens.js";
|
|
7
7
|
import { extracthtml } from "../scrape/extract.js";
|
|
8
|
+
import { normalizeresponse } from "../scrape/normalize.js";
|
|
8
9
|
import { browseragent } from "../browser/agent.js";
|
|
9
10
|
|
|
10
11
|
/** Selects the fetch or browser execution path. */
|
|
@@ -19,8 +20,9 @@ export async function scrapeurl(url, options = {}) {
|
|
|
19
20
|
const target = safeurl(url);
|
|
20
21
|
const response = await (options.fetcher ?? fetch)(target, { signal: options.signal, headers: options.headers });
|
|
21
22
|
if (!response.ok) throw new Error(`scrape request failed with ${response.status}`);
|
|
22
|
-
const
|
|
23
|
-
|
|
23
|
+
const normalized = await normalizeresponse(response, { url: target, defaultcontenttype: "text/html", maxbytes: options.maxbytes });
|
|
24
|
+
const result = normalized.kind === "html" ? scrapehtml(normalized.content, target, options) : { content: normalized.content, data: normalized.data, metadata: { url: target, contenttype: normalized.contenttype, size: normalized.size }, bytes: normalized.bytes };
|
|
25
|
+
return formatresult(result, options);
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
/** Extracts a serializable result from HTML without network access. */
|
package/license.md
CHANGED
|
@@ -198,6 +198,6 @@
|
|
|
198
198
|
|
|
199
199
|
You should have received a copy of the Proprietary Source-Available
|
|
200
200
|
License along with this program. If not, see
|
|
201
|
-
<https://github.com/
|
|
201
|
+
<https://github.com/wenathlan/saddle/blob/main/license.txt>
|
|
202
202
|
|
|
203
203
|
Also add information on how to contact you by electronic and paper mail.
|
package/license.txt
CHANGED
|
@@ -198,6 +198,6 @@
|
|
|
198
198
|
|
|
199
199
|
You should have received a copy of the Proprietary Source-Available
|
|
200
200
|
License along with this program. If not, see
|
|
201
|
-
<https://github.com/
|
|
201
|
+
<https://github.com/wenathlan/saddle/blob/main/license.txt>
|
|
202
202
|
|
|
203
203
|
Also add information on how to contact you by electronic and paper mail.
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenathlan/saddle",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.4",
|
|
4
4
|
"description": "binary computing engine that turns distributed storage into a publishable working set",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "GPL-3.0-only",
|
|
7
7
|
"publishConfig": { "access": "public" },
|
|
8
|
-
"repository": { "type": "git", "url": "https://github.com/
|
|
9
|
-
"bugs": { "url": "https://github.com/
|
|
10
|
-
"homepage": "https://github.com/
|
|
8
|
+
"repository": { "type": "git", "url": "https://github.com/wenathlan/saddle.git" },
|
|
9
|
+
"bugs": { "url": "https://github.com/wenathlan/saddle/issues" },
|
|
10
|
+
"homepage": "https://github.com/wenathlan/saddle#readme",
|
|
11
11
|
"keywords": ["distributed computing", "virtual memory", "runner", "storage", "automation"],
|
|
12
12
|
"engines": { "node": ">=22" },
|
|
13
13
|
"bin": { "saddle": "./cli/main.js" },
|
|
@@ -37,10 +37,11 @@
|
|
|
37
37
|
},
|
|
38
38
|
"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"],
|
|
39
39
|
"scripts": {
|
|
40
|
-
"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",
|
|
40
|
+
"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",
|
|
41
41
|
"formatcheck": "node format/check.js",
|
|
42
42
|
"test": "node --test tests/*.test.js",
|
|
43
43
|
"run": "node cli/main.js",
|
|
44
|
+
"extension:build": "node extension/build.js",
|
|
44
45
|
"pack:check": "npm run check && npm run formatcheck && npm test && npm pack --dry-run",
|
|
45
46
|
"prepublishOnly": "npm run pack:check"
|
|
46
47
|
}
|
package/packager/manifest.js
CHANGED
|
@@ -19,7 +19,7 @@ export function binaryplan(manifest, options = {}) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function containerplan(manifest, options = {}) {
|
|
22
|
-
const base = options.base ?? "node:
|
|
22
|
+
const base = options.base ?? "node:26.7.0-alpine";
|
|
23
23
|
const workdir = options.workdir ?? "/app";
|
|
24
24
|
const command = options.command ?? ["node", manifest.entry];
|
|
25
25
|
const lines = [`from ${base}`, `workdir ${workdir}`, "copy package.json package-lock.json ./", "run npm ci --omit=dev", "copy . .", `cmd ${JSON.stringify(command)}`];
|
package/readme.txt
CHANGED
|
@@ -50,27 +50,27 @@
|
|
|
50
50
|
<p align="center">
|
|
51
51
|
<strong>Storage-backed jobs, scraping contracts and portable runners for Node.js.</strong><br/>
|
|
52
52
|
<strong>Binary computing agent, agent browser, computer-use, scraper and packager.</strong><br/>
|
|
53
|
-
<a href="https://github.com/
|
|
54
|
-
<a href="https://github.com/
|
|
55
|
-
<a href="https://github.com/
|
|
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
56
|
</p>
|
|
57
57
|
|
|
58
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
59
|
|
|
60
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
61
|
|
|
62
|
-
Ships as a library, CLI, binary, n8n node, CRX extension, Android/iOS and Tauri desktop app.
|
|
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
63
|
|
|
64
64
|
## Start here
|
|
65
65
|
|
|
66
66
|
Saddle requires **Node.js 22 or newer**.
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
npm install @
|
|
69
|
+
npm install @wenathlan/saddle
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
```js
|
|
73
|
-
import { scrapeurl, formatforagent } from "@
|
|
73
|
+
import { scrapeurl, formatforagent } from "@wenathlan/saddle";
|
|
74
74
|
|
|
75
75
|
const result = await scrapeurl("https://example.com", { format: "markdown" });
|
|
76
76
|
const context = formatforagent(result, { maxchunksize: 2000, keypoints: 4 });
|
|
@@ -130,7 +130,7 @@ Saddle coordinates contracts instead of hiding providers. A repo + CI runner is
|
|
|
130
130
|
- repository_dispatch = IPC
|
|
131
131
|
|
|
132
132
|
```js
|
|
133
|
-
import { engine, eventbus, inprocess, localmemory, localstorage, scheduler } from "@
|
|
133
|
+
import { engine, eventbus, inprocess, localmemory, localstorage, scheduler } from "@wenathlan/saddle";
|
|
134
134
|
const events = eventbus();
|
|
135
135
|
const run = engine({
|
|
136
136
|
storage: localstorage("./.saddle-data"),
|
|
@@ -160,4 +160,4 @@ saddle deploy --target netlify
|
|
|
160
160
|
## Security boundaries
|
|
161
161
|
|
|
162
162
|
| Boundary | Policy |
|
|
163
|
-
| --- | ---
|
|
163
|
+
| --- | --- |
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* content normalization classifies bounded response bytes without owning a parser, transport or storage backend.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const extensions = Object.freeze({
|
|
6
|
+
".json": "application/json",
|
|
7
|
+
".map": "application/json",
|
|
8
|
+
".xml": "application/xml",
|
|
9
|
+
".rss": "application/rss+xml",
|
|
10
|
+
".atom": "application/atom+xml",
|
|
11
|
+
".md": "text/markdown",
|
|
12
|
+
".markdown": "text/markdown",
|
|
13
|
+
".html": "text/html",
|
|
14
|
+
".htm": "text/html",
|
|
15
|
+
".txt": "text/plain",
|
|
16
|
+
".csv": "text/csv"
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Detects a normalized media type from a header, URL suffix or caller fallback. */
|
|
20
|
+
export function detectcontenttype(header, url, fallback = "application/octet-stream") {
|
|
21
|
+
const value = String(header ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
22
|
+
if (value) return value;
|
|
23
|
+
try {
|
|
24
|
+
const pathname = new URL(url).pathname.toLowerCase();
|
|
25
|
+
const suffix = Object.keys(extensions).find((extension) => pathname.endsWith(extension));
|
|
26
|
+
if (suffix) return extensions[suffix];
|
|
27
|
+
} catch { /* invalid or absent URLs remain caller-owned metadata */ }
|
|
28
|
+
return String(fallback).toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Normalizes bounded input bytes into a serializable text, JSON or binary result. */
|
|
32
|
+
export function normalizeresult(input, options = {}) {
|
|
33
|
+
const contenttype = detectcontenttype(options.contenttype, options.url, options.defaultcontenttype ?? "application/octet-stream");
|
|
34
|
+
const bytes = tobytes(input);
|
|
35
|
+
const maxbytes = boundedlimit(options.maxbytes, 2_000_000);
|
|
36
|
+
if (bytes.byteLength > maxbytes) throw normalizationerror("CONTENT_TOO_LARGE", `content exceeds ${maxbytes} bytes`);
|
|
37
|
+
const kind = contentkind(contenttype);
|
|
38
|
+
if (kind === "binary") return { contenttype, kind, size: bytes.byteLength, bytes };
|
|
39
|
+
const text = new TextDecoder(options.charset ?? "utf-8", { fatal: false }).decode(bytes).replaceAll("\r\n", "\n");
|
|
40
|
+
if (text.length > boundedlimit(options.maxtext, maxbytes)) throw normalizationerror("TEXT_TOO_LARGE", "decoded content exceeds the configured text limit");
|
|
41
|
+
if (kind === "json") {
|
|
42
|
+
try { return { contenttype, kind, size: bytes.byteLength, content: text, data: JSON.parse(text) }; } catch (error) { throw normalizationerror("INVALID_JSON", `invalid JSON content: ${error.message}`); }
|
|
43
|
+
}
|
|
44
|
+
return { contenttype, kind, size: bytes.byteLength, content: text };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Reads a caller-provided response body after checking declared and actual size limits. */
|
|
48
|
+
export async function normalizeresponse(response, options = {}) {
|
|
49
|
+
if (!response || typeof response !== "object") throw new TypeError("response is required");
|
|
50
|
+
const headers = response.headers;
|
|
51
|
+
const header = typeof headers?.get === "function" ? headers.get("content-type") : headers?.["content-type"];
|
|
52
|
+
const declared = Number(typeof headers?.get === "function" ? headers.get("content-length") : headers?.["content-length"]);
|
|
53
|
+
const maxbytes = boundedlimit(options.maxbytes, 2_000_000);
|
|
54
|
+
if (Number.isFinite(declared) && declared > maxbytes) throw normalizationerror("CONTENT_TOO_LARGE", `content exceeds ${maxbytes} bytes`);
|
|
55
|
+
const body = typeof response.arrayBuffer === "function" ? await response.arrayBuffer() : await response.text();
|
|
56
|
+
return normalizeresult(body, { ...options, contenttype: header ?? options.contenttype, url: options.url ?? response.url });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function contentkind(contenttype) {
|
|
60
|
+
if (contenttype === "application/json" || contenttype.endsWith("+json")) return "json";
|
|
61
|
+
if (contenttype === "text/html" || contenttype === "application/xhtml+xml") return "html";
|
|
62
|
+
if (contenttype === "application/xml" || contenttype === "text/xml" || contenttype.endsWith("+xml")) return "xml";
|
|
63
|
+
if (contenttype === "text/markdown") return "markdown";
|
|
64
|
+
if (contenttype.startsWith("text/")) return "text";
|
|
65
|
+
return "binary";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function tobytes(input) {
|
|
69
|
+
if (typeof input === "string") return new TextEncoder().encode(input);
|
|
70
|
+
if (input instanceof Uint8Array) return input;
|
|
71
|
+
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
|
72
|
+
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
73
|
+
throw new TypeError("content must be a string, ArrayBuffer or typed array");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function boundedlimit(value, fallback) {
|
|
77
|
+
const limit = Number(value ?? fallback);
|
|
78
|
+
if (!Number.isSafeInteger(limit) || limit < 1) throw new TypeError("content limit must be a positive safe integer");
|
|
79
|
+
return limit;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizationerror(code, message) {
|
|
83
|
+
const error = new Error(message);
|
|
84
|
+
error.code = code;
|
|
85
|
+
return error;
|
|
86
|
+
}
|
package/sessions/replay.js
CHANGED
|
@@ -5,17 +5,57 @@ export async function replay(session, adapter, options = {}) {
|
|
|
5
5
|
if (!session?.events || typeof adapter?.move !== "function") throw new TypeError("replay requires session events and browser adapter");
|
|
6
6
|
const speed = options.speed ?? 1;
|
|
7
7
|
let previous = 0;
|
|
8
|
+
let currentcontext = normalizecontext(options.initialcontext);
|
|
9
|
+
let contextswitches = 0;
|
|
8
10
|
for (const event of session.events) {
|
|
9
11
|
const wait = Math.max(0, (event.t - previous) / speed);
|
|
10
12
|
if (wait) await delay(wait);
|
|
11
13
|
previous = event.t;
|
|
14
|
+
const eventcontext = normalizecontext(event.context ?? event);
|
|
15
|
+
if (eventcontext && contextchanged(currentcontext, eventcontext)) {
|
|
16
|
+
await restorecontext(adapter, eventcontext, currentcontext);
|
|
17
|
+
currentcontext = eventcontext;
|
|
18
|
+
contextswitches += 1;
|
|
19
|
+
}
|
|
12
20
|
if (event.type === "move") await adapter.move(event);
|
|
13
21
|
else if (event.type === "click") await adapter.click(event);
|
|
14
22
|
else if (event.type === "drag") await adapter.drag(event);
|
|
15
23
|
else if (event.type === "scroll") await adapter.scroll(event);
|
|
16
24
|
else if (event.type === "key") await adapter.key(event);
|
|
17
25
|
}
|
|
18
|
-
return { events: session.events.length, duration: previous / speed };
|
|
26
|
+
return { events: session.events.length, duration: previous / speed, contextswitches };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizecontext(value) {
|
|
30
|
+
if (value === undefined || value === null) return undefined;
|
|
31
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new TypeError("replay context must be an object");
|
|
32
|
+
const context = {};
|
|
33
|
+
for (const name of ["windowid", "tabid", "frameid"]) if (value[name] !== undefined) {
|
|
34
|
+
if (typeof value[name] !== "string" || !value[name]) throw new TypeError(`replay ${name} must be a non-empty string`);
|
|
35
|
+
context[name] = value[name];
|
|
36
|
+
}
|
|
37
|
+
return Object.keys(context).length ? context : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function contextchanged(previous, next) {
|
|
41
|
+
return ["windowid", "tabid", "frameid"].some((name) => previous?.[name] !== next[name]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function restorecontext(adapter, next, previous) {
|
|
45
|
+
if (typeof adapter.restorecontext === "function") {
|
|
46
|
+
await adapter.restorecontext({ ...next }, previous ? { ...previous } : undefined);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const methods = [["windowid", "selectwindow"], ["tabid", "selecttab"], ["frameid", "selectframe"]];
|
|
50
|
+
for (const [name, method] of methods) if (next[name] !== undefined && previous?.[name] !== next[name]) {
|
|
51
|
+
if (typeof adapter[method] !== "function") {
|
|
52
|
+
const error = new Error(`replay adapter cannot restore ${name}`);
|
|
53
|
+
error.code = "REPLAY_CONTEXT_UNSUPPORTED";
|
|
54
|
+
error.context = name;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
await adapter[method](next[name], { ...next });
|
|
58
|
+
}
|
|
19
59
|
}
|
|
20
60
|
|
|
21
61
|
function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); }
|
package/workflow/templates.js
CHANGED
|
@@ -5,14 +5,14 @@ import { workflowinputs } from "./manifest.js";
|
|
|
5
5
|
|
|
6
6
|
export function githubworkflow(manifest) {
|
|
7
7
|
const input = workflowinputs(manifest);
|
|
8
|
-
return `name: ${manifest.name}\non:\n workflow_dispatch:\n inputs:\n jobid:\n required: true\n type: string\n command:\n required: true\n type: string\n default: ${manifest.command}\n repository_dispatch:\n types: [saddle-job]\npermissions:\n contents: read\njobs:\n process:\n runs-on: ubuntu-latest\n timeout-minutes: ${input.timeoutminutes}\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version:
|
|
8
|
+
return `name: ${manifest.name}\non:\n workflow_dispatch:\n inputs:\n jobid:\n required: true\n type: string\n command:\n required: true\n type: string\n default: ${manifest.command}\n repository_dispatch:\n types: [saddle-job]\npermissions:\n contents: read\njobs:\n process:\n runs-on: ubuntu-latest\n timeout-minutes: ${input.timeoutminutes}\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 26.7.0\n - run: npm ci\n - run: \${{ github.event.inputs.command || '${manifest.command}' }}\n env:\n SBOT_JOB_ID: \${{ github.event.inputs.jobid || github.event.client_payload.jobid }}\n - uses: actions/upload-artifact@v4\n with:\n name: saddle-results\n path: ${manifest.artifacts.join("\n ")}\n if-no-files-found: ignore\n`;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function forgejoworkflow(manifest) { return genericworkflow(manifest, "forgejo"); }
|
|
12
12
|
export function giteaworkflow(manifest) { return genericworkflow(manifest, "gitea"); }
|
|
13
|
-
export function woodpeckerworkflow(manifest) { return `when:\n - event: push\n - event: manual\nsteps:\n process:\n image: node:
|
|
13
|
+
export function woodpeckerworkflow(manifest) { return `when:\n - event: push\n - event: manual\nsteps:\n process:\n image: node:26.7.0\n commands:\n - npm ci\n - ${manifest.command}\n`;
|
|
14
14
|
}
|
|
15
|
-
export function gitlabworkflow(manifest) { return `stages:\n - process\nprocess:\n stage: process\n image: node:
|
|
15
|
+
export function gitlabworkflow(manifest) { return `stages:\n - process\nprocess:\n stage: process\n image: node:26.7.0\n script:\n - npm ci\n - ${manifest.command}\n artifacts:\n when: always\n paths:\n${manifest.artifacts.map((item) => ` - ${item}`).join("\n")}\n`; }
|
|
16
16
|
|
|
17
|
-
function genericworkflow(manifest, name) { return `name: ${manifest.name}\non:\n workflow_dispatch:\njobs:\n process:\n runs-on: ubuntu-latest\n timeout-minutes: ${manifest.timeoutminutes}\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version:
|
|
17
|
+
function genericworkflow(manifest, name) { return `name: ${manifest.name}\non:\n workflow_dispatch:\njobs:\n process:\n runs-on: ubuntu-latest\n timeout-minutes: ${manifest.timeoutminutes}\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 26.7.0\n - run: npm ci\n - run: ${manifest.command}\n`;
|
|
18
18
|
}
|