@bradensbay/globals 0.2.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.
Files changed (39) hide show
  1. package/README.md +58 -0
  2. package/dist/src/index.d.ts +27 -0
  3. package/dist/src/index.d.ts.map +1 -0
  4. package/dist/src/index.js +23 -0
  5. package/dist/src/index.js.map +1 -0
  6. package/dist/src/native/channel.d.ts +21 -0
  7. package/dist/src/native/channel.d.ts.map +1 -0
  8. package/dist/src/native/channel.js +14 -0
  9. package/dist/src/native/channel.js.map +1 -0
  10. package/dist/src/native/owner-core.d.ts +57 -0
  11. package/dist/src/native/owner-core.d.ts.map +1 -0
  12. package/dist/src/native/owner-core.js +83 -0
  13. package/dist/src/native/owner-core.js.map +1 -0
  14. package/dist/src/native/owner.d.ts +17 -0
  15. package/dist/src/native/owner.d.ts.map +1 -0
  16. package/dist/src/native/owner.js +78 -0
  17. package/dist/src/native/owner.js.map +1 -0
  18. package/dist/src/native/paths.d.ts +3 -0
  19. package/dist/src/native/paths.d.ts.map +1 -0
  20. package/dist/src/native/paths.js +12 -0
  21. package/dist/src/native/paths.js.map +1 -0
  22. package/dist/src/native/reader-core.d.ts +30 -0
  23. package/dist/src/native/reader-core.d.ts.map +1 -0
  24. package/dist/src/native/reader-core.js +81 -0
  25. package/dist/src/native/reader-core.js.map +1 -0
  26. package/dist/src/native/renderer.d.ts +9 -0
  27. package/dist/src/native/renderer.d.ts.map +1 -0
  28. package/dist/src/native/renderer.js +32 -0
  29. package/dist/src/native/renderer.js.map +1 -0
  30. package/dist/src/paths.d.ts +33 -0
  31. package/dist/src/paths.d.ts.map +1 -0
  32. package/dist/src/paths.js +60 -0
  33. package/dist/src/paths.js.map +1 -0
  34. package/dist/src/persistence.d.ts +46 -0
  35. package/dist/src/persistence.d.ts.map +1 -0
  36. package/dist/src/persistence.js +100 -0
  37. package/dist/src/persistence.js.map +1 -0
  38. package/package.json +40 -0
  39. package/preload-async.cjs +56 -0
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @bradensbay/globals
2
+
3
+ The Electron integration for [Globals](https://github.com/christianGRogers/globals) over the
4
+ native transport ([ADR 0003](../../docs/adr/0003-native-transport.md)): the owner is a plain
5
+ object in the main process, trusted windows map one shared memory region from their preloads
6
+ and read synchronously, and windows that keep their sandbox get the asynchronous tier.
7
+
8
+ The trade, first: **a window that maps the region runs with `sandbox: false`.** Context
9
+ isolation stays on and the page gets no Node access, but the Chromium OS sandbox for that
10
+ renderer is off. A window that must keep its sandbox uses the asynchronous tier and never
11
+ maps anything.
12
+
13
+ ## Main process
14
+
15
+ ```ts
16
+ import { startNativeOwner } from "@bradensbay/globals";
17
+
18
+ const owner = await startNativeOwner({
19
+ initial: { count: 0, rows: [] },
20
+ operations: {
21
+ increment(draft, payload: { by: number }) {
22
+ draft.count += payload.by;
23
+ },
24
+ },
25
+ persistence: {}, // optional: rehydrate on boot, save commits under userData
26
+ });
27
+
28
+ owner.store.get(); // the owner reads its own store synchronously
29
+ await owner.update((draft) => …); // or writes it directly
30
+ ```
31
+
32
+ ## A trusted window's preload, with `sandbox: false`
33
+
34
+ ```ts
35
+ import { connectNative } from "@bradensbay/globals/preload";
36
+
37
+ const store = await connectNative();
38
+ store.get(); // synchronous, never stale, never torn
39
+ store.select(["rows", 3, "name"]);
40
+ await store.dispatch("increment", { by: 1 });
41
+ ```
42
+
43
+ Expose whole operations to the page through `contextBridge`, not per-property reads: a
44
+ bridge crossing costs about a microsecond, so the decode layer belongs on the preload side.
45
+ The [example application](../../examples/native-multi-window) shows the shape.
46
+
47
+ ## A sandboxed window
48
+
49
+ ```ts
50
+ // main process
51
+ import { asyncPreloadPath } from "@bradensbay/globals";
52
+ new BrowserWindow({ webPreferences: { preload: asyncPreloadPath(), sandbox: true } });
53
+ ```
54
+
55
+ The page receives `window.globalsAsync`: `read(path?)`, `dispatch(operation, payload)`, and
56
+ `subscribe(listener)`. There is no synchronous `get` on this tier, deliberately.
57
+
58
+ Full guide: [docs/electron.md](../../docs/electron.md).
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @bradensbay/globals
3
+ *
4
+ * The Electron integration over the native transport. The owner is a plain object in the
5
+ * main process; each window that shares memory maps the region file from its preload; a
6
+ * window that keeps its sandbox gets the asynchronous tier instead. There is no hidden
7
+ * owner window, no privileged scheme, and no handshake: the machinery this package once
8
+ * carried for the window.open topology was deleted when ADR 0003 landed, and the history
9
+ * holds it.
10
+ *
11
+ * Two entry points, one per process side:
12
+ *
13
+ * Main process "@bradensbay/globals" startNativeOwner, asyncPreloadPath
14
+ * Preload "@bradensbay/globals/preload" connectNative, for sandbox: false windows
15
+ *
16
+ * A sandboxed window loads the shipped preload-async.cjs (located by asyncPreloadPath) and
17
+ * reads by asking; it never maps the region.
18
+ */
19
+ export { startNativeOwner, asyncPreloadPath } from "./native/owner.js";
20
+ export type { StartNativeOwnerOptions } from "./native/owner.js";
21
+ export { createNativeOwner, restoreNativeOwner } from "./native/owner-core.js";
22
+ export type { NativeOperation, NativeOwner, NativeOwnerOptions } from "./native/owner-core.js";
23
+ export { SnapshotStore } from "./persistence.js";
24
+ export type { PersistenceOptions } from "./persistence.js";
25
+ export { NATIVE_CHANNEL, HELLO, DISPATCH, COMMIT, READ } from "./native/channel.js";
26
+ export type { DispatchMessage, Hello } from "./native/channel.js";
27
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACvE,YAAY,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AACjE,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC/E,YAAY,EAAE,eAAe,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC/F,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,YAAY,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AACpF,YAAY,EAAE,eAAe,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @bradensbay/globals
3
+ *
4
+ * The Electron integration over the native transport. The owner is a plain object in the
5
+ * main process; each window that shares memory maps the region file from its preload; a
6
+ * window that keeps its sandbox gets the asynchronous tier instead. There is no hidden
7
+ * owner window, no privileged scheme, and no handshake: the machinery this package once
8
+ * carried for the window.open topology was deleted when ADR 0003 landed, and the history
9
+ * holds it.
10
+ *
11
+ * Two entry points, one per process side:
12
+ *
13
+ * Main process "@bradensbay/globals" startNativeOwner, asyncPreloadPath
14
+ * Preload "@bradensbay/globals/preload" connectNative, for sandbox: false windows
15
+ *
16
+ * A sandboxed window loads the shipped preload-async.cjs (located by asyncPreloadPath) and
17
+ * reads by asking; it never maps the region.
18
+ */
19
+ export { startNativeOwner, asyncPreloadPath } from "./native/owner.js";
20
+ export { createNativeOwner, restoreNativeOwner } from "./native/owner-core.js";
21
+ export { SnapshotStore } from "./persistence.js";
22
+ export { NATIVE_CHANNEL, HELLO, DISPATCH, COMMIT, READ } from "./native/channel.js";
23
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAEvE,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAE/E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEjD,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The IPC channel names the native transport uses. A module of its own with no Electron
3
+ * import, because both process sides need the names and neither may load the other's
4
+ * modules.
5
+ */
6
+ export declare const NATIVE_CHANNEL = "globals:native";
7
+ export declare const HELLO = "globals:native:hello";
8
+ export declare const DISPATCH = "globals:native:dispatch";
9
+ export declare const COMMIT = "globals:native:commit";
10
+ export declare const READ = "globals:native:read";
11
+ /** What the main process answers a connecting window with. */
12
+ export interface Hello {
13
+ regionPath: string;
14
+ version: number;
15
+ }
16
+ /** A write intent. The renderer never writes memory; it asks the owner to. */
17
+ export interface DispatchMessage {
18
+ operation: string;
19
+ payload: unknown;
20
+ }
21
+ //# sourceMappingURL=channel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../../src/native/channel.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,cAAc,mBAAmB,CAAC;AAC/C,eAAO,MAAM,KAAK,yBAA4B,CAAC;AAC/C,eAAO,MAAM,QAAQ,4BAA+B,CAAC;AACrD,eAAO,MAAM,MAAM,0BAA6B,CAAC;AAIjD,eAAO,MAAM,IAAI,wBAA2B,CAAC;AAE7C,8DAA8D;AAC9D,MAAM,WAAW,KAAK;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,8EAA8E;AAC9E,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;CAClB"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The IPC channel names the native transport uses. A module of its own with no Electron
3
+ * import, because both process sides need the names and neither may load the other's
4
+ * modules.
5
+ */
6
+ export const NATIVE_CHANNEL = "globals:native";
7
+ export const HELLO = `${NATIVE_CHANNEL}:hello`;
8
+ export const DISPATCH = `${NATIVE_CHANNEL}:dispatch`;
9
+ export const COMMIT = `${NATIVE_CHANNEL}:commit`;
10
+ // The asynchronous tier: a sandboxed window cannot map the region, so it reads by asking.
11
+ // The names are duplicated as literals in preload-async.cjs, which a sandboxed preload's
12
+ // restricted require cannot share with this module; keep them in step.
13
+ export const READ = `${NATIVE_CHANNEL}:read`;
14
+ //# sourceMappingURL=channel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel.js","sourceRoot":"","sources":["../../../src/native/channel.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,gBAAgB,CAAC;AAC/C,MAAM,CAAC,MAAM,KAAK,GAAG,GAAG,cAAc,QAAQ,CAAC;AAC/C,MAAM,CAAC,MAAM,QAAQ,GAAG,GAAG,cAAc,WAAW,CAAC;AACrD,MAAM,CAAC,MAAM,MAAM,GAAG,GAAG,cAAc,SAAS,CAAC;AACjD,0FAA0F;AAC1F,yFAAyF;AACzF,uEAAuE;AACvE,MAAM,CAAC,MAAM,IAAI,GAAG,GAAG,cAAc,OAAO,CAAC"}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The owner over the native transport, with no Electron import so it is testable in plain
3
+ * Node. The Electron glue in ./owner.js is a thin layer over this.
4
+ *
5
+ * The owner is the ordinary core OwnerStore writing a private SharedArrayBuffer, exactly as
6
+ * the worker-thread topology uses it. What this module adds is publication: after every
7
+ * commit, the arena bytes are flushed into the mapped region under the region's seqlock, so
8
+ * any process that syncs the region gets a consistent copy of a committed arena and decodes
9
+ * it with the untouched core reader.
10
+ *
11
+ * Growth is deliberately disabled: the region's size is fixed at creation, so the arena is
12
+ * created with maxByteLength equal to byteLength and a full arena raises ArenaFullError
13
+ * rather than growing past what readers mapped. A growable region is future work with a
14
+ * re-handshake, not a default.
15
+ */
16
+ import { OwnerStore, type OwnerOptions } from "@bradensbay/globals-core";
17
+ import { SnapshotStore, type PersistenceOptions } from "../persistence.js";
18
+ /** A named write the owner is willing to apply. Everything a window may do is one of these. */
19
+ export type NativeOperation<State, Payload = unknown> = (draft: State, payload: Payload) => void;
20
+ export interface NativeOwnerOptions<State> {
21
+ /** Where the region file lives. Every window that connects maps this path. */
22
+ regionPath: string;
23
+ /** The state the store holds before the first operation. */
24
+ initial: State;
25
+ /** The writes windows may request, by name. */
26
+ operations?: Record<string, NativeOperation<State, any>>;
27
+ /** Arena and region size in bytes. Fixed for the life of the region. */
28
+ byteLength?: number;
29
+ /** Core arena options other than size, passed through. */
30
+ arena?: Omit<OwnerOptions, "byteLength" | "maxByteLength">;
31
+ /** An already constructed snapshot store to save every commit into. */
32
+ snapshots?: SnapshotStore;
33
+ }
34
+ export interface NativeOwner<State> {
35
+ /** The core store, for reads, subscriptions, and direct writes from the owning process. */
36
+ readonly store: OwnerStore;
37
+ readonly regionPath: string;
38
+ /** The snapshot store saving commits, when persistence is configured. */
39
+ readonly snapshots: SnapshotStore | undefined;
40
+ /** The region version: the number of commits flushed. */
41
+ version(): number;
42
+ /** Apply a named operation, the same call a window's dispatch arrives as. */
43
+ dispatch(operation: string, payload: unknown): Promise<number>;
44
+ /** A typed direct write from the owning process, without naming an operation. */
45
+ update(recipe: (draft: State) => void): Promise<number>;
46
+ close(): void;
47
+ }
48
+ export declare function createNativeOwner<State>(options: NativeOwnerOptions<State>): NativeOwner<State>;
49
+ /**
50
+ * Create the owner with persistence: rehydrate the last saved snapshot if one exists, use
51
+ * the configured initial state otherwise, and save every commit from then on. Asynchronous
52
+ * because rehydration reads a file, and startup is the one place that wait belongs.
53
+ */
54
+ export declare function restoreNativeOwner<State>(options: NativeOwnerOptions<State> & {
55
+ persistence: PersistenceOptions;
56
+ }): Promise<NativeOwner<State>>;
57
+ //# sourceMappingURL=owner-core.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"owner-core.d.ts","sourceRoot":"","sources":["../../../src/native/owner-core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAGzE,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAE3E,+FAA+F;AAC/F,MAAM,MAAM,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,IAAI,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;AAEjG,MAAM,WAAW,kBAAkB,CAAC,KAAK;IACvC,8EAA8E;IAC9E,UAAU,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,OAAO,EAAE,KAAK,CAAC;IACf,+CAA+C;IAE/C,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC;IACzD,wEAAwE;IACxE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0DAA0D;IAC1D,KAAK,CAAC,EAAE,IAAI,CAAC,YAAY,EAAE,YAAY,GAAG,eAAe,CAAC,CAAC;IAC3D,uEAAuE;IACvE,SAAS,CAAC,EAAE,aAAa,CAAC;CAC3B;AAED,MAAM,WAAW,WAAW,CAAC,KAAK;IAChC,2FAA2F;IAC3F,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,yEAAyE;IACzE,QAAQ,CAAC,SAAS,EAAE,aAAa,GAAG,SAAS,CAAC;IAC9C,yDAAyD;IACzD,OAAO,IAAI,MAAM,CAAC;IAClB,6EAA6E;IAC7E,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/D,iFAAiF;IACjF,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACxD,KAAK,IAAI,IAAI,CAAC;CACf;AAID,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,kBAAkB,CAAC,KAAK,CAAC,GAAG,WAAW,CAAC,KAAK,CAAC,CAuD/F;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,KAAK,EAC5C,OAAO,EAAE,kBAAkB,CAAC,KAAK,CAAC,GAAG;IAAE,WAAW,EAAE,kBAAkB,CAAA;CAAE,GACvE,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAQ7B"}
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The owner over the native transport, with no Electron import so it is testable in plain
3
+ * Node. The Electron glue in ./owner.js is a thin layer over this.
4
+ *
5
+ * The owner is the ordinary core OwnerStore writing a private SharedArrayBuffer, exactly as
6
+ * the worker-thread topology uses it. What this module adds is publication: after every
7
+ * commit, the arena bytes are flushed into the mapped region under the region's seqlock, so
8
+ * any process that syncs the region gets a consistent copy of a committed arena and decodes
9
+ * it with the untouched core reader.
10
+ *
11
+ * Growth is deliberately disabled: the region's size is fixed at creation, so the arena is
12
+ * created with maxByteLength equal to byteLength and a full arena raises ArenaFullError
13
+ * rather than growing past what readers mapped. A growable region is future work with a
14
+ * re-handshake, not a default.
15
+ */
16
+ import { OwnerStore } from "@bradensbay/globals-core";
17
+ import { OwnerRegion } from "@bradensbay/globals-shm";
18
+ import { SnapshotStore } from "../persistence.js";
19
+ const DEFAULT_BYTE_LENGTH = 1 << 20;
20
+ export function createNativeOwner(options) {
21
+ const byteLength = options.byteLength ?? DEFAULT_BYTE_LENGTH;
22
+ const operations = options.operations ?? {};
23
+ const store = OwnerStore.create(options.initial, {
24
+ ...options.arena,
25
+ byteLength,
26
+ maxByteLength: byteLength,
27
+ });
28
+ const region = OwnerRegion.create(options.regionPath, store.buffer.byteLength);
29
+ // Whole-buffer flush per commit. The bump allocator makes dirty ranges cheap to compute
30
+ // and they are the planned refinement, but a megabyte costs about sixteen microseconds to
31
+ // publish, so correctness ships first.
32
+ const flush = () => region.flush(new Uint8Array(store.buffer));
33
+ // OwnerStore.create committed the initial state before anyone could subscribe.
34
+ flush();
35
+ const snapshots = options.snapshots;
36
+ const unsubscribe = store.subscribe(() => {
37
+ flush();
38
+ // The flush above published the bytes; this queues the debounced durable copy, which
39
+ // must never sit on the commit path.
40
+ snapshots?.save(store.snapshot().toJSON(), store.version);
41
+ });
42
+ return {
43
+ store,
44
+ regionPath: options.regionPath,
45
+ snapshots,
46
+ version: () => region.version(),
47
+ dispatch(operation, payload) {
48
+ const apply = operations[operation];
49
+ if (apply === undefined) {
50
+ return Promise.reject(new Error(`unknown operation "${operation}". The owner declares: ${Object.keys(operations).join(", ") || "none"}`));
51
+ }
52
+ // The store resolves with the arena's internal version, which is not the currency
53
+ // readers deal in: they see region commit counts. The flush subscriber has already
54
+ // run by the time update resolves, so the region version here includes this commit,
55
+ // and a caller can hand it to any reader meaningfully.
56
+ return store.update((draft) => apply(draft, payload)).then(() => region.version());
57
+ },
58
+ update(recipe) {
59
+ return store.update(recipe).then(() => region.version());
60
+ },
61
+ close() {
62
+ unsubscribe();
63
+ void snapshots?.flush();
64
+ store.close();
65
+ region.close();
66
+ },
67
+ };
68
+ }
69
+ /**
70
+ * Create the owner with persistence: rehydrate the last saved snapshot if one exists, use
71
+ * the configured initial state otherwise, and save every commit from then on. Asynchronous
72
+ * because rehydration reads a file, and startup is the one place that wait belongs.
73
+ */
74
+ export async function restoreNativeOwner(options) {
75
+ const snapshots = new SnapshotStore(options.persistence);
76
+ const loaded = await snapshots.load();
77
+ return createNativeOwner({
78
+ ...options,
79
+ initial: (loaded?.value ?? options.initial),
80
+ snapshots,
81
+ });
82
+ }
83
+ //# sourceMappingURL=owner-core.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"owner-core.js","sourceRoot":"","sources":["../../../src/native/owner-core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,UAAU,EAAqB,MAAM,0BAA0B,CAAC;AACzE,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,EAAE,aAAa,EAA2B,MAAM,mBAAmB,CAAC;AAoC3E,MAAM,mBAAmB,GAAG,CAAC,IAAI,EAAE,CAAC;AAEpC,MAAM,UAAU,iBAAiB,CAAQ,OAAkC;IACzE,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,mBAAmB,CAAC;IAC7D,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,EAAE,CAAC;IAC5C,MAAM,KAAK,GAAG,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE;QAC/C,GAAG,OAAO,CAAC,KAAK;QAChB,UAAU;QACV,aAAa,EAAE,UAAU;KAC1B,CAAC,CAAC;IACH,MAAM,MAAM,GAAG,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,UAAU,EAAE,KAAK,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAE/E,wFAAwF;IACxF,0FAA0F;IAC1F,uCAAuC;IACvC,MAAM,KAAK,GAAG,GAAW,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC;IAEvE,+EAA+E;IAC/E,KAAK,EAAE,CAAC;IACR,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;IACpC,MAAM,WAAW,GAAG,KAAK,CAAC,SAAS,CAAC,GAAG,EAAE;QACvC,KAAK,EAAE,CAAC;QACR,qFAAqF;QACrF,qCAAqC;QACrC,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;IAC5D,CAAC,CAAC,CAAC;IAEH,OAAO;QACL,KAAK;QACL,UAAU,EAAE,OAAO,CAAC,UAAU;QAC9B,SAAS;QACT,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE;QAC/B,QAAQ,CAAC,SAAS,EAAE,OAAO;YACzB,MAAM,KAAK,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC;YACpC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,OAAO,OAAO,CAAC,MAAM,CACnB,IAAI,KAAK,CACP,sBAAsB,SAAS,0BAA0B,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,MAAM,EAAE,CACxG,CACF,CAAC;YACJ,CAAC;YACD,kFAAkF;YAClF,mFAAmF;YACnF,oFAAoF;YACpF,uDAAuD;YACvD,OAAO,KAAK,CAAC,MAAM,CAAQ,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;QAC5F,CAAC;QACD,MAAM,CAAC,MAAM;YACX,OAAO,KAAK,CAAC,MAAM,CAAQ,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;QAClE,CAAC;QACD,KAAK;YACH,WAAW,EAAE,CAAC;YACd,KAAK,SAAS,EAAE,KAAK,EAAE,CAAC;YACxB,KAAK,CAAC,KAAK,EAAE,CAAC;YACd,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,OAAwE;IAExE,MAAM,SAAS,GAAG,IAAI,aAAa,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACzD,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,IAAI,EAAE,CAAC;IACtC,OAAO,iBAAiB,CAAC;QACvB,GAAG,OAAO;QACV,OAAO,EAAE,CAAC,MAAM,EAAE,KAAK,IAAI,OAAO,CAAC,OAAO,CAAU;QACpD,SAAS;KACV,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,17 @@
1
+ import type { PersistenceOptions } from "../persistence.js";
2
+ import { type NativeOwner, type NativeOwnerOptions } from "./owner-core.js";
3
+ export type { NativeOperation, NativeOwner, NativeOwnerOptions } from "./owner-core.js";
4
+ export { asyncPreloadPath } from "./paths.js";
5
+ export interface StartNativeOwnerOptions<State> extends Omit<NativeOwnerOptions<State>, "regionPath" | "snapshots"> {
6
+ /** Defaults to a file named globals.region under the app's userData directory. */
7
+ regionPath?: string;
8
+ /**
9
+ * Persist commits and rehydrate on start. The file defaults to globals.snapshot.json
10
+ * under the app's userData directory.
11
+ */
12
+ persistence?: Omit<PersistenceOptions, "file"> & {
13
+ file?: string;
14
+ };
15
+ }
16
+ export declare function startNativeOwner<State>(options: StartNativeOwnerOptions<State>): Promise<NativeOwner<State>>;
17
+ //# sourceMappingURL=owner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"owner.d.ts","sourceRoot":"","sources":["../../../src/native/owner.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAE5D,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACxB,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EAAE,eAAe,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACxF,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C,MAAM,WAAW,uBAAuB,CAAC,KAAK,CAC5C,SAAQ,IAAI,CAAC,kBAAkB,CAAC,KAAK,CAAC,EAAE,YAAY,GAAG,WAAW,CAAC;IACnE,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,IAAI,CAAC,kBAAkB,EAAE,MAAM,CAAC,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACpE;AAOD,wBAAsB,gBAAgB,CAAC,KAAK,EAC1C,OAAO,EAAE,uBAAuB,CAAC,KAAK,CAAC,GACtC,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CA+D7B"}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The Electron glue for the native owner: main process only.
3
+ *
4
+ * The owner is a plain object in the main process. There is no hidden window, no privileged
5
+ * scheme, no isolation headers, and no handshake that hands a buffer to anyone: windows map
6
+ * the region file themselves from their preloads, and everything the main process owes them
7
+ * is the region's path and a content-free ping per commit.
8
+ */
9
+ import { app, ipcMain } from "electron";
10
+ import { join } from "node:path";
11
+ import { COMMIT, DISPATCH, HELLO, READ } from "./channel.js";
12
+ import { createNativeOwner, restoreNativeOwner, } from "./owner-core.js";
13
+ export { asyncPreloadPath } from "./paths.js";
14
+ function materialise(value) {
15
+ if (value === null || typeof value !== "object")
16
+ return value;
17
+ return JSON.parse(JSON.stringify(value));
18
+ }
19
+ export async function startNativeOwner(options) {
20
+ const regionPath = options.regionPath ?? join(app.getPath("userData"), "globals.region");
21
+ const owner = options.persistence
22
+ ? await restoreNativeOwner({
23
+ ...options,
24
+ regionPath,
25
+ persistence: {
26
+ file: join(app.getPath("userData"), "globals.snapshot.json"),
27
+ ...options.persistence,
28
+ },
29
+ })
30
+ : createNativeOwner({ ...options, regionPath });
31
+ const subscribers = new Set();
32
+ ipcMain.handle(HELLO, (event) => {
33
+ const sender = event.sender;
34
+ if (!subscribers.has(sender)) {
35
+ subscribers.add(sender);
36
+ sender.once("destroyed", () => subscribers.delete(sender));
37
+ }
38
+ return { regionPath, version: owner.version() };
39
+ });
40
+ ipcMain.handle(DISPATCH, (_event, message) => owner.dispatch(message.operation, message.payload));
41
+ // The asynchronous tier. The lazy view is a Proxy and structured clone refuses proxies,
42
+ // so what crosses the wire is materialised first. This path is for windows that keep
43
+ // their sandbox; it reads by request and pays a round trip, which is the documented trade.
44
+ ipcMain.handle(READ, (_event, message) => {
45
+ const value = message?.path === undefined
46
+ ? owner.store.snapshot().toJSON()
47
+ : materialise(owner.store.select(message.path));
48
+ return { version: owner.version(), value };
49
+ });
50
+ // The flush subscriber inside createNativeOwner registered first, so by the time this
51
+ // runs the commit is already in the region and the ping cannot arrive ahead of the bytes.
52
+ const unsubscribe = owner.store.subscribe(() => {
53
+ const version = owner.version();
54
+ for (const sender of subscribers) {
55
+ if (!sender.isDestroyed())
56
+ sender.send(COMMIT, version);
57
+ }
58
+ });
59
+ // A debounced snapshot that never gets its final write is a stale rehydrate next boot.
60
+ const onQuit = () => {
61
+ void owner.snapshots?.flush();
62
+ };
63
+ if (owner.snapshots)
64
+ app.on("before-quit", onQuit);
65
+ return {
66
+ ...owner,
67
+ close() {
68
+ unsubscribe();
69
+ app.removeListener("before-quit", onQuit);
70
+ ipcMain.removeHandler(HELLO);
71
+ ipcMain.removeHandler(DISPATCH);
72
+ ipcMain.removeHandler(READ);
73
+ subscribers.clear();
74
+ owner.close();
75
+ },
76
+ };
77
+ }
78
+ //# sourceMappingURL=owner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"owner.js","sourceRoot":"","sources":["../../../src/native/owner.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ /** The shipped preload for the asynchronous tier: sandboxed windows that read by asking. */
2
+ export declare function asyncPreloadPath(): string;
3
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../../src/native/paths.ts"],"names":[],"mappings":"AAOA,4FAA4F;AAC5F,wBAAgB,gBAAgB,IAAI,MAAM,CAGzC"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Filesystem locations the main process needs to hand to webPreferences. A module rather
3
+ * than a constant, because the path is only knowable relative to the installed package.
4
+ */
5
+ import { fileURLToPath } from "node:url";
6
+ import { dirname, join } from "node:path";
7
+ /** The shipped preload for the asynchronous tier: sandboxed windows that read by asking. */
8
+ export function asyncPreloadPath() {
9
+ // dist/src/native/paths.js sits three directories below the package root.
10
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "preload-async.cjs");
11
+ }
12
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.js","sourceRoot":"","sources":["../../../src/native/paths.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAE1C,4FAA4F;AAC5F,MAAM,UAAU,gBAAgB;IAC9B,0EAA0E;IAC1E,OAAO,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,mBAAmB,CAAC,CAAC;AAC9F,CAAC"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The reading side over the native transport, with no Electron import. The preload glue in
3
+ * ./renderer.js is a thin layer over this.
4
+ *
5
+ * The read path: one native version check per read; when the region moved, one
6
+ * seqlock-consistent copy into a fresh private buffer, and the untouched core reader decodes
7
+ * that. A read can never observe a stale version and never a torn one. A snapshot pins the
8
+ * buffer it was taken from: superseded buffers stay alive exactly as long as something still
9
+ * reads them, and ordinary garbage collection is the whole reclamation story. Nothing here
10
+ * writes shared memory, and cross process epochs do not exist to manage.
11
+ */
12
+ import { type ReadableStore, type Snapshot } from "@bradensbay/globals-core";
13
+ export declare class NativeReaderSource implements ReadableStore {
14
+ #private;
15
+ private constructor();
16
+ static attach(regionPath: string): NativeReaderSource;
17
+ /** The live region version, one native call. */
18
+ get version(): number;
19
+ get(): unknown;
20
+ select(path: readonly (string | number)[]): unknown;
21
+ snapshot(): Snapshot;
22
+ subscribe(listener: () => void): () => void;
23
+ /**
24
+ * Tell the source a commit happened. The integration calls this off a notification
25
+ * message; nothing on the read path depends on it, it only drives rerenders.
26
+ */
27
+ notify(): void;
28
+ close(): void;
29
+ }
30
+ //# sourceMappingURL=reader-core.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reader-core.d.ts","sourceRoot":"","sources":["../../../src/native/reader-core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAe,KAAK,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAG1F,qBAAa,kBAAmB,YAAW,aAAa;;IAOtD,OAAO,eAEN;IAED,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,kBAAkB,CAEpD;IAED,gDAAgD;IAChD,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED,GAAG,IAAI,OAAO,CAEb;IAED,MAAM,CAAC,IAAI,EAAE,SAAS,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,GAAG,OAAO,CAElD;IAED,QAAQ,IAAI,QAAQ,CAEnB;IAED,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAG1C;IAED;;;OAGG;IACH,MAAM,IAAI,IAAI,CAKb;IAED,KAAK,IAAI,IAAI,CAIZ;CAsBF"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The reading side over the native transport, with no Electron import. The preload glue in
3
+ * ./renderer.js is a thin layer over this.
4
+ *
5
+ * The read path: one native version check per read; when the region moved, one
6
+ * seqlock-consistent copy into a fresh private buffer, and the untouched core reader decodes
7
+ * that. A read can never observe a stale version and never a torn one. A snapshot pins the
8
+ * buffer it was taken from: superseded buffers stay alive exactly as long as something still
9
+ * reads them, and ordinary garbage collection is the whole reclamation story. Nothing here
10
+ * writes shared memory, and cross process epochs do not exist to manage.
11
+ */
12
+ import { ReaderStore } from "@bradensbay/globals-core";
13
+ import { ReaderRegion } from "@bradensbay/globals-shm";
14
+ export class NativeReaderSource {
15
+ #region;
16
+ #held = 0;
17
+ #store = null;
18
+ #notified = 0;
19
+ #listeners = new Set();
20
+ constructor(region) {
21
+ this.#region = region;
22
+ }
23
+ static attach(regionPath) {
24
+ return new NativeReaderSource(ReaderRegion.attach(regionPath));
25
+ }
26
+ /** The live region version, one native call. */
27
+ get version() {
28
+ return this.#region.version();
29
+ }
30
+ get() {
31
+ return this.#ensure().get();
32
+ }
33
+ select(path) {
34
+ return this.#ensure().select(path);
35
+ }
36
+ snapshot() {
37
+ return this.#ensure().snapshot();
38
+ }
39
+ subscribe(listener) {
40
+ this.#listeners.add(listener);
41
+ return () => this.#listeners.delete(listener);
42
+ }
43
+ /**
44
+ * Tell the source a commit happened. The integration calls this off a notification
45
+ * message; nothing on the read path depends on it, it only drives rerenders.
46
+ */
47
+ notify() {
48
+ const version = this.#region.version();
49
+ if (version === this.#notified)
50
+ return;
51
+ this.#notified = version;
52
+ for (const listener of this.#listeners)
53
+ listener();
54
+ }
55
+ close() {
56
+ this.#region.close();
57
+ this.#store = null;
58
+ this.#listeners.clear();
59
+ }
60
+ #ensure() {
61
+ const version = this.#region.version();
62
+ if (version === 0) {
63
+ throw new Error("the region holds no commit yet: the owner has not flushed");
64
+ }
65
+ if (this.#store === null || version !== this.#held) {
66
+ // A fresh private buffer per observed commit. The previous store is dropped, not
67
+ // closed: snapshots taken from it keep their buffer alive until they are collected.
68
+ //
69
+ // A plain ArrayBuffer, deliberately. Blink hides the SharedArrayBuffer constructor
70
+ // from a renderer that is not cross origin isolated, unsandboxed preloads included,
71
+ // and nothing here needs sharing: the buffer is this process's private copy. Atomics
72
+ // operate on non-shared buffers, and the read path never waits, so the core reader
73
+ // works unchanged; only its declared type expects the shared flavour.
74
+ const copy = new ArrayBuffer(this.#region.dataSize);
75
+ this.#held = this.#region.sync(new Uint8Array(copy));
76
+ this.#store = new ReaderStore(copy);
77
+ }
78
+ return this.#store;
79
+ }
80
+ }
81
+ //# sourceMappingURL=reader-core.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reader-core.js","sourceRoot":"","sources":["../../../src/native/reader-core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,WAAW,EAAqC,MAAM,0BAA0B,CAAC;AAC1F,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAEvD,MAAM,OAAO,kBAAkB;IAC7B,OAAO,CAAe;IACtB,KAAK,GAAG,CAAC,CAAC;IACV,MAAM,GAAuB,IAAI,CAAC;IAClC,SAAS,GAAG,CAAC,CAAC;IACL,UAAU,GAAG,IAAI,GAAG,EAAc,CAAC;IAE5C,YAAoB,MAAoB;QACtC,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;IACxB,CAAC;IAED,MAAM,CAAC,MAAM,CAAC,UAAkB;QAC9B,OAAO,IAAI,kBAAkB,CAAC,YAAY,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC;IACjE,CAAC;IAED,gDAAgD;IAChD,IAAI,OAAO;QACT,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;IAChC,CAAC;IAED,GAAG;QACD,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,CAAC;IAC9B,CAAC;IAED,MAAM,CAAC,IAAkC;QACvC,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACrC,CAAC;IAED,QAAQ;QACN,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC;IACnC,CAAC;IAED,SAAS,CAAC,QAAoB;QAC5B,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC9B,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAChD,CAAC;IAED;;;OAGG;IACH,MAAM;QACJ,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;QACvC,IAAI,OAAO,KAAK,IAAI,CAAC,SAAS;YAAE,OAAO;QACvC,IAAI,CAAC,SAAS,GAAG,OAAO,CAAC;QACzB,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,UAAU;YAAE,QAAQ,EAAE,CAAC;IACrD,CAAC;IAED,KAAK;QACH,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;QACrB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACnB,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;IAC1B,CAAC;IAED,OAAO;QACL,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;QACvC,IAAI,OAAO,KAAK,CAAC,EAAE,CAAC;YAClB,MAAM,IAAI,KAAK,CAAC,2DAA2D,CAAC,CAAC;QAC/E,CAAC;QACD,IAAI,IAAI,CAAC,MAAM,KAAK,IAAI,IAAI,OAAO,KAAK,IAAI,CAAC,KAAK,EAAE,CAAC;YACnD,iFAAiF;YACjF,oFAAoF;YACpF,EAAE;YACF,mFAAmF;YACnF,oFAAoF;YACpF,qFAAqF;YACrF,mFAAmF;YACnF,sEAAsE;YACtE,MAAM,IAAI,GAAG,IAAI,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;YACpD,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC;YACrD,IAAI,CAAC,MAAM,GAAG,IAAI,WAAW,CAAC,IAAoC,CAAC,CAAC;QACtE,CAAC;QACD,OAAO,IAAI,CAAC,MAAM,CAAC;IACrB,CAAC;CACF"}
@@ -0,0 +1,9 @@
1
+ import type { ReadableStore } from "@bradensbay/globals-core";
2
+ export type { NativeReaderSource } from "./reader-core.js";
3
+ export interface NativeConnection extends ReadableStore {
4
+ /** Ask the owner to apply a named operation. Resolves with the committed version. */
5
+ dispatch(operation: string, payload?: unknown): Promise<number>;
6
+ close(): void;
7
+ }
8
+ export declare function connectNative(): Promise<NativeConnection>;
9
+ //# sourceMappingURL=renderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../../../src/native/renderer.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAY,MAAM,0BAA0B,CAAC;AAIxE,YAAY,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAE3D,MAAM,WAAW,gBAAiB,SAAQ,aAAa;IACrD,qFAAqF;IACrF,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAChE,KAAK,IAAI,IAAI,CAAC;CACf;AAED,wBAAsB,aAAa,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAqB/D"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The preload side of the native transport.
3
+ *
4
+ * This module runs in a preload with sandbox: false and context isolation on: a Node
5
+ * context that can load the addon, in a window whose page cannot. The decode layer lives
6
+ * here on purpose, because every contextBridge crossing costs about a microsecond; expose
7
+ * whole operations to the page, not per-property reads.
8
+ */
9
+ import { ipcRenderer } from "electron";
10
+ import { COMMIT, DISPATCH, HELLO } from "./channel.js";
11
+ import { NativeReaderSource } from "./reader-core.js";
12
+ export async function connectNative() {
13
+ const hello = (await ipcRenderer.invoke(HELLO));
14
+ const source = NativeReaderSource.attach(hello.regionPath);
15
+ const onCommit = () => source.notify();
16
+ ipcRenderer.on(COMMIT, onCommit);
17
+ return {
18
+ get: () => source.get(),
19
+ select: (path) => source.select(path),
20
+ snapshot: () => source.snapshot(),
21
+ subscribe: (listener) => source.subscribe(listener),
22
+ get version() {
23
+ return source.version;
24
+ },
25
+ dispatch: (operation, payload) => ipcRenderer.invoke(DISPATCH, { operation, payload }),
26
+ close() {
27
+ ipcRenderer.removeListener(COMMIT, onCommit);
28
+ source.close();
29
+ },
30
+ };
31
+ }
32
+ //# sourceMappingURL=renderer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"renderer.js","sourceRoot":"","sources":["../../../src/native/renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAGvC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAc,MAAM,cAAc,CAAC;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAUtD,MAAM,CAAC,KAAK,UAAU,aAAa;IACjC,MAAM,KAAK,GAAG,CAAC,MAAM,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC,CAAU,CAAC;IACzD,MAAM,MAAM,GAAG,kBAAkB,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IAC3D,MAAM,QAAQ,GAAG,GAAS,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;IAC7C,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAEjC,OAAO;QACL,GAAG,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,GAAG,EAAE;QACvB,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;QACrC,QAAQ,EAAE,GAAa,EAAE,CAAC,MAAM,CAAC,QAAQ,EAAE;QAC3C,SAAS,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC;QACnD,IAAI,OAAO;YACT,OAAO,MAAM,CAAC,OAAO,CAAC;QACxB,CAAC;QACD,QAAQ,EAAE,CAAC,SAAS,EAAE,OAAO,EAAE,EAAE,CAC/B,WAAW,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,OAAO,EAAE,CAAoB;QACzE,KAAK;YACH,WAAW,CAAC,cAAc,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;YAC7C,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Serving logic with no Electron import in it.
3
+ *
4
+ * Kept separate for a practical reason. Outside an Electron process the `electron` module is
5
+ * a shim whose only export is the path to the binary, so anything that imports it cannot be
6
+ * loaded by a plain Node test runner. Putting the path resolution here means the security
7
+ * relevant part of serving files can be tested directly, which is worth more than keeping it
8
+ * next to the code that calls it.
9
+ */
10
+ export declare const DEFAULT_SCHEME = "globals-app";
11
+ /** The headers that make `crossOriginIsolated` true. */
12
+ export declare const ISOLATION_HEADERS: {
13
+ readonly "cross-origin-opener-policy": "same-origin";
14
+ readonly "cross-origin-embedder-policy": "require-corp";
15
+ readonly "cross-origin-resource-policy": "same-origin";
16
+ };
17
+ export declare const MIME: Record<string, string>;
18
+ /**
19
+ * Turn a request pathname into a file inside the served root, or undefined when it escapes.
20
+ *
21
+ * A traversal that reaches outside the root is not a missing file, it is a hole, so the
22
+ * segments are filtered after decoding and the result is checked against the root again.
23
+ *
24
+ * The rule that catches application authors is the ordinary one: **a page can only import
25
+ * what the served root contains.** An import that climbs above the root with `..` never
26
+ * reaches the filesystem above it, because the browser resolves the specifier against the
27
+ * scheme origin first and the climb is already collapsed by the time the request arrives.
28
+ * Serve a root that contains everything the pages import.
29
+ */
30
+ export declare function resolveRequestPath(root: string, pathname: string, index: string): string | undefined;
31
+ /** The URL a window should load for a page under the served root. */
32
+ export declare function pageUrl(page: string, scheme?: string): string;
33
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../src/paths.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,eAAO,MAAM,cAAc,gBAAgB,CAAC;AAE5C,wDAAwD;AACxD,eAAO,MAAM,iBAAiB;aAC5B,4BAA4B,EAAE,aAAa;aAC3C,8BAA8B,EAAE,cAAc;aAC9C,8BAA8B,EAAE,aAAa;CACrC,CAAC;AAEX,eAAO,MAAM,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAevC,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,MAAM,GAAG,SAAS,CAYpB;AAED,qEAAqE;AACrE,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,GAAE,MAAuB,GAAG,MAAM,CAE7E"}
@@ -0,0 +1,60 @@
1
+ import { join, normalize, sep } from "node:path";
2
+ /**
3
+ * Serving logic with no Electron import in it.
4
+ *
5
+ * Kept separate for a practical reason. Outside an Electron process the `electron` module is
6
+ * a shim whose only export is the path to the binary, so anything that imports it cannot be
7
+ * loaded by a plain Node test runner. Putting the path resolution here means the security
8
+ * relevant part of serving files can be tested directly, which is worth more than keeping it
9
+ * next to the code that calls it.
10
+ */
11
+ export const DEFAULT_SCHEME = "globals-app";
12
+ /** The headers that make `crossOriginIsolated` true. */
13
+ export const ISOLATION_HEADERS = {
14
+ "cross-origin-opener-policy": "same-origin",
15
+ "cross-origin-embedder-policy": "require-corp",
16
+ "cross-origin-resource-policy": "same-origin",
17
+ };
18
+ export const MIME = {
19
+ ".html": "text/html; charset=utf-8",
20
+ ".js": "text/javascript; charset=utf-8",
21
+ ".mjs": "text/javascript; charset=utf-8",
22
+ ".css": "text/css; charset=utf-8",
23
+ ".json": "application/json; charset=utf-8",
24
+ ".map": "application/json; charset=utf-8",
25
+ ".svg": "image/svg+xml",
26
+ ".png": "image/png",
27
+ ".jpg": "image/jpeg",
28
+ ".jpeg": "image/jpeg",
29
+ ".gif": "image/gif",
30
+ ".webp": "image/webp",
31
+ ".woff": "font/woff",
32
+ ".woff2": "font/woff2",
33
+ };
34
+ /**
35
+ * Turn a request pathname into a file inside the served root, or undefined when it escapes.
36
+ *
37
+ * A traversal that reaches outside the root is not a missing file, it is a hole, so the
38
+ * segments are filtered after decoding and the result is checked against the root again.
39
+ *
40
+ * The rule that catches application authors is the ordinary one: **a page can only import
41
+ * what the served root contains.** An import that climbs above the root with `..` never
42
+ * reaches the filesystem above it, because the browser resolves the specifier against the
43
+ * scheme origin first and the climb is already collapsed by the time the request arrives.
44
+ * Serve a root that contains everything the pages import.
45
+ */
46
+ export function resolveRequestPath(root, pathname, index) {
47
+ const normalisedRoot = normalize(root);
48
+ const segments = decodeURIComponent(pathname)
49
+ .split("/")
50
+ .filter((segment) => segment.length > 0 && segment !== "." && segment !== "..");
51
+ const file = normalize(segments.length === 0 ? join(normalisedRoot, index) : join(normalisedRoot, ...segments));
52
+ if (file !== normalisedRoot && !file.startsWith(normalisedRoot + sep))
53
+ return undefined;
54
+ return file;
55
+ }
56
+ /** The URL a window should load for a page under the served root. */
57
+ export function pageUrl(page, scheme = DEFAULT_SCHEME) {
58
+ return `${scheme}://app/${page.replace(/^\/+/u, "")}`;
59
+ }
60
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.js","sourceRoot":"","sources":["../../src/paths.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAEjD;;;;;;;;GAQG;AAEH,MAAM,CAAC,MAAM,cAAc,GAAG,aAAa,CAAC;AAE5C,wDAAwD;AACxD,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,4BAA4B,EAAE,aAAa;IAC3C,8BAA8B,EAAE,cAAc;IAC9C,8BAA8B,EAAE,aAAa;CACrC,CAAC;AAEX,MAAM,CAAC,MAAM,IAAI,GAA2B;IAC1C,OAAO,EAAE,0BAA0B;IACnC,KAAK,EAAE,gCAAgC;IACvC,MAAM,EAAE,gCAAgC;IACxC,MAAM,EAAE,yBAAyB;IACjC,OAAO,EAAE,iCAAiC;IAC1C,MAAM,EAAE,iCAAiC;IACzC,MAAM,EAAE,eAAe;IACvB,MAAM,EAAE,WAAW;IACnB,MAAM,EAAE,YAAY;IACpB,OAAO,EAAE,YAAY;IACrB,MAAM,EAAE,WAAW;IACnB,OAAO,EAAE,YAAY;IACrB,OAAO,EAAE,WAAW;IACpB,QAAQ,EAAE,YAAY;CACvB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,kBAAkB,CAChC,IAAY,EACZ,QAAgB,EAChB,KAAa;IAEb,MAAM,cAAc,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;IACvC,MAAM,QAAQ,GAAG,kBAAkB,CAAC,QAAQ,CAAC;SAC1C,KAAK,CAAC,GAAG,CAAC;SACV,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,IAAI,OAAO,KAAK,GAAG,IAAI,OAAO,KAAK,IAAI,CAAC,CAAC;IAElF,MAAM,IAAI,GAAG,SAAS,CACpB,QAAQ,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,EAAE,GAAG,QAAQ,CAAC,CACxF,CAAC;IAEF,IAAI,IAAI,KAAK,cAAc,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,cAAc,GAAG,GAAG,CAAC;QAAE,OAAO,SAAS,CAAC;IACxF,OAAO,IAAI,CAAC;AACd,CAAC;AAED,qEAAqE;AACrE,MAAM,UAAU,OAAO,CAAC,IAAY,EAAE,MAAM,GAAW,cAAc;IACnE,OAAO,GAAG,MAAM,UAAU,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,EAAE,CAAC;AACxD,CAAC"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Persistence.
3
+ *
4
+ * A hook on commit and a rehydrate path on boot, with the temp file and rename discipline
5
+ * that makes a crash mid write leave either the old file or the new one, never half of
6
+ * either.
7
+ *
8
+ * Writes are debounced and coalesced. A store committing a thousand times a second must not
9
+ * produce a thousand disk writes, and the last one is the only one that matters.
10
+ */
11
+ export interface PersistenceOptions {
12
+ /** Where the snapshot lives. The directory is created if it does not exist. */
13
+ file: string;
14
+ /** Milliseconds to wait for further commits before writing. */
15
+ debounceMs?: number;
16
+ /** Serialise. Defaults to JSON with a version envelope. */
17
+ serialise?: (value: unknown, version: number) => string;
18
+ /** Parse. Defaults to the matching JSON envelope reader. */
19
+ deserialise?: (text: string) => {
20
+ value: unknown;
21
+ version: number;
22
+ } | undefined;
23
+ /** Called when a write or a read fails. Persistence must never take the app down. */
24
+ onError?: (error: unknown, phase: "load" | "save") => void;
25
+ }
26
+ export declare class SnapshotStore {
27
+ #private;
28
+ constructor(options: PersistenceOptions);
29
+ get writeCount(): number;
30
+ /**
31
+ * Read the persisted value.
32
+ *
33
+ * A missing file is not an error: a first run has nothing to rehydrate. A corrupt file is
34
+ * reported through onError and treated as missing, because refusing to start is a worse
35
+ * outcome than starting empty.
36
+ */
37
+ load(): Promise<{
38
+ value: unknown;
39
+ version: number;
40
+ } | undefined>;
41
+ /** Queue a save. Coalesces with any save already queued. */
42
+ save(value: unknown, version: number): void;
43
+ /** Write whatever is queued now. Called on quit, and by tests. */
44
+ flush(): Promise<void>;
45
+ }
46
+ //# sourceMappingURL=persistence.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../src/persistence.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AAEH,MAAM,WAAW,kBAAkB;IACjC,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IACxD,4DAA4D;IAC5D,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;IAChF,qFAAqF;IACrF,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,KAAK,IAAI,CAAC;CAC5D;AAyBD,qBAAa,aAAa;;IAQxB,YAAY,OAAO,EAAE,kBAAkB,EAEtC;IAED,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED;;;;;;OAMG;IACG,IAAI,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC,CAUrE;IAED,4DAA4D;IAC5D,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAQ1C;IAED,kEAAkE;IAClE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAarB;CAwBF"}
@@ -0,0 +1,100 @@
1
+ import { readFile, rename, writeFile, mkdir, unlink } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ function defaultSerialise(value, version) {
4
+ const envelope = {
5
+ format: 1,
6
+ savedAt: new Date().toISOString(),
7
+ version,
8
+ value,
9
+ };
10
+ return JSON.stringify(envelope);
11
+ }
12
+ function defaultDeserialise(text) {
13
+ const parsed = JSON.parse(text);
14
+ if (parsed === null || typeof parsed !== "object" || parsed.format !== 1)
15
+ return undefined;
16
+ return { value: parsed.value, version: typeof parsed.version === "number" ? parsed.version : 0 };
17
+ }
18
+ export class SnapshotStore {
19
+ #options;
20
+ #timer;
21
+ #pending;
22
+ #writing = Promise.resolve();
23
+ #writes = 0;
24
+ constructor(options) {
25
+ this.#options = { debounceMs: 250, ...options };
26
+ }
27
+ get writeCount() {
28
+ return this.#writes;
29
+ }
30
+ /**
31
+ * Read the persisted value.
32
+ *
33
+ * A missing file is not an error: a first run has nothing to rehydrate. A corrupt file is
34
+ * reported through onError and treated as missing, because refusing to start is a worse
35
+ * outcome than starting empty.
36
+ */
37
+ async load() {
38
+ try {
39
+ const text = await readFile(this.#options.file, "utf8");
40
+ const parse = this.#options.deserialise ?? defaultDeserialise;
41
+ return parse(text);
42
+ }
43
+ catch (error) {
44
+ const code = error.code;
45
+ if (code !== "ENOENT")
46
+ this.#options.onError?.(error, "load");
47
+ return undefined;
48
+ }
49
+ }
50
+ /** Queue a save. Coalesces with any save already queued. */
51
+ save(value, version) {
52
+ this.#pending = { value, version };
53
+ if (this.#timer !== undefined)
54
+ return;
55
+ this.#timer = setTimeout(() => {
56
+ this.#timer = undefined;
57
+ void this.flush();
58
+ }, this.#options.debounceMs);
59
+ this.#timer.unref?.();
60
+ }
61
+ /** Write whatever is queued now. Called on quit, and by tests. */
62
+ flush() {
63
+ if (this.#timer !== undefined) {
64
+ clearTimeout(this.#timer);
65
+ this.#timer = undefined;
66
+ }
67
+ const pending = this.#pending;
68
+ this.#pending = undefined;
69
+ if (pending === undefined)
70
+ return this.#writing;
71
+ // Serialise writes against each other. Two overlapping renames onto the same path is a
72
+ // race with no upside.
73
+ this.#writing = this.#writing.then(() => this.#write(pending.value, pending.version));
74
+ return this.#writing;
75
+ }
76
+ async #write(value, version) {
77
+ const serialise = this.#options.serialise ?? defaultSerialise;
78
+ const target = this.#options.file;
79
+ // The temp file sits beside the target so the rename stays on one filesystem. A rename
80
+ // across devices is a copy, and a copy is not atomic.
81
+ const temporary = join(dirname(target), `.${version}.${process.pid}.tmp`);
82
+ try {
83
+ await mkdir(dirname(target), { recursive: true });
84
+ await writeFile(temporary, serialise(value, version), "utf8");
85
+ await rename(temporary, target);
86
+ this.#writes += 1;
87
+ }
88
+ catch (error) {
89
+ this.#options.onError?.(error, "save");
90
+ try {
91
+ await unlink(temporary);
92
+ }
93
+ catch {
94
+ // The temp file may not exist, which is the common case when writeFile is what
95
+ // failed. Nothing to report.
96
+ }
97
+ }
98
+ }
99
+ }
100
+ //# sourceMappingURL=persistence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persistence.js","sourceRoot":"","sources":["../../src/persistence.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@bradensbay/globals",
3
+ "version": "0.2.0",
4
+ "description": "Electron integration over the native transport: the owner in the main process, preload-mapped regions for trusted windows, and the asynchronous tier for windows that keep their sandbox.",
5
+ "type": "module",
6
+ "main": "./dist/src/index.js",
7
+ "types": "./dist/src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/src/index.d.ts",
11
+ "import": "./dist/src/index.js"
12
+ },
13
+ "./preload": {
14
+ "types": "./dist/src/native/renderer.d.ts",
15
+ "import": "./dist/src/native/renderer.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist/src",
20
+ "preload-async.cjs",
21
+ "README.md"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20.11.0"
25
+ },
26
+ "dependencies": {
27
+ "@bradensbay/globals-core": "0.2.0",
28
+ "@bradensbay/globals-shm": "0.2.0"
29
+ },
30
+ "peerDependencies": {
31
+ "electron": ">=28 <35"
32
+ },
33
+ "sideEffects": false,
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/christianGRogers/globals.git",
38
+ "directory": "packages/electron"
39
+ }
40
+ }
@@ -0,0 +1,56 @@
1
+ // The asynchronous tier's preload, for windows that keep their sandbox.
2
+ //
3
+ // A sandboxed preload is CommonJS with a require that reaches only "electron", so this file
4
+ // is self contained and the channel names are literals; src/native/channel.ts is the other
5
+ // copy, and the comment there points back here. Nothing in this file maps the region or
6
+ // loads native code: a sandboxed window reads by asking the owner, which is the documented
7
+ // trade for keeping the Chromium sandbox.
8
+ //
9
+ // What the page gets is deliberately small and visibly asynchronous. There is no get() at
10
+ // all: a synchronous read does not exist on this tier, and an API that pretended otherwise
11
+ // would be the exact confusion the type system elsewhere works to prevent.
12
+ const { contextBridge, ipcRenderer } = require("electron");
13
+
14
+ const HELLO = "globals:native:hello";
15
+ const DISPATCH = "globals:native:dispatch";
16
+ const COMMIT = "globals:native:commit";
17
+ const READ = "globals:native:read";
18
+
19
+ // Subscribing early means the first commit ping can arrive before the page even asks.
20
+ const hello = ipcRenderer.invoke(HELLO);
21
+
22
+ const listeners = new Set();
23
+ ipcRenderer.on(COMMIT, (_event, version) => {
24
+ for (const listener of listeners) {
25
+ try {
26
+ listener(version);
27
+ } catch {
28
+ // A throwing page listener must not take the bridge down.
29
+ }
30
+ }
31
+ });
32
+
33
+ contextBridge.exposeInMainWorld("globalsAsync", {
34
+ tier: "async",
35
+
36
+ /** Resolves with { version, value }: the whole state, or one path of it. */
37
+ read(path) {
38
+ return ipcRenderer.invoke(READ, path === undefined ? undefined : { path });
39
+ },
40
+
41
+ /** Ask the owner to apply a named operation. Resolves with the committed version. */
42
+ dispatch(operation, payload) {
43
+ return ipcRenderer.invoke(DISPATCH, { operation, payload });
44
+ },
45
+
46
+ /** Called with the new version after every commit. Returns an unsubscribe function. */
47
+ subscribe(listener) {
48
+ listeners.add(listener);
49
+ return () => listeners.delete(listener);
50
+ },
51
+
52
+ /** Resolves once the connection to the owner exists. */
53
+ ready() {
54
+ return hello.then((h) => h.version);
55
+ },
56
+ });