mirinjs 0.1.0-alpha.25 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirinjs",
3
- "version": "0.1.0-alpha.25",
3
+ "version": "0.1.0-alpha.27",
4
4
  "description": "Build desktop apps with Bun, TypeScript, and Chromium (CEF). The Bun-native Electron alternative.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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> ? K : never]: T[K] extends EventProc<infer P>
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
- : never;
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
- : never;
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 eventCache = new Map<string, { on(l: (p: unknown) => void): () => void }>();
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
- if (typeof prop !== "string") return undefined;
59
- // Procedure kind is unknowable from the type-erased proxy; expose a callable
60
- // that is *also* event-shaped. The router type makes misuse a compile error.
61
- const callable = (input: unknown) => transport().call(prop, input);
62
- let ev = eventCache.get(prop);
63
- if (!ev) {
64
- ev = { on: (listener) => transport().onEvent(prop, listener) };
65
- eventCache.set(prop, ev);
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 Object.assign(callable, ev);
94
+ return node(`${path}${SEPARATOR}${prop}`);
68
95
  },
69
96
  });
70
97
  }
@@ -183,6 +183,14 @@ export interface MirinConfig {
183
183
  * app worker owns AppKit/CEF (see docs/architecture.md).
184
184
  */
185
185
  workers?: Record<string, string>;
186
+ /**
187
+ * Extra directories bundled verbatim into the app (`Contents/Resources/<name>`
188
+ * on macOS, `resources/<name>` elsewhere), for assets the main process reads
189
+ * at runtime: a bundled server, templates, data files. Maps a filename-safe
190
+ * name to a project-relative directory. Resolve one with `resolveResource(name)`;
191
+ * under `mirin dev` it points at the source directory.
192
+ */
193
+ resources?: Record<string, string>;
186
194
  /**
187
195
  * Custom URL schemes this app registers as a handler for (macOS
188
196
  * `CFBundleURLTypes`), e.g. `["anko"]` so `anko://…` links launch the app (or
package/src/index.ts CHANGED
@@ -62,7 +62,7 @@ export type { MenuItemTemplate, MenuRole } from "./menu.ts";
62
62
  export { menu } from "./menu.ts";
63
63
  export type { NotificationOptions } from "./notification.ts";
64
64
  export { notification } from "./notification.ts";
65
- export { NotAttachedError, resolveWorker } from "./runtime.ts";
65
+ export { NotAttachedError, resolveResource, resolveWorker } from "./runtime.ts";
66
66
  export { globalShortcut } from "./shortcut.ts";
67
67
  export type { SidecarOptions, SidecarProcess } from "./sidecar.ts";
68
68
  export { resolveSidecar, sidecar } from "./sidecar.ts";
package/src/logger.ts CHANGED
@@ -14,7 +14,9 @@
14
14
  * Every emitted line is also recorded to the devtools sink (docs/agent-devtools.md).
15
15
  */
16
16
 
17
+ import { format } from "node:util";
17
18
  import { record } from "./devtools/sink.ts";
19
+ import { writeLine } from "./shared/console.ts";
18
20
  import { firstError, formatArgs, formatStack } from "./shared/format.ts";
19
21
 
20
22
  export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
@@ -97,8 +99,7 @@ export class Logger {
97
99
  const name = this.#scope ? `[mirin:${this.#scope}]` : "[mirin]";
98
100
  const prefix = useColor ? `${COLOR.dim}${name}${COLOR.reset}` : name;
99
101
  const tag = useColor ? `${COLOR[level]}${level}${COLOR.reset}` : level;
100
- const out = level === "warn" || level === "error" ? console.error : console.log;
101
- out(`${prefix} ${tag}`, ...args);
102
+ writeLine(level === "warn" || level === "error" ? 2 : 1, `${prefix} ${tag} ${format(...args)}`);
102
103
  this.#record(level, args);
103
104
  }
104
105
 
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
- // Type-level `any` keeps the server able to store any concrete app router.
48
- #router?: Router<any>;
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
- /** Type-level `any` keeps the caller's concrete router shape. */
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.#router = router;
103
+ this.#procedures = procedures(router);
102
104
  }
103
105
 
104
- /** The registered procedures, for the inspector's `/state`: a tool can discover
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
- const routes: Record<string, Procedure> = this.#router?.routes ?? {};
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.#router?.routes[frame.method];
165
+ const proc = this.#procedures.get(frame.method);
152
166
  if (!proc || (proc.type !== "query" && proc.type !== "mutation")) {
153
- const error = `no such procedure: ${frame.method}`;
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
- export interface Router<T extends Record<string, Procedure> = Record<string, Procedure>> {
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
- router<T extends Record<string, Procedure>>(routes: T): Router<T> {
45
- return { type: "router", routes };
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. */
package/src/runtime.ts CHANGED
@@ -4,12 +4,13 @@
4
4
  * API lives in app/index.ts and the feature modules, which subscribe here.
5
5
  */
6
6
 
7
- import { join } from "node:path";
7
+ import { isAbsolute, join } from "node:path";
8
8
  import { workerData } from "node:worker_threads";
9
9
  import type { DevtoolsConfig, WindowConfig } from "./config/index.ts";
10
10
  import { parseDevtoolsConfig } from "./devtools/options.ts";
11
11
  import { Core } from "./native.ts";
12
12
  import { RpcServer } from "./rpc-server.ts";
13
+ import { installWorkerConsole } from "./shared/console.ts";
13
14
 
14
15
  export interface ManifestWindowConfig extends WindowConfig {
15
16
  name: string;
@@ -32,6 +33,8 @@ export interface Runtime {
32
33
  sidecarDir?: string;
33
34
  /** Dir holding bundled extra-worker JS (for `resolveWorker`). */
34
35
  workersDir?: string;
36
+ /** Declared resource directories: source paths in dev, bundle-relative names in prod. */
37
+ resources: Record<string, string>;
35
38
  /** `devtools` block from the manifest, resolved by the devtools module. */
36
39
  devtools?: DevtoolsConfig;
37
40
  }
@@ -137,7 +140,11 @@ export function claimBootSlot(): boolean {
137
140
  export function boot(): void {
138
141
  const data = (workerData ?? {}) as {
139
142
  corePath?: string;
140
- manifest?: { windows?: Record<string, WindowConfig>; devtools?: unknown };
143
+ manifest?: {
144
+ windows?: Record<string, WindowConfig>;
145
+ devtools?: unknown;
146
+ resources?: Record<string, string>;
147
+ };
141
148
  id?: string;
142
149
  devUrl?: string;
143
150
  resourcesDir?: string;
@@ -146,6 +153,7 @@ export function boot(): void {
146
153
  };
147
154
  const corePath = data.corePath ?? process.env.MIRIN_CORE;
148
155
  if (!corePath) return; // not under the host; the API stays detached
156
+ installWorkerConsole();
149
157
  if (!claimBootSlot()) {
150
158
  console.error(
151
159
  "[mirin] refusing to boot a second mirinjs copy in this process — the bundle " +
@@ -199,6 +207,7 @@ export function boot(): void {
199
207
  corePath,
200
208
  sidecarDir: data.sidecarDir,
201
209
  workersDir: data.workersDir,
210
+ resources: data.manifest?.resources ?? {},
202
211
  devtools: parseDevtoolsConfig(data.manifest?.devtools),
203
212
  };
204
213
  core.onEvent(dispatch);
@@ -210,6 +219,19 @@ export function boot(): void {
210
219
  * Works in `mirin dev` and the built `.app`. Extra workers run off the main
211
220
  * thread and must not call window/native APIs.
212
221
  */
222
+ /**
223
+ * Resolve a bundled resource directory (declared in `mirin.config.ts` `resources`)
224
+ * to an absolute path. Under `mirin dev` this is the source directory; in a built
225
+ * app it lives under the bundle's resources directory.
226
+ */
227
+ export function resolveResource(name: string): string {
228
+ const entry = current?.resources[name];
229
+ if (!current || entry === undefined) throw new NotAttachedError(`resolveResource("${name}")`);
230
+ if (isAbsolute(entry)) return entry;
231
+ if (!current.resourcesDir) throw new NotAttachedError(`resolveResource("${name}")`);
232
+ return join(current.resourcesDir, entry);
233
+ }
234
+
213
235
  export function resolveWorker(name: string): string {
214
236
  const dir = current?.workersDir;
215
237
  if (!dir) throw new NotAttachedError("resolveWorker");
@@ -0,0 +1,46 @@
1
+ import { writeSync } from "node:fs";
2
+ import { format } from "node:util";
3
+ import { record } from "../devtools/sink.ts";
4
+ import type { DevEventLevel } from "../devtools/types.ts";
5
+ import { firstError, formatArgs, formatStack } from "./format.ts";
6
+
7
+ export type LineWriter = (fd: 1 | 2, line: string) => void;
8
+
9
+ // The app Worker's console normally hands output to the main thread, which sits
10
+ // inside CEF's message loop for the life of the app, so nothing is ever written.
11
+ // A direct fd write from the Worker thread bypasses that.
12
+ export function writeLine(fd: 1 | 2, line: string): void {
13
+ try {
14
+ writeSync(fd, line.endsWith("\n") ? line : `${line}\n`);
15
+ } catch {
16
+ // A closed pipe (parent gone) must not take the app down with a log line.
17
+ }
18
+ }
19
+
20
+ const CONSOLE_LEVELS = {
21
+ log: "info",
22
+ info: "info",
23
+ debug: "debug",
24
+ warn: "warn",
25
+ error: "error",
26
+ } as const satisfies Record<string, DevEventLevel>;
27
+
28
+ export type ConsoleMethod = keyof typeof CONSOLE_LEVELS;
29
+
30
+ /** Route `console.*` from the Worker to fd 1/2 directly and into the devtools sink. */
31
+ export function installWorkerConsole(write: LineWriter = writeLine): void {
32
+ for (const method of Object.keys(CONSOLE_LEVELS) as ConsoleMethod[]) {
33
+ const level = CONSOLE_LEVELS[method];
34
+ console[method] = (...args: unknown[]) => {
35
+ write(level === "warn" || level === "error" ? 2 : 1, format(...args));
36
+ const error = firstError(args);
37
+ record({
38
+ src: "main",
39
+ level,
40
+ type: "console",
41
+ msg: formatArgs(args),
42
+ data: { method, ...(error !== undefined ? { stack: formatStack(error) } : {}) },
43
+ });
44
+ };
45
+ }
46
+ }
@@ -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
+ }
@@ -0,0 +1,45 @@
1
+ // Loose semver: "1.2.3", "v1.2.3", "1.2.3-nightly.4". Numeric segments compare
2
+ // as numbers, anything else lexically; a prerelease sorts below its release.
3
+ function parse(version: string): { main: string[]; pre: string[] } {
4
+ const trimmed = version.trim().replace(/^v/i, "");
5
+ const plus = trimmed.indexOf("+");
6
+ const noBuild = plus === -1 ? trimmed : trimmed.slice(0, plus);
7
+ const dash = noBuild.indexOf("-");
8
+ const main = (dash === -1 ? noBuild : noBuild.slice(0, dash)).split(".");
9
+ const pre = dash === -1 ? [] : noBuild.slice(dash + 1).split(".");
10
+ return { main, pre };
11
+ }
12
+
13
+ function compareSegment(a: string | undefined, b: string | undefined): number {
14
+ if (a === undefined && b === undefined) return 0;
15
+ if (a === undefined) return -1;
16
+ if (b === undefined) return 1;
17
+ const na = /^\d+$/.test(a) ? Number(a) : null;
18
+ const nb = /^\d+$/.test(b) ? Number(b) : null;
19
+ if (na !== null && nb !== null) return na === nb ? 0 : na < nb ? -1 : 1;
20
+ if (na !== null) return -1;
21
+ if (nb !== null) return 1;
22
+ return a === b ? 0 : a < b ? -1 : 1;
23
+ }
24
+
25
+ /** Negative when `a` is older than `b`, zero when equal, positive when newer. */
26
+ export function compareVersions(a: string, b: string): number {
27
+ const pa = parse(a);
28
+ const pb = parse(b);
29
+ for (let i = 0; i < Math.max(pa.main.length, pb.main.length); i++) {
30
+ const c = compareSegment(pa.main[i] ?? "0", pb.main[i] ?? "0");
31
+ if (c !== 0) return c;
32
+ }
33
+ if (pa.pre.length === 0 && pb.pre.length === 0) return 0;
34
+ if (pa.pre.length === 0) return 1;
35
+ if (pb.pre.length === 0) return -1;
36
+ for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
37
+ const c = compareSegment(pa.pre[i], pb.pre[i]);
38
+ if (c !== 0) return c;
39
+ }
40
+ return 0;
41
+ }
42
+
43
+ export function isNewerVersion(candidate: string, current: string): boolean {
44
+ return compareVersions(candidate, current) > 0;
45
+ }
@@ -25,6 +25,7 @@ import { downloadVerifiedArtifact, verifyFileSha256 } from "./lib/integrity.ts";
25
25
  import { parseManifest } from "./lib/manifest.ts";
26
26
  import { IS_LINUX, IS_MAC, IS_WINDOWS, platformName } from "./lib/platform.ts";
27
27
  import { artifactUrl, assertTrustedUpdateUrl, trustedBaseUrl } from "./lib/urls.ts";
28
+ import { isNewerVersion } from "./lib/version.ts";
28
29
  import type {
29
30
  Listener,
30
31
  Manifest,
@@ -85,7 +86,7 @@ export class Updater {
85
86
  platform: platformName(),
86
87
  arch: process.arch,
87
88
  });
88
- if (!m.version || m.version === v.version) {
89
+ if (!m.version || !isNewerVersion(m.version, v.version)) {
89
90
  this.#manifest = null;
90
91
  this.#setStatus("idle");
91
92
  return null;