toiljs 0.0.68 → 0.0.70

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/client/.tsbuildinfo +1 -1
  4. package/build/client/rpc.js +10 -4
  5. package/build/client/stream/client.js +108 -5
  6. package/build/compiler/.tsbuildinfo +1 -1
  7. package/build/compiler/index.d.ts +2 -0
  8. package/build/compiler/index.js +282 -2
  9. package/build/compiler/toil-docs.generated.js +3 -2
  10. package/build/compiler/vite.js +8 -0
  11. package/build/devserver/.tsbuildinfo +1 -1
  12. package/build/devserver/daemon/host.d.ts +1 -7
  13. package/build/devserver/daemon/host.js +5 -59
  14. package/build/devserver/daemon/index.d.ts +1 -0
  15. package/build/devserver/daemon/index.js +17 -4
  16. package/build/devserver/db/database.js +1 -1
  17. package/build/devserver/db/routeKinds.d.ts +6 -0
  18. package/build/devserver/db/routeKinds.js +40 -0
  19. package/build/devserver/index.d.ts +0 -1
  20. package/build/devserver/index.js +0 -1
  21. package/build/devserver/runtime/module.d.ts +1 -0
  22. package/build/devserver/runtime/module.js +18 -2
  23. package/build/devserver/stream/index.js +4 -3
  24. package/build/devserver/wasm/surface.d.ts +2 -0
  25. package/build/devserver/wasm/surface.js +35 -4
  26. package/docs/derive.md +159 -0
  27. package/docs/index.md +1 -1
  28. package/docs/streams.md +49 -18
  29. package/examples/basic/server/services/Stats.ts +11 -3
  30. package/examples/basic/server/services/remotes.ts +8 -2
  31. package/package.json +3 -2
  32. package/server/runtime/exports/index.ts +8 -1
  33. package/server/runtime/index.ts +1 -0
  34. package/server/runtime/rpc/Rpc.ts +66 -0
  35. package/src/client/rpc.ts +21 -12
  36. package/src/client/stream/client.ts +138 -8
  37. package/src/compiler/index.ts +352 -2
  38. package/src/compiler/toil-docs.generated.ts +3 -2
  39. package/src/compiler/vite.ts +16 -0
  40. package/src/devserver/daemon/host.ts +10 -110
  41. package/src/devserver/daemon/index.ts +19 -6
  42. package/src/devserver/db/database.ts +1 -1
  43. package/src/devserver/db/routeKinds.ts +44 -0
  44. package/src/devserver/index.ts +0 -1
  45. package/src/devserver/runtime/host.ts +3 -7
  46. package/src/devserver/runtime/module.ts +30 -4
  47. package/src/devserver/stream/index.ts +8 -4
  48. package/src/devserver/wasm/surface.ts +33 -4
  49. package/test/daemon-build.test.ts +53 -0
  50. package/test/daemon-catalog.test.ts +78 -3
  51. package/test/daemon-emulation.test.ts +27 -29
  52. package/test/devserver-database.test.ts +93 -0
  53. package/test/fixtures/bignum-wire/spec.ts +3 -5
  54. package/test/fixtures/daemon-app.ts +25 -21
  55. package/test/fixtures/stream-typed.ts +41 -0
  56. package/test/rpc-dispatch.test.ts +132 -0
  57. package/test/rpc-kinds.test.ts +18 -0
  58. package/test/rpc.test.ts +20 -4
  59. package/test/stream-emulation.test.ts +39 -0
  60. package/build/devserver/mstore/store.d.ts +0 -18
  61. package/build/devserver/mstore/store.js +0 -82
  62. package/src/devserver/mstore/store.ts +0 -121
@@ -1,6 +1,7 @@
1
1
  import { customSection } from '../wasm/sections.js';
2
2
  import { DbFunctionKind } from './types.js';
3
3
  const SECTION = 'toildb.route_kinds';
4
+ const RPC_SECTION = 'toildb.rpc_kinds';
4
5
  const VERSION = 1;
5
6
  const MAX_SECTION_BYTES = 128 * 1024;
6
7
  const MAX_ROUTES = 2048;
@@ -58,6 +59,45 @@ export function routeKindForRequest(routes, method, path) {
58
59
  }
59
60
  return null;
60
61
  }
62
+ export function parseRpcKinds(wasm) {
63
+ let section;
64
+ try {
65
+ section = customSection(wasm, RPC_SECTION);
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ if (section === null)
71
+ return [];
72
+ if (section.length > MAX_SECTION_BYTES)
73
+ return [];
74
+ const r = new Reader(section);
75
+ const version = r.u16();
76
+ if (!r.ok || version !== VERSION)
77
+ return [];
78
+ const count = r.u16();
79
+ if (!r.ok || count > MAX_ROUTES)
80
+ return [];
81
+ const methods = [];
82
+ for (let i = 0; i < count && r.ok; i++) {
83
+ const methodId = r.u32();
84
+ const kindByte = r.u8();
85
+ const kind = kindByte === 0 ? DbFunctionKind.Query : kindByte === 1 ? DbFunctionKind.Action : null;
86
+ if (!r.ok || kind === null)
87
+ return [];
88
+ methods.push({ methodId, kind });
89
+ }
90
+ if (!r.ok || r.remaining() !== 0)
91
+ return [];
92
+ return methods;
93
+ }
94
+ export function rpcKindForId(methods, methodId) {
95
+ for (const m of methods) {
96
+ if (m.methodId === methodId)
97
+ return m.kind;
98
+ }
99
+ return DbFunctionKind.Query;
100
+ }
61
101
  function validPattern(pattern) {
62
102
  if (pattern.length === 0 || !pattern.startsWith('/'))
63
103
  return false;
@@ -18,4 +18,3 @@ export { cronMatches, cronNeverFires, nextCronFireMs } from './daemon/cron.js';
18
18
  export { parseSurface } from './wasm/surface.js';
19
19
  export type { Surface, SurfaceFlags } from './wasm/surface.js';
20
20
  export { customSection } from './wasm/sections.js';
21
- export { DevMemoryStore, devMemoryStore } from './mstore/store.js';
@@ -9,4 +9,3 @@ export { buildDaemonImports, freshDaemonState } from './daemon/host.js';
9
9
  export { cronMatches, cronNeverFires, nextCronFireMs } from './daemon/cron.js';
10
10
  export { parseSurface } from './wasm/surface.js';
11
11
  export { customSection } from './wasm/sections.js';
12
- export { DevMemoryStore, devMemoryStore } from './mstore/store.js';
@@ -16,6 +16,7 @@ export declare class WasmServerModule {
16
16
  private module;
17
17
  private loadedMtimeMs;
18
18
  private routeKinds;
19
+ private rpcKinds;
19
20
  private derives;
20
21
  private derivesDirty;
21
22
  constructor(wasmPath: string);
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { DbFunctionKind, derivesForWrites, parseDerives, persistDb, setDbCatalog, } from '../db/index.js';
3
- import { parseRouteKinds, routeKindForRequest } from '../db/routeKinds.js';
3
+ import { parseRouteKinds, parseRpcKinds, routeKindForRequest, rpcKindForId, } from '../db/routeKinds.js';
4
4
  import { decodeResponseEnvelope, encodeRequestEnvelope, unpackHandleResult, } from '../http/envelope.js';
5
5
  import { buildHostImports, freshDispatchState } from './host.js';
6
6
  export { WasmAbortError } from './host.js';
@@ -87,6 +87,7 @@ export class WasmServerModule {
87
87
  module = null;
88
88
  loadedMtimeMs = -1;
89
89
  routeKinds = [];
90
+ rpcKinds = [];
90
91
  derives = [];
91
92
  derivesDirty = false;
92
93
  constructor(wasmPath) {
@@ -103,6 +104,7 @@ export class WasmServerModule {
103
104
  catch {
104
105
  this.module = null;
105
106
  this.routeKinds = [];
107
+ this.rpcKinds = [];
106
108
  this.derives = [];
107
109
  this.derivesDirty = false;
108
110
  this.loadedMtimeMs = -1;
@@ -116,6 +118,7 @@ export class WasmServerModule {
116
118
  this.assertExportSurface(module);
117
119
  setDbCatalog(bytes);
118
120
  this.routeKinds = parseRouteKinds(bytes);
121
+ this.rpcKinds = parseRpcKinds(bytes);
119
122
  this.derives = parseDerives(bytes);
120
123
  this.module = module;
121
124
  this.loadedMtimeMs = mtimeMs;
@@ -130,7 +133,20 @@ export class WasmServerModule {
130
133
  const ref = { memory: null };
131
134
  const state = freshDispatchState();
132
135
  state.clientIp = req.clientIp ?? '';
133
- state.db.functionKind = dbFunctionKindForRequest(this.routeKinds, req.method, req.path);
136
+ const rpcPath = req.path.split('?')[0] ?? req.path;
137
+ const rpcMethod = req.method.toUpperCase();
138
+ const rpcMutating = rpcMethod === 'POST' || rpcMethod === 'PUT' || rpcMethod === 'PATCH' || rpcMethod === 'DELETE';
139
+ if (rpcPath === '/__toil_rpc' && rpcMutating) {
140
+ const idHeader = req.headers.find(([n]) => n.toLowerCase() === 'toil-rpc')?.[1];
141
+ const id = idHeader !== undefined && /^\d+$/.test(idHeader) ? Number(idHeader) : NaN;
142
+ state.db.functionKind =
143
+ Number.isInteger(id) && id <= 0xffffffff
144
+ ? rpcKindForId(this.rpcKinds, id)
145
+ : DbFunctionKind.Query;
146
+ }
147
+ else {
148
+ state.db.functionKind = dbFunctionKindForRequest(this.routeKinds, req.method, req.path);
149
+ }
134
150
  const instance = new WebAssembly.Instance(this.module, buildHostImports(ref, state));
135
151
  const exports = instance.exports;
136
152
  ref.memory = exports.memory;
@@ -35,8 +35,8 @@ export class DevStreamBox {
35
35
  }
36
36
  static load(wasm) {
37
37
  const surface = parseSurface(wasm);
38
- if (surface === 'invalid' || surface.targetMode !== 'hot') {
39
- throw new Error('stream box requires a hot artifact with a valid toil.surface');
38
+ if (surface === 'invalid' || surface.targetMode !== 'hot' || !surface.flags.stream) {
39
+ throw new Error('stream box requires a hot stream artifact with a valid toil.surface');
40
40
  }
41
41
  const ref = { memory: null };
42
42
  const state = freshStreamBoxState();
@@ -105,7 +105,8 @@ export class DevStreamBox {
105
105
  };
106
106
  }
107
107
  static resolveStreamInfo(e) {
108
- if (typeof e.stream_info_offset !== 'function' || typeof e.stream_info_capacity !== 'function') {
108
+ if (typeof e.stream_info_offset !== 'function' ||
109
+ typeof e.stream_info_capacity !== 'function') {
109
110
  return null;
110
111
  }
111
112
  return { offset: e.stream_info_offset() >>> 0, cap: e.stream_info_capacity() >>> 0 };
@@ -1,3 +1,5 @@
1
+ export declare const SURFACE_FORMAT_VERSION = 1;
2
+ export declare const SURFACE_ABI_VERSION = 1;
1
3
  export interface SurfaceFlags {
2
4
  readonly rest: boolean;
3
5
  readonly stream: boolean;
@@ -1,5 +1,16 @@
1
1
  import { DataReader } from 'toiljs/io';
2
2
  import { customSection } from './sections.js';
3
+ export const SURFACE_FORMAT_VERSION = 1;
4
+ export const SURFACE_ABI_VERSION = 1;
5
+ const TARGET_HOT = 0;
6
+ const TARGET_COLD = 1;
7
+ const FLAG_REST = 1 << 0;
8
+ const FLAG_STREAM = 1 << 1;
9
+ const FLAG_DAEMON = 1 << 2;
10
+ const FLAG_SCHEDULED = 1 << 3;
11
+ const FLAG_DATABASE = 1 << 4;
12
+ const FLAG_RENDER = 1 << 5;
13
+ const FLAG_KNOWN_MASK = FLAG_REST | FLAG_STREAM | FLAG_DAEMON | FLAG_SCHEDULED | FLAG_DATABASE | FLAG_RENDER;
3
14
  export function parseSurface(wasm) {
4
15
  let sec;
5
16
  try {
@@ -11,16 +22,36 @@ export function parseSurface(wasm) {
11
22
  if (sec === null)
12
23
  return 'invalid';
13
24
  const r = new DataReader(sec);
14
- r.readU16();
15
- const targetMode = r.readU8() === 1 ? 'cold' : 'hot';
16
- r.readU8();
25
+ const version = r.readU16();
26
+ if (!r.ok || version !== SURFACE_FORMAT_VERSION)
27
+ return 'invalid';
28
+ const targetModeByte = r.readU8();
29
+ if (!r.ok || (targetModeByte !== TARGET_HOT && targetModeByte !== TARGET_COLD)) {
30
+ return 'invalid';
31
+ }
32
+ const targetMode = targetModeByte === TARGET_COLD ? 'cold' : 'hot';
33
+ const reserved0 = r.readU8();
34
+ if (!r.ok || reserved0 !== 0)
35
+ return 'invalid';
17
36
  const f = r.readU32();
37
+ if (!r.ok || (f & ~FLAG_KNOWN_MASK) !== 0)
38
+ return 'invalid';
39
+ if ((f & FLAG_SCHEDULED) !== 0 && (f & FLAG_DAEMON) === 0)
40
+ return 'invalid';
41
+ if (targetMode === 'hot' && (f & (FLAG_DAEMON | FLAG_SCHEDULED)) !== 0) {
42
+ return 'invalid';
43
+ }
44
+ if (targetMode === 'cold' && (f & (FLAG_REST | FLAG_STREAM | FLAG_RENDER)) !== 0) {
45
+ return 'invalid';
46
+ }
18
47
  const abiVersion = r.readU16();
48
+ if (!r.ok || abiVersion !== SURFACE_ABI_VERSION)
49
+ return 'invalid';
19
50
  const buildId = r.readString();
20
51
  const fingerprint = r.readU32();
21
52
  const dataCoherenceHash = r.readU32();
22
53
  const pairCoherenceHash = r.readU32();
23
- if (!r.ok)
54
+ if (!r.ok || r.remaining() !== 0)
24
55
  return 'invalid';
25
56
  return {
26
57
  targetMode,
package/docs/derive.md ADDED
@@ -0,0 +1,159 @@
1
+ # Derive (materialized views)
2
+
3
+ `@derive` precomputes a read-optimized **view** from your data so reads stay
4
+ fast and never scan. A request handler (`@get` runs as a *query*, `@post`/`@put`/
5
+ `@delete` as an *action*) is not allowed to scan, reading "the latest N events"
6
+ or "every member of a set" could fan out across unbounded rows, so those scans
7
+ are barred on the request path. A `@derive` does the scan **off** the request
8
+ path: it folds your event log / counters into a `View`, and your route serves
9
+ that view with a single keyed read.
10
+
11
+ ```ts
12
+ @database
13
+ class GuestbookDb {
14
+ @collection static entries: Events<GuestKey, GuestEntry>;
15
+ @collection static totals: Counter<GuestKey>;
16
+ @collection static book: View<GuestKey, GuestbookView>;
17
+
18
+ // Recompute the view from the sources. Runs after a signature is written
19
+ // (and when a box first loads). A derive MAY scan + publish; a route may not.
20
+ @derive
21
+ recompute(): void {
22
+ const key = new GuestKey('main');
23
+ const view = new GuestbookView();
24
+ view.total = GuestbookDb.totals.get(key); // counter read
25
+ view.entries = GuestbookDb.entries.latest(key, 10); // scan, allowed here
26
+ GuestbookDb.book.publish(key, view); // publish the materialized view
27
+ }
28
+ }
29
+ ```
30
+
31
+ ## Why a derive
32
+
33
+ ToilDB gates every data op by the *function kind* it runs under:
34
+
35
+ - **query** (`@get`/`@head`) and **action** (`@post`/`@put`/`@patch`/`@delete`)
36
+ may do keyed reads and (actions only) writes, but **not scans**
37
+ (`events.latest`, `membership.list`).
38
+ - **derive** may do everything a read can, **plus** scans, plus
39
+ `view.publish`/`append`/`counter.add`.
40
+
41
+ So if a page needs "the 10 newest entries" or "the leaderboard", you cannot read
42
+ that directly in the `@get`. Instead a `@derive` builds it once into a `View`,
43
+ and the `@get` reads the view by key, which is not a scan.
44
+
45
+ ## Declaring a derive
46
+
47
+ A derive is a method on your `@database` class, alongside the collections it
48
+ reads and the `View` it writes:
49
+
50
+ ```ts
51
+ @database
52
+ class MyDb {
53
+ @collection static events: Events<Key, Fact>; // a source
54
+ @collection static home: View<Key, HomePage>; // the materialized view
55
+
56
+ @derive
57
+ rebuild(): void {
58
+ // read sources, build the value, publish it
59
+ }
60
+ }
61
+ ```
62
+
63
+ Rules:
64
+
65
+ - A `@derive` method takes **no arguments and returns `void`**.
66
+ - A `@database` may declare **multiple** `@derive` methods; each is run
67
+ independently.
68
+ - The view value (`HomePage` above) and the key are ordinary `@data` types, so
69
+ they round-trip through the codec like any other stored value.
70
+
71
+ ## `View<K, V>`
72
+
73
+ A `View` is a published, read-optimized projection. Its API:
74
+
75
+ ```ts
76
+ view.get(key) // V | null - the published view, or null if none yet
77
+ view.require(key) // V - like get, but traps if nothing is published
78
+ view.publish(key, value) // void - overwrite the view (derive/job only)
79
+ ```
80
+
81
+ `publish` is only allowed from a `@derive` (or a `@job`); the host assigns the
82
+ version so a later publish always supersedes an earlier one. `get`/`require` are
83
+ plain keyed reads, allowed from any handler, including a `@get` route.
84
+
85
+ ## When derives run
86
+
87
+ You never call a derive yourself. The runtime runs it for you:
88
+
89
+ - **After a write to a source.** When a request writes one of a database's
90
+ source collections (an `events.append`/`append_once`, a `counter.add`, or a
91
+ record `create`/`patch`), that database's derives run right after the response
92
+ is produced, so the view reflects the new data on the next read. Many writes to
93
+ one database in a single request coalesce into one recompute.
94
+ - **On box load.** When a server box starts or hot-reloads (or the underlying
95
+ source data changed out of band), the views are rebuilt from their sources
96
+ before the first read is served. This is also where a value type's `@migrate`
97
+ runs against old stored events, as the derive re-reads and republishes them.
98
+
99
+ A derive's own writes (its `view.publish`) never re-trigger it.
100
+
101
+ The same code runs under `toiljs dev` (the in-process emulator) and on the
102
+ production edge, no flags or wiring to change.
103
+
104
+ ## Reading a view from a route
105
+
106
+ The route just reads the view by key, which is a non-scan read and so is legal in
107
+ a `@get`:
108
+
109
+ ```ts
110
+ @rest('guestbook')
111
+ class Guestbook {
112
+ @get('/')
113
+ list(): GuestbookView {
114
+ const key = new GuestKey('main');
115
+ const view = GuestbookDb.book.get(key);
116
+ return view == null ? new GuestbookView() : view; // empty until first publish
117
+ }
118
+
119
+ @post('/')
120
+ sign(input: NewMessage): GuestbookView {
121
+ const key = new GuestKey('main');
122
+ GuestbookDb.entries.append(key, new GuestEntry(input.author, input.message, 0));
123
+ GuestbookDb.totals.add(key, 1);
124
+ // The @derive republishes `book` right after this action returns, so the
125
+ // entries list is served by GET. The action just acks with the new total
126
+ // (a counter read is allowed here; a scan is not).
127
+ const view = new GuestbookView();
128
+ view.total = GuestbookDb.totals.get(key);
129
+ return view;
130
+ }
131
+ }
132
+ ```
133
+
134
+ ## How it fits together (the guestbook)
135
+
136
+ The `examples/basic` guestbook is the end-to-end demo:
137
+
138
+ 1. `POST /guestbook` (an action) appends the signature to an `Events` stream and
139
+ bumps a `Counter`. It returns the running total, but it does **not** read the
140
+ entry list (that would be a scan).
141
+ 2. The runtime then runs `@derive recompute()` under the derive kind: it scans
142
+ `entries.latest(...)`, reads the `totals` counter, and `publish`es a fresh
143
+ `GuestbookView`.
144
+ 3. `GET /guestbook` (a query) reads `book.get(...)`, a single keyed read, and
145
+ returns the precomputed total + newest entries.
146
+
147
+ Sign twice and the total climbs across requests, because the data lives in
148
+ ToilDB (and its view), not in module memory.
149
+
150
+ ## Notes
151
+
152
+ - A derive **recomputes** the view from whatever its method reads (here, the
153
+ latest 10 events). It is a fresh recompute on each trigger, so it suits views
154
+ built from a bounded read (latest N, a counter total, a small set). Folding an
155
+ unbounded full event log incrementally is a separate, more advanced pattern.
156
+ - Because publishes are last-writer-wins and a derive recomputes from the source
157
+ of truth, a view always converges to a correct snapshot of its sources.
158
+ - See also: [`data.md`](data.md) for `@data` value types, and the ToilDB host
159
+ ABI for the exact `derive_run` / `toildb.derives` contract.
package/docs/index.md CHANGED
@@ -27,4 +27,4 @@ toilscript-to-WebAssembly server target.
27
27
 
28
28
  See [routing.md](./routing.md), [client.md](./client.md), [styling.md](./styling.md),
29
29
  [server.md](./server.md), [ssr.md](./ssr.md), [rpc.md](./rpc.md), [tiers.md](./tiers.md),
30
- [streams.md](./streams.md), [daemon.md](./daemon.md), [cli.md](./cli.md).
30
+ [streams.md](./streams.md), [daemon.md](./daemon.md), [derive.md](./derive.md), [cli.md](./cli.md).
package/docs/streams.md CHANGED
@@ -52,7 +52,6 @@ optional - declare only the ones you need; a missing hook is a no-op.
52
52
  | `@message` | an inbound frame arrives. |
53
53
  | `@close` | the connection closes gracefully (the box is torn down after this hook). |
54
54
  | `@disconnect` | the transport is lost abruptly. |
55
- | `@channel` | an opt-in distributed channel delivers a message (advanced; see below). |
56
55
 
57
56
  The `Echo` example above shows why state survives: `count` is set to `0` in
58
57
  `@connect`, incremented on every `@message`, and the increments **accumulate**.
@@ -60,9 +59,9 @@ That is only possible because the same resident box handles every event for the
60
59
  connection. A `@rest` handler's fields would reset on each request, since a
61
60
  fresh handler is constructed per request.
62
61
 
63
- `@channel` is an opt-in **distributed** channel (advanced) - a way for boxes to
64
- exchange messages beyond a single connection. It is mentioned here for
65
- completeness; most streams use only the four connection-lifecycle hooks.
62
+ Distributed stream channels are not part of the live v1 ABI. The edge rejects
63
+ stream artifacts that declare a channel hook until the channel fan-out runtime
64
+ exists.
66
65
 
67
66
  ## Placement
68
67
 
@@ -117,30 +116,62 @@ build/server/release-cold.wasm # L4 daemon (exports: daemon_start, sched
117
116
 
118
117
  See [Tiers](./tiers.md) for how the three artifacts map to the deployment tiers.
119
118
 
120
- ## What runs today
119
+ ## Reading and replying to messages
121
120
 
122
- The stream lifecycle hooks (`@connect` / `@message` / `@close` / `@disconnect`)
123
- run **today**, and this proves a resident box keeps state across events - that is
124
- exactly what the `Echo` example demonstrates by counting frames.
125
-
126
- Reading the inbound frame **bytes** and replying is the **next increment**, not
127
- yet available. That bridge is the `StreamPacket` / `StreamOutbound` API and the
128
- typed `Server.STREAM.echo.connect()` client. The intended shape, once it lands:
121
+ `@message` receives the inbound frame as a `StreamPacket` and returns a
122
+ `StreamOutbound`. `StreamPacket.bytes()` is the raw frame payload;
123
+ `StreamOutbound.reply(bytes)` stages one frame back to the client (return an empty
124
+ `StreamOutbound` to accept the frame without replying). The same resident box
125
+ handles every frame, so state on its fields persists across messages.
129
126
 
130
127
  ```ts
131
- @message reply(packet: StreamPacket): StreamOutbound {
128
+ @message
129
+ reply(packet: StreamPacket): StreamOutbound {
132
130
  return StreamOutbound.reply(packet.bytes()); // echo the bytes back
133
131
  }
134
132
  ```
135
133
 
134
+ ## Typed messages
135
+
136
+ By default a `@message` payload is **raw bytes**. Opt into a decoded `@data` value
137
+ with `@stream({ message: T })`: the `@message` hook then receives the named `@data`
138
+ class, decoded from the frame for you. The reply stays raw (`StreamOutbound`).
139
+
140
+ ```ts
141
+ @data
142
+ class ChatMsg { text: string = ''; }
143
+
144
+ @stream({ message: ChatMsg })
145
+ class Chat {
146
+ @message
147
+ onMessage(msg: ChatMsg): StreamOutbound { // decoded @data, not raw bytes
148
+ return StreamOutbound.reply(new TextEncoder().encode(msg.text));
149
+ }
150
+ }
151
+ ```
152
+
153
+ ## The client
154
+
155
+ A `@stream` class is reachable from the browser as `Server.Stream.<ClassName>`. The
156
+ typed client is generated into `shared/server.ts` (the same place `Server.REST`
157
+ lands), so no manual wiring is needed. `connect()` opens a WebSocket to the class's
158
+ route and resolves a channel:
159
+
136
160
  ```ts
137
- const stream = await Server.STREAM.echo.connect();
138
- stream.send(new TextEncoder().encode('hello'));
161
+ const chat = await Server.Stream.Chat.connect();
162
+ chat.onMessage((bytes) => { /* a reply frame, always raw bytes */ });
163
+ chat.send(new ChatMsg('hello')); // a typed stream: send() encodes the @data for you
164
+ chat.onClose((code) => { /* a 0x02xx stream close code */ });
165
+ chat.close();
139
166
  ```
140
167
 
141
- Until then, the hooks run on the connection lifecycle and you observe state
142
- through fields, as `Echo` does. See the comments in
143
- `examples/streams/server/streams/Echo.ts` for the authoritative note.
168
+ - The channel key is the **class name** (`Server.Stream.Chat`); it connects to the
169
+ class's mount route (`/Chat`).
170
+ - A **raw** `@stream` channel sends `Uint8Array`; a **typed** `@stream({ message: T })`
171
+ channel sends the `@data` class and encodes it on the wire for you.
172
+ - The inbound reply is **always raw bytes** - the server's `StreamOutbound` is raw.
173
+ - `connect()` resolves once the upgrade completes; a `@connect` reject (or any
174
+ later server close) surfaces through `onClose(code)`.
144
175
 
145
176
  ---
146
177
 
@@ -1,10 +1,18 @@
1
1
  import { store } from '../core/store';
2
2
 
3
- /** Typed RPC service (transport still a TODO): reached as `Server.stats.playerCount()` on the client. */
4
- /*@service
3
+ /** Typed RPC service: reached as `Server.stats.playerCount()` on the client (POSTs /__toil_rpc). */
4
+ @service
5
5
  class Stats {
6
6
  @remote
7
7
  public playerCount(): i32 {
8
8
  return store.size;
9
9
  }
10
- }*/
10
+
11
+ // @auth on a @remote: the RPC dispatcher must reject with 401 when there is no valid session,
12
+ // exactly like an @auth @rest route (the guard is compiler-injected into __rpcDispatch).
13
+ @remote
14
+ @auth
15
+ public secretCount(): i32 {
16
+ return store.size;
17
+ }
18
+ }
@@ -1,7 +1,13 @@
1
1
  /** Free `@remote` functions: callable as `Server.<name>()` on the client. */
2
2
 
3
3
  /** `Server.ping(n)` on the client. */
4
- /*@remote
4
+ @remote
5
5
  function ping(n: i32): i32 {
6
6
  return n + 1;
7
- }*/
7
+ }
8
+
9
+ /** `Server.echoParts(parts)` — exercises the `Uint8Array[]` arg + result wire (writeBytes/readBytes loop). */
10
+ @remote
11
+ function echoParts(parts: Uint8Array[]): Uint8Array[] {
12
+ return parts;
13
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.68",
4
+ "version": "0.0.70",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -134,7 +134,7 @@
134
134
  "nodemailer": "^9.0.1",
135
135
  "picocolors": "^1.1.1",
136
136
  "sharp": "^0.35.2",
137
- "toilscript": "^0.1.43",
137
+ "toilscript": "^0.1.45",
138
138
  "typescript-eslint": "^8.62.0",
139
139
  "vite": "^8.1.0",
140
140
  "vite-imagetools": "^10.0.1",
@@ -145,6 +145,7 @@
145
145
  "prettier": ">=3.0.0",
146
146
  "react": ">=18.0.0",
147
147
  "react-dom": ">=18.0.0",
148
+ "toilscript": ">=0.1.45",
148
149
  "typescript": ">=6.0.0"
149
150
  },
150
151
  "overrides": {
@@ -16,6 +16,7 @@
16
16
  import { Server } from '../env/Server';
17
17
  import { decodeRequest, encodeResponse } from '../envelope';
18
18
  import { Response } from '../response';
19
+ import { Rpc } from '../rpc/Rpc';
19
20
 
20
21
  // Ensure the cookie library is in every build so its `@global` types
21
22
  // (`Cookie`, `Cookies`, `SecureCookies`, ...) register as ambient globals,
@@ -57,8 +58,14 @@ export function handle(req_ofs: i32, req_len: i32): i64 {
57
58
  // can read its cookies with no argument. Cleared in resetCurrentHandler.
58
59
  Server.currentRequest = req;
59
60
  const handler = Server.currentHandler();
61
+ // Run the handler lifecycle hooks around BOTH the RPC and the normal path, so an app that does
62
+ // central bookkeeping/auth in onRequestStarted is not silently bypassed for /__toil_rpc.
60
63
  handler.onRequestStarted(req);
61
- resp = handler.handle(req);
64
+ // Reserved RPC endpoint: a `POST /__toil_rpc` carrying a `toil-rpc` method id dispatches to the
65
+ // registered @service/@remote method (which applies its own @auth/@ratelimit guards); any other
66
+ // request returns null here and falls through to the normal handler.
67
+ const rpcHit = Rpc.dispatch(req);
68
+ resp = rpcHit != null ? rpcHit : handler.handle(req);
62
69
  handler.onRequestCompleted(req, resp);
63
70
  }
64
71
 
@@ -29,6 +29,7 @@ export { SlotValues, SlotValue, HtmlBuilder } from './ssr/slots';
29
29
 
30
30
  // HTTP layer (`@rest` / `@route`).
31
31
  export { Rest, RestRegistry, RouteFn } from './rest/Rest';
32
+ export { Rpc, RpcRegistry, RpcFn, RPC_PATH, RPC_HEADER } from './rpc/Rpc';
32
33
  export { RouteContext } from './rest/RouteContext';
33
34
  export { matchRoute } from './rest/match';
34
35
  export { RestHandler } from './rest/RestHandler';
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The auto-populated RPC dispatcher. Every `@service` method and free `@remote`
3
+ * function self-registers here at module init (compiler-injected), keyed by a
4
+ * deterministic method id (FNV-1a of `"Service.method"` / `"fnName"` — the same
5
+ * hash the generated client sends). The wasm `handle` export dispatches a
6
+ * reserved `POST /__toil_rpc` (method id in the `toil-rpc` header, `@data`-encoded
7
+ * args in the body) to the matching method and returns its `@data`-encoded
8
+ * result. Mirrors `Rest` (../rest/Rest.ts); calls are STATELESS — a fresh service
9
+ * instance per call, exactly like a `@rest` controller.
10
+ */
11
+
12
+ import { Method, Request } from '../request';
13
+ import { Response } from '../response';
14
+
15
+ /** The reserved path a generated RPC client POSTs to. */
16
+ export const RPC_PATH: string = '/__toil_rpc';
17
+ /** The request header carrying the decimal `u32` method id. */
18
+ export const RPC_HEADER: string = 'toil-rpc';
19
+
20
+ /** A registered RPC method: takes the encoded-args body and returns a `Response` - the encoded result
21
+ * (`Response.bytes`) on success, or a guard's `401`/`429` when the method carries `@auth`/`@ratelimit`.
22
+ * The compiler injects these (see toilscript injectService/injectRemote). */
23
+ export type RpcFn = (body: Uint8Array) => Response;
24
+
25
+ export class RpcRegistry {
26
+ private ids: Array<u32> = new Array<u32>();
27
+ private fns: Array<RpcFn> = new Array<RpcFn>();
28
+
29
+ /** Compiler-injected: register one `@service` method / `@remote` function by id. Not for direct use. */
30
+ register(id: u32, fn: RpcFn): void {
31
+ this.ids.push(id);
32
+ this.fns.push(fn);
33
+ }
34
+
35
+ /**
36
+ * Dispatch a reserved `POST /__toil_rpc` call to its registered method. Returns `null` for ANY
37
+ * non-RPC request (wrong method, wrong path, no id header) so the caller falls through to the
38
+ * normal handler; returns a `400` for a well-formed RPC call whose id is unknown.
39
+ */
40
+ dispatch(req: Request): Response | null {
41
+ if (req.method != Method.POST) return null;
42
+ let path = req.path;
43
+ const q = path.indexOf('?');
44
+ if (q >= 0) path = path.substring(0, q);
45
+ if (path != RPC_PATH) return null;
46
+ const raw = req.header(RPC_HEADER);
47
+ if (raw == null) return null;
48
+ const id = U32.parseInt(raw, 10);
49
+ for (let i = 0, n = this.ids.length; i < n; i++) {
50
+ if (this.ids[i] == id) {
51
+ // The injected dispatcher returns the full Response (encoded result, or a 401/429 from
52
+ // its @auth/@ratelimit guard).
53
+ return this.fns[i](req.body);
54
+ }
55
+ }
56
+ return Response.badRequest('unknown rpc method');
57
+ }
58
+
59
+ /** Number of registered methods (diagnostics / tests). */
60
+ get size(): i32 {
61
+ return this.ids.length;
62
+ }
63
+ }
64
+
65
+ /** The process-wide RPC dispatcher singleton. */
66
+ export const Rpc: RpcRegistry = new RpcRegistry();