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 +149 -0
- package/api/index.d.ts +69 -4
- package/api/index.js +31 -2
- package/bin/janela.mjs +9 -1
- package/package.json +2 -2
- package/runtime/janela.ts +400 -265
- package/runtime/types.ts +58 -57
- package/templates/react/files/src/App.tsx +12 -6
- package/templates/react/files/src-host/main.ts +43 -18
- package/templates/solid/files/src/App.tsx +9 -4
- package/templates/solid/files/src-host/main.ts +43 -18
- package/templates/svelte/files/src/App.svelte +9 -4
- package/templates/svelte/files/src-host/main.ts +43 -18
- package/templates/vue/files/src/App.vue +13 -7
- package/templates/vue/files/src-host/main.ts +43 -18
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):
|
|
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
|
-
):
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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/
|
|
16
|
+
"types": "./runtime/janela.ts"
|
|
17
17
|
},
|
|
18
18
|
"./global": {
|
|
19
19
|
"types": "./api/global.d.ts"
|