janela 0.4.0 → 0.5.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,80 @@ 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, emit, on, onAsync, type JanelaApp } from "janela/host";
142
+
143
+ export const commands = defineCommands<{
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
+
149
+ export const events = defineEvents<{ added: number }>();
150
+
151
+ export type App = { commands: typeof commands; events: typeof events };
152
+
153
+ export function setup(app: JanelaApp): void {
154
+ on(app, commands, "add", (args) => { // args inferred: { a: number; b: number }
155
+ emit(app, events, "added", args.a + args.b);
156
+ return args.a + args.b; // return type checked against the contract
157
+ });
158
+ onAsync(app, commands, "wait", (args, resolve) => {
159
+ app.sleep(args.ms, () => resolve("waited " + args.ms + "ms"));
160
+ });
161
+ }
162
+ ```
163
+
164
+ ```ts
165
+ // src/App.tsx (or .vue, .svelte …)
166
+ import { createClient } from "janela/api";
167
+ import type { App } from "../src-host/main"; // type-only: erased at compile time
168
+
169
+ const client = createClient<App>();
170
+
171
+ const sum = await client.invoke("add", { a: 2, b: 40 }); // sum: number
172
+ const off = client.on("added", (v) => console.log(v)); // v: number
173
+ off(); // unsubscribe
174
+ ```
175
+
176
+ Now these are all compile errors:
177
+
178
+ ```ts
179
+ await client.invoke("addd", { a: 1, b: 2 }); // unknown command
180
+ await client.invoke("add", { a: 1, b: "2" }); // wrong argument type
181
+ const s: string = await client.invoke("add", { a: 1, b: 2 }); // wrong result
182
+ client.on("addedd", () => {}); // unknown event
183
+ client.on("added", (v: string) => {}); // wrong payload type
184
+ ```
185
+
186
+ Two things worth knowing:
187
+
188
+ - **`import type` is erased**, so no host code is bundled into the page — the
189
+ contract is a type edge and nothing more. (Verified: the built frontend
190
+ bundle contains none of the host's strings.)
191
+ - **Types erase at runtime.** Payloads still cross as JSON and nothing
192
+ validates a malformed one. This is compile-time safety, like Tauri's
193
+ `invoke<T>()` — the difference is that here the types come from the host's
194
+ own declarations rather than from an assertion you write by hand, which is
195
+ only possible because both sides are TypeScript.
196
+
197
+ Write `args: null` for a command that takes nothing — `args: undefined`
198
+ lowers to a zero-parameter function and fails to compile.
199
+
200
+ The untyped `invoke` / `listen` still work unchanged; the contract is
201
+ additive, and the `vanilla` template still uses the global.
202
+
129
203
  ## Async commands
130
204
 
131
205
  A command that has to wait — or to chew through real work — should not freeze
@@ -235,6 +309,41 @@ nested modal loop would otherwise re-enter the host loop underneath a live TS
235
309
  frame; [docs/native-shell.md](../../docs/native-shell.md) has the details, the
236
310
  per-platform table, and the Windows GUI-subsystem note.
237
311
 
312
+ ## Migrating from 0.4.x
313
+
314
+ Nothing breaks: `app.command`, `app.emit`, and the untyped `invoke` / `listen`
315
+ all work exactly as before, and the `vanilla` template is unchanged.
316
+
317
+ Two things are new:
318
+
319
+ - `listen()` (and the injected `janela.listen`) now **return a disposer**.
320
+ Previously they returned nothing, so existing code is unaffected.
321
+ - The **typed contract** — `defineCommands` / `defineEvents` on the host,
322
+ `createClient<App>()` on the page. The framework templates now scaffold with
323
+ it. See [The typed contract](#the-typed-contract).
324
+
325
+ To adopt it in an existing app, declare what the host already exposes and swap
326
+ the registrations:
327
+
328
+ ```ts
329
+ // before
330
+ app.command("add", (args) => {
331
+ const a = args as { a: number; b: number };
332
+ return a.a + a.b;
333
+ });
334
+
335
+ // after
336
+ export const commands = defineCommands<{
337
+ add: { args: { a: number; b: number }; result: number };
338
+ }>();
339
+ export type App = { commands: typeof commands; events: typeof events };
340
+
341
+ on(app, commands, "add", (args) => args.a + args.b); // args inferred, no cast
342
+ ```
343
+
344
+ then on the page, replace `invoke<number>("add", …)` with
345
+ `client.invoke("add", …)` built from `createClient<App>()`.
346
+
238
347
  ## Migrating from 0.3.x
239
348
 
240
349
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.4.0",
3
+ "version": "0.5.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": {
package/runtime/janela.ts CHANGED
@@ -67,6 +67,11 @@ const BOOTSTRAP =
67
67
  " listen: function (event, cb) {" +
68
68
  " if (!window.__wvListeners[event]) window.__wvListeners[event] = [];" +
69
69
  " window.__wvListeners[event].push(cb);" +
70
+ " return function () {" +
71
+ " var a = window.__wvListeners[event] || [];" +
72
+ " var i = a.indexOf(cb);" +
73
+ " if (i >= 0) a.splice(i, 1);" +
74
+ " };" +
70
75
  " }," +
71
76
  "};" +
72
77
  "window.__wvEmit = function (event, payload) {" +
@@ -80,7 +85,11 @@ const BOOTSTRAP =
80
85
  export type {
81
86
  AsyncCommandHandler,
82
87
  CommandHandler,
88
+ CommandShape,
89
+ CommandShapes,
90
+ Commands,
83
91
  DialogFilter,
92
+ Events,
84
93
  FsCallback,
85
94
  JanelaApp,
86
95
  OpenDialogOptions,
@@ -88,6 +97,11 @@ export type {
88
97
  WindowConfig,
89
98
  } from "./types";
90
99
 
100
+ // The typed-contract helpers are values, so they are re-exported as values.
101
+ // A project's `import { defineCommands } from "janela/host"` is rewritten to
102
+ // this module by the CLI before scriptc sees it.
103
+ export { defineCommands, defineEvents, emit, on, onAsync } from "./types";
104
+
91
105
  import type {
92
106
  AsyncCommandHandler,
93
107
  CommandHandler,
package/runtime/types.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  * - re-exported by runtime/janela.ts, which is what the compiled build
9
9
  * actually links against (the CLI copies both files into .janela/build/).
10
10
  *
11
- * Keep it declaration-only: no runtime code lives here.
11
+ * Mostly declarations; the typed-contract helpers at the bottom are the only
12
+ * runtime code, and they are deliberately trivial.
12
13
  */
13
14
 
14
15
  // Handlers take the invoked arguments as a value and return a value; the
@@ -129,3 +130,122 @@ export interface JanelaApp {
129
130
  /** Show the page and block until the window closes. Returns the run status. */
130
131
  run: (html: string) => number;
131
132
  }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Typed IPC contract
136
+ // ---------------------------------------------------------------------------
137
+ //
138
+ // The contract is a TYPE the host declares and the frontend imports with
139
+ // `import type`. Because both sides are TypeScript, no code generation is
140
+ // involved and nothing can drift: the frontend checks against the host's own
141
+ // declarations, and the import is erased, so no host code reaches the bundle.
142
+ //
143
+ // Payloads still cross as JSON, so these types are compile-time only. Nothing
144
+ // validates a malformed payload at runtime.
145
+ //
146
+ // SHAPE NOTE: the registrars below are standalone generic FUNCTIONS taking the
147
+ // contract as a value, rather than methods on a returned registrar object.
148
+ // That is not a style choice — scriptc cannot dispatch a generic method
149
+ // through an interface-typed receiver (SC1090), so `commands.on(...)` does not
150
+ // compile in a host build, while `on(app, commands, ...)` does.
151
+
152
+ /** One command's argument and result types. */
153
+ export interface CommandShape {
154
+ args: unknown;
155
+ result: unknown;
156
+ }
157
+
158
+ /** A contract's command table: name → shape. */
159
+ export type CommandShapes = Record<string, CommandShape>;
160
+
161
+ /**
162
+ * A declared command contract. Carries `M` at the type level only — the value
163
+ * is empty, and exists so that inference has something to read at a call site.
164
+ */
165
+ export interface Commands<M extends CommandShapes> {
166
+ __commands?: M;
167
+ }
168
+
169
+ /** A declared event contract: event name → payload type. */
170
+ export interface Events<E> {
171
+ __events?: E;
172
+ }
173
+
174
+ /**
175
+ * Declare the commands a host exposes.
176
+ *
177
+ * ```ts
178
+ * export const commands = defineCommands<{
179
+ * add: { args: { a: number; b: number }; result: number };
180
+ * }>();
181
+ * ```
182
+ */
183
+ export function defineCommands<M extends CommandShapes>(): Commands<M> {
184
+ return {};
185
+ }
186
+
187
+ /** Declare the events a host emits: `defineEvents<{ added: number }>()`. */
188
+ export function defineEvents<E>(): Events<E> {
189
+ return {};
190
+ }
191
+
192
+ /**
193
+ * Register a command against the contract. `args` is inferred from the
194
+ * contract, and the return type is checked against it, so the handler is
195
+ * written once with no casts.
196
+ *
197
+ * ```ts
198
+ * on(app, commands, "add", (args) => args.a + args.b);
199
+ * ```
200
+ */
201
+ export function on<M extends CommandShapes, K extends keyof M & string>(
202
+ app: JanelaApp,
203
+ _commands: Commands<M>,
204
+ name: K,
205
+ handler: (args: M[K]["args"]) => M[K]["result"],
206
+ ): void {
207
+ app.command(name, (args: unknown) => handler(args as M[K]["args"]));
208
+ }
209
+
210
+ /**
211
+ * Register a command that answers on a later turn. `resolve` takes the
212
+ * contract's result type; see AsyncCommandHandler for the timing rules.
213
+ */
214
+ export function onAsync<M extends CommandShapes, K extends keyof M & string>(
215
+ app: JanelaApp,
216
+ _commands: Commands<M>,
217
+ name: K,
218
+ handler: (
219
+ args: M[K]["args"],
220
+ resolve: (value: M[K]["result"]) => void,
221
+ reject: (reason: unknown) => void,
222
+ ) => void,
223
+ ): void {
224
+ app.commandAsync(
225
+ name,
226
+ (args: unknown, resolve: (value: unknown) => void, reject: (reason: unknown) => void) => {
227
+ handler(
228
+ args as M[K]["args"],
229
+ (value: M[K]["result"]) => resolve(value),
230
+ reject,
231
+ );
232
+ },
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Emit a declared event. The name must exist in the contract and the payload
238
+ * must match its type.
239
+ *
240
+ * ```ts
241
+ * emit(app, events, "added", 42);
242
+ * ```
243
+ */
244
+ export function emit<E, K extends keyof E & string>(
245
+ app: JanelaApp,
246
+ _events: Events<E>,
247
+ name: K,
248
+ payload: E[K],
249
+ ): void {
250
+ app.emit(name, payload);
251
+ }
@@ -1,7 +1,12 @@
1
1
  import { useEffect, useState } from "react";
2
- import { invoke, listen } from "janela/api";
2
+ import { createClient } from "janela/api";
3
+ // Type-only import of the host contract: erased at compile time, so no host
4
+ // code is bundled into the page.
5
+ import type { App as Contract } from "../src-host/main";
3
6
  import "./App.css";
4
7
 
8
+ const client = createClient<Contract>();
9
+
5
10
  export default function App() {
6
11
  const [greeting, setGreeting] = useState("…");
7
12
  const [a, setA] = useState(2);
@@ -10,16 +15,17 @@ export default function App() {
10
15
  const [events, setEvents] = useState<string[]>([]);
11
16
 
12
17
  useEffect(() => {
13
- // Backend→frontend events. The payload arrives as a value, and the
14
- // generic says which value.
15
- listen<number>("added", (value) =>
18
+ // The event name is checked against the contract, `value` is inferred,
19
+ // and `on` returns a disposer.
20
+ const off = client.on("added", (value) =>
16
21
  setEvents((prev) => [`host emitted: ${value}`, ...prev]),
17
22
  );
18
- invoke<string>("greet", { name: "__NAME__" }).then(setGreeting);
23
+ client.invoke("greet", { name: "__NAME__" }).then(setGreeting);
24
+ return off;
19
25
  }, []);
20
26
 
21
27
  const add = async () =>
22
- setSum(await invoke<number>("add", { a, b }));
28
+ setSum(await client.invoke("add", { a, b }));
23
29
 
24
30
  return (
25
31
  <>
@@ -1,42 +1,68 @@
1
1
  // src-host/main.ts — your app's backend, compiled to native code by scriptc.
2
2
  //
3
- // Register commands here; the page calls them with `await janela.invoke(name, args)`.
4
- // Handlers take the arguments as a value and return a value — the runtime owns
5
- // JSON at the boundary, so there is no parsing or stringifying to do here.
3
+ // The contract below is the single declaration of what this app exposes. The
4
+ // frontend imports `App` with `import type`, so the page is checked against
5
+ // these exact types command names, argument shapes, results and event
6
+ // payloads — with no code generation and nothing to keep in sync.
6
7
  //
7
- // Gotcha inherited from scriptc: never use a bare FFI-backed call as a
8
- // complete variable initializer wrap it in any expression (`+ 0`). Plain
9
- // TypeScript like everything in this file is unaffected.
8
+ // Two gotchas inherited from scriptc:
9
+ // - never use a bare FFI-backed call as a complete variable initializer;
10
+ // wrap it in any expression (`+ 0`);
11
+ // - write `args: null` for a command that takes nothing, not `undefined` —
12
+ // an `undefined` argument lowers to a zero-parameter function and fails
13
+ // to compile.
10
14
 
11
- import type { JanelaApp } from "janela/host";
15
+ import {
16
+ defineCommands,
17
+ defineEvents,
18
+ emit,
19
+ on,
20
+ onAsync,
21
+ type JanelaApp,
22
+ } from "janela/host";
23
+
24
+ export const commands = defineCommands<{
25
+ add: { args: { a: number; b: number }; result: number };
26
+ greet: { args: { name: string }; result: string };
27
+ log: { args: string; result: null };
28
+ wait: { args: { ms: number }; result: string };
29
+ quit: { args: null; result: null };
30
+ }>();
31
+
32
+ export const events = defineEvents<{
33
+ added: number;
34
+ }>();
35
+
36
+ /** The contract the page imports with `import type { App } from "../src-host/main"`. */
37
+ export type App = { commands: typeof commands; events: typeof events };
12
38
 
13
39
  export function setup(app: JanelaApp): void {
14
- app.command("add", (args) => {
15
- const a = args as { a: number; b: number };
16
- const sum = a.a + a.b;
17
- // Backend→frontend event: the page listens with janela.listen("added", …).
18
- app.emit("added", sum);
40
+ // `args` is inferred from the contract, and the return type is checked
41
+ // against it no casts, and no way to drift from what the page expects.
42
+ on(app, commands, "add", (args) => {
43
+ const sum = args.a + args.b;
44
+ emit(app, events, "added", sum);
19
45
  return sum;
20
46
  });
21
47
 
22
- app.command("greet", (args) => {
23
- const a = args as { name: string };
24
- return "Hello, " + a.name + " — from the native TS binary";
48
+ on(app, commands, "greet", (args) => {
49
+ return "Hello, " + args.name + " from the native TS binary";
25
50
  });
26
51
 
27
- app.command("log", (args) => {
28
- console.log("[host] page says:", args as string);
52
+ on(app, commands, "log", (args) => {
53
+ console.log("[host] page says:", args);
54
+ return null;
29
55
  });
30
56
 
31
57
  // An async command: answers later, without freezing the window.
32
- app.commandAsync("wait", (args, resolve) => {
33
- const a = args as { ms: number };
34
- app.sleep(a.ms, () => {
35
- resolve("waited " + a.ms + "ms without blocking the UI");
58
+ onAsync(app, commands, "wait", (args, resolve) => {
59
+ app.sleep(args.ms, () => {
60
+ resolve("waited " + args.ms + "ms without blocking the UI");
36
61
  });
37
62
  });
38
63
 
39
- app.command("quit", () => {
64
+ on(app, commands, "quit", (_args) => {
40
65
  app.quit();
66
+ return null;
41
67
  });
42
68
  }
@@ -1,7 +1,12 @@
1
1
  import { createSignal, onMount, For, Show } from "solid-js";
2
- import { invoke, listen } from "janela/api";
2
+ import { createClient } from "janela/api";
3
+ // Type-only import of the host contract: erased at compile time, so no host
4
+ // code is bundled into the page.
5
+ import type { App as Contract } from "../src-host/main";
3
6
  import "./App.css";
4
7
 
8
+ const client = createClient<Contract>();
9
+
5
10
  export default function App() {
6
11
  const [greeting, setGreeting] = createSignal("…");
7
12
  const [a, setA] = createSignal(2);
@@ -11,16 +16,16 @@ export default function App() {
11
16
 
12
17
  // Backend→frontend events. The payload arrives as a value, and the generic
13
18
  // says which value.
14
- listen<number>("added", (value) =>
19
+ client.on("added", (value) =>
15
20
  setEvents((prev) => [`host emitted: ${value}`, ...prev]),
16
21
  );
17
22
 
18
23
  onMount(async () =>
19
- setGreeting(await invoke<string>("greet", { name: "__NAME__" })),
24
+ setGreeting(await client.invoke("greet", { name: "__NAME__" })),
20
25
  );
21
26
 
22
27
  const add = async () =>
23
- setSum(await invoke<number>("add", { a: a(), b: b() }));
28
+ setSum(await client.invoke("add", { a: a(), b: b() }));
24
29
 
25
30
  return (
26
31
  <>
@@ -1,42 +1,68 @@
1
1
  // src-host/main.ts — your app's backend, compiled to native code by scriptc.
2
2
  //
3
- // Register commands here; the page calls them with `await janela.invoke(name, args)`.
4
- // Handlers take the arguments as a value and return a value — the runtime owns
5
- // JSON at the boundary, so there is no parsing or stringifying to do here.
3
+ // The contract below is the single declaration of what this app exposes. The
4
+ // frontend imports `App` with `import type`, so the page is checked against
5
+ // these exact types command names, argument shapes, results and event
6
+ // payloads — with no code generation and nothing to keep in sync.
6
7
  //
7
- // Gotcha inherited from scriptc: never use a bare FFI-backed call as a
8
- // complete variable initializer wrap it in any expression (`+ 0`). Plain
9
- // TypeScript like everything in this file is unaffected.
8
+ // Two gotchas inherited from scriptc:
9
+ // - never use a bare FFI-backed call as a complete variable initializer;
10
+ // wrap it in any expression (`+ 0`);
11
+ // - write `args: null` for a command that takes nothing, not `undefined` —
12
+ // an `undefined` argument lowers to a zero-parameter function and fails
13
+ // to compile.
10
14
 
11
- import type { JanelaApp } from "janela/host";
15
+ import {
16
+ defineCommands,
17
+ defineEvents,
18
+ emit,
19
+ on,
20
+ onAsync,
21
+ type JanelaApp,
22
+ } from "janela/host";
23
+
24
+ export const commands = defineCommands<{
25
+ add: { args: { a: number; b: number }; result: number };
26
+ greet: { args: { name: string }; result: string };
27
+ log: { args: string; result: null };
28
+ wait: { args: { ms: number }; result: string };
29
+ quit: { args: null; result: null };
30
+ }>();
31
+
32
+ export const events = defineEvents<{
33
+ added: number;
34
+ }>();
35
+
36
+ /** The contract the page imports with `import type { App } from "../src-host/main"`. */
37
+ export type App = { commands: typeof commands; events: typeof events };
12
38
 
13
39
  export function setup(app: JanelaApp): void {
14
- app.command("add", (args) => {
15
- const a = args as { a: number; b: number };
16
- const sum = a.a + a.b;
17
- // Backend→frontend event: the page listens with janela.listen("added", …).
18
- app.emit("added", sum);
40
+ // `args` is inferred from the contract, and the return type is checked
41
+ // against it no casts, and no way to drift from what the page expects.
42
+ on(app, commands, "add", (args) => {
43
+ const sum = args.a + args.b;
44
+ emit(app, events, "added", sum);
19
45
  return sum;
20
46
  });
21
47
 
22
- app.command("greet", (args) => {
23
- const a = args as { name: string };
24
- return "Hello, " + a.name + " — from the native TS binary";
48
+ on(app, commands, "greet", (args) => {
49
+ return "Hello, " + args.name + " from the native TS binary";
25
50
  });
26
51
 
27
- app.command("log", (args) => {
28
- console.log("[host] page says:", args as string);
52
+ on(app, commands, "log", (args) => {
53
+ console.log("[host] page says:", args);
54
+ return null;
29
55
  });
30
56
 
31
57
  // An async command: answers later, without freezing the window.
32
- app.commandAsync("wait", (args, resolve) => {
33
- const a = args as { ms: number };
34
- app.sleep(a.ms, () => {
35
- resolve("waited " + a.ms + "ms without blocking the UI");
58
+ onAsync(app, commands, "wait", (args, resolve) => {
59
+ app.sleep(args.ms, () => {
60
+ resolve("waited " + args.ms + "ms without blocking the UI");
36
61
  });
37
62
  });
38
63
 
39
- app.command("quit", () => {
64
+ on(app, commands, "quit", (_args) => {
40
65
  app.quit();
66
+ return null;
41
67
  });
42
68
  }
@@ -1,5 +1,10 @@
1
1
  <script lang="ts">
2
- import { invoke, listen } from "janela/api";
2
+ import { createClient } from "janela/api";
3
+ // Type-only import of the host contract: erased at compile time, so no
4
+ // host code is bundled into the page.
5
+ import type { App as Contract } from "../src-host/main";
6
+
7
+ const client = createClient<Contract>();
3
8
 
4
9
  let greeting = $state("…");
5
10
  let a = $state(2);
@@ -9,12 +14,12 @@
9
14
 
10
15
  // Backend→frontend events. The payload arrives as a value, and the generic
11
16
  // says which value.
12
- listen<number>("added", (value) => (events = [`host emitted: ${value}`, ...events]));
17
+ client.on("added", (value) => (events = [`host emitted: ${value}`, ...events]));
13
18
 
14
- invoke<string>("greet", { name: "__NAME__" }).then((g) => (greeting = g));
19
+ client.invoke("greet", { name: "__NAME__" }).then((g) => (greeting = g));
15
20
 
16
21
  async function add() {
17
- sum = await invoke<number>("add", { a: Number(a), b: Number(b) });
22
+ sum = await client.invoke("add", { a: Number(a), b: Number(b) });
18
23
  }
19
24
  </script>
20
25
 
@@ -1,42 +1,68 @@
1
1
  // src-host/main.ts — your app's backend, compiled to native code by scriptc.
2
2
  //
3
- // Register commands here; the page calls them with `await janela.invoke(name, args)`.
4
- // Handlers take the arguments as a value and return a value — the runtime owns
5
- // JSON at the boundary, so there is no parsing or stringifying to do here.
3
+ // The contract below is the single declaration of what this app exposes. The
4
+ // frontend imports `App` with `import type`, so the page is checked against
5
+ // these exact types command names, argument shapes, results and event
6
+ // payloads — with no code generation and nothing to keep in sync.
6
7
  //
7
- // Gotcha inherited from scriptc: never use a bare FFI-backed call as a
8
- // complete variable initializer wrap it in any expression (`+ 0`). Plain
9
- // TypeScript like everything in this file is unaffected.
8
+ // Two gotchas inherited from scriptc:
9
+ // - never use a bare FFI-backed call as a complete variable initializer;
10
+ // wrap it in any expression (`+ 0`);
11
+ // - write `args: null` for a command that takes nothing, not `undefined` —
12
+ // an `undefined` argument lowers to a zero-parameter function and fails
13
+ // to compile.
10
14
 
11
- import type { JanelaApp } from "janela/host";
15
+ import {
16
+ defineCommands,
17
+ defineEvents,
18
+ emit,
19
+ on,
20
+ onAsync,
21
+ type JanelaApp,
22
+ } from "janela/host";
23
+
24
+ export const commands = defineCommands<{
25
+ add: { args: { a: number; b: number }; result: number };
26
+ greet: { args: { name: string }; result: string };
27
+ log: { args: string; result: null };
28
+ wait: { args: { ms: number }; result: string };
29
+ quit: { args: null; result: null };
30
+ }>();
31
+
32
+ export const events = defineEvents<{
33
+ added: number;
34
+ }>();
35
+
36
+ /** The contract the page imports with `import type { App } from "../src-host/main"`. */
37
+ export type App = { commands: typeof commands; events: typeof events };
12
38
 
13
39
  export function setup(app: JanelaApp): void {
14
- app.command("add", (args) => {
15
- const a = args as { a: number; b: number };
16
- const sum = a.a + a.b;
17
- // Backend→frontend event: the page listens with janela.listen("added", …).
18
- app.emit("added", sum);
40
+ // `args` is inferred from the contract, and the return type is checked
41
+ // against it no casts, and no way to drift from what the page expects.
42
+ on(app, commands, "add", (args) => {
43
+ const sum = args.a + args.b;
44
+ emit(app, events, "added", sum);
19
45
  return sum;
20
46
  });
21
47
 
22
- app.command("greet", (args) => {
23
- const a = args as { name: string };
24
- return "Hello, " + a.name + " — from the native TS binary";
48
+ on(app, commands, "greet", (args) => {
49
+ return "Hello, " + args.name + " from the native TS binary";
25
50
  });
26
51
 
27
- app.command("log", (args) => {
28
- console.log("[host] page says:", args as string);
52
+ on(app, commands, "log", (args) => {
53
+ console.log("[host] page says:", args);
54
+ return null;
29
55
  });
30
56
 
31
57
  // An async command: answers later, without freezing the window.
32
- app.commandAsync("wait", (args, resolve) => {
33
- const a = args as { ms: number };
34
- app.sleep(a.ms, () => {
35
- resolve("waited " + a.ms + "ms without blocking the UI");
58
+ onAsync(app, commands, "wait", (args, resolve) => {
59
+ app.sleep(args.ms, () => {
60
+ resolve("waited " + args.ms + "ms without blocking the UI");
36
61
  });
37
62
  });
38
63
 
39
- app.command("quit", () => {
64
+ on(app, commands, "quit", (_args) => {
40
65
  app.quit();
66
+ return null;
41
67
  });
42
68
  }
@@ -1,6 +1,11 @@
1
1
  <script setup lang="ts">
2
- import { onMounted, ref } from "vue";
3
- import { invoke, listen } from "janela/api";
2
+ import { onMounted, onUnmounted, ref } from "vue";
3
+ import { createClient } from "janela/api";
4
+ // A type-only import of the host's contract. It is erased at compile time, so
5
+ // no host code is bundled into the page — this is purely a type edge.
6
+ import type { App } from "../src-host/main";
7
+
8
+ const client = createClient<App>();
4
9
 
5
10
  const greeting = ref("…");
6
11
  const a = ref(2);
@@ -8,16 +13,17 @@ const b = ref(40);
8
13
  const sum = ref<number | null>(null);
9
14
  const events = ref<string[]>([]);
10
15
 
11
- // Backend→frontend events. The payload arrives as a value, not a JSON string,
12
- // and the generic says which value.
13
- listen<number>("added", (value) => events.value.unshift(`host emitted: ${value}`));
16
+ // The event name is checked against the contract and `value` is inferred.
17
+ // `on` returns a disposer.
18
+ const off = client.on("added", (value) => events.value.unshift(`host emitted: ${value}`));
19
+ onUnmounted(off);
14
20
 
15
21
  onMounted(async () => {
16
- greeting.value = await invoke<string>("greet", { name: "__NAME__" });
22
+ greeting.value = await client.invoke("greet", { name: "__NAME__" });
17
23
  });
18
24
 
19
25
  async function add() {
20
- sum.value = await invoke<number>("add", { a: Number(a.value), b: Number(b.value) });
26
+ sum.value = await client.invoke("add", { a: Number(a.value), b: Number(b.value) });
21
27
  }
22
28
  </script>
23
29
 
@@ -1,42 +1,68 @@
1
1
  // src-host/main.ts — your app's backend, compiled to native code by scriptc.
2
2
  //
3
- // Register commands here; the page calls them with `await janela.invoke(name, args)`.
4
- // Handlers take the arguments as a value and return a value — the runtime owns
5
- // JSON at the boundary, so there is no parsing or stringifying to do here.
3
+ // The contract below is the single declaration of what this app exposes. The
4
+ // frontend imports `App` with `import type`, so the page is checked against
5
+ // these exact types command names, argument shapes, results and event
6
+ // payloads — with no code generation and nothing to keep in sync.
6
7
  //
7
- // Gotcha inherited from scriptc: never use a bare FFI-backed call as a
8
- // complete variable initializer wrap it in any expression (`+ 0`). Plain
9
- // TypeScript like everything in this file is unaffected.
8
+ // Two gotchas inherited from scriptc:
9
+ // - never use a bare FFI-backed call as a complete variable initializer;
10
+ // wrap it in any expression (`+ 0`);
11
+ // - write `args: null` for a command that takes nothing, not `undefined` —
12
+ // an `undefined` argument lowers to a zero-parameter function and fails
13
+ // to compile.
10
14
 
11
- import type { JanelaApp } from "janela/host";
15
+ import {
16
+ defineCommands,
17
+ defineEvents,
18
+ emit,
19
+ on,
20
+ onAsync,
21
+ type JanelaApp,
22
+ } from "janela/host";
23
+
24
+ export const commands = defineCommands<{
25
+ add: { args: { a: number; b: number }; result: number };
26
+ greet: { args: { name: string }; result: string };
27
+ log: { args: string; result: null };
28
+ wait: { args: { ms: number }; result: string };
29
+ quit: { args: null; result: null };
30
+ }>();
31
+
32
+ export const events = defineEvents<{
33
+ added: number;
34
+ }>();
35
+
36
+ /** The contract the page imports with `import type { App } from "../src-host/main"`. */
37
+ export type App = { commands: typeof commands; events: typeof events };
12
38
 
13
39
  export function setup(app: JanelaApp): void {
14
- app.command("add", (args) => {
15
- const a = args as { a: number; b: number };
16
- const sum = a.a + a.b;
17
- // Backend→frontend event: the page listens with janela.listen("added", …).
18
- app.emit("added", sum);
40
+ // `args` is inferred from the contract, and the return type is checked
41
+ // against it no casts, and no way to drift from what the page expects.
42
+ on(app, commands, "add", (args) => {
43
+ const sum = args.a + args.b;
44
+ emit(app, events, "added", sum);
19
45
  return sum;
20
46
  });
21
47
 
22
- app.command("greet", (args) => {
23
- const a = args as { name: string };
24
- return "Hello, " + a.name + " — from the native TS binary";
48
+ on(app, commands, "greet", (args) => {
49
+ return "Hello, " + args.name + " from the native TS binary";
25
50
  });
26
51
 
27
- app.command("log", (args) => {
28
- console.log("[host] page says:", args as string);
52
+ on(app, commands, "log", (args) => {
53
+ console.log("[host] page says:", args);
54
+ return null;
29
55
  });
30
56
 
31
57
  // An async command: answers later, without freezing the window.
32
- app.commandAsync("wait", (args, resolve) => {
33
- const a = args as { ms: number };
34
- app.sleep(a.ms, () => {
35
- resolve("waited " + a.ms + "ms without blocking the UI");
58
+ onAsync(app, commands, "wait", (args, resolve) => {
59
+ app.sleep(args.ms, () => {
60
+ resolve("waited " + args.ms + "ms without blocking the UI");
36
61
  });
37
62
  });
38
63
 
39
- app.command("quit", () => {
64
+ on(app, commands, "quit", (_args) => {
40
65
  app.quit();
66
+ return null;
41
67
  });
42
68
  }