@x-tech-laboratories/x-libs 0.14.1 → 0.15.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 (151) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/client.d.ts +32 -0
  3. package/dist/agent/client.js +148 -0
  4. package/dist/agent/commands.d.ts +23 -0
  5. package/dist/agent/commands.js +118 -0
  6. package/dist/agent/keys.d.ts +14 -0
  7. package/dist/agent/keys.js +81 -0
  8. package/dist/agent/long_runs.d.ts +32 -0
  9. package/dist/agent/long_runs.js +316 -0
  10. package/dist/agent/protocol.d.ts +148 -0
  11. package/dist/agent/protocol.js +131 -0
  12. package/dist/agent/session.d.ts +50 -0
  13. package/dist/agent/session.js +225 -0
  14. package/dist/agent/tasks.d.ts +17 -0
  15. package/dist/agent/tasks.js +15 -0
  16. package/dist/backups.d.ts +31 -0
  17. package/dist/backups.js +43 -0
  18. package/dist/dates.d.ts +9 -0
  19. package/dist/dates.js +60 -0
  20. package/dist/durations.d.ts +11 -0
  21. package/dist/durations.js +49 -0
  22. package/dist/electron/app.d.ts +13 -0
  23. package/dist/electron/app.js +27 -0
  24. package/dist/electron/bridge.d.ts +55 -0
  25. package/dist/electron/bridge.js +105 -0
  26. package/dist/electron/events.d.ts +21 -0
  27. package/dist/electron/events.js +25 -0
  28. package/dist/electron/fetch.d.ts +52 -0
  29. package/dist/electron/fetch.js +175 -0
  30. package/dist/electron/window.d.ts +96 -0
  31. package/dist/electron/window.js +128 -0
  32. package/dist/log.d.ts +30 -0
  33. package/dist/log.js +79 -0
  34. package/dist/server/agents/agent.d.ts +27 -0
  35. package/dist/server/agents/agent.js +16 -0
  36. package/dist/server/agents/connection.d.ts +51 -0
  37. package/dist/server/agents/connection.js +239 -0
  38. package/dist/server/agents/hub.d.ts +86 -0
  39. package/dist/server/agents/hub.js +241 -0
  40. package/dist/server/agents/local.d.ts +24 -0
  41. package/dist/server/agents/local.js +62 -0
  42. package/dist/server/agents/route.d.ts +3 -0
  43. package/dist/server/agents/route.js +24 -0
  44. package/dist/server/backup_files.d.ts +3 -0
  45. package/dist/server/backup_files.js +25 -0
  46. package/dist/server/config.d.ts +39 -0
  47. package/dist/server/config.js +109 -0
  48. package/dist/server/cors.d.ts +8 -0
  49. package/dist/server/cors.js +13 -0
  50. package/dist/server/events.d.ts +33 -0
  51. package/dist/server/events.js +68 -0
  52. package/dist/server/hash_password.d.ts +1 -0
  53. package/dist/server/hash_password.js +46 -0
  54. package/dist/server/login_page.d.ts +8 -0
  55. package/dist/server/login_page.js +79 -0
  56. package/dist/server/passwords.d.ts +8 -0
  57. package/dist/server/passwords.js +59 -0
  58. package/dist/server/replies.d.ts +12 -0
  59. package/dist/server/replies.js +41 -0
  60. package/dist/server/sign_in.d.ts +27 -0
  61. package/dist/server/sign_in.js +95 -0
  62. package/dist/server/web_folder.d.ts +11 -0
  63. package/dist/server/web_folder.js +40 -0
  64. package/dist/web/dom.d.ts +7 -0
  65. package/dist/web/dom.js +32 -0
  66. package/dist/web/drag.d.ts +3 -0
  67. package/dist/web/drag.js +13 -0
  68. package/dist/web/errors.d.ts +1 -0
  69. package/dist/web/errors.js +27 -0
  70. package/dist/web/format.d.ts +11 -0
  71. package/dist/web/format.js +82 -0
  72. package/dist/web/menus.d.ts +17 -0
  73. package/dist/web/menus.js +40 -0
  74. package/dist/web/prefs.d.ts +12 -0
  75. package/dist/web/prefs.js +62 -0
  76. package/dist/web/react/badges.d.ts +35 -0
  77. package/dist/web/react/badges.js +32 -0
  78. package/dist/web/react/controls.d.ts +86 -0
  79. package/dist/web/react/controls.js +154 -0
  80. package/dist/web/react/dialog.d.ts +45 -0
  81. package/dist/web/react/dialog.js +82 -0
  82. package/dist/web/react/following.d.ts +25 -0
  83. package/dist/web/react/following.js +34 -0
  84. package/dist/web/react/layout.d.ts +111 -0
  85. package/dist/web/react/layout.js +85 -0
  86. package/dist/web/react/list_picker.d.ts +19 -0
  87. package/dist/web/react/list_picker.js +50 -0
  88. package/dist/web/react/markdown.d.ts +8 -0
  89. package/dist/web/react/markdown.js +40 -0
  90. package/dist/web/react/media.d.ts +24 -0
  91. package/dist/web/react/media.js +18 -0
  92. package/dist/web/react/menu_button.d.ts +10 -0
  93. package/dist/web/react/menu_button.js +16 -0
  94. package/dist/web/react/menus.d.ts +31 -0
  95. package/dist/web/react/page_view.d.ts +16 -0
  96. package/dist/web/react/page_view.js +6 -0
  97. package/dist/web/react/prefs.d.ts +2 -0
  98. package/dist/web/react/prefs.js +11 -0
  99. package/dist/web/react/progress.d.ts +10 -0
  100. package/dist/web/react/progress.js +9 -0
  101. package/dist/web/react/rating.d.ts +8 -0
  102. package/dist/web/react/rating.js +23 -0
  103. package/dist/web/react/reveal.d.ts +10 -0
  104. package/dist/web/react/reveal.js +30 -0
  105. package/dist/web/react/shell.d.ts +46 -0
  106. package/dist/web/react/shell.js +94 -0
  107. package/dist/web/react/shortcuts.d.ts +23 -0
  108. package/dist/web/react/shortcuts.js +95 -0
  109. package/dist/web/react/split.d.ts +23 -0
  110. package/dist/web/react/split.js +94 -0
  111. package/dist/web/react/states.d.ts +36 -0
  112. package/dist/web/react/states.js +64 -0
  113. package/dist/web/react/store.d.ts +4 -0
  114. package/dist/web/react/store.js +7 -0
  115. package/dist/web/react/suggestions.d.ts +23 -0
  116. package/dist/web/react/suggestions.js +57 -0
  117. package/dist/web/react/table.d.ts +78 -0
  118. package/dist/web/react/table.js +404 -0
  119. package/dist/web/react/test_stubs.d.ts +1 -0
  120. package/dist/web/react/test_stubs.js +31 -0
  121. package/dist/web/react/time.d.ts +5 -0
  122. package/dist/web/react/time.js +19 -0
  123. package/dist/web/react/toasts.d.ts +29 -0
  124. package/dist/web/react/toasts.js +72 -0
  125. package/dist/web/react/tree.d.ts +28 -0
  126. package/dist/web/react/tree.js +183 -0
  127. package/dist/web/react/volume.d.ts +8 -0
  128. package/dist/web/react/volume.js +42 -0
  129. package/dist/web/react/watch.d.ts +3 -0
  130. package/dist/web/react/watch.js +5 -0
  131. package/dist/web/router.d.ts +7 -0
  132. package/dist/web/router.js +29 -0
  133. package/dist/web/selection.d.ts +25 -0
  134. package/dist/web/selection.js +106 -0
  135. package/dist/web/split.d.ts +15 -0
  136. package/dist/web/split.js +104 -0
  137. package/dist/web/states.d.ts +3 -0
  138. package/dist/web/states.js +10 -0
  139. package/dist/web/store.d.ts +11 -0
  140. package/dist/web/store.js +17 -0
  141. package/dist/web/tables.d.ts +28 -0
  142. package/dist/web/tables.js +88 -0
  143. package/dist/web/theme.d.ts +14 -0
  144. package/dist/web/theme.js +52 -0
  145. package/dist/web/tree.d.ts +40 -0
  146. package/dist/web/tree.js +130 -0
  147. package/dist/web/virtual.d.ts +7 -0
  148. package/dist/web/virtual.js +10 -0
  149. package/dist/web/watch.d.ts +11 -0
  150. package/dist/web/watch.js +37 -0
  151. package/package.json +1 -1
@@ -0,0 +1,105 @@
1
+ // One table of method names serves the Electron preload, the main process and a web page's calls to its server alike.
2
+ export function define_bridge(api_name, method_names) {
3
+ const channel = (method) => `${api_name}:${method}`;
4
+ const is_method = (method) => method_names.includes(method);
5
+ function client_through(call) {
6
+ return Object.fromEntries(method_names.map((method) => [method, (...args) => call(method, args)]));
7
+ }
8
+ async function dispatch(implementation, method, args) {
9
+ if (!is_method(method))
10
+ throw new Error(`${api_name} has no method ${method}`);
11
+ const answer = implementation[method];
12
+ return answer.apply(implementation, args);
13
+ }
14
+ const ipc_client = (ipc_renderer, options = {}) => {
15
+ const invoke = options.envelopes ? unwrapping_invoker(ipc_renderer) : clean_invoker(ipc_renderer);
16
+ return client_through((method, args) => invoke.invoke(channel(method), ...args));
17
+ };
18
+ return {
19
+ api_name,
20
+ method_names,
21
+ channel,
22
+ dispatch,
23
+ ipc_client,
24
+ expose: (context_bridge, ipc_renderer, options) => {
25
+ context_bridge.exposeInMainWorld(api_name, ipc_client(ipc_renderer, options));
26
+ },
27
+ handle: (ipc_main, implementation, options = {}) => {
28
+ for (const method of method_names) {
29
+ ipc_main.handle(channel(method), (event, ...args) => {
30
+ const answer = () => {
31
+ if (options.trusted && !options.trusted((event ?? {})))
32
+ throw new Error("Untrusted sender.");
33
+ return dispatch(implementation, method, args);
34
+ };
35
+ return options.envelopes ? to_envelope(answer) : answer();
36
+ });
37
+ }
38
+ return () => { for (const method of method_names)
39
+ ipc_main.removeHandler(channel(method)); };
40
+ },
41
+ web_rpc: client_through,
42
+ };
43
+ }
44
+ // ── Envelopes and errors ─────────────────────────────────────────────────────
45
+ // Runs the work and puts its result or its failure's words in an envelope.
46
+ export async function to_envelope(work) {
47
+ try {
48
+ return { ok: true, result: await work() };
49
+ }
50
+ catch (error) {
51
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
52
+ }
53
+ }
54
+ const REMOTE_ERROR_PREFIX = /^Error invoking remote method '[^']*': (?:\w*Error: )?/;
55
+ // Electron reports a rejected handler as "Error invoking remote method 'x': Error: words", which the page shows as the words alone.
56
+ export function clean_error_message(message) {
57
+ return message.replace(REMOTE_ERROR_PREFIX, "");
58
+ }
59
+ // An ipcRenderer whose rejections carry clean messages.
60
+ export function clean_invoker(ipc_renderer) {
61
+ return {
62
+ invoke: async (channel, ...args) => {
63
+ try {
64
+ return await ipc_renderer.invoke(channel, ...args);
65
+ }
66
+ catch (error) {
67
+ if (error instanceof Error)
68
+ throw new Error(clean_error_message(error.message));
69
+ throw error;
70
+ }
71
+ },
72
+ };
73
+ }
74
+ // An ipcRenderer that opens envelope replies, throwing a failure as an Error with its message.
75
+ export function unwrapping_invoker(ipc_renderer) {
76
+ return {
77
+ invoke: async (channel, ...args) => {
78
+ const envelope = (await ipc_renderer.invoke(channel, ...args));
79
+ if (!envelope.ok)
80
+ throw new Error(envelope.error);
81
+ return envelope.result;
82
+ },
83
+ };
84
+ }
85
+ // ── Cloning ──────────────────────────────────────────────────────────────────
86
+ // A value that survives structured clone passes as it is, and anything else, such as a Proxy, crosses as plain JSON.
87
+ export function to_cloneable(value) {
88
+ if (value === null || typeof value !== "object")
89
+ return value;
90
+ try {
91
+ structuredClone(value);
92
+ return value;
93
+ }
94
+ catch {
95
+ return JSON.parse(JSON.stringify(value));
96
+ }
97
+ }
98
+ // The same client with every argument made cloneable first, since Electron clones arguments before the preload sees them.
99
+ export function with_cloneable_arguments(client) {
100
+ const wrapped = {};
101
+ for (const [name, method] of Object.entries(client)) {
102
+ wrapped[name] = (...args) => method(...args.map(to_cloneable));
103
+ }
104
+ return wrapped;
105
+ }
@@ -0,0 +1,21 @@
1
+ import type { ContextBridgeLike } from "./bridge.ts";
2
+ export interface IpcRendererEventsLike {
3
+ on(channel: string, listener: (event: unknown, message: unknown) => void): unknown;
4
+ removeListener(channel: string, listener: (event: unknown, message: unknown) => void): unknown;
5
+ }
6
+ export interface WindowLike {
7
+ webContents: {
8
+ send(channel: string, message: unknown): void;
9
+ };
10
+ }
11
+ export interface EventsClient<Events extends Record<string, unknown>> {
12
+ on<Name extends keyof Events & string>(name: Name, handler: (payload: Events[Name]) => void): () => void;
13
+ }
14
+ export interface EventsBridge<Events extends Record<string, unknown>> {
15
+ api_name: string;
16
+ channel: string;
17
+ ipc_client: (ipc_renderer: IpcRendererEventsLike) => EventsClient<Events>;
18
+ expose: (context_bridge: ContextBridgeLike, ipc_renderer: IpcRendererEventsLike) => void;
19
+ emit: <Name extends keyof Events & string>(windows: Iterable<WindowLike>, name: Name, payload: Events[Name]) => void;
20
+ }
21
+ export declare function define_events<Events extends Record<string, unknown>>(api_name: string): EventsBridge<Events>;
@@ -0,0 +1,25 @@
1
+ // Messages the main process pushes to its pages, such as progress, on one channel and subscribed to by name.
2
+ export function define_events(api_name) {
3
+ const channel = `${api_name}:event`;
4
+ const ipc_client = (ipc_renderer) => ({
5
+ on: (name, handler) => {
6
+ const listener = (_event, message) => {
7
+ const { name: sent_name, payload } = message;
8
+ if (sent_name === name)
9
+ handler(payload);
10
+ };
11
+ ipc_renderer.on(channel, listener);
12
+ return () => void ipc_renderer.removeListener(channel, listener);
13
+ },
14
+ });
15
+ return {
16
+ api_name,
17
+ channel,
18
+ ipc_client,
19
+ expose: (context_bridge, ipc_renderer) => context_bridge.exposeInMainWorld(api_name, ipc_client(ipc_renderer)),
20
+ emit: (windows, name, payload) => {
21
+ for (const window of windows)
22
+ window.webContents.send(channel, { name, payload });
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,52 @@
1
+ export type HeaderList = [string, string][];
2
+ export interface FetchRequest {
3
+ url: string;
4
+ method: string;
5
+ headers: HeaderList;
6
+ body?: Uint8Array<ArrayBuffer>;
7
+ }
8
+ export interface FetchAnswer {
9
+ status: number;
10
+ status_text: string;
11
+ headers: HeaderList;
12
+ body: Uint8Array<ArrayBuffer> | null;
13
+ }
14
+ export type FetchMethod = (request: FetchRequest) => FetchAnswer;
15
+ export interface StreamRequest extends FetchRequest {
16
+ stream_id: string;
17
+ }
18
+ export type StreamHead = Omit<FetchAnswer, "body">;
19
+ export type StreamChunk = {
20
+ stream_id: string;
21
+ bytes: Uint8Array<ArrayBuffer>;
22
+ } | {
23
+ stream_id: string;
24
+ done: true;
25
+ } | {
26
+ stream_id: string;
27
+ error: string;
28
+ };
29
+ export type StreamStartMethod = (request: StreamRequest) => StreamHead;
30
+ export type StreamCancelMethod = (stream_id: string) => void;
31
+ export interface FetchPolicy {
32
+ hosts: readonly string[];
33
+ allow_http?: boolean;
34
+ }
35
+ export declare function is_allowed_url(url: string, policy: FetchPolicy): boolean;
36
+ export declare function create_main_fetch(policy: FetchPolicy, send?: typeof fetch): (request: FetchRequest) => Promise<FetchAnswer>;
37
+ export interface MainStreamFetch {
38
+ start: (request: StreamRequest) => Promise<StreamHead>;
39
+ cancel: (stream_id: string) => void;
40
+ }
41
+ export declare function create_main_stream_fetch(policy: FetchPolicy, emit: (chunk: StreamChunk) => void, send?: typeof fetch): MainStreamFetch;
42
+ export interface PageFetchOptions {
43
+ pinned_headers?: Record<string, string>;
44
+ }
45
+ export declare function to_response(answer: FetchAnswer): Response;
46
+ export declare function create_page_fetch(send: (request: FetchRequest) => Promise<FetchAnswer>, options?: PageFetchOptions): (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
47
+ export interface PageStreamBridge {
48
+ start: (request: StreamRequest) => Promise<StreamHead>;
49
+ cancel?: (stream_id: string) => Promise<void> | void;
50
+ on_chunk: (handler: (chunk: StreamChunk) => void) => () => void;
51
+ }
52
+ export declare function create_page_stream_fetch(bridge: PageStreamBridge, options?: PageFetchOptions): (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
@@ -0,0 +1,175 @@
1
+ // Fetching from Electron's main process for a page whose own requests the browser's cross-origin rules would block, with the body whole or streamed in chunks.
2
+ const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
3
+ export function is_allowed_url(url, policy) {
4
+ let parsed;
5
+ try {
6
+ parsed = new URL(url);
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ if (parsed.protocol !== "https:" && !(policy.allow_http && parsed.protocol === "http:"))
12
+ return false;
13
+ return policy.hosts.some((host) => host.startsWith("*.") ? parsed.hostname.endsWith(host.slice(1)) : parsed.host === host || parsed.hostname === host);
14
+ }
15
+ // The body arrives already unpacked, so the headers describing the packed body are dropped.
16
+ const DROPPED_ANSWER_HEADERS = new Set(["content-encoding", "content-length"]);
17
+ // The main process's half: sends the page's request with Node's fetch, refusing any URL outside the policy.
18
+ export function create_main_fetch(policy, send = fetch) {
19
+ return async (request) => {
20
+ if (!is_allowed_url(request.url, policy))
21
+ throw new Error(`fetching ${request.url} is not allowed`);
22
+ const response = await send(request.url, { method: request.method, headers: request.headers, body: request.body });
23
+ const headers = [];
24
+ response.headers.forEach((value, name) => {
25
+ if (!DROPPED_ANSWER_HEADERS.has(name))
26
+ headers.push([name, value]);
27
+ });
28
+ const has_body = !NULL_BODY_STATUSES.has(response.status) && request.method !== "HEAD";
29
+ return {
30
+ status: response.status,
31
+ status_text: response.statusText,
32
+ headers,
33
+ body: has_body ? new Uint8Array(await response.arrayBuffer()) : null,
34
+ };
35
+ };
36
+ }
37
+ // The main process's half of a streamed fetch: answers with the head and sends each chunk of the body through emit as it arrives.
38
+ export function create_main_stream_fetch(policy, emit, send = fetch) {
39
+ const open = new Map();
40
+ async function pump(stream_id, body, controller) {
41
+ const reader = body.getReader();
42
+ try {
43
+ while (!controller.signal.aborted) {
44
+ const { done, value } = await reader.read();
45
+ if (done)
46
+ break;
47
+ emit({ stream_id, bytes: new Uint8Array(value) });
48
+ }
49
+ if (!controller.signal.aborted)
50
+ emit({ stream_id, done: true });
51
+ }
52
+ catch (error) {
53
+ if (!controller.signal.aborted)
54
+ emit({ stream_id, error: error instanceof Error ? error.message : String(error) });
55
+ }
56
+ finally {
57
+ open.delete(stream_id);
58
+ }
59
+ }
60
+ return {
61
+ start: async (request) => {
62
+ if (!is_allowed_url(request.url, policy))
63
+ throw new Error(`fetching ${request.url} is not allowed`);
64
+ const controller = new AbortController();
65
+ open.set(request.stream_id, controller);
66
+ const response = await send(request.url, { method: request.method, headers: request.headers, body: request.body, signal: controller.signal });
67
+ const headers = [];
68
+ response.headers.forEach((value, name) => {
69
+ if (!DROPPED_ANSWER_HEADERS.has(name))
70
+ headers.push([name, value]);
71
+ });
72
+ const has_body = response.body !== null && !NULL_BODY_STATUSES.has(response.status) && request.method !== "HEAD";
73
+ if (has_body)
74
+ void pump(request.stream_id, response.body, controller);
75
+ else {
76
+ open.delete(request.stream_id);
77
+ emit({ stream_id: request.stream_id, done: true });
78
+ }
79
+ return { status: response.status, status_text: response.statusText, headers };
80
+ },
81
+ cancel: (stream_id) => {
82
+ open.get(stream_id)?.abort();
83
+ open.delete(stream_id);
84
+ },
85
+ };
86
+ }
87
+ function abort_error() {
88
+ return new DOMException("The request was aborted", "AbortError");
89
+ }
90
+ // Rejects once the signal aborts, though the main process still finishes the request.
91
+ function until_aborted(promise, signal) {
92
+ if (!signal)
93
+ return promise;
94
+ if (signal.aborted)
95
+ return Promise.reject(abort_error());
96
+ return new Promise((resolve, reject) => {
97
+ signal.addEventListener("abort", () => reject(abort_error()), { once: true });
98
+ promise.then(resolve, reject);
99
+ });
100
+ }
101
+ export function to_response(answer) {
102
+ const body = NULL_BODY_STATUSES.has(answer.status) ? null : answer.body;
103
+ return new Response(body, { status: answer.status, statusText: answer.status_text, headers: answer.headers });
104
+ }
105
+ // The page's half: a fetch that sends each request through the bridge method, for libraries that take a fetch.
106
+ export function create_page_fetch(send, options = {}) {
107
+ return async (input, init) => {
108
+ const request = new Request(input, init);
109
+ // A plain Headers keeps names a Request would drop, such as Origin and Referer.
110
+ const headers = new Headers(request.headers);
111
+ for (const [name, value] of Object.entries(options.pinned_headers ?? {}))
112
+ headers.set(name, value);
113
+ const body = request.method === "GET" || request.method === "HEAD" ? undefined : new Uint8Array(await request.arrayBuffer());
114
+ const answer = await until_aborted(send({ url: request.url, method: request.method, headers: [...headers.entries()], body }), init?.signal ?? request.signal);
115
+ return to_response(answer);
116
+ };
117
+ }
118
+ let next_stream_number = 0;
119
+ const new_stream_id = () => `${Date.now().toString(36)}-${(next_stream_number++).toString(36)}`;
120
+ // The page's half of a streamed fetch: a fetch whose Response body is a ReadableStream fed by the chunks, so a player reads them as they land.
121
+ export function create_page_stream_fetch(bridge, options = {}) {
122
+ return async (input, init) => {
123
+ const request = new Request(input, init);
124
+ const signal = init?.signal ?? request.signal;
125
+ const stream_id = new_stream_id();
126
+ const headers = new Headers(request.headers);
127
+ for (const [name, value] of Object.entries(options.pinned_headers ?? {}))
128
+ headers.set(name, value);
129
+ const body = request.method === "GET" || request.method === "HEAD" ? undefined : new Uint8Array(await request.arrayBuffer());
130
+ // Chunks may land before the head answers, so the listener is in place first and the stream holds them.
131
+ let stop_listening = () => { };
132
+ const stream = new ReadableStream({
133
+ start(controller) {
134
+ const finish = () => { stop_listening(); signal?.removeEventListener("abort", on_abort); };
135
+ const on_abort = () => {
136
+ finish();
137
+ void bridge.cancel?.(stream_id);
138
+ try {
139
+ controller.error(abort_error());
140
+ }
141
+ catch { /* already closed */ }
142
+ };
143
+ stop_listening = bridge.on_chunk((chunk) => {
144
+ if (chunk.stream_id !== stream_id)
145
+ return;
146
+ if ("bytes" in chunk)
147
+ controller.enqueue(chunk.bytes);
148
+ else {
149
+ finish();
150
+ if ("error" in chunk)
151
+ controller.error(new TypeError(chunk.error));
152
+ else
153
+ controller.close();
154
+ }
155
+ });
156
+ signal?.addEventListener("abort", on_abort, { once: true });
157
+ },
158
+ cancel() {
159
+ stop_listening();
160
+ void bridge.cancel?.(stream_id);
161
+ },
162
+ });
163
+ try {
164
+ const head = await until_aborted(bridge.start({ stream_id, url: request.url, method: request.method, headers: [...headers.entries()], body }), signal);
165
+ const streams = !NULL_BODY_STATUSES.has(head.status) && request.method !== "HEAD";
166
+ if (!streams)
167
+ void stream.cancel();
168
+ return new Response(streams ? stream : null, { status: head.status, statusText: head.status_text, headers: head.headers });
169
+ }
170
+ catch (error) {
171
+ stop_listening();
172
+ throw error;
173
+ }
174
+ };
175
+ }
@@ -0,0 +1,96 @@
1
+ export interface Rectangle {
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ }
7
+ export interface BrowserWindowOptionsLike {
8
+ title: string;
9
+ icon?: string;
10
+ x?: number;
11
+ y?: number;
12
+ width: number;
13
+ height: number;
14
+ minWidth?: number;
15
+ minHeight?: number;
16
+ show: boolean;
17
+ backgroundColor: string;
18
+ titleBarStyle?: "default" | "hidden" | "hiddenInset";
19
+ webPreferences: {
20
+ preload?: string;
21
+ sandbox: boolean;
22
+ contextIsolation: boolean;
23
+ nodeIntegration: boolean;
24
+ backgroundThrottling: boolean;
25
+ };
26
+ }
27
+ export interface NavigationEventLike {
28
+ preventDefault(): void;
29
+ }
30
+ export interface BrowserWindowLike {
31
+ webContents: {
32
+ setWindowOpenHandler(handler: (details: {
33
+ url: string;
34
+ }) => {
35
+ action: "deny";
36
+ }): void;
37
+ on(event: "will-navigate", listener: (event: NavigationEventLike, url: string) => void): unknown;
38
+ };
39
+ on(event: string, listener: () => void): unknown;
40
+ once(event: string, listener: () => void): unknown;
41
+ show(): void;
42
+ maximize(): void;
43
+ isMaximized(): boolean;
44
+ getNormalBounds(): Rectangle;
45
+ setBackgroundColor(color: string): void;
46
+ loadURL(url: string): Promise<void>;
47
+ loadFile(path: string): Promise<void>;
48
+ }
49
+ export interface NativeThemeLike {
50
+ shouldUseDarkColors: boolean;
51
+ on(event: "updated", listener: () => void): unknown;
52
+ removeListener(event: "updated", listener: () => void): unknown;
53
+ }
54
+ export interface ShellLike {
55
+ openExternal(url: string): Promise<void>;
56
+ }
57
+ export interface ScreenLike {
58
+ getAllDisplays(): {
59
+ workArea: Rectangle;
60
+ }[];
61
+ }
62
+ export interface Size {
63
+ width: number;
64
+ height: number;
65
+ }
66
+ export interface WindowOptions {
67
+ title: string;
68
+ icon?: string;
69
+ preload?: string;
70
+ dev_url?: string;
71
+ file: string;
72
+ default_size: Size;
73
+ min_size?: Size;
74
+ state_file?: string;
75
+ native_theme: NativeThemeLike;
76
+ shell: ShellLike;
77
+ screen?: ScreenLike;
78
+ background?: {
79
+ light: string;
80
+ dark: string;
81
+ };
82
+ sandbox?: boolean;
83
+ background_throttling?: boolean;
84
+ title_bar_style?: "default" | "hidden" | "hiddenInset";
85
+ before_write?: (path: string) => void;
86
+ save_delay_ms?: number;
87
+ }
88
+ export interface WindowState {
89
+ bounds: Rectangle;
90
+ is_maximized: boolean;
91
+ }
92
+ export declare function read_window_state(state_file: string): WindowState | null;
93
+ export declare function write_window_state(state_file: string, state: WindowState): void;
94
+ export declare function is_external_link(url: string): boolean;
95
+ export declare function is_app_page(url: string, options: Pick<WindowOptions, "dev_url" | "file">): boolean;
96
+ export declare function create_window<Window extends BrowserWindowLike>(BrowserWindow: new (options: BrowserWindowOptionsLike) => Window, options: WindowOptions): Window;
@@ -0,0 +1,128 @@
1
+ // An Electron app's main window: links and pages of other sites open in the browser, the background follows the system theme, and its size and place are remembered.
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ const DEFAULT_BACKGROUND = { light: "#f5f6f8", dark: "#0f1115" };
6
+ const DEFAULT_SAVE_DELAY_MS = 500;
7
+ // ── State file ───────────────────────────────────────────────────────────────
8
+ // A missing, unreadable or malformed file reads as no saved state.
9
+ export function read_window_state(state_file) {
10
+ try {
11
+ const state = JSON.parse(readFileSync(state_file, "utf8"));
12
+ const bounds = state.bounds;
13
+ const is_whole = (value) => Number.isInteger(value);
14
+ if (!bounds || ![bounds.x, bounds.y, bounds.width, bounds.height].every(is_whole) || bounds.width <= 0 || bounds.height <= 0)
15
+ return null;
16
+ return { bounds, is_maximized: state.is_maximized === true };
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ // Writes beside the file and renames over it, so a crash mid-write never leaves half a file.
23
+ export function write_window_state(state_file, state) {
24
+ mkdirSync(dirname(state_file), { recursive: true });
25
+ const temporary_file = `${state_file}.tmp`;
26
+ writeFileSync(temporary_file, JSON.stringify(state));
27
+ renameSync(temporary_file, state_file);
28
+ }
29
+ function overlaps(first, second) {
30
+ return first.x < second.x + second.width && second.x < first.x + first.width && first.y < second.y + second.height && second.y < first.y + first.height;
31
+ }
32
+ // ── Links ────────────────────────────────────────────────────────────────────
33
+ const EXTERNAL_SCHEMES = new Set(["http:", "https:", "mailto:"]);
34
+ // Only web and mail links reach the browser, never a file or another app's scheme.
35
+ export function is_external_link(url) {
36
+ try {
37
+ return EXTERNAL_SCHEMES.has(new URL(url).protocol);
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ // True for the app's own pages: the dev server's origin, or files beside the built page.
44
+ export function is_app_page(url, options) {
45
+ try {
46
+ const target = new URL(url);
47
+ if (options.dev_url)
48
+ return target.origin === new URL(options.dev_url).origin;
49
+ const page_folder = new URL(".", pathToFileURL(options.file)).href;
50
+ return target.protocol === "file:" && target.href.startsWith(page_folder);
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ function open_outside(shell, url) {
57
+ if (is_external_link(url))
58
+ void shell.openExternal(url);
59
+ }
60
+ // ── Window ───────────────────────────────────────────────────────────────────
61
+ export function create_window(BrowserWindow, options) {
62
+ const background = options.background ?? DEFAULT_BACKGROUND;
63
+ const background_color = () => options.native_theme.shouldUseDarkColors ? background.dark : background.light;
64
+ const saved = options.state_file ? read_window_state(options.state_file) : null;
65
+ const on_a_screen = saved && (!options.screen || options.screen.getAllDisplays().some((display) => overlaps(saved.bounds, display.workArea)));
66
+ const size = saved?.bounds ?? options.default_size;
67
+ const window = new BrowserWindow({
68
+ title: options.title,
69
+ icon: options.icon,
70
+ ...(on_a_screen ? { x: saved.bounds.x, y: saved.bounds.y } : {}),
71
+ width: size.width,
72
+ height: size.height,
73
+ minWidth: options.min_size?.width,
74
+ minHeight: options.min_size?.height,
75
+ show: false,
76
+ backgroundColor: background_color(),
77
+ ...(options.title_bar_style ? { titleBarStyle: options.title_bar_style } : {}),
78
+ webPreferences: { preload: options.preload, sandbox: options.sandbox ?? false, contextIsolation: true, nodeIntegration: false, backgroundThrottling: options.background_throttling ?? true },
79
+ });
80
+ window.once("ready-to-show", () => {
81
+ if (saved?.is_maximized)
82
+ window.maximize();
83
+ window.show();
84
+ });
85
+ window.webContents.setWindowOpenHandler(({ url }) => {
86
+ open_outside(options.shell, url);
87
+ return { action: "deny" };
88
+ });
89
+ // A link that would load another site in the window opens in the browser instead.
90
+ window.webContents.on("will-navigate", (event, url) => {
91
+ if (is_app_page(url, options))
92
+ return;
93
+ event.preventDefault();
94
+ open_outside(options.shell, url);
95
+ });
96
+ const follow_theme = () => window.setBackgroundColor(background_color());
97
+ options.native_theme.on("updated", follow_theme);
98
+ window.on("closed", () => options.native_theme.removeListener("updated", follow_theme));
99
+ if (options.state_file)
100
+ remember_state(window, options.state_file, options.save_delay_ms ?? DEFAULT_SAVE_DELAY_MS, options.before_write);
101
+ if (options.dev_url)
102
+ void window.loadURL(options.dev_url);
103
+ else
104
+ void window.loadFile(options.file);
105
+ return window;
106
+ }
107
+ // Saves a moment after the last move or resize, and at once when the window closes.
108
+ function remember_state(window, state_file, save_delay_ms, before_write) {
109
+ let timer;
110
+ const save_now = () => {
111
+ clearTimeout(timer);
112
+ timer = undefined;
113
+ try {
114
+ before_write?.(state_file);
115
+ write_window_state(state_file, { bounds: window.getNormalBounds(), is_maximized: window.isMaximized() });
116
+ }
117
+ catch {
118
+ // A refused or failed save keeps the last one, and the window carries on.
119
+ }
120
+ };
121
+ const save_soon = () => {
122
+ clearTimeout(timer);
123
+ timer = setTimeout(save_now, save_delay_ms);
124
+ };
125
+ for (const event of ["resize", "move", "maximize", "unmaximize"])
126
+ window.on(event, save_soon);
127
+ window.on("close", save_now);
128
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ export type Level = "critical" | "error" | "warn" | "info" | "debug";
2
+ export declare const levels: Level[];
3
+ export declare const default_level: Level;
4
+ export interface LevelSpec {
5
+ default: Level;
6
+ scopes: Record<string, Level>;
7
+ }
8
+ export declare function parse_level_spec(spec: string | undefined, fallback?: Level): LevelSpec;
9
+ export declare function level_for(spec: LevelSpec, scope: string): Level;
10
+ export declare function is_enabled(spec: LevelSpec, scope: string, level: Level): boolean;
11
+ export interface LogRecord {
12
+ at: number;
13
+ level: Level;
14
+ scope: string;
15
+ message: string;
16
+ data?: unknown;
17
+ }
18
+ export type LogSink = (record: LogRecord) => void;
19
+ export declare function format_record(record: LogRecord): string;
20
+ export interface Logger {
21
+ critical(message: string, data?: unknown): void;
22
+ error(message: string, data?: unknown): void;
23
+ warn(message: string, data?: unknown): void;
24
+ info(message: string, data?: unknown): void;
25
+ debug(message: string, data?: unknown): void;
26
+ enabled(level: Level): boolean;
27
+ child(scope: string): Logger;
28
+ }
29
+ export type SpecSource = LevelSpec | (() => LevelSpec);
30
+ export declare function create_logger(scope: string, spec: SpecSource, sink: LogSink): Logger;