Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,43 @@

Vines between slothlet api trees.

[Slothlet](https://github.com/CLDMV/slothlet) composes a folder of modules into an api **tree**. A **vine** connects two trees across an execution boundary β€” a Web Worker, another thread, another process, or another machine β€” by mounting **forwarding leaves**: stubs that live at the callee's identical logical path in the caller's tree, so `self.exts.foo.bar()` works the same whether `foo` is co-located or isolated. Slothlet cannot tell a vine leaf from a real one β€” including its permission identity, so slothlet's own permission system gates every cross-boundary call before it dispatches.
[Slothlet](https://github.com/CLDMV/slothlet) composes a folder of modules into an api **tree**. A **vine** connects two trees across an execution boundary β€” a Web Worker, another thread, another process, or another machine β€” by mounting **forwarding leaves**: stubs that live at the callee's identical logical path in the caller's tree, so `self.exts.foo.bar()` works the same whether `foo` is co-located or isolated. Slothlet cannot tell a vine leaf from a real one β€” including its permission identity, so a rule targeting `exts.foo.bar` gates the forwarding stub exactly as it would gate the real leaf, before it dispatches.

That gating follows slothlet's own rule about **who is calling**: a call made by a MODULE (`self.exts.foo.bar()`) is checked against the permission rules, and a denied one never runs the stub body, so it never reaches the wire. A call made through the bound handle `slothlet()` returned β€” the host itself β€” carries host standing and is not checked. That carve-out is slothlet's design, not a vine gap, but it does mean "permission-gated" describes module-initiated calls; a host that forwards on someone else's behalf is responsible for its own authorization.

## Status

**Pre-implementation scaffold.** The design is proven (the forwarding mechanism runs in production node-side in a consuming project, built entirely on slothlet's public API) and the browser transposition is being spiked. The package publishes nothing yet.
**Feature-complete for v1; not yet published.** `grow`, `serve`, the frame protocol, the error taxonomy, and the reusable Channel conformance harness are in place, and **all five built-in transports are implemented and tested over their real boundaries**: `loopback` (in-process reference), `post-message` (a real `worker_threads` `MessageChannel` structured-clone hop), `worker-threads` (a real `Worker`), `process` (a real forked child over IPC), and `websocket` (a real `ws` connection on an ephemeral port). Every transport runs the shared Channel conformance suite plus the full six-point e2e bar against real slothlet instances. The package publishes nothing yet.

> **Note on `process`:** the transport declares structured-clone fidelity, which requires the child to be forked with `{ serialization: "advanced" }`. Under Node's default `"json"` serialization, rich types (`Date`, `Map`, `Set`) degrade the same way the websocket JSON codec degrades them; the plain frame envelope works either way.

The normative protocol lives in [`docs/DESIGN.md`](docs/DESIGN.md); the wire frames are in [`schemas/frame.schema.json`](schemas/frame.schema.json).

## Usage shape (dot notation β€” the slothlet idiom)

```js
import * as vine from "@cldmv/slothlet-vine";
import { createChannel } from "@cldmv/slothlet-vine/transport/post-message";
import { createPair } from "@cldmv/slothlet-vine/transport/loopback";

const [near, far] = createPair();

// serve this instance's leaves to the far side
const serving = await vine.serve(workerApi, far, { paths: ["exts"] });

// mount the far tree's leaves into this instance, at identical paths
const link = await vine.grow(hostApi, near, { budgetMs: 5000 });
await hostApi.exts.pdfViewer.open("a.pdf"); // executes on the serving instance

const channel = createChannel(worker);
vine.grow(api, channel); // mount the far tree's leaves into this instance
vine.serve(api, channel); // serve this instance's leaves to the far side
await link.close(); // stubs unmounted; in-flight calls settle VINE_CLOSED
serving.close();
```

Single-word leaves, context carried by the namespace β€” never `growVine()`-style camelCase that repeats the package's own name.

## Design

- **Async-only forwarding**: calls serialize to `{ type: "call", callId, path, args, context }` frames; correlation by `callId`; settle-once; per-call budget timers; a dead far side force-settles every in-flight call with a coded error instead of hanging.
- **The `Channel` seam**: every transport implements `{ send(message), onMessage(handler), close() }`. The bridge consumes only this interface β€” transports are injected, never imported by the core.
- **Async-only, data-only forwarding**: calls serialize to `{ type: "call", callId, path, args }` frames; correlation by `callId`; settle-once; per-call budget timers; a dead far side force-settles every in-flight call with a coded error instead of hanging. A function-valued argument is refused at the edge (`VINE_DATA_ONLY`) before anything is sent.
- **The `Channel` seam**: every transport implements `{ send(message), onMessage(handler), close?(), onClose?(handler) }` plus a capability declaration. The core consumes only this interface β€” transports are injected, never imported by it.
- **Batteries included, dependencies contained**: built-in transports ship as subpath exports so each one's dependency loads only if imported:
- `slothlet-vine/transport/loopback` β€” in-process pair (tests, simulation)
- `slothlet-vine/transport/post-message` β€” browser `Worker` / `MessagePort` (structured clone; no codec)
Expand Down
Loading
Loading