@electrojs/runtime 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anton Ryuben
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,386 @@
1
+ # @electrojs/runtime
2
+
3
+ The main-process runtime for Electro applications.
4
+
5
+ `@electrojs/runtime` manages the full lifecycle of an Electron main process: scanning decorator metadata, building the module graph, wiring dependency injection, running lifecycle hooks, exposing a typed IPC bridge, dispatching signals, and scheduling background jobs. It is the layer between your application code and the Electron APIs.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install @electrojs/runtime
13
+ ```
14
+
15
+ > Peer dependency: `@electrojs/common` must be installed alongside.
16
+
17
+ ---
18
+
19
+ ## Quick start
20
+
21
+ ```ts
22
+ import { Module, Injectable, command, query } from "@electrojs/common";
23
+ import { AppKernel, createConsoleLogger } from "@electrojs/runtime";
24
+
25
+ @Injectable()
26
+ class GreetingService {
27
+ @query()
28
+ hello() {
29
+ return "world";
30
+ }
31
+
32
+ @command()
33
+ setLocale(locale: string) {
34
+ // ...
35
+ }
36
+ }
37
+
38
+ @Module({ providers: [GreetingService] })
39
+ class AppModule {}
40
+
41
+ const kernel = AppKernel.create(AppModule, {
42
+ logger: createConsoleLogger(),
43
+ });
44
+ await kernel.start();
45
+ ```
46
+
47
+ `AppKernel.create` scans `AppModule`, validates the module graph, creates the DI container, instantiates module declarations, and runs startup lifecycle hooks. That single call is enough to bootstrap a working application.
48
+
49
+ Modules, providers, windows, and views also receive `this.logger` through the authoring API, so app-level logs can use the same runtime logger contract and be redirected to a file, Sentry, or any other sink by passing a custom logger into `AppKernel.create(...)`.
50
+
51
+ ---
52
+
53
+ ## Modules
54
+
55
+ Every feature lives inside a module. A module declares what it owns and what it shares:
56
+
57
+ ```ts
58
+ @Module({
59
+ imports: [DatabaseModule],
60
+ providers: [UserService],
61
+ views: [UserView],
62
+ windows: [UserWindow],
63
+ exports: [UserService, UserWindow],
64
+ })
65
+ class UserModule {}
66
+ ```
67
+
68
+ - **imports** -- modules this module depends on. Their exported declarations become available for injection.
69
+ - **providers** -- service and infrastructure classes managed by this module.
70
+ - **views** -- renderer view classes owned by this module.
71
+ - **windows** -- window host classes owned by this module.
72
+ - **exports** -- declarations shared with modules that import this one.
73
+
74
+ The framework builds a directed acyclic graph from imports, detects cycles at startup, and instantiates modules in dependency-first (topological) order.
75
+
76
+ ---
77
+
78
+ ## Providers and dependency injection
79
+
80
+ Providers are classes decorated with `@Injectable()`. They hold business logic, repositories, and infrastructure. Views and windows are declared separately in `@Module({ views, windows })`.
81
+
82
+ Inject dependencies with the `inject()` function:
83
+
84
+ ```ts
85
+ import { inject, Injector } from "@electrojs/runtime";
86
+
87
+ @Injectable()
88
+ class AuthService {
89
+ private readonly db = inject(DatabaseService);
90
+ private readonly config = inject(ConfigService);
91
+
92
+ @query()
93
+ getMe() {
94
+ return this.db.findUser(this.config.get("userId"));
95
+ }
96
+ }
97
+ ```
98
+
99
+ `inject()` works inside:
100
+
101
+ - **Property initializers** -- during construction of framework-managed classes
102
+ - **Lifecycle hooks** -- `onInit`, `onReady`, `onShutdown`, `onDispose`
103
+ - **Capability handlers** -- methods decorated with `@command`, `@query`, `@signal`, `@job`
104
+
105
+ Calling it outside these scopes throws a `DIError`.
106
+
107
+ ### Scopes
108
+
109
+ - `singleton` (default) -- one instance per module injector
110
+ - `transient` -- a new instance every time the token is resolved
111
+
112
+ ```ts
113
+ @Injectable({ scope: "transient" })
114
+ class RequestContext {}
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Lifecycle hooks
120
+
121
+ Modules and providers can implement lifecycle hooks to participate in startup and shutdown:
122
+
123
+ ```ts
124
+ @Injectable()
125
+ class DatabaseService {
126
+ async onInit() {
127
+ await this.pool.connect();
128
+ }
129
+
130
+ async onReady() {
131
+ await this.runMigrations();
132
+ }
133
+
134
+ async onShutdown() {
135
+ await this.pool.end();
136
+ }
137
+
138
+ onDispose() {
139
+ this.logger.flush();
140
+ }
141
+ }
142
+ ```
143
+
144
+ | Hook | Phase | Purpose |
145
+ | ------------ | -------- | ---------------------------------------------------- |
146
+ | `onInit` | Startup | Set up resources (connections, state) |
147
+ | `onReady` | Startup | Cross-module coordination, everything is initialized |
148
+ | `onShutdown` | Shutdown | Release resources gracefully |
149
+ | `onDispose` | Shutdown | Final cleanup (file handles, timers) |
150
+
151
+ **Ordering:** Startup hooks run per-module in dependency-first order. Within each module, provider hooks run before the module's own hook. Shutdown runs in the reverse order -- module first, then its providers.
152
+
153
+ If a startup hook throws, the framework automatically rolls back already-initialized modules by calling their shutdown hooks.
154
+
155
+ ---
156
+
157
+ ## Bridge (IPC)
158
+
159
+ The bridge connects the main process to renderer views with typed channels. Decorate methods with `@command()` or `@query()` to expose them to the renderer:
160
+
161
+ ```ts
162
+ @Injectable()
163
+ class FileService {
164
+ @query()
165
+ async listFiles(directory: string): Promise<string[]> {
166
+ return fs.readdir(directory);
167
+ }
168
+
169
+ @command()
170
+ async deleteFile(path: string): Promise<void> {
171
+ await fs.unlink(path);
172
+ }
173
+ }
174
+ ```
175
+
176
+ - **Queries** are read-only operations -- they return data without side effects.
177
+ - **Commands** perform mutations.
178
+
179
+ Channel names are derived as `moduleId:methodName` (e.g. `file:listFiles`). Views declare which channels they can access:
180
+
181
+ ```ts
182
+ @View({
183
+ source: "view:main",
184
+ access: ["file:listFiles", "file:deleteFile"],
185
+ })
186
+ class MainView extends ViewProvider {}
187
+ ```
188
+
189
+ Calls from a view to channels not listed in `access` are rejected by the `BridgeAccessGuard`.
190
+
191
+ ---
192
+
193
+ ## Signals
194
+
195
+ Signals provide fire-and-forget cross-module communication. Any provider can publish; any provider can subscribe.
196
+
197
+ ### Declarative handlers
198
+
199
+ Use the `@signal()` decorator to subscribe a method at bootstrap time:
200
+
201
+ ```ts
202
+ @Injectable()
203
+ class NotificationService {
204
+ @signal({ id: "user-logged-in" })
205
+ onUserLogin(payload: { userId: string }) {
206
+ this.showWelcome(payload.userId);
207
+ }
208
+ }
209
+ ```
210
+
211
+ ### Programmatic API
212
+
213
+ Use `SignalBus` directly for runtime subscriptions:
214
+
215
+ ```ts
216
+ @Injectable()
217
+ class AuditService {
218
+ private readonly bus = inject(SignalBus);
219
+
220
+ onInit() {
221
+ this.bus.subscribe("user-logged-in", (payload) => {
222
+ this.log("login", payload);
223
+ });
224
+ }
225
+ }
226
+ ```
227
+
228
+ Publishing a signal:
229
+
230
+ ```ts
231
+ this.bus.publish("user-logged-in", { userId: "42" });
232
+ ```
233
+
234
+ Handlers run asynchronously via microtasks. Each handler retains the injection context from the point where it was subscribed, so `inject()` works correctly inside handlers.
235
+
236
+ ---
237
+
238
+ ## Jobs
239
+
240
+ Jobs are background tasks that can run on a cron schedule or be triggered manually. Decorate a method with `@job()`:
241
+
242
+ ```ts
243
+ @Injectable()
244
+ class SyncService {
245
+ @job({ cron: "0 * * * *" })
246
+ async syncUsers(context: JobContext) {
247
+ const users = await this.fetchRemoteUsers();
248
+
249
+ for (const [i, user] of users.entries()) {
250
+ if (context.isCancelled) break;
251
+ context.reportProgress(((i + 1) / users.length) * 100);
252
+ await this.upsert(user);
253
+ }
254
+ }
255
+ }
256
+ ```
257
+
258
+ The first argument is always a `JobContext`, which provides:
259
+
260
+ - `isCancelled` -- check whether the job was cancelled
261
+ - `reportProgress(percent)` -- report 0-100 progress
262
+
263
+ Jobs without a `cron` option are manual-only. Use `JobRegistry` to control them at runtime:
264
+
265
+ ```ts
266
+ const jobs = inject(JobRegistry);
267
+ await jobs.run("sync:syncUsers"); // trigger manually
268
+ jobs.cancel("sync:syncUsers"); // cancel a running job
269
+ const state = jobs.getState("sync:syncUsers"); // { status, progress, lastRunAt }
270
+ ```
271
+
272
+ ---
273
+
274
+ ## Desktop
275
+
276
+ Windows and views are first-class providers. Extend the base classes `WindowProvider` and `ViewProvider` to manage Electron windows and their content.
277
+
278
+ ### Windows
279
+
280
+ ```ts
281
+ @Window({ id: "main", configuration: { width: 1280, height: 800 } })
282
+ class MainWindow extends WindowProvider {
283
+ private readonly mainView = inject(MainView);
284
+
285
+ onReady() {
286
+ this.create();
287
+ this.mount(this.mainView);
288
+ this.show();
289
+ }
290
+ }
291
+ ```
292
+
293
+ `WindowProvider` gives you: `create()`, `mount(view)`, `show()`, `hide()`, `focus()`, `close()`, `getBounds()`.
294
+
295
+ ### Views
296
+
297
+ ```ts
298
+ @View({
299
+ source: "view:main",
300
+ access: ["file:listFiles", "file:deleteFile"],
301
+ signals: ["user-logged-in"],
302
+ })
303
+ class MainView extends ViewProvider {
304
+ async onReady() {
305
+ await this.load();
306
+ this.setBounds({ x: 0, y: 0, width: 1280, height: 800 });
307
+ this.setBackgroundColor("#00000000");
308
+ }
309
+ }
310
+ ```
311
+
312
+ `ViewProvider` gives you: `load()`, `setBounds(rect)`, `setBackgroundColor(color)`, `focus()`. Views are created with strict security defaults: `sandbox: true`, `contextIsolation: true`, `nodeIntegration: false`.
313
+
314
+ ---
315
+
316
+ ## Exports
317
+
318
+ ```ts
319
+ // App
320
+ import { AppKernel } from "@electrojs/runtime";
321
+
322
+ // Container
323
+ import { inject, InjectionContext, Injector } from "@electrojs/runtime";
324
+
325
+ // Modules
326
+ import { ModuleRef, ProviderRef, ModuleRegistry } from "@electrojs/runtime";
327
+ import { scanModules, validateAppDefinition } from "@electrojs/runtime";
328
+
329
+ // Signals
330
+ import { SignalBus, SignalContext } from "@electrojs/runtime";
331
+
332
+ // Jobs
333
+ import { JobContext, JobRegistry } from "@electrojs/runtime";
334
+
335
+ // Desktop
336
+ import { WindowProvider, ViewProvider, WindowManager, ViewManager, RendererRegistry, RendererSession } from "@electrojs/runtime";
337
+
338
+ // Bridge
339
+ import { BridgeAccessGuard, BridgeDispatcher, BridgeHandler, serializeBridgeError } from "@electrojs/runtime";
340
+
341
+ // Errors
342
+ import { RuntimeError, BootstrapError, DIError, LifecycleError, BridgeError, SignalError, JobError } from "@electrojs/runtime";
343
+ ```
344
+
345
+ Type-only imports:
346
+
347
+ ```ts
348
+ import type {
349
+ AppDefinition,
350
+ ModuleDefinition,
351
+ ProviderDefinition,
352
+ ViewDefinition,
353
+ WindowDefinition,
354
+ BridgeMethodDefinition,
355
+ SignalHandlerDefinition,
356
+ JobDefinition,
357
+ KernelState,
358
+ ModuleStatus,
359
+ LifecycleTarget,
360
+ ModuleSnapshot,
361
+ ProviderSnapshot,
362
+ JobStatus,
363
+ JobRuntimeState,
364
+ BridgeRequest,
365
+ BridgeResponse,
366
+ SignalHandler,
367
+ SignalListener,
368
+ ContextualSignalHandler,
369
+ ProviderKind,
370
+ BridgeMethodKind,
371
+ } from "@electrojs/runtime";
372
+ ```
373
+
374
+ ---
375
+
376
+ ## Package layering
377
+
378
+ ```txt
379
+ @electrojs/common ← decorators, metadata, DI primitives
380
+
381
+ @electrojs/runtime ← this package: DI container, lifecycle, bridge, signals, jobs, desktop
382
+
383
+ @electrojs/renderer ← renderer-side bridge client, signal subscriptions
384
+ ```
385
+
386
+ `@electrojs/common` defines the shared vocabulary. `@electrojs/runtime` implements the main-process engine. `@electrojs/renderer` provides the renderer-side counterpart.
@@ -0,0 +1 @@
1
+ const e=`electro:bridge`,t=`electro:register`;function n(e){return new Promise(t=>setTimeout(t,e))}function r(r){let{viewId:a,ipcRenderer:o}=r,s=o.invoke(t,{viewId:a});async function c(t,n){let r=crypto.randomUUID();return await o.invoke(e,{callId:r,channel:t,args:n})}async function l(e,t){await s;for(let r=0;r<80;r+=1){let i=await c(e,t);if(i.error?.code!==`ELECTRO_BRIDGE_KERNEL_NOT_READY`||r===79)return i;await n(25)}return c(e,t)}return{async invoke(e,t){let n=await l(e,t);if(n.error){let e=Error(n.error.message);throw n.error.code&&(e.code=n.error.code),e}return n.result},subscribe(e,t){let n=(n,...r)=>{let i=r[0];i.signalId===e&&t(i.payload)};return o.on(i,n),()=>{o.removeListener(i,n)}},once(e,t){let n=!1,r=(a,...s)=>{let c=s[0];c.signalId===e&&!n&&(n=!0,o.removeListener(i,r),t(c.payload))};return o.on(i,r),()=>{n||(n=!0,o.removeListener(i,r))}}}}const i=`electro:signal`,a={bridge:e,register:t,signal:i};export{r as n,a as t};
@@ -0,0 +1,62 @@
1
+ //#region src/bridge/client.d.ts
2
+ /**
3
+ * Creates the preload-side bridge client that connects renderer processes
4
+ * to the main process via Electron IPC.
5
+ *
6
+ * This function is called inside generated preload scripts. It returns an object
7
+ * matching the {@link RendererPreloadApi} contract expected by `@electrojs/renderer`:
8
+ * - `invoke(channel, payload)` — sends a bridge request to the main process
9
+ * - `subscribe(signalKey, listener)` — listens for signals forwarded from the main process
10
+ * - `once(signalKey, listener)` — like `subscribe`, but auto-removes after the first match
11
+ *
12
+ * On creation the client sends an `electro:register` message so the main process
13
+ * can create a {@link RendererSession} for this renderer's `webContentsId`.
14
+ *
15
+ * @example Generated preload script (produced by `@electrojs/codegen`):
16
+ * ```ts
17
+ * import { contextBridge, ipcRenderer } from "electron";
18
+ * import { createBridgeClient } from "@electrojs/runtime";
19
+ *
20
+ * contextBridge.exposeInMainWorld("__ELECTRO_RENDERER__", createBridgeClient({
21
+ * viewId: "main",
22
+ * ipcRenderer,
23
+ * }));
24
+ * ```
25
+ */
26
+ /**
27
+ * Minimal subset of Electron's `IpcRenderer` used by the bridge client.
28
+ * Avoids importing the full Electron types at the package level.
29
+ */
30
+ interface ElectroIpcRenderer {
31
+ invoke(channel: string, ...args: unknown[]): Promise<unknown>;
32
+ on(channel: string, listener: (event: unknown, ...args: unknown[]) => void): void;
33
+ removeListener(channel: string, listener: (event: unknown, ...args: unknown[]) => void): void;
34
+ }
35
+ /** Configuration accepted by {@link createBridgeClient}. */
36
+ interface BridgeClientConfig {
37
+ /** The view ID this renderer is associated with (from `@View({ id })` or derived from source). */
38
+ readonly viewId: string;
39
+ /** Electron's `ipcRenderer` instance, available in preload scripts. */
40
+ readonly ipcRenderer: ElectroIpcRenderer;
41
+ }
42
+ /** The object exposed on `window.__ELECTRO_RENDERER__` for the renderer package to consume. */
43
+ interface PreloadBridgeApi {
44
+ invoke(channel: string, payload: readonly unknown[]): Promise<unknown>;
45
+ subscribe(signalKey: string, listener: (payload: unknown) => void): () => void;
46
+ once(signalKey: string, listener: (payload: unknown) => void): () => void;
47
+ }
48
+ /**
49
+ * Create a bridge client for use in Electron preload scripts.
50
+ *
51
+ * The returned object is intended to be passed to
52
+ * `contextBridge.exposeInMainWorld("__ELECTRO_RENDERER__", ...)`.
53
+ */
54
+ declare function createBridgeClient(config: BridgeClientConfig): PreloadBridgeApi;
55
+ /** Well-known IPC channel names used by the Electro bridge protocol. */
56
+ declare const IPC_CHANNELS: {
57
+ readonly bridge: "electro:bridge";
58
+ readonly register: "electro:register";
59
+ readonly signal: "electro:signal";
60
+ };
61
+ //#endregion
62
+ export { createBridgeClient as a, PreloadBridgeApi as i, ElectroIpcRenderer as n, IPC_CHANNELS as r, BridgeClientConfig as t };
@@ -0,0 +1,2 @@
1
+ import { a as createBridgeClient, i as PreloadBridgeApi, n as ElectroIpcRenderer, r as IPC_CHANNELS, t as BridgeClientConfig } from "./client-CVXQ55OB.mjs";
2
+ export { type BridgeClientConfig, type ElectroIpcRenderer, IPC_CHANNELS, type PreloadBridgeApi, createBridgeClient };
@@ -0,0 +1 @@
1
+ import{n as e,t}from"./client-CD3ueTol.mjs";export{t as IPC_CHANNELS,e as createBridgeClient};