mirinjs 0.1.0-alpha.26 → 0.1.0-alpha.27
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/package.json +1 -1
- package/src/app/facade.ts +1 -16
- package/src/app/lib/rpc-emitters.ts +37 -0
- package/src/app/types.ts +8 -3
- package/src/client.ts +39 -12
- package/src/rpc-server.ts +24 -10
- package/src/rpc.ts +23 -4
- package/src/shared/rpc-paths.ts +83 -0
package/package.json
CHANGED
package/src/app/facade.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { Updater } from "../updater/index.ts";
|
|
|
12
12
|
import { updater } from "../updater/index.ts";
|
|
13
13
|
import { Emitter } from "./lib/emitter.ts";
|
|
14
14
|
import { normalizeMaterial } from "./lib/material.ts";
|
|
15
|
+
import { type RpcEmitters, rpcEmitters } from "./lib/rpc-emitters.ts";
|
|
15
16
|
import type {
|
|
16
17
|
AppEvents,
|
|
17
18
|
BroadcastEmitters,
|
|
@@ -139,22 +140,6 @@ export class WindowHandle extends Emitter<WindowEvents> {
|
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
type RpcEmitters = Record<
|
|
143
|
-
string,
|
|
144
|
-
{ emit(payload: unknown): void; broadcast(payload: unknown): void }
|
|
145
|
-
>;
|
|
146
|
-
|
|
147
|
-
function rpcEmitters(send: (method: string, payload: unknown) => void): RpcEmitters {
|
|
148
|
-
return new Proxy({} as RpcEmitters, {
|
|
149
|
-
get(_t, method: string) {
|
|
150
|
-
return {
|
|
151
|
-
emit: (payload: unknown) => send(method, payload),
|
|
152
|
-
broadcast: (payload: unknown) => send(method, payload),
|
|
153
|
-
};
|
|
154
|
-
},
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
143
|
class Windows {
|
|
159
144
|
#byName = new Map<string, WindowHandle>();
|
|
160
145
|
#byId = new Map<number, WindowHandle>();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The untyped emitters behind `app.rpc` and a window's `rpc`: `rpc.progress`
|
|
3
|
+
* sends the event `progress`, and `rpc.window.focused` the nested router's
|
|
4
|
+
* `window.focused` (docs/api-design.md §3). `app.serve` hands out the same
|
|
5
|
+
* object typed by the router (BroadcastEmitters).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { SEPARATOR } from "../../shared/rpc-paths.ts";
|
|
9
|
+
|
|
10
|
+
/** An event by name, and the events of a nested router under it (`rpc.window.focused`). */
|
|
11
|
+
export type RpcEmitter = {
|
|
12
|
+
emit(payload: unknown): void;
|
|
13
|
+
broadcast(payload: unknown): void;
|
|
14
|
+
} & RpcEmitters;
|
|
15
|
+
|
|
16
|
+
export interface RpcEmitters {
|
|
17
|
+
readonly [name: string]: RpcEmitter;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function rpcEmitters(send: (method: string, payload: unknown) => void): RpcEmitters {
|
|
21
|
+
const node = (method: string): RpcEmitter =>
|
|
22
|
+
new Proxy({} as RpcEmitter, {
|
|
23
|
+
get(_t, prop) {
|
|
24
|
+
if (prop === "emit" || prop === "broadcast") {
|
|
25
|
+
return (payload: unknown) => send(method, payload);
|
|
26
|
+
}
|
|
27
|
+
// Not a promise, and nothing behind symbols.
|
|
28
|
+
if (typeof prop !== "string" || prop === "then") return undefined;
|
|
29
|
+
return node(`${method}${SEPARATOR}${prop}`);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
return new Proxy({} as RpcEmitters, {
|
|
33
|
+
get(_t, method) {
|
|
34
|
+
return typeof method === "string" ? node(method) : undefined;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/app/types.ts
CHANGED
|
@@ -41,13 +41,18 @@ export interface WindowOpenOptions extends WindowConfig {
|
|
|
41
41
|
name?: string;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** Typed push emitters derived from a router's `event` procedures
|
|
44
|
+
/** Typed push emitters derived from a router's `event` procedures, nested
|
|
45
|
+
* routers under their names (`rpc.window.focused.broadcast(...)`).
|
|
45
46
|
* Type-level `any` keeps the caller's router payload inference intact. */
|
|
46
47
|
export type BroadcastEmitters<R extends Router<any>> = R extends Router<infer T>
|
|
47
48
|
? {
|
|
48
|
-
[K in keyof T as T[K] extends EventProc<any>
|
|
49
|
+
[K in keyof T as T[K] extends EventProc<any> | Router<any>
|
|
50
|
+
? K
|
|
51
|
+
: never]: T[K] extends EventProc<infer P>
|
|
49
52
|
? { broadcast(payload: P): void }
|
|
50
|
-
:
|
|
53
|
+
: T[K] extends Router<any>
|
|
54
|
+
? BroadcastEmitters<T[K]>
|
|
55
|
+
: never;
|
|
51
56
|
}
|
|
52
57
|
: never;
|
|
53
58
|
|
package/src/client.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { EventProc, MutationProc, QueryProc, Router } from "./rpc.ts";
|
|
9
|
+
import { SEPARATOR } from "./shared/rpc-paths.ts";
|
|
9
10
|
|
|
10
11
|
// Type-level `any` preserves the app router's concrete procedure inference for UI callers.
|
|
11
12
|
export type ClientFor<R extends Router<any>> = R extends Router<infer T>
|
|
@@ -16,7 +17,9 @@ export type ClientFor<R extends Router<any>> = R extends Router<infer T>
|
|
|
16
17
|
? (input: I) => Promise<Awaited<O>>
|
|
17
18
|
: T[K] extends EventProc<infer P>
|
|
18
19
|
? { on(listener: (payload: P) => void): () => void }
|
|
19
|
-
:
|
|
20
|
+
: T[K] extends Router<any>
|
|
21
|
+
? ClientFor<T[K]>
|
|
22
|
+
: never;
|
|
20
23
|
}
|
|
21
24
|
: never;
|
|
22
25
|
|
|
@@ -51,20 +54,44 @@ function transport(): MirinTransport {
|
|
|
51
54
|
*/
|
|
52
55
|
// Type-level `any` keeps `client<AppRouter>()` inference intact.
|
|
53
56
|
export function client<R extends Router<any>>(): ClientFor<R> {
|
|
54
|
-
const
|
|
55
|
-
|
|
57
|
+
const nodes = new Map<string, unknown>();
|
|
58
|
+
const node = (path: string): unknown => {
|
|
59
|
+
let found = nodes.get(path);
|
|
60
|
+
if (found === undefined) {
|
|
61
|
+
found = clientNode(path, node);
|
|
62
|
+
nodes.set(path, found);
|
|
63
|
+
}
|
|
64
|
+
return found;
|
|
65
|
+
};
|
|
56
66
|
return new Proxy({} as ClientFor<R>, {
|
|
57
67
|
get(_target, prop) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
return typeof prop === "string" ? node(prop) : undefined;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What a function's caller expects to find on it; anything else is a nested name. */
|
|
74
|
+
const FUNCTION_MEMBERS = new Set(["apply", "bind", "call", "toString"]);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One name in the client, `path` from the root. Procedure kind is unknowable
|
|
78
|
+
* from the type-erased proxy, so it is all of them at once: callable (a query
|
|
79
|
+
* or mutation), event-shaped (`.on`), and a router (any other name is a child
|
|
80
|
+
* at `path.name`). The router type makes misuse a compile error, and
|
|
81
|
+
* rpc.router keeps nested routers off the names used here.
|
|
82
|
+
*/
|
|
83
|
+
function clientNode(path: string, node: (path: string) => unknown): unknown {
|
|
84
|
+
const call = (input: unknown) => transport().call(path, input);
|
|
85
|
+
const on = (listener: (payload: unknown) => void) => transport().onEvent(path, listener);
|
|
86
|
+
return new Proxy(call, {
|
|
87
|
+
get(target, prop, receiver) {
|
|
88
|
+
if (prop === "on") return on;
|
|
89
|
+
// Not a promise: `await` and promise helpers must not take a node for one.
|
|
90
|
+
if (prop === "then") return undefined;
|
|
91
|
+
if (typeof prop !== "string" || FUNCTION_MEMBERS.has(prop)) {
|
|
92
|
+
return Reflect.get(target, prop, receiver);
|
|
66
93
|
}
|
|
67
|
-
return
|
|
94
|
+
return node(`${path}${SEPARATOR}${prop}`);
|
|
68
95
|
},
|
|
69
96
|
});
|
|
70
97
|
}
|
package/src/rpc-server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { devtoolsOptions } from "./devtools/options.ts";
|
|
|
9
9
|
import { record } from "./devtools/sink.ts";
|
|
10
10
|
import type { Procedure, Router, RpcContext } from "./rpc.ts";
|
|
11
11
|
import { formatArg } from "./shared/format.ts";
|
|
12
|
+
import { procedures, SEPARATOR } from "./shared/rpc-paths.ts";
|
|
12
13
|
|
|
13
14
|
interface SocketData {
|
|
14
15
|
webview: number;
|
|
@@ -44,8 +45,8 @@ export type ControlHandler = (frame: ControlFrame, webview: number) => void;
|
|
|
44
45
|
export class RpcServer {
|
|
45
46
|
readonly token = crypto.randomUUID();
|
|
46
47
|
#server?: ReturnType<typeof Bun.serve>;
|
|
47
|
-
|
|
48
|
-
#
|
|
48
|
+
/** The served router's procedures by path (`greet`, `window.min`). */
|
|
49
|
+
#procedures = new Map<string, Procedure>();
|
|
49
50
|
#control?: ControlHandler;
|
|
50
51
|
#sockets = new Set<ServerWebSocket<SocketData>>();
|
|
51
52
|
|
|
@@ -96,16 +97,16 @@ export class RpcServer {
|
|
|
96
97
|
await server.stop(true);
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
/**
|
|
100
|
+
/** Serve `router`, replacing the one served before (compose routers to serve
|
|
101
|
+
* several). Type-level `any` keeps the caller's concrete router shape. */
|
|
100
102
|
setRouter(router: Router<any>): void {
|
|
101
|
-
this.#
|
|
103
|
+
this.#procedures = procedures(router);
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
/** The registered procedures, for the inspector's `/state`: a tool can
|
|
105
|
-
* the RPC surface without reading source. */
|
|
106
|
+
/** The registered procedures by path, for the inspector's `/state`: a tool can
|
|
107
|
+
* discover the RPC surface without reading source. */
|
|
106
108
|
routes(): { name: string; type: string }[] {
|
|
107
|
-
|
|
108
|
-
return Object.entries(routes).map(([name, proc]) => ({ name, type: proc.type }));
|
|
109
|
+
return [...this.#procedures].map(([name, proc]) => ({ name, type: proc.type }));
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
/** Register the handler for internal `mirin:*` control actions (e.g. window drag). */
|
|
@@ -127,6 +128,19 @@ export class RpcServer {
|
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
/** Why `method` can't be called: unknown, an event, or a router rather than one of its procedures. */
|
|
132
|
+
#notCallable(method: string, proc: Procedure | undefined): string {
|
|
133
|
+
if (proc?.type === "event") {
|
|
134
|
+
return `${method} is an event: the main process emits it and the UI listens with .on()`;
|
|
135
|
+
}
|
|
136
|
+
const prefix = `${method}${SEPARATOR}`;
|
|
137
|
+
const inside = [...this.#procedures.keys()].filter((name) => name.startsWith(prefix));
|
|
138
|
+
if (inside.length > 0) {
|
|
139
|
+
return `no such procedure: ${method} is a router; call one of its procedures (${inside.slice(0, 5).join(", ")}${inside.length > 5 ? ", …" : ""})`;
|
|
140
|
+
}
|
|
141
|
+
return `no such procedure: ${method}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
130
144
|
async #onMessage(ws: ServerWebSocket<SocketData>, raw: string | Buffer): Promise<void> {
|
|
131
145
|
let frame: IncomingFrame;
|
|
132
146
|
try {
|
|
@@ -148,9 +162,9 @@ export class RpcServer {
|
|
|
148
162
|
const webview = ws.data.webview;
|
|
149
163
|
traceRequest(frame, webview);
|
|
150
164
|
|
|
151
|
-
const proc = this.#
|
|
165
|
+
const proc = this.#procedures.get(frame.method);
|
|
152
166
|
if (!proc || (proc.type !== "query" && proc.type !== "mutation")) {
|
|
153
|
-
const error =
|
|
167
|
+
const error = this.#notCallable(frame.method, proc);
|
|
154
168
|
traceFailure(frame, webview, 0, error, "unknown-procedure");
|
|
155
169
|
reply(false, { error });
|
|
156
170
|
return;
|
package/src/rpc.ts
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Imported by BOTH the main process (handlers run here, in the Bun Worker)
|
|
5
5
|
* and UI code (type-only, via `client<Router>()` from mirin/client).
|
|
6
|
-
* The router is global; handlers learn their caller from `ctx`.
|
|
6
|
+
* The router is global; handlers learn their caller from `ctx`. Routers nest:
|
|
7
|
+
* a router's routes can be other routers, and their procedures are called by
|
|
8
|
+
* path (`api.window.min()`, `window.min` on the wire).
|
|
7
9
|
*/
|
|
8
10
|
|
|
11
|
+
import { procedures } from "./shared/rpc-paths.ts";
|
|
12
|
+
|
|
9
13
|
export interface RpcContext {
|
|
10
14
|
/** Name of the calling window, if it was opened with one. */
|
|
11
15
|
window?: string;
|
|
@@ -35,14 +39,29 @@ export interface EventProc<P> {
|
|
|
35
39
|
// no runtime value is widened through this union.
|
|
36
40
|
export type Procedure = QueryProc<any, any> | MutationProc<any, any> | EventProc<any>;
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
/** What a router holds: procedures, and other routers under their own names. */
|
|
43
|
+
export interface Routes {
|
|
44
|
+
// Type-level `any` keeps each nested router's own procedure types.
|
|
45
|
+
readonly [name: string]: Procedure | Router<any>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Router<T extends Routes = Routes> {
|
|
39
49
|
readonly type: "router";
|
|
40
50
|
readonly routes: T;
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
export const rpc = {
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Define a router. Nested routers keep their procedures apart
|
|
56
|
+
* (`window.close` and `install.close` are two procedures). Throws when two
|
|
57
|
+
* procedures end up with the same path, or when a nested router uses a name
|
|
58
|
+
* the client needs (`on`, `then`, `emit`, `broadcast`, `apply`, `bind`,
|
|
59
|
+
* `call`, `toString`).
|
|
60
|
+
*/
|
|
61
|
+
router<T extends Routes>(routes: T): Router<T> {
|
|
62
|
+
const router: Router<T> = { type: "router", routes };
|
|
63
|
+
procedures(router);
|
|
64
|
+
return router;
|
|
46
65
|
},
|
|
47
66
|
|
|
48
67
|
/** Request/response, semantically a read. Runtime-identical to mutation in the MVP. */
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nested routers on the wire (docs/api-design.md §3). A router can hold other
|
|
3
|
+
* routers; each procedure is then named by its path, joined with dots
|
|
4
|
+
* (`window.min`), which is the method name a request frame carries. Flat
|
|
5
|
+
* routers keep their names as they were.
|
|
6
|
+
*
|
|
7
|
+
* The UI client and the main-process emitters are proxies that only know the
|
|
8
|
+
* router's type, so inside a nested router a few names belong to them: on a
|
|
9
|
+
* client node `on` subscribes, `then` would make it look like a promise, and
|
|
10
|
+
* `apply`, `bind`, `call`, `toString` keep it a normal function; on an emitter
|
|
11
|
+
* node `emit` and `broadcast` send. A nested router can't use those names.
|
|
12
|
+
* Top-level names are not restricted, as before.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Procedure, Router } from "../rpc.ts";
|
|
16
|
+
|
|
17
|
+
export const SEPARATOR = ".";
|
|
18
|
+
|
|
19
|
+
export const RESERVED_NESTED_NAMES: ReadonlySet<string> = new Set([
|
|
20
|
+
"on",
|
|
21
|
+
"then",
|
|
22
|
+
"emit",
|
|
23
|
+
"broadcast",
|
|
24
|
+
"apply",
|
|
25
|
+
"bind",
|
|
26
|
+
"call",
|
|
27
|
+
"toString",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const KINDS = new Set(["query", "mutation", "event"]);
|
|
31
|
+
|
|
32
|
+
export function isRouter(value: unknown): value is Router {
|
|
33
|
+
if (typeof value !== "object" || value === null) return false;
|
|
34
|
+
const candidate = value as { type?: unknown; routes?: unknown };
|
|
35
|
+
return (
|
|
36
|
+
candidate.type === "router" && typeof candidate.routes === "object" && candidate.routes !== null
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isProcedure(value: unknown): value is Procedure {
|
|
41
|
+
if (typeof value !== "object" || value === null) return false;
|
|
42
|
+
return KINDS.has(String((value as { type?: unknown }).type));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every procedure of `router` by its full path. Throws on a value that is not
|
|
47
|
+
* a procedure or a router, on two procedures with the same path (a flat
|
|
48
|
+
* `"window.min"` beside a nested `window: { min }`), on a router that contains
|
|
49
|
+
* itself, and on a reserved name inside a nested router.
|
|
50
|
+
*/
|
|
51
|
+
export function procedures(router: Router): Map<string, Procedure> {
|
|
52
|
+
const found = new Map<string, Procedure>();
|
|
53
|
+
const walk = (current: Router, prefix: string, open: Set<Router>): void => {
|
|
54
|
+
open.add(current);
|
|
55
|
+
for (const [name, value] of Object.entries(current.routes)) {
|
|
56
|
+
const path = prefix === "" ? name : `${prefix}${SEPARATOR}${name}`;
|
|
57
|
+
if (prefix !== "" && RESERVED_NESTED_NAMES.has(name)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`rpc.router: "${path}" can't be used inside a nested router: ${[...RESERVED_NESTED_NAMES].join(", ")} are taken by the client and the event emitters. Rename it.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (isRouter(value)) {
|
|
63
|
+
if (open.has(value)) throw new Error(`rpc.router: "${path}" contains itself`);
|
|
64
|
+
walk(value, path, open);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!isProcedure(value)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`rpc.router: "${path}" is not a procedure or a router (use rpc.query, rpc.mutation, rpc.event, or rpc.router)`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (found.has(path)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`rpc.router: two procedures are named "${path}" (a nested router's procedures are named <router>${SEPARATOR}<procedure>). Rename one.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
found.set(path, value);
|
|
78
|
+
}
|
|
79
|
+
open.delete(current);
|
|
80
|
+
};
|
|
81
|
+
walk(router, "", new Set());
|
|
82
|
+
return found;
|
|
83
|
+
}
|