toiljs 0.0.69 → 0.0.71
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 +1 -1
- 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/streams.md +3 -4
- 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 +133 -5
- package/src/compiler/index.ts +352 -2
- package/src/compiler/toil-docs.generated.ts +1 -1
- 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/rpc-dispatch.test.ts +132 -0
- package/test/rpc-kinds.test.ts +18 -0
- package/test/rpc.test.ts +20 -4
- 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/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
|
|
|
@@ -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.71",
|
|
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.46",
|
|
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.46",
|
|
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();
|
package/src/client/rpc.ts
CHANGED
|
@@ -3,14 +3,12 @@
|
|
|
3
3
|
* the toilscript server build into the project's `shared/server.ts`
|
|
4
4
|
* (`declare global { const Server }`); this module is the runtime behind it.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* - `Server.REST.<controller>.<route>(args)`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* network dispatch is a TODO. Build the server (`npm run build:server`) to
|
|
13
|
-
* (re)generate the typed surface.
|
|
6
|
+
* Three surfaces live under `Server`, all attached by the generated `shared/server.ts` on import:
|
|
7
|
+
* - `Server.REST.<controller>.<route>(args)` - a fetch client (`globalThis.__toilRest`).
|
|
8
|
+
* - `Server.Stream.<class>.connect()` - the stream client (`globalThis.__toilStream`).
|
|
9
|
+
* - `Server.<service>.<method>(args)` and a free `Server.<remote>(args)` - the RPC client
|
|
10
|
+
* (`globalThis.__toilRpc`): each POSTs to `/__toil_rpc` with a method id and @data-encoded args.
|
|
11
|
+
* Build the server (`npm run build:server`) to (re)generate the typed surface + attach the clients.
|
|
14
12
|
*/
|
|
15
13
|
|
|
16
14
|
/** A recursive proxy that throws on call, used when the REST client hasn't loaded. */
|
|
@@ -18,7 +16,7 @@ function restMissingStub(path: string): unknown {
|
|
|
18
16
|
const call = (): never => {
|
|
19
17
|
throw new Error(
|
|
20
18
|
`toiljs REST: ${path}() is unavailable. The generated REST client has not loaded - ` +
|
|
21
|
-
`
|
|
19
|
+
`run 'npm run build:server' to generate shared/server.ts; the client then attaches automatically.`,
|
|
22
20
|
);
|
|
23
21
|
};
|
|
24
22
|
return new Proxy(call, {
|
|
@@ -37,7 +35,7 @@ function streamMissingStub(path: string): unknown {
|
|
|
37
35
|
const call = (): never => {
|
|
38
36
|
throw new Error(
|
|
39
37
|
`toiljs Stream: ${path}() is unavailable. The generated stream client has not loaded - ` +
|
|
40
|
-
`
|
|
38
|
+
`run 'npm run build:server' to generate shared/server.ts; the client then attaches automatically.`,
|
|
41
39
|
);
|
|
42
40
|
};
|
|
43
41
|
return new Proxy(call, {
|
|
@@ -55,8 +53,8 @@ function streamMissingStub(path: string): unknown {
|
|
|
55
53
|
function rpcStub(path: string): unknown {
|
|
56
54
|
const call = (): never => {
|
|
57
55
|
throw new Error(
|
|
58
|
-
`toiljs RPC: ${path}() is
|
|
59
|
-
`
|
|
56
|
+
`toiljs RPC: ${path}() is unavailable. The generated RPC client has not loaded - ` +
|
|
57
|
+
`run 'npm run build:server' to generate shared/server.ts; the client then attaches automatically.`,
|
|
60
58
|
);
|
|
61
59
|
};
|
|
62
60
|
return new Proxy(call, {
|
|
@@ -73,6 +71,12 @@ function rpcStub(path: string): unknown {
|
|
|
73
71
|
const stream = (globalThis as { __toilStream?: unknown }).__toilStream;
|
|
74
72
|
return stream !== undefined ? stream : streamMissingStub('Server.Stream');
|
|
75
73
|
}
|
|
74
|
+
// `Server.<service>` / `Server.<remote>` surface the generated RPC client. Its top-level
|
|
75
|
+
// keys are the @service names (-> a `{ method }` object) and free @remote functions.
|
|
76
|
+
if (path === 'Server') {
|
|
77
|
+
const rpc = (globalThis as { __toilRpc?: Record<string, unknown> }).__toilRpc;
|
|
78
|
+
if (rpc !== undefined && prop in rpc) return rpc[prop];
|
|
79
|
+
}
|
|
76
80
|
return rpcStub(`${path}.${prop}`);
|
|
77
81
|
},
|
|
78
82
|
apply() {
|
|
@@ -86,3 +90,8 @@ function rpcStub(path: string): unknown {
|
|
|
86
90
|
* come from the generated `shared/server.ts`. toiljs assigns this to `globalThis`.
|
|
87
91
|
*/
|
|
88
92
|
export const Server: unknown = rpcStub('Server');
|
|
93
|
+
|
|
94
|
+
// Back the generated `declare global { const Server }` (shared/server.ts) with a runtime value, so an
|
|
95
|
+
// app reaches `Server.REST` / `Server.Stream.<Name>` via the global without an import (the design note
|
|
96
|
+
// above). Idempotent - the proxy is a singleton; importing `toiljs/client` evaluates this once.
|
|
97
|
+
(globalThis as Record<string, unknown>).Server = Server;
|