Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The TypeScript track

The same mirror of Part I, in Deno. Every listing is in examples/deno/. ikigai-deno is not on JSR yet, so the listings import it by path, from a sibling checkout of it beside this repository’s; edit the import if yours is elsewhere. ikigai-deno’s own code imports @std/crypto (for the BLAKE3 digests in its contract IRIs) by that bare name, which its own deno.json maps for it and a script importing it by path never sees. So every deno run here passes --config examples/deno/deno.json, a three-line import map beside the listings that maps @std/crypto to JSR. A first run fetches it from JSR before it starts.

The blocks that start a peer and mount it (Binding and the family below) are replayed against the book’s pinned ikigai-cli on every change to this repository, with ikigai-deno at its main, by scripts/polyglot-tracks.sh: the peers are started from these very lines, and what each ikigai command prints, cache verdicts included, is compared with the page. The client listings were run against a served ikigai-cli 0.1.18 as they were written and are not replayed.

If the kernel from the Python track is still serving /tmp/ikbook-kernel.sock, skip the first two lines. The second one waits for the kernel’s socket, because a client started at once can find none yet.

ikigai serve /tmp/ikbook-kernel.sock &
until [ -S /tmp/ikbook-kernel.sock ]; do sleep 0.2; done
deno run -A --config examples/deno/deno.json examples/deno/client.ts /tmp/ikbook-kernel.sock

Resolution: a name, over the wire

// A name, resolved. `connect` speaks the wire protocol over the kernel's socket; the
// answer is a representation — bytes with a type — exactly what a Rust caller gets.
const k = await connect(path);
const rep = await k.source("urn:iki:fn:toUpper", { in: "resource oriented computing" });
console.log(rep.text); // RESOURCE ORIENTED COMPUTING
console.log(rep.mediaType); // text/plain;charset=utf-8
console.log(CacheStatus[rep.cacheStatus]); // how the kernel's cache answered: Miss, Hit, Uncacheable

connect returns a client whose methods are the five verbs; source is a Promise of a representation — text, media type, cache verdict. The cache verdict is worth a look on your first run: if another client already resolved that request against the same kernel, it says Hit before you have done anything. The cache belongs to the kernel, not to the connection.

Hello, resource: an endpoint with explicit args

// The description is explicit here — `args:` is the ArgSpec list, stated the way the
// Rust chapter stated it — and the handler receives the named arguments. (ikigai-deno's
// `./zod` entry point derives the same list from a schema; this file stays
// zero-dependency on purpose.) `cacheable: true` is the same claim `.cacheable()` makes
// in Rust; whether a kernel that mounts this peer caches the answers is the kernel's
// call, and the chapter shows what ikigai-cli does with it.
export const hello = endpoint("urn:ts:hello", {
  summary: "Greet someone",
  args: [{
    name: "who",
    required: true,
    summary: "the name to greet",
    class: XSD_STRING,
  }],
  cacheable: true,
}, ({ who }) => `Hello, ${who}!`);

// A list in, a list out: one item per line, each shouted, so a pipe that feeds it a list
// and reverses what comes back shows every stage's mark on every line.
export const shout = endpoint("urn:ts:shout", {
  summary: "Shout each line of a list",
  args: [{
    name: "text",
    required: true,
    summary: "the lines to shout, one per line",
    class: XSD_STRING,
  }],
  cacheable: true,
}, ({ text }) =>
  String(text).split("\n").map((line) => `${line.toUpperCase()}!`).join("\n"));
if (import.meta.main) {
  const path = Deno.args[0] ?? "/tmp/ts-hello.sock";
  console.error(`serving urn:ts:hello and urn:ts:shout on ${path}`);
  const server = new Server([hello, shout], path);
  Deno.addSignalListener("SIGINT", () => server.shutdown());
  await server.serve(); // blocks; speaks the wire protocol
}

Explicit args: here — the ArgSpec list stated the way the Rust chapter stated it, and zero-dependency. ikigai-deno’s ./zod entry point derives the same list from a schema instead (z.object({ who: z.string().describe(…) })), and that is the closer analog to “the signature is the contract”; the sibling repository’s examples/endpoints.ts shows it. Either way, a Rust host sees one description.

Why an endpoint describes itself: its description, as JSON or Turtle

// Self-description, as data: the JSON face of Meta, parsed — the ArgSpecs an endpoint
// declared, which is what an agent's tool definition is made of.
const card = await k.describe("urn:iki:fn:toUpper");
console.log(card?.id, (card?.inputs as { name: string }[]).map((a) => a.name)); // toUpper [ "in" ]
// ...and the same card as a graph.
const turtle = await k.meta("urn:iki:fn:toUpper", "text/turtle");
console.log(turtle.text.split("\n")[0]); // @prefix ik: <https://ikigai-rs.dev/ns#> .

describe is the JSON face of Meta, parsed; meta(iri, "text/turtle") is the graph. The same two faces the Rust chapters used, reached from a runtime that has never parsed Turtle.

Binding: a mount is a binding

Start the peer, and wait for its socket before you mount it:

rm -f /tmp/ts-hello.sock
deno run -A --config examples/deno/deno.json examples/deno/hello.ts /tmp/ts-hello.sock &
until [ -S /tmp/ts-hello.sock ]; do sleep 0.2; done

The wait is not decoration. Deno fetches @std/crypto from JSR on a first run before the peer binds its socket, and an ikigai --mount started at once fails with No such file or directory. The rm -f clears a socket an earlier run left behind, which would otherwise end the wait before this peer is listening.

$ ikigai --plain --mount urn:ts:=/tmp/ts-hello.sock -c 'source urn:ts:hello who=Ada'
Hello, Ada!
[uncacheable]
$ ikigai --plain --mount urn:ts:=/tmp/ts-hello.sock -c 'source urn:demo:split "resource,oriented,computing" | urn:ts:shout | urn:iki:fn:reverseList' -c 'source urn:kernel:uncached'
COMPUTING!
ORIENTED!
RESOURCE!
[1 computed · 2 uncacheable]
uncached (why a computed read was not cached; the last 64, most recent first)
  urn:iki:fn:reverseList      ×1  upstream  [root]
  urn:ts:shout                ×1  declared  [root]

[uncacheable]
— batch: 2 commands · 1 computed · 3 uncacheable

The pipe is the part to notice: Rust into TypeScript into Rust, in the host’s grammar, with none of the three knowing the others’ language. urn:demo:split (Rust) turns the commas into a list of three lines. urn:ts:shout (TypeScript) takes that list and shouts each line. urn:iki:fn:reverseList (Rust) reverses the order of the lines, so computing comes first. Each stage leaves a mark you can see: the list’s three items, the capitals and ! on every line, and the reversed order. So the answer shows that both crossings happened, and in that order. The host owns the topology; the peers own their answers.

Now read the verdicts. Both endpoints declare cacheable: true, and the peer marks its answers that way on the wire. ikigai-cli 0.2.6, the version this page is replayed against, caches neither: every read through --mount is [uncacheable], and urn:kernel:uncached gives declared as the reason. Only split, a Rust endpoint in the host, was computed and kept. reverseList is uncacheable too, for a different reason, upstream: it read an answer that could not be cached. That is the mount’s rule, on purpose: a kernel that mounts a peer stores no copy of what the peer answers, because a change on the peer’s side (a file it watches, a write that reaches it by another route) never crosses back to the mounting kernel, so a stored copy could be served stale for as long as the host runs. The declared reason is the mount’s own declaration, not the peer’s. So cacheable: true on a served peer describes its answers; through --mount, the host does not keep them.

What resolution buys you: the kernel’s cache and trace, from outside

// Cached once: the probe says not yet; a resolution; the probe says served now.
console.log(await k.isCached("urn:iki:fn:toUpper", { in: "a b" })); // false
await k.source("urn:iki:fn:toUpper", { in: "a b" });
console.log(await k.isCached("urn:iki:fn:toUpper", { in: "a b" })); // true
// Traced: the kernel records its own events and ships them back over the wire.
const [, events] = await k.sourceTraced("urn:iki:fn:toUpper", { in: "a b" });
for (const event of events) {
  console.log(event.target, event.cacheHit ? "cache_hit" : "computed");
}

Capabilities: a scoped connect

// Capabilities: connect under a narrowed authority and the kernel enforces it — a write
// to the file workspace under an inspect-only scope is refused, and the refusal arrives
// typed, as `DeniedError`, not as a string to parse.
const narrow = await connect(path, {
  capability: Capability.scoped(["urn:cap:kernel:inspect"]),
});
try {
  await narrow.sink("urn:file:notes.txt", "nope");
} catch (err) {
  if (err instanceof DeniedError) console.log("denied:", err.message);
  else throw err;
}
narrow.close();

instanceof DeniedError — the taxonomy crossed the wire, which is wire protocol v7’s whole point and what examples/http_status.ts in the sibling repository turns into a 403.

Families: one door, many names, four verbs

Every name so far was one resource. Most interesting resources come in families, such as a note for every name or a cell for every square, and a Rust endpoint binds a family with a UriTemplate. In TypeScript a family is family(pattern, options), with one chained call per verb. The template’s variables are declared as bindings and arrive in the handler’s arguments by name:

// One door for every `urn:ts:note:{name}`: `{name}` is a Level 1 variable, matched the
// way ikigai_core::UriTemplate matches it, and it reaches each handler by name.
export const note = family("urn:ts:note:{name}", {
  id: "ts-note",
  summary: "A note, by name",
  bindings: { name: { summary: "the note's name", class: XSD_STRING } },
})
  .source({
    summary: "the note's text; NotFound if there is none",
    cacheable: true,
  }, ({ name }) => {
    const text = notes.get(String(name));
    if (text === undefined) throw new NotFoundError(`no note called ${name}`);
    return text;
  })
  .sink({ summary: "write the note" }, ({ name, content }) => {
    notes.set(String(name), String(content));
    return `wrote ${name}`;
  })
  .delete({ summary: "forget the note" }, ({ name }) => {
    notes.delete(String(name));
    return `forgot ${name}`;
  });
// No `.exists(...)`: Exists defaults to "would Source succeed", so a NotFoundError is
// `false` — cacheable exactly when Source is.

{name} is a Level 1 variable, and it matches the way ikigai_core::UriTemplate matches, line for line. A variable followed by a literal captures up to the leftmost occurrence of that literal, a trailing variable takes the rest, and every capture is non-empty. The host and the peer have to agree on which names a pattern covers, because the host reads the peer’s catalog to decide what to forward, so the face mirrors core’s rule and does not approximate it. The catalog lists the template, so list shows one family, not every note anyone has written.

Each verb gets its own contract, which is core’s per-verb ActionSpec form: a Source that is cacheable and answers NotFound for a missing note, a Sink whose body arrives as content, and a Delete. Exists is not written at all. It defaults to “would Source succeed”, so a NotFound answers false.

rm -f /tmp/ts-notes.sock
deno run -A --config examples/deno/deno.json examples/deno/notes.ts /tmp/ts-notes.sock &
until [ -S /tmp/ts-notes.sock ]; do sleep 0.2; done
$ ikigai --plain --mount urn:ts:=/tmp/ts-notes.sock -c 'sink urn:ts:note:milk two liters' \
  -c 'source urn:ts:note:milk' -c 'source urn:ts:note:milk' \
  -c 'sink urn:ts:note:milk three liters' -c 'source urn:ts:note:milk' \
  -c 'source urn:ts:note:index' -c 'delete urn:ts:note:milk' -c 'source urn:ts:note:milk'
wrote milk
[uncacheable]
two liters
[uncacheable]
two liters
[uncacheable]
wrote milk
[uncacheable]
three liters
[uncacheable]
milk
[uncacheable]
forgot milk
[uncacheable]
error: not found: no note called milk
— batch: 8 commands · 7 uncacheable

Read the verdicts. The note’s Source declares itself cacheable, and, as in Binding, ikigai-cli 0.2.6 caches nothing a mounted peer answers, so every read here reached the peer. The peer keeps no cache of its own either, and that is why the read after the second write saw the new text. The writes and the delete are uncacheable whatever the host does, because a Sink or Delete answer always is, and the index is uncacheable on purpose: it reads every note, and a write is about only the note it wrote. Exists, from a client:

const k = await connect("/tmp/ts-notes.sock"); // the peer itself; Exists is a read
console.log((await k.exists("urn:ts:note:milk")).text); // true, after `sink urn:ts:note:milk two liters`
console.log((await k.exists("urn:ts:note:bread")).text); // false

Which door answers

Two rules, and the second one is the reason the first can be trusted.

The first declared door that matches wins, as in core’s EndpointSpace. The family would match urn:ts:note:index too, with {name} capturing index. The index works because it is declared first:

// An exact name, declared FIRST: the family below would match `urn:ts:note:index` too
// (its `{name}` would capture `index`), and the first declared door that matches wins.
// Not cacheable: it reads every note, and a write cuts only the note it wrote.
export const index = endpoint("urn:ts:note:index", {
  summary: "The notes there are, one name per line",
}, () => [...notes.keys()].sort().join("\n"));

Every door has two forms, and every door’s declared form is tried before any door’s stripped form. --mount urn:ts:= is an alias mount: the host rewrites urn:ts:note:milk to urn:note:milk before forwarding it, the way Preferential resolution described. A peer cannot know the prefix it was mounted at, only that it was aliased, so it answers each door at its declared pattern and at that pattern with the first segment stripped: urn:ts:note:{name} and urn:note:{name}. If the forms were tried door by door, one door’s stripped form could shadow the next door’s declared name. Trying every declared form first means a stripped form can only answer a name that no door declares. A connection whose hello says it comes from an alias mount is sending stripped names, so for that connection the order reverses, and stripped forms go first. The Python face follows the same rule, so a family behaves the same whichever language serves it. Because only the first segment can be stripped, a family mounted deeper, at urn:ts:note: for example, needs --override, which forwards names unchanged.

A family is how a store in another language holds a game: the sixth part of the tic-tac-toe arc, Tic-tac-toe, VI, which you have already read, serves the game’s stored cell as one, from TypeScript, under a Rust host that keeps everything else.

What an L0 peer cannot do

The same limit as the Python track, and it is a property of the wire, not of the language: a served peer has no back-channel into the kernel invoking it, so a TypeScript endpoint cannot source another resource mid-invocation and cannot inherit a golden thread. It is a leaf. Composition — pipes, maps, compose — happens in the host above it, as the pipe above showed.