toiljs 0.0.69 → 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 (58) hide show
  1. package/CHANGELOG.md +5 -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 +1 -1
  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/streams.md +3 -4
  27. package/examples/basic/server/services/Stats.ts +11 -3
  28. package/examples/basic/server/services/remotes.ts +8 -2
  29. package/package.json +3 -2
  30. package/server/runtime/exports/index.ts +8 -1
  31. package/server/runtime/index.ts +1 -0
  32. package/server/runtime/rpc/Rpc.ts +66 -0
  33. package/src/client/rpc.ts +21 -12
  34. package/src/client/stream/client.ts +133 -5
  35. package/src/compiler/index.ts +352 -2
  36. package/src/compiler/toil-docs.generated.ts +1 -1
  37. package/src/compiler/vite.ts +16 -0
  38. package/src/devserver/daemon/host.ts +10 -110
  39. package/src/devserver/daemon/index.ts +19 -6
  40. package/src/devserver/db/database.ts +1 -1
  41. package/src/devserver/db/routeKinds.ts +44 -0
  42. package/src/devserver/index.ts +0 -1
  43. package/src/devserver/runtime/host.ts +3 -7
  44. package/src/devserver/runtime/module.ts +30 -4
  45. package/src/devserver/stream/index.ts +8 -4
  46. package/src/devserver/wasm/surface.ts +33 -4
  47. package/test/daemon-build.test.ts +53 -0
  48. package/test/daemon-catalog.test.ts +78 -3
  49. package/test/daemon-emulation.test.ts +27 -29
  50. package/test/devserver-database.test.ts +93 -0
  51. package/test/fixtures/bignum-wire/spec.ts +3 -5
  52. package/test/fixtures/daemon-app.ts +25 -21
  53. package/test/rpc-dispatch.test.ts +132 -0
  54. package/test/rpc-kinds.test.ts +18 -0
  55. package/test/rpc.test.ts +20 -4
  56. package/build/devserver/mstore/store.d.ts +0 -18
  57. package/build/devserver/mstore/store.js +0 -82
  58. 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/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
 
@@ -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.69",
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.44",
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();
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
- * Two surfaces live under `Server`:
7
- * - `Server.REST.<controller>.<route>(args)` is a WORKING fetch client. The
8
- * generated `shared/server.ts` attaches it to `globalThis.__toilRest` when
9
- * imported; the proxy below surfaces it under `Server.REST`.
10
- * - `Server.<service>.<method>()` (RPC) is not wired yet, so any call throws.
11
- * The pipeline (tags -> generated types -> proxy) is in place; only the
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
- `import a type from your 'shared/server' (so the client attaches), or run 'npm run build:server'.`,
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
- `import a type from your 'shared/server' (so the client attaches), or run 'npm run build:server'.`,
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 not available yet. The client<->server transport ` +
59
- `is not wired; this is a generated stub. Remote calls will work once transport lands.`,
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;