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.
- package/CHANGELOG.md +10 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/rpc.js +10 -4
- package/build/client/stream/client.js +108 -5
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/index.d.ts +2 -0
- package/build/compiler/index.js +282 -2
- package/build/compiler/toil-docs.generated.js +3 -2
- package/build/compiler/vite.js +8 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/daemon/host.d.ts +1 -7
- package/build/devserver/daemon/host.js +5 -59
- package/build/devserver/daemon/index.d.ts +1 -0
- package/build/devserver/daemon/index.js +17 -4
- package/build/devserver/db/database.js +1 -1
- package/build/devserver/db/routeKinds.d.ts +6 -0
- package/build/devserver/db/routeKinds.js +40 -0
- package/build/devserver/index.d.ts +0 -1
- package/build/devserver/index.js +0 -1
- package/build/devserver/runtime/module.d.ts +1 -0
- package/build/devserver/runtime/module.js +18 -2
- package/build/devserver/stream/index.js +4 -3
- package/build/devserver/wasm/surface.d.ts +2 -0
- package/build/devserver/wasm/surface.js +35 -4
- package/docs/derive.md +159 -0
- package/docs/index.md +1 -1
- package/docs/streams.md +49 -18
- package/examples/basic/server/services/Stats.ts +11 -3
- package/examples/basic/server/services/remotes.ts +8 -2
- package/package.json +3 -2
- package/server/runtime/exports/index.ts +8 -1
- package/server/runtime/index.ts +1 -0
- package/server/runtime/rpc/Rpc.ts +66 -0
- package/src/client/rpc.ts +21 -12
- package/src/client/stream/client.ts +138 -8
- package/src/compiler/index.ts +352 -2
- package/src/compiler/toil-docs.generated.ts +3 -2
- package/src/compiler/vite.ts +16 -0
- package/src/devserver/daemon/host.ts +10 -110
- package/src/devserver/daemon/index.ts +19 -6
- package/src/devserver/db/database.ts +1 -1
- package/src/devserver/db/routeKinds.ts +44 -0
- package/src/devserver/index.ts +0 -1
- package/src/devserver/runtime/host.ts +3 -7
- package/src/devserver/runtime/module.ts +30 -4
- package/src/devserver/stream/index.ts +8 -4
- package/src/devserver/wasm/surface.ts +33 -4
- package/test/daemon-build.test.ts +53 -0
- package/test/daemon-catalog.test.ts +78 -3
- package/test/daemon-emulation.test.ts +27 -29
- package/test/devserver-database.test.ts +93 -0
- package/test/fixtures/bignum-wire/spec.ts +3 -5
- package/test/fixtures/daemon-app.ts +25 -21
- package/test/fixtures/stream-typed.ts +41 -0
- package/test/rpc-dispatch.test.ts +132 -0
- package/test/rpc-kinds.test.ts +18 -0
- package/test/rpc.test.ts +20 -4
- package/test/stream-emulation.test.ts +39 -0
- package/build/devserver/mstore/store.d.ts +0 -18
- package/build/devserver/mstore/store.js +0 -82
- 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';
|
package/build/devserver/index.js
CHANGED
|
@@ -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';
|
|
@@ -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
|
-
|
|
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' ||
|
|
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,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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
##
|
|
119
|
+
## Reading and replying to messages
|
|
121
120
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
`
|
|
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
|
|
4
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/server/runtime/index.ts
CHANGED
|
@@ -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();
|