janela 0.4.0 → 0.6.0

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/README.md CHANGED
@@ -126,6 +126,83 @@ export function setup(app: JanelaApp): void {
126
126
  }
127
127
  ```
128
128
 
129
+ ## The typed contract
130
+
131
+ The API above works, but `invoke<number>("add", …)` is an *assertion*: nothing
132
+ checks that the command exists, that the arguments match, or that the result
133
+ is really a number. Rename a command and the page still compiles.
134
+
135
+ Declare a contract instead, and both sides are checked against the same
136
+ declarations — no code generation, nothing to keep in sync. This is the
137
+ framework templates' default.
138
+
139
+ ```ts
140
+ // src-host/main.ts
141
+ import { defineCommands, defineEvents, type JanelaApp } from "janela/host";
142
+
143
+ export type AppCommands = {
144
+ add: { args: { a: number; b: number }; result: number };
145
+ greet: { args: { name: string }; result: string };
146
+ wait: { args: { ms: number }; result: string };
147
+ };
148
+ export type AppEvents = { added: number };
149
+
150
+ export const commands = defineCommands<AppCommands>();
151
+ export const events = defineEvents<AppEvents>();
152
+
153
+ export type App = { commands: typeof commands; events: typeof events };
154
+
155
+ // Typing the app with the contract is what makes the methods below checked.
156
+ export function setup(app: JanelaApp<AppCommands, AppEvents>): void {
157
+ app.command("add", (args) => { // args inferred: { a: number; b: number }
158
+ app.emit("added", args.a + args.b); // event name and payload checked
159
+ return args.a + args.b; // return type checked against the contract
160
+ });
161
+ app.commandAsync("wait", (args, resolve) => {
162
+ app.sleep(args.ms, () => resolve("waited " + args.ms + "ms"));
163
+ });
164
+ }
165
+ ```
166
+
167
+ ```ts
168
+ // src/App.tsx (or .vue, .svelte …)
169
+ import { createClient } from "janela/api";
170
+ import type { App } from "../src-host/main"; // type-only: erased at compile time
171
+
172
+ const client = createClient<App>();
173
+
174
+ const sum = await client.invoke("add", { a: 2, b: 40 }); // sum: number
175
+ const off = client.on("added", (v) => console.log(v)); // v: number
176
+ off(); // unsubscribe
177
+ ```
178
+
179
+ Now these are all compile errors:
180
+
181
+ ```ts
182
+ await client.invoke("addd", { a: 1, b: 2 }); // unknown command
183
+ await client.invoke("add", { a: 1, b: "2" }); // wrong argument type
184
+ const s: string = await client.invoke("add", { a: 1, b: 2 }); // wrong result
185
+ client.on("addedd", () => {}); // unknown event
186
+ client.on("added", (v: string) => {}); // wrong payload type
187
+ ```
188
+
189
+ Two things worth knowing:
190
+
191
+ - **`import type` is erased**, so no host code is bundled into the page — the
192
+ contract is a type edge and nothing more. (Verified: the built frontend
193
+ bundle contains none of the host's strings.)
194
+ - **Types erase at runtime.** Payloads still cross as JSON and nothing
195
+ validates a malformed one. This is compile-time safety, like Tauri's
196
+ `invoke<T>()` — the difference is that here the types come from the host's
197
+ own declarations rather than from an assertion you write by hand, which is
198
+ only possible because both sides are TypeScript.
199
+
200
+ Write `args: null` for a command that takes nothing — `args: undefined`
201
+ lowers to a zero-parameter function and fails to compile.
202
+
203
+ The untyped `invoke` / `listen` still work unchanged; the contract is
204
+ additive, and the `vanilla` template still uses the global.
205
+
129
206
  ## Async commands
130
207
 
131
208
  A command that has to wait — or to chew through real work — should not freeze
@@ -235,6 +312,78 @@ nested modal loop would otherwise re-enter the host loop underneath a live TS
235
312
  frame; [docs/native-shell.md](../../docs/native-shell.md) has the details, the
236
313
  per-platform table, and the Windows GUI-subsystem note.
237
314
 
315
+ ## Migrating from 0.5.x
316
+
317
+ The contract now rides on the app itself, so the standalone registrars are no
318
+ longer needed. Type the app with your contract and call its methods:
319
+
320
+ ```ts
321
+ // before (0.5.x)
322
+ export function setup(app: JanelaApp): void {
323
+ on(app, commands, "add", (args) => args.a + args.b);
324
+ onAsync(app, commands, "wait", (args, resolve) => { … });
325
+ emit(app, events, "added", 42);
326
+ }
327
+
328
+ // after (0.6.x)
329
+ export function setup(app: JanelaApp<AppCommands, AppEvents>): void {
330
+ app.command("add", (args) => args.a + args.b);
331
+ app.commandAsync("wait", (args, resolve) => { … });
332
+ app.emit("added", 42);
333
+ }
334
+ ```
335
+
336
+ Declare each contract as a named type so the same one feeds `defineCommands`
337
+ and the `setup` signature:
338
+
339
+ ```ts
340
+ export type AppCommands = { add: { args: { a: number; b: number }; result: number } };
341
+ export type AppEvents = { added: number };
342
+ export const commands = defineCommands<AppCommands>();
343
+ export const events = defineEvents<AppEvents>();
344
+ ```
345
+
346
+ `on`, `onAsync` and `emit` still work — they are `@deprecated` one-line
347
+ wrappers now — so 0.5.x code keeps compiling. The page side is unchanged:
348
+ `createClient<App>()` and `client.invoke(...)` are exactly as before. An app
349
+ with no contract needs no change at all: `setup(app: JanelaApp)` still gets an
350
+ untyped `app.command(name, handler)`.
351
+
352
+ ## Migrating from 0.4.x
353
+
354
+ Nothing breaks: `app.command`, `app.emit`, and the untyped `invoke` / `listen`
355
+ all work exactly as before, and the `vanilla` template is unchanged.
356
+
357
+ Two things are new:
358
+
359
+ - `listen()` (and the injected `janela.listen`) now **return a disposer**.
360
+ Previously they returned nothing, so existing code is unaffected.
361
+ - The **typed contract** — `defineCommands` / `defineEvents` on the host,
362
+ `createClient<App>()` on the page. The framework templates now scaffold with
363
+ it. See [The typed contract](#the-typed-contract).
364
+
365
+ To adopt it in an existing app, declare what the host already exposes and swap
366
+ the registrations:
367
+
368
+ ```ts
369
+ // before
370
+ app.command("add", (args) => {
371
+ const a = args as { a: number; b: number };
372
+ return a.a + a.b;
373
+ });
374
+
375
+ // after
376
+ export const commands = defineCommands<{
377
+ add: { args: { a: number; b: number }; result: number };
378
+ }>();
379
+ export type App = { commands: typeof commands; events: typeof events };
380
+
381
+ app.command("add", (args) => args.a + args.b); // args inferred, no cast
382
+ ```
383
+
384
+ then on the page, replace `invoke<number>("add", …)` with
385
+ `client.invoke("add", …)` built from `createClient<App>()`.
386
+
238
387
  ## Migrating from 0.3.x
239
388
 
240
389
  Nothing breaks: the injected `janela` global still works exactly as before.
package/api/index.d.ts CHANGED
@@ -6,10 +6,15 @@
6
6
  * parse.
7
7
  */
8
8
 
9
+ import type { CommandShapes, Commands, Events } from "../runtime/types";
10
+
11
+ /** Removes a subscription created by `listen` / `client.on`. */
12
+ export type Unlisten = () => void;
13
+
9
14
  /** The bridge janela injects as `window.janela` before each document loads. */
10
15
  export interface JanelaBridge {
11
16
  invoke<T = unknown>(cmd: string, args?: unknown): Promise<T>;
12
- listen<T = unknown>(event: string, cb: (payload: T) => void): void;
17
+ listen<T = unknown>(event: string, cb: (payload: T) => void): Unlisten;
13
18
  }
14
19
 
15
20
  /**
@@ -19,19 +24,79 @@ export interface JanelaBridge {
19
24
  * const sum = await invoke<number>("add", { a: 2, b: 40 });
20
25
  * ```
21
26
  *
27
+ * The generic is an assertion, not a check — prefer `createClient` when the
28
+ * host declares a contract, which verifies the name, the arguments and the
29
+ * result against the host's own types.
30
+ *
22
31
  * Rejects if the command is unknown, if the handler rejected, or if the page
23
32
  * is not running inside a janela window.
24
33
  */
25
34
  export declare function invoke<T = unknown>(cmd: string, args?: unknown): Promise<T>;
26
35
 
27
36
  /**
28
- * Subscribe to an event the host sends with `app.emit`.
37
+ * Subscribe to an event the host sends with `app.emit`. Returns a disposer.
29
38
  *
30
39
  * ```ts
31
- * listen<number>("added", (sum) => console.log(sum));
40
+ * const off = listen<number>("added", (sum) => console.log(sum));
41
+ * off();
32
42
  * ```
33
43
  */
34
44
  export declare function listen<T = unknown>(
35
45
  event: string,
36
46
  cb: (payload: T) => void,
37
- ): void;
47
+ ): Unlisten;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Typed contract
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /** Anything shaped like a host contract module's exported `App` type. */
54
+ export interface Contract {
55
+ commands: Commands<CommandShapes>;
56
+ events: Events<unknown>;
57
+ }
58
+
59
+ /** The command table declared by a contract. */
60
+ export type CommandsOf<A> = A extends { commands: Commands<infer M> } ? M : never;
61
+
62
+ /** The event table declared by a contract. */
63
+ export type EventsOf<A> = A extends { events: Events<infer E> } ? E : never;
64
+
65
+ /**
66
+ * A client bound to a host contract: command names, argument shapes, result
67
+ * types and event payloads are all checked against the host's declarations.
68
+ */
69
+ export interface JanelaClient<A> {
70
+ /**
71
+ * Call a declared command. Unknown names and wrong argument shapes are
72
+ * compile errors, and the result type comes from the contract.
73
+ */
74
+ invoke<K extends keyof CommandsOf<A> & string>(
75
+ name: K,
76
+ args: CommandsOf<A>[K]["args"],
77
+ ): Promise<CommandsOf<A>[K]["result"]>;
78
+ /**
79
+ * Subscribe to a declared event; the payload type is inferred. Returns a
80
+ * disposer that removes the subscription.
81
+ */
82
+ on<K extends keyof EventsOf<A> & string>(
83
+ event: K,
84
+ cb: (payload: EventsOf<A>[K]) => void,
85
+ ): Unlisten;
86
+ }
87
+
88
+ /**
89
+ * Build a client checked against a host's contract.
90
+ *
91
+ * ```ts
92
+ * import type { App } from "../src-host/main";
93
+ * const client = createClient<App>();
94
+ * const sum = await client.invoke("add", { a: 2, b: 40 }); // number
95
+ * const off = client.on("added", (v) => console.log(v)); // v: number
96
+ * ```
97
+ *
98
+ * `import type` is erased, so no host code is bundled into the page — the
99
+ * contract is a type edge and nothing else. Payloads still cross as JSON, so
100
+ * this is compile-time safety; nothing is validated at runtime.
101
+ */
102
+ export declare function createClient<A>(): JanelaClient<A>;
package/api/index.js CHANGED
@@ -29,7 +29,36 @@ export async function invoke(cmd, args) {
29
29
  return bridge().invoke(cmd, args);
30
30
  }
31
31
 
32
- /** Subscribe to an event the host sends with `app.emit`. */
32
+ /**
33
+ * Subscribe to an event the host sends with `app.emit`. Returns a function
34
+ * that removes the subscription.
35
+ */
33
36
  export function listen(event, cb) {
34
- bridge().listen(event, cb);
37
+ const off = bridge().listen(event, cb);
38
+ // Hosts before 0.5.0 returned nothing from listen; keep a working disposer
39
+ // either way rather than handing back undefined.
40
+ if (typeof off === "function") return off;
41
+ return function unlisten() {
42
+ const all = globalThis.__wvListeners ? globalThis.__wvListeners[event] : undefined;
43
+ if (!all) return;
44
+ const i = all.indexOf(cb);
45
+ if (i >= 0) all.splice(i, 1);
46
+ };
47
+ }
48
+
49
+ /**
50
+ * A client bound to a host's declared contract. The type parameter carries the
51
+ * command and event tables; at runtime this is the same bridge as `invoke` and
52
+ * `listen`, so the contract costs nothing and validates nothing — it is
53
+ * checked entirely by the compiler.
54
+ */
55
+ export function createClient() {
56
+ return {
57
+ invoke(name, args) {
58
+ return invoke(name, args);
59
+ },
60
+ on(event, cb) {
61
+ return listen(event, cb);
62
+ },
63
+ };
35
64
  }
package/bin/janela.mjs CHANGED
@@ -516,10 +516,18 @@ function build(root, { devUrl = null, gui = true } = {}) {
516
516
  join(buildDir, "entry.ts"),
517
517
  `// Generated by janela — do not edit.\n` +
518
518
  `import { createApp } from "./janela";\n` +
519
+ `import type { CommandShapes, JanelaApp } from "./janela";\n` +
519
520
  `import { WINDOW } from "./config";\n` +
520
521
  `import { INDEX_HTML } from "./frontend";\n` +
521
522
  `import { setup } from "./main";\n\n` +
522
- `const app = createApp(WINDOW);\n` +
523
+ `// The app's type parameters are read back off setup()'s own signature,\n` +
524
+ `// so a contract-typed setup(app: JanelaApp<App>) and a plain\n` +
525
+ `// setup(app: JanelaApp) each get an app instantiated to match. scriptc\n` +
526
+ `// monomorphises generic classes, so the right instantiation must be\n` +
527
+ `// CONSTRUCTED here - no cast can bridge JanelaApp<A> and JanelaApp<B>.\n` +
528
+ `type CmdsOf<F> = F extends (app: JanelaApp<infer C, infer _E>) => void ? C : CommandShapes;\n` +
529
+ `type EvtsOf<F> = F extends (app: JanelaApp<infer _C, infer E>) => void ? E : Record<string, unknown>;\n\n` +
530
+ `const app = createApp<CmdsOf<typeof setup>, EvtsOf<typeof setup>>(WINDOW);\n` +
523
531
  `setup(app);\n` +
524
532
  `const rc = app.run(INDEX_HTML) + 0;\n` +
525
533
  `console.log("[janela] run returned", rc);\n`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Desktop apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "default": "./api/index.js"
14
14
  },
15
15
  "./host": {
16
- "types": "./runtime/types.ts"
16
+ "types": "./runtime/janela.ts"
17
17
  },
18
18
  "./global": {
19
19
  "types": "./api/global.d.ts"