@cordy/electro 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/dist/index.d.mts +791 -0
- package/dist/index.mjs +1592 -0
- package/package.json +67 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1592 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Cron } from "croner";
|
|
4
|
+
|
|
5
|
+
//#region \0rolldown/runtime.js
|
|
6
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
7
|
+
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/config/define-config.ts
|
|
10
|
+
function defineConfig(input) {
|
|
11
|
+
const windows = input.windows ?? [];
|
|
12
|
+
const seen = /* @__PURE__ */ new Set();
|
|
13
|
+
for (const win of windows) {
|
|
14
|
+
if (seen.has(win.name)) throw new Error(`[electro] defineConfig: duplicate window name "${win.name}"`);
|
|
15
|
+
seen.add(win.name);
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
runtime: input.runtime,
|
|
19
|
+
windows
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/config/caller.ts
|
|
25
|
+
/** Resolve the file path of the caller (skipping electro config internals). */
|
|
26
|
+
function getCallerPath() {
|
|
27
|
+
const stack = (/* @__PURE__ */ new Error()).stack;
|
|
28
|
+
if (!stack) return void 0;
|
|
29
|
+
const lines = stack.split("\n");
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const match = line.match(/(?:at\s+.*\()?((?:file:\/\/)?[^\s)]+):\d+:\d+\)?/);
|
|
32
|
+
if (!match) continue;
|
|
33
|
+
const rawPath = match[1];
|
|
34
|
+
if (rawPath.includes("/src/config/") || rawPath.includes("/dist/config") || rawPath.includes("@cordy/electro") || rawPath.includes("/packages/electro/dist/")) continue;
|
|
35
|
+
if (rawPath.startsWith("file://")) try {
|
|
36
|
+
return decodeURIComponent(new URL(rawPath).pathname);
|
|
37
|
+
} catch {
|
|
38
|
+
return rawPath;
|
|
39
|
+
}
|
|
40
|
+
return rawPath;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/config/define-runtime.ts
|
|
46
|
+
function defineRuntime(input) {
|
|
47
|
+
if (!input.entry || input.entry.trim().length === 0) throw new Error("[electro] defineRuntime: entry must be a non-empty string");
|
|
48
|
+
return {
|
|
49
|
+
entry: input.entry,
|
|
50
|
+
vite: input.vite,
|
|
51
|
+
__source: getCallerPath() ?? ""
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/config/define-window.ts
|
|
57
|
+
const WINDOW_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
58
|
+
function defineWindow(input) {
|
|
59
|
+
if (!input.name || input.name.trim().length === 0) throw new Error("[electro] defineWindow: name must be a non-empty string");
|
|
60
|
+
if (!WINDOW_NAME_PATTERN.test(input.name)) throw new Error(`[electro] defineWindow: name "${input.name}" is invalid. Must match ${WINDOW_NAME_PATTERN.toString()}`);
|
|
61
|
+
if (!input.entry || input.entry.trim().length === 0) throw new Error("[electro] defineWindow: entry must be a non-empty string");
|
|
62
|
+
const lifecycle = input.lifecycle ?? "singleton";
|
|
63
|
+
const close = input.behavior?.close ?? (lifecycle === "multi" ? "destroy" : "hide");
|
|
64
|
+
if (lifecycle === "multi" && close === "hide") throw new Error("[electro] defineWindow: behavior.close \"hide\" is only allowed with lifecycle \"singleton\". Multi-instance windows must use \"destroy\".");
|
|
65
|
+
return {
|
|
66
|
+
name: input.name,
|
|
67
|
+
entry: input.entry,
|
|
68
|
+
type: input.type,
|
|
69
|
+
features: input.features,
|
|
70
|
+
vite: input.vite,
|
|
71
|
+
preload: input.preload,
|
|
72
|
+
lifecycle,
|
|
73
|
+
autoShow: input.autoShow ?? false,
|
|
74
|
+
behavior: { close },
|
|
75
|
+
window: input.window,
|
|
76
|
+
__source: getCallerPath() ?? ""
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/core/event-bus/accessor.ts
|
|
82
|
+
/**
|
|
83
|
+
* Scoped event access per feature.
|
|
84
|
+
*
|
|
85
|
+
* - `publish("name", payload)` -> publishes as `"ownerId:name"`
|
|
86
|
+
* - `on("dep:name", handler)` -> validates `dep` is a declared dependency
|
|
87
|
+
* - `on("name", handler)` -> subscribes to own `"ownerId:name"`
|
|
88
|
+
*/
|
|
89
|
+
var EventAccessor = class {
|
|
90
|
+
constructor(bus, ownerId, declaredDeps) {
|
|
91
|
+
this.bus = bus;
|
|
92
|
+
this.ownerId = ownerId;
|
|
93
|
+
this.declaredDeps = declaredDeps;
|
|
94
|
+
}
|
|
95
|
+
publish(event, payload) {
|
|
96
|
+
this.bus.publish(`${this.ownerId}:${event}`, payload);
|
|
97
|
+
}
|
|
98
|
+
on(event, handler) {
|
|
99
|
+
const colonIdx = event.indexOf(":");
|
|
100
|
+
if (colonIdx === -1) return this.bus.subscribe(`${this.ownerId}:${event}`, handler, this.ownerId);
|
|
101
|
+
const depId = event.slice(0, colonIdx);
|
|
102
|
+
if (!this.declaredDeps.has(depId)) throw new Error(`Feature "${this.ownerId}" cannot subscribe to "${event}": "${depId}" is not a declared dependency`);
|
|
103
|
+
return this.bus.subscribe(event, handler, this.ownerId);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/core/event-bus/helpers.ts
|
|
109
|
+
function createEvent(id, defaults) {
|
|
110
|
+
if (!id || id.trim().length === 0) throw new Error("createEvent: id is required");
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
defaults,
|
|
114
|
+
payload() {
|
|
115
|
+
throw new Error("phantom method — not callable");
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/core/feature/enums.ts
|
|
122
|
+
let FeatureStatus = /* @__PURE__ */ function(FeatureStatus) {
|
|
123
|
+
FeatureStatus["NONE"] = "none";
|
|
124
|
+
FeatureStatus["REGISTERED"] = "registered";
|
|
125
|
+
FeatureStatus["INITIALIZING"] = "initializing";
|
|
126
|
+
FeatureStatus["READY"] = "ready";
|
|
127
|
+
FeatureStatus["ACTIVATING"] = "activating";
|
|
128
|
+
FeatureStatus["ACTIVATED"] = "activated";
|
|
129
|
+
FeatureStatus["DEACTIVATING"] = "deactivating";
|
|
130
|
+
FeatureStatus["DEACTIVATED"] = "deactivated";
|
|
131
|
+
FeatureStatus["DESTROYING"] = "destroying";
|
|
132
|
+
FeatureStatus["DESTROYED"] = "destroyed";
|
|
133
|
+
FeatureStatus["ERROR"] = "error";
|
|
134
|
+
return FeatureStatus;
|
|
135
|
+
}({});
|
|
136
|
+
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/core/feature/helpers.ts
|
|
139
|
+
/**
|
|
140
|
+
* Method for creating a feature
|
|
141
|
+
* @param config - The configuration object for the feature
|
|
142
|
+
* @returns The feature configuration object
|
|
143
|
+
*/
|
|
144
|
+
function createFeature(config) {
|
|
145
|
+
if (!config.id) throw new Error("Feature must have an id");
|
|
146
|
+
return config;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/core/runtime/enums.ts
|
|
151
|
+
let RuntimeState = /* @__PURE__ */ function(RuntimeState) {
|
|
152
|
+
RuntimeState["CREATED"] = "created";
|
|
153
|
+
RuntimeState["STARTING"] = "starting";
|
|
154
|
+
RuntimeState["RUNNING"] = "running";
|
|
155
|
+
RuntimeState["STOPPING"] = "stopping";
|
|
156
|
+
RuntimeState["STOPPED"] = "stopped";
|
|
157
|
+
RuntimeState["FAILED"] = "failed";
|
|
158
|
+
return RuntimeState;
|
|
159
|
+
}({});
|
|
160
|
+
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/window/default-factory.ts
|
|
163
|
+
/**
|
|
164
|
+
* Default WindowFactory — creates Electron BrowserWindow or BaseWindow
|
|
165
|
+
* from a WindowDefinition at runtime.
|
|
166
|
+
*/
|
|
167
|
+
function createDefaultWindowFactory() {
|
|
168
|
+
return { create(definition) {
|
|
169
|
+
if (definition.type === "browser-window") {
|
|
170
|
+
const { BrowserWindow } = __require("electron");
|
|
171
|
+
return new BrowserWindow({
|
|
172
|
+
show: definition.autoShow ?? false,
|
|
173
|
+
...definition.window ?? {}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const { BaseWindow: BW } = __require("electron");
|
|
177
|
+
return new BW({
|
|
178
|
+
show: definition.autoShow ?? false,
|
|
179
|
+
...definition.window ?? {}
|
|
180
|
+
});
|
|
181
|
+
} };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/window/manager.ts
|
|
186
|
+
/**
|
|
187
|
+
* WindowManager — registry, creation, and lifecycle coordinator for windows.
|
|
188
|
+
*
|
|
189
|
+
* Manages window definitions and their instances through a WindowFactory
|
|
190
|
+
* abstraction. Enforces singleton/multi lifecycle semantics and provides
|
|
191
|
+
* querying and bulk destruction capabilities.
|
|
192
|
+
*/
|
|
193
|
+
var WindowManager = class {
|
|
194
|
+
factory;
|
|
195
|
+
definitions = /* @__PURE__ */ new Map();
|
|
196
|
+
instances = /* @__PURE__ */ new Map();
|
|
197
|
+
constructor(factory) {
|
|
198
|
+
this.factory = factory;
|
|
199
|
+
}
|
|
200
|
+
/** Register a window definition. Throws on duplicate name. */
|
|
201
|
+
registerDefinition(definition) {
|
|
202
|
+
if (this.definitions.has(definition.name)) throw new Error(`Window definition duplicate: "${definition.name}" is already registered`);
|
|
203
|
+
this.definitions.set(definition.name, definition);
|
|
204
|
+
}
|
|
205
|
+
/** Check whether a definition is registered for the given name. */
|
|
206
|
+
hasDefinition(name) {
|
|
207
|
+
return this.definitions.has(name);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Create a window from a registered definition.
|
|
211
|
+
*
|
|
212
|
+
* - Throws if no definition exists for `name`.
|
|
213
|
+
* - For singleton lifecycle (the default), throws if a live instance already exists.
|
|
214
|
+
* - For multi lifecycle, always creates a new instance.
|
|
215
|
+
*/
|
|
216
|
+
createWindow(name) {
|
|
217
|
+
const definition = this.definitions.get(name);
|
|
218
|
+
if (!definition) throw new Error(`Cannot create window: no definition found for "${name}"`);
|
|
219
|
+
if ((definition.lifecycle ?? "singleton") === "singleton") {
|
|
220
|
+
if (this.getAliveInstances(name).length > 0) throw new Error(`Cannot create window "${name}": singleton instance already exists`);
|
|
221
|
+
}
|
|
222
|
+
const window = this.factory.create(definition);
|
|
223
|
+
const electrified = window;
|
|
224
|
+
electrified.load = async () => {
|
|
225
|
+
if (typeof window.loadURL !== "function") throw new Error(`Cannot load window "${name}": BaseWindow does not support loadURL/loadFile. Set type: "browser-window" in your window definition.`);
|
|
226
|
+
const bw = window;
|
|
227
|
+
const devUrl = process.env[`ELECTRO_DEV_URL_${name}`];
|
|
228
|
+
if (devUrl) {
|
|
229
|
+
await bw.loadURL(devUrl);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const { app } = await import("electron");
|
|
233
|
+
await bw.loadFile(join(app.getAppPath(), ".electro", "out", "renderer", name, "index.html"));
|
|
234
|
+
};
|
|
235
|
+
const list = this.instances.get(name);
|
|
236
|
+
if (list) list.push(electrified);
|
|
237
|
+
else this.instances.set(name, [electrified]);
|
|
238
|
+
return electrified;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Get the first alive window for the given name, or null.
|
|
242
|
+
* Cleans up destroyed instances as a side effect.
|
|
243
|
+
*/
|
|
244
|
+
getWindow(name) {
|
|
245
|
+
const list = this.instances.get(name);
|
|
246
|
+
if (!list) return null;
|
|
247
|
+
const alive = list.filter((w) => !w.isDestroyed());
|
|
248
|
+
this.instances.set(name, alive);
|
|
249
|
+
return alive.length > 0 ? alive[0] : null;
|
|
250
|
+
}
|
|
251
|
+
/** Destroy all instances for the given name. No-op if name is unknown. */
|
|
252
|
+
destroyWindow(name) {
|
|
253
|
+
const list = this.instances.get(name);
|
|
254
|
+
if (!list) return;
|
|
255
|
+
for (const window of list) if (!window.isDestroyed()) window.destroy();
|
|
256
|
+
this.instances.set(name, []);
|
|
257
|
+
}
|
|
258
|
+
/** Destroy all tracked windows across all names. */
|
|
259
|
+
destroyAll() {
|
|
260
|
+
for (const [name] of this.instances) this.destroyWindow(name);
|
|
261
|
+
}
|
|
262
|
+
/** Returns a snapshot of all tracked windows (including destroyed ones). */
|
|
263
|
+
list() {
|
|
264
|
+
const result = [];
|
|
265
|
+
for (const [name, windows] of this.instances) for (const window of windows) result.push({
|
|
266
|
+
name,
|
|
267
|
+
windowId: window.id,
|
|
268
|
+
destroyed: window.isDestroyed()
|
|
269
|
+
});
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
/** Return alive (non-destroyed) instances for a given name. */
|
|
273
|
+
getAliveInstances(name) {
|
|
274
|
+
const list = this.instances.get(name);
|
|
275
|
+
if (!list) return [];
|
|
276
|
+
return list.filter((w) => !w.isDestroyed());
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/core/event-bus/event-bus.ts
|
|
282
|
+
var EventBus = class {
|
|
283
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
284
|
+
publish(channel, payload) {
|
|
285
|
+
const subs = this.subscriptions.get(channel);
|
|
286
|
+
if (!subs) return;
|
|
287
|
+
for (const sub of subs) sub.handler(payload);
|
|
288
|
+
}
|
|
289
|
+
subscribe(channel, handler, ownerId) {
|
|
290
|
+
const sub = {
|
|
291
|
+
channel,
|
|
292
|
+
handler,
|
|
293
|
+
ownerId
|
|
294
|
+
};
|
|
295
|
+
let channelSubs = this.subscriptions.get(channel);
|
|
296
|
+
if (!channelSubs) {
|
|
297
|
+
channelSubs = /* @__PURE__ */ new Set();
|
|
298
|
+
this.subscriptions.set(channel, channelSubs);
|
|
299
|
+
}
|
|
300
|
+
channelSubs.add(sub);
|
|
301
|
+
return () => {
|
|
302
|
+
channelSubs.delete(sub);
|
|
303
|
+
if (channelSubs.size === 0) this.subscriptions.delete(channel);
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
removeByOwner(ownerId) {
|
|
307
|
+
for (const [channel, subs] of this.subscriptions) {
|
|
308
|
+
for (const sub of subs) if (sub.ownerId === ownerId) subs.delete(sub);
|
|
309
|
+
if (subs.size === 0) this.subscriptions.delete(channel);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/window/accessor.ts
|
|
316
|
+
/**
|
|
317
|
+
* Thin accessor bound to FeatureContext.
|
|
318
|
+
*
|
|
319
|
+
* Provides `createWindow(name)` and `getWindow(name)` for features.
|
|
320
|
+
* Delegates all work to WindowManager.
|
|
321
|
+
*/
|
|
322
|
+
var WindowAccessor = class {
|
|
323
|
+
constructor(manager) {
|
|
324
|
+
this.manager = manager;
|
|
325
|
+
}
|
|
326
|
+
createWindow(name) {
|
|
327
|
+
return this.manager.createWindow(name);
|
|
328
|
+
}
|
|
329
|
+
getWindow(name) {
|
|
330
|
+
return this.manager.getWindow(name);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
//#endregion
|
|
335
|
+
//#region src/core/service/enums.ts
|
|
336
|
+
let ServiceScope = /* @__PURE__ */ function(ServiceScope) {
|
|
337
|
+
ServiceScope["PRIVATE"] = "private";
|
|
338
|
+
ServiceScope["INTERNAL"] = "internal";
|
|
339
|
+
ServiceScope["EXPOSED"] = "exposed";
|
|
340
|
+
return ServiceScope;
|
|
341
|
+
}({});
|
|
342
|
+
let ServiceStatus = /* @__PURE__ */ function(ServiceStatus) {
|
|
343
|
+
ServiceStatus["REGISTERED"] = "registered";
|
|
344
|
+
ServiceStatus["ACTIVE"] = "active";
|
|
345
|
+
ServiceStatus["DESTROYED"] = "destroyed";
|
|
346
|
+
return ServiceStatus;
|
|
347
|
+
}({});
|
|
348
|
+
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/core/service/accessor.ts
|
|
351
|
+
/**
|
|
352
|
+
* Implements `ctx.getService(name)` with scope-aware access control.
|
|
353
|
+
*
|
|
354
|
+
* - `"serviceId"` — own-feature lookup, all scopes visible, returns API directly.
|
|
355
|
+
* - `"featureId:serviceId"` — cross-feature lookup, only INTERNAL + EXPOSED visible.
|
|
356
|
+
*/
|
|
357
|
+
var ServiceAccessor = class {
|
|
358
|
+
constructor(own, deps) {
|
|
359
|
+
this.own = own;
|
|
360
|
+
this.deps = deps;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Resolve a service by name.
|
|
364
|
+
*
|
|
365
|
+
* @param name Either `"serviceId"` (own feature) or `"featureId:serviceId"` (cross-feature).
|
|
366
|
+
* @returns The service API directly.
|
|
367
|
+
* @throws If the service or feature is not found, or scope is not accessible.
|
|
368
|
+
*/
|
|
369
|
+
get(name) {
|
|
370
|
+
const colonIdx = name.indexOf(":");
|
|
371
|
+
if (colonIdx === -1) return this.resolveOwn(name);
|
|
372
|
+
const featureId = name.slice(0, colonIdx);
|
|
373
|
+
const serviceId = name.slice(colonIdx + 1);
|
|
374
|
+
return this.resolveDep(featureId, serviceId);
|
|
375
|
+
}
|
|
376
|
+
resolveOwn(serviceId) {
|
|
377
|
+
const result = this.own.get(serviceId);
|
|
378
|
+
if (!result) throw new Error(`Service "${serviceId}" not found in own feature`);
|
|
379
|
+
return result.api;
|
|
380
|
+
}
|
|
381
|
+
resolveDep(featureId, serviceId) {
|
|
382
|
+
const manager = this.deps.get(featureId);
|
|
383
|
+
if (!manager) throw new Error(`Feature "${featureId}" is not a declared dependency`);
|
|
384
|
+
const result = manager.get(serviceId);
|
|
385
|
+
if (!result) throw new Error(`Service "${serviceId}" not found in feature "${featureId}"`);
|
|
386
|
+
if (result.scope === ServiceScope.PRIVATE) throw new Error(`Service "${serviceId}" in feature "${featureId}" is private and not accessible cross-feature`);
|
|
387
|
+
return result.api;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/core/service/manager.ts
|
|
393
|
+
/**
|
|
394
|
+
* Per-feature registry and lifecycle coordinator for {@link Service} instances.
|
|
395
|
+
*
|
|
396
|
+
* One service per id — `get()` returns `{ api, scope }` directly.
|
|
397
|
+
*/
|
|
398
|
+
var ServiceManager = class {
|
|
399
|
+
/** Primary storage: `id` → Service */
|
|
400
|
+
services = /* @__PURE__ */ new Map();
|
|
401
|
+
_isShutdown = false;
|
|
402
|
+
constructor(ctx) {
|
|
403
|
+
this.ctx = ctx;
|
|
404
|
+
}
|
|
405
|
+
/** Add a service to the registry. Throws on duplicate id. */
|
|
406
|
+
register(service) {
|
|
407
|
+
if (this.services.has(service.id)) throw new Error(`Duplicate service: "${service.id}"`);
|
|
408
|
+
this.services.set(service.id, service);
|
|
409
|
+
}
|
|
410
|
+
/** Remove a service by id. Destroys it before removing. */
|
|
411
|
+
unregister(serviceId) {
|
|
412
|
+
const service = this.services.get(serviceId);
|
|
413
|
+
if (!service) return;
|
|
414
|
+
service.destroy();
|
|
415
|
+
this.services.delete(serviceId);
|
|
416
|
+
}
|
|
417
|
+
/** Build all registered services. No-op after shutdown. */
|
|
418
|
+
startup() {
|
|
419
|
+
if (this._isShutdown) return;
|
|
420
|
+
for (const service of this.services.values()) service.build(this.ctx);
|
|
421
|
+
}
|
|
422
|
+
/** Destroy all registered services. Marks manager as shut down. */
|
|
423
|
+
shutdown() {
|
|
424
|
+
this._isShutdown = true;
|
|
425
|
+
for (const service of this.services.values()) service.destroy();
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Get a service by id, returning its API and scope.
|
|
429
|
+
*
|
|
430
|
+
* @returns `{ api, scope }` or `null` if service id is unknown or not yet built
|
|
431
|
+
*/
|
|
432
|
+
get(serviceId) {
|
|
433
|
+
const service = this.services.get(serviceId);
|
|
434
|
+
if (!service) return null;
|
|
435
|
+
const api = service.api();
|
|
436
|
+
if (api == null) return null;
|
|
437
|
+
return {
|
|
438
|
+
api,
|
|
439
|
+
scope: service.scope
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/** Return status for a given service id. Throws if unknown. */
|
|
443
|
+
status(serviceId) {
|
|
444
|
+
const service = this.services.get(serviceId);
|
|
445
|
+
if (!service) throw new Error(`Service "${serviceId}" not found`);
|
|
446
|
+
return service.status();
|
|
447
|
+
}
|
|
448
|
+
/** Return status for all registered services. */
|
|
449
|
+
list() {
|
|
450
|
+
return Array.from(this.services.values()).map((s) => s.status());
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/core/state-machine/state-machine.ts
|
|
456
|
+
var StateMachine = class {
|
|
457
|
+
_current;
|
|
458
|
+
_transitions;
|
|
459
|
+
_name;
|
|
460
|
+
_listeners = /* @__PURE__ */ new Set();
|
|
461
|
+
constructor(config) {
|
|
462
|
+
this._current = config.initial;
|
|
463
|
+
this._transitions = config.transitions;
|
|
464
|
+
this._name = config.name ?? "StateMachine";
|
|
465
|
+
}
|
|
466
|
+
get current() {
|
|
467
|
+
return this._current;
|
|
468
|
+
}
|
|
469
|
+
transition(target) {
|
|
470
|
+
const allowed = this._transitions[this._current];
|
|
471
|
+
if (!allowed || !allowed.includes(target)) throw new Error(`Illegal transition: "${this._current}" \u2192 "${target}" for "${this._name}"`);
|
|
472
|
+
const from = this._current;
|
|
473
|
+
this._current = target;
|
|
474
|
+
for (const listener of this._listeners) listener(from, target);
|
|
475
|
+
}
|
|
476
|
+
canTransition(target) {
|
|
477
|
+
const allowed = this._transitions[this._current];
|
|
478
|
+
return !!allowed && allowed.includes(target);
|
|
479
|
+
}
|
|
480
|
+
assertState(...allowed) {
|
|
481
|
+
if (!allowed.includes(this._current)) {
|
|
482
|
+
const list = allowed.map((s) => `"${s}"`).join(", ");
|
|
483
|
+
throw new Error(`"${this._name}" expected state ${list}, but current is "${this._current}"`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
onTransition(cb) {
|
|
487
|
+
this._listeners.add(cb);
|
|
488
|
+
return () => {
|
|
489
|
+
this._listeners.delete(cb);
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
//#endregion
|
|
495
|
+
//#region src/core/task/handle.ts
|
|
496
|
+
/**
|
|
497
|
+
* Ergonomic handle for a single {@link Task}.
|
|
498
|
+
*
|
|
499
|
+
* Provides per-task control: start, queue, stop, enable, disable, clear, status.
|
|
500
|
+
* Created by Feature and bound to `ctx.getTask(name)`.
|
|
501
|
+
*/
|
|
502
|
+
var TaskHandle = class {
|
|
503
|
+
constructor(task, ctx = void 0) {
|
|
504
|
+
this.task = task;
|
|
505
|
+
this.ctx = ctx;
|
|
506
|
+
}
|
|
507
|
+
/** Execute immediately (respects overlap policy). */
|
|
508
|
+
async start(payload) {
|
|
509
|
+
await this.task.start(payload);
|
|
510
|
+
}
|
|
511
|
+
/** Push payload to FIFO queue, processes sequentially. */
|
|
512
|
+
queue(payload) {
|
|
513
|
+
this.task.queue(payload);
|
|
514
|
+
}
|
|
515
|
+
/** Abort current execution. Queue continues processing. */
|
|
516
|
+
stop() {
|
|
517
|
+
this.task.stop();
|
|
518
|
+
}
|
|
519
|
+
/** Re-enable the task (cron, ready for start/queue). */
|
|
520
|
+
enable() {
|
|
521
|
+
if (this.ctx) this.task.enable(this.ctx);
|
|
522
|
+
}
|
|
523
|
+
/** Abort current + clear queue + stop cron. */
|
|
524
|
+
disable(mode) {
|
|
525
|
+
this.task.disable(mode);
|
|
526
|
+
}
|
|
527
|
+
/** Clear the FIFO queue without stopping current execution. */
|
|
528
|
+
clear() {
|
|
529
|
+
this.task.clear();
|
|
530
|
+
}
|
|
531
|
+
/** Snapshot of the task's current state. */
|
|
532
|
+
status() {
|
|
533
|
+
return this.task.status();
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
//#endregion
|
|
538
|
+
//#region src/core/task/manager.ts
|
|
539
|
+
/**
|
|
540
|
+
* Registry and lifecycle coordinator for {@link Task} instances.
|
|
541
|
+
*
|
|
542
|
+
* Manages a flat collection of tasks: registration, bulk startup/shutdown,
|
|
543
|
+
* individual enable/disable, manual execution, and status queries.
|
|
544
|
+
*
|
|
545
|
+
* Does **not** own scheduling or retry — those belong to {@link Task}.
|
|
546
|
+
*/
|
|
547
|
+
var TaskManager = class {
|
|
548
|
+
tasks = /* @__PURE__ */ new Map();
|
|
549
|
+
_isShutdown = false;
|
|
550
|
+
constructor(ctx) {
|
|
551
|
+
this.ctx = ctx;
|
|
552
|
+
}
|
|
553
|
+
/** Add a task to the registry. Throws on duplicate `task.id`. */
|
|
554
|
+
register(task) {
|
|
555
|
+
if (this.tasks.has(task.id)) throw new Error(`Duplicate task id: "${task.id}"`);
|
|
556
|
+
this.tasks.set(task.id, task);
|
|
557
|
+
}
|
|
558
|
+
/** Remove a task, force-disabling it first. No-op for unknown ids. */
|
|
559
|
+
unregister(taskId) {
|
|
560
|
+
const task = this.tasks.get(taskId);
|
|
561
|
+
if (!task) return;
|
|
562
|
+
task.disable("force");
|
|
563
|
+
this.tasks.delete(taskId);
|
|
564
|
+
}
|
|
565
|
+
/** Enable all registered tasks. No-op after {@link shutdown}. */
|
|
566
|
+
startup() {
|
|
567
|
+
if (this._isShutdown) return;
|
|
568
|
+
for (const task of this.tasks.values()) task.enable(this.ctx);
|
|
569
|
+
}
|
|
570
|
+
/** Disable all registered tasks. Marks the manager as shut down (startup becomes a no-op). */
|
|
571
|
+
shutdown(mode = "graceful") {
|
|
572
|
+
this._isShutdown = true;
|
|
573
|
+
for (const task of this.tasks.values()) task.disable(mode);
|
|
574
|
+
}
|
|
575
|
+
/** Enable a single task by id. Throws if the task is not registered. */
|
|
576
|
+
enable(taskId) {
|
|
577
|
+
this.getTaskInstance(taskId).enable(this.ctx);
|
|
578
|
+
}
|
|
579
|
+
/** Disable a single task by id. Throws if the task is not registered. */
|
|
580
|
+
disable(taskId, mode = "graceful") {
|
|
581
|
+
this.getTaskInstance(taskId).disable(mode);
|
|
582
|
+
}
|
|
583
|
+
/** Trigger a manual execution of the task. Throws if the task is not registered. */
|
|
584
|
+
async start(taskId, payload) {
|
|
585
|
+
await this.getTaskInstance(taskId).start(payload);
|
|
586
|
+
}
|
|
587
|
+
/** Return the current status snapshot for a single task. */
|
|
588
|
+
status(taskId) {
|
|
589
|
+
return this.getTaskInstance(taskId).status();
|
|
590
|
+
}
|
|
591
|
+
/** Return status snapshots for all registered tasks. */
|
|
592
|
+
list() {
|
|
593
|
+
return Array.from(this.tasks.values()).map((t) => t.status());
|
|
594
|
+
}
|
|
595
|
+
/** Return the raw Task instance. Throws if not registered. */
|
|
596
|
+
getTaskInstance(taskId) {
|
|
597
|
+
const task = this.tasks.get(taskId);
|
|
598
|
+
if (!task) throw new Error(`Task "${taskId}" not found`);
|
|
599
|
+
return task;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
//#endregion
|
|
604
|
+
//#region src/core/feature/handle.ts
|
|
605
|
+
/**
|
|
606
|
+
* Ergonomic handle for a declared dependency Feature.
|
|
607
|
+
*
|
|
608
|
+
* Provides per-feature control: status, enable, disable.
|
|
609
|
+
* Created by Feature and bound to `ctx.getFeature(name)`.
|
|
610
|
+
*/
|
|
611
|
+
var FeatureHandle = class {
|
|
612
|
+
constructor(feature, manager) {
|
|
613
|
+
this.feature = feature;
|
|
614
|
+
this.manager = manager;
|
|
615
|
+
}
|
|
616
|
+
/** Current lifecycle state. */
|
|
617
|
+
status() {
|
|
618
|
+
return this.feature.status;
|
|
619
|
+
}
|
|
620
|
+
/** Re-activate the feature (from DEACTIVATED or ERROR). */
|
|
621
|
+
async enable() {
|
|
622
|
+
await this.manager.enable(this.feature.id);
|
|
623
|
+
}
|
|
624
|
+
/** Deactivate the feature. */
|
|
625
|
+
async disable() {
|
|
626
|
+
await this.manager.disable(this.feature.id);
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
//#endregion
|
|
631
|
+
//#region src/core/feature/feature.ts
|
|
632
|
+
/** Allowed state transitions for the Feature FSM. */
|
|
633
|
+
const TRANSITIONS = {
|
|
634
|
+
[FeatureStatus.NONE]: [FeatureStatus.REGISTERED],
|
|
635
|
+
[FeatureStatus.REGISTERED]: [FeatureStatus.INITIALIZING],
|
|
636
|
+
[FeatureStatus.INITIALIZING]: [FeatureStatus.READY, FeatureStatus.ERROR],
|
|
637
|
+
[FeatureStatus.READY]: [FeatureStatus.ACTIVATING],
|
|
638
|
+
[FeatureStatus.ACTIVATING]: [FeatureStatus.ACTIVATED, FeatureStatus.ERROR],
|
|
639
|
+
[FeatureStatus.ACTIVATED]: [FeatureStatus.DEACTIVATING],
|
|
640
|
+
[FeatureStatus.DEACTIVATING]: [FeatureStatus.DEACTIVATED, FeatureStatus.ERROR],
|
|
641
|
+
[FeatureStatus.DEACTIVATED]: [FeatureStatus.ACTIVATING, FeatureStatus.DESTROYING],
|
|
642
|
+
[FeatureStatus.DESTROYING]: [FeatureStatus.DESTROYED, FeatureStatus.ERROR],
|
|
643
|
+
[FeatureStatus.DESTROYED]: [],
|
|
644
|
+
[FeatureStatus.ERROR]: [FeatureStatus.ACTIVATING, FeatureStatus.DESTROYING]
|
|
645
|
+
};
|
|
646
|
+
var Feature = class {
|
|
647
|
+
state;
|
|
648
|
+
controller = new AbortController();
|
|
649
|
+
context;
|
|
650
|
+
serviceManager = null;
|
|
651
|
+
taskManager = null;
|
|
652
|
+
eventBus = null;
|
|
653
|
+
constructor(config, logger) {
|
|
654
|
+
this.config = config;
|
|
655
|
+
this.logger = logger;
|
|
656
|
+
this.state = new StateMachine({
|
|
657
|
+
transitions: TRANSITIONS,
|
|
658
|
+
initial: FeatureStatus.NONE,
|
|
659
|
+
name: `feature "${config.id}"`
|
|
660
|
+
});
|
|
661
|
+
this.context = {
|
|
662
|
+
getService: () => {
|
|
663
|
+
throw new Error("Services not yet initialized");
|
|
664
|
+
},
|
|
665
|
+
getTask: () => {
|
|
666
|
+
throw new Error("Tasks not yet initialized");
|
|
667
|
+
},
|
|
668
|
+
getFeature: () => {
|
|
669
|
+
throw new Error("Features not yet initialized");
|
|
670
|
+
},
|
|
671
|
+
events: {
|
|
672
|
+
publish: () => {
|
|
673
|
+
throw new Error("Events not yet initialized");
|
|
674
|
+
},
|
|
675
|
+
on: () => {
|
|
676
|
+
throw new Error("Events not yet initialized");
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
createWindow: () => {
|
|
680
|
+
throw new Error("Window manager not available");
|
|
681
|
+
},
|
|
682
|
+
getWindow: () => {
|
|
683
|
+
throw new Error("Window manager not available");
|
|
684
|
+
},
|
|
685
|
+
logger: this.logger,
|
|
686
|
+
signal: this.controller.signal
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
get id() {
|
|
690
|
+
return this.config.id;
|
|
691
|
+
}
|
|
692
|
+
get status() {
|
|
693
|
+
return this.state.current;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Validate and apply an FSM transition.
|
|
697
|
+
* @throws If the transition is not allowed from the current state.
|
|
698
|
+
*/
|
|
699
|
+
transition(target) {
|
|
700
|
+
this.state.transition(target);
|
|
701
|
+
}
|
|
702
|
+
async initialize(features, manager, eventBus, windowManager) {
|
|
703
|
+
this.buildContext(features, manager, eventBus, windowManager);
|
|
704
|
+
await this.config.onInitialize?.(this.context);
|
|
705
|
+
}
|
|
706
|
+
async activate() {
|
|
707
|
+
await this.config.onActivate?.(this.context);
|
|
708
|
+
this.taskManager?.startup();
|
|
709
|
+
}
|
|
710
|
+
async deactivate() {
|
|
711
|
+
if (this.eventBus) this.eventBus.removeByOwner(this.id);
|
|
712
|
+
this.taskManager?.shutdown();
|
|
713
|
+
if (this.serviceManager) this.serviceManager.shutdown();
|
|
714
|
+
await this.config.onDeactivate?.(this.context);
|
|
715
|
+
}
|
|
716
|
+
async destroy() {
|
|
717
|
+
await this.config.onDestroy?.(this.context);
|
|
718
|
+
}
|
|
719
|
+
buildContext(features, manager, eventBus, windowManager) {
|
|
720
|
+
this.context.signal = this.controller.signal;
|
|
721
|
+
this.context.logger = this.logger;
|
|
722
|
+
this.serviceManager = new ServiceManager(this.context);
|
|
723
|
+
for (const service of this.config.services ?? []) this.serviceManager.register(service);
|
|
724
|
+
this.serviceManager.startup();
|
|
725
|
+
const deps = /* @__PURE__ */ new Map();
|
|
726
|
+
for (const dep of features) if (dep.serviceManager) deps.set(dep.id, dep.serviceManager);
|
|
727
|
+
const accessor = new ServiceAccessor(this.serviceManager, deps);
|
|
728
|
+
this.context.getService = ((name) => accessor.get(name));
|
|
729
|
+
this.taskManager = new TaskManager(this.context);
|
|
730
|
+
for (const task of this.config.tasks ?? []) this.taskManager.register(task);
|
|
731
|
+
this.context.getTask = ((name) => {
|
|
732
|
+
return new TaskHandle(this.taskManager.getTaskInstance(name), this.context);
|
|
733
|
+
});
|
|
734
|
+
const declaredDeps = new Set(this.config.dependencies ?? []);
|
|
735
|
+
this.context.getFeature = ((name) => {
|
|
736
|
+
if (!declaredDeps.has(name)) throw new Error(`Feature "${name}" is not a declared dependency of "${this.id}"`);
|
|
737
|
+
const dep = features.find((f) => f.id === name);
|
|
738
|
+
if (!dep) throw new Error(`Feature "${name}" not found`);
|
|
739
|
+
return new FeatureHandle(dep, manager);
|
|
740
|
+
});
|
|
741
|
+
if (eventBus) {
|
|
742
|
+
this.eventBus = eventBus;
|
|
743
|
+
const eventAccessor = new EventAccessor(eventBus, this.id, declaredDeps);
|
|
744
|
+
const eventDefaults = /* @__PURE__ */ new Map();
|
|
745
|
+
for (const evt of this.config.events ?? []) if (evt.defaults !== void 0) eventDefaults.set(evt.id, evt.defaults);
|
|
746
|
+
this.context.events = {
|
|
747
|
+
publish: (event, payload) => {
|
|
748
|
+
const resolved = payload ?? eventDefaults.get(event);
|
|
749
|
+
eventAccessor.publish(event, resolved);
|
|
750
|
+
},
|
|
751
|
+
on: (event, handler) => {
|
|
752
|
+
return eventAccessor.on(event, handler);
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
if (windowManager) {
|
|
757
|
+
const windowAccessor = new WindowAccessor(windowManager);
|
|
758
|
+
this.context.createWindow = (name) => windowAccessor.createWindow(name);
|
|
759
|
+
this.context.getWindow = (name) => windowAccessor.getWindow(name);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
//#endregion
|
|
765
|
+
//#region src/core/feature/manager.ts
|
|
766
|
+
var FeatureManager = class {
|
|
767
|
+
registry = /* @__PURE__ */ new Map();
|
|
768
|
+
/** Global service ownership: serviceId → featureId. */
|
|
769
|
+
serviceOwners = /* @__PURE__ */ new Map();
|
|
770
|
+
/** Global task ownership: taskId → featureId. */
|
|
771
|
+
taskOwners = /* @__PURE__ */ new Map();
|
|
772
|
+
windowManager = null;
|
|
773
|
+
constructor(logger, eventBus = void 0) {
|
|
774
|
+
this.logger = logger;
|
|
775
|
+
this.eventBus = eventBus;
|
|
776
|
+
}
|
|
777
|
+
/** @internal Set the window manager (called by Electron layer before bootstrap). */
|
|
778
|
+
setWindowManager(windowManager) {
|
|
779
|
+
this.windowManager = windowManager;
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Register a feature or a list of features.
|
|
783
|
+
* If a feature with the same ID is already registered, a warning is logged and the feature is skipped.
|
|
784
|
+
* @throws If a service or task ID is already owned by another feature.
|
|
785
|
+
*/
|
|
786
|
+
register(features) {
|
|
787
|
+
const list = Array.isArray(features) ? features : [features];
|
|
788
|
+
for (const item of list) {
|
|
789
|
+
if (this.registry.has(item.id)) {
|
|
790
|
+
this.logger.warn("FeatureManager", `Feature "${item.id}" is already registered. Skipping.`);
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
const seenSvcIds = /* @__PURE__ */ new Set();
|
|
794
|
+
for (const svc of item.services ?? []) {
|
|
795
|
+
if (seenSvcIds.has(svc.id)) throw new Error(`Duplicate service "${svc.id}" within feature "${item.id}" — one service per ID.`);
|
|
796
|
+
seenSvcIds.add(svc.id);
|
|
797
|
+
const owner = this.serviceOwners.get(svc.id);
|
|
798
|
+
if (owner !== void 0) throw new Error(`Service "${svc.id}" is already registered by feature "${owner}". Feature "${item.id}" cannot claim it — service IDs must be globally unique.`);
|
|
799
|
+
}
|
|
800
|
+
for (const task of item.tasks ?? []) {
|
|
801
|
+
const owner = this.taskOwners.get(task.id);
|
|
802
|
+
if (owner !== void 0) throw new Error(`Task "${task.id}" is already registered by feature "${owner}". Feature "${item.id}" cannot claim it — task IDs must be globally unique.`);
|
|
803
|
+
}
|
|
804
|
+
for (const svc of item.services ?? []) this.serviceOwners.set(svc.id, item.id);
|
|
805
|
+
for (const task of item.tasks ?? []) this.taskOwners.set(task.id, item.id);
|
|
806
|
+
const feature = new Feature(item, this.logger);
|
|
807
|
+
this.registry.set(feature.id, feature);
|
|
808
|
+
feature.transition(FeatureStatus.REGISTERED);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
get(id) {
|
|
812
|
+
return this.registry.get(id);
|
|
813
|
+
}
|
|
814
|
+
/** Returns all registered features. */
|
|
815
|
+
list() {
|
|
816
|
+
return [...this.registry.values()];
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Bootstrap all features in dependency order.
|
|
820
|
+
* Calls initialize -> activate on each feature.
|
|
821
|
+
* The feature receives a context built externally (by Runtime).
|
|
822
|
+
*/
|
|
823
|
+
async bootstrap() {
|
|
824
|
+
const order = this.reorder();
|
|
825
|
+
for (const id of order) await this.initialize(id);
|
|
826
|
+
for (const id of order) await this.activate(id);
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Initialize a feature.
|
|
830
|
+
* Initializes the feature and sets its state to "ready" or "error".
|
|
831
|
+
*/
|
|
832
|
+
async initialize(id) {
|
|
833
|
+
const feature = this.registry.get(id);
|
|
834
|
+
if (feature?.status !== FeatureStatus.REGISTERED) return;
|
|
835
|
+
feature.transition(FeatureStatus.INITIALIZING);
|
|
836
|
+
try {
|
|
837
|
+
const features = [];
|
|
838
|
+
const deps = feature.config.dependencies ?? [];
|
|
839
|
+
for (const dep of deps) features.push(this.registry.get(dep));
|
|
840
|
+
await feature.initialize(features, this, this.eventBus, this.windowManager ?? void 0);
|
|
841
|
+
feature.transition(FeatureStatus.READY);
|
|
842
|
+
} catch (err) {
|
|
843
|
+
feature.transition(FeatureStatus.ERROR);
|
|
844
|
+
this.logger.error(id, `initialize failed`, { error: err instanceof Error ? err.message : String(err) });
|
|
845
|
+
if (feature.config.critical) throw new Error(`Critical feature "${id}" failed to initialize`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Activate a feature.
|
|
850
|
+
* @param id The ID of the feature to activate.
|
|
851
|
+
* @param allowRetry When true, ERROR state features can be retried (used by enable()).
|
|
852
|
+
*/
|
|
853
|
+
async activate(id, allowRetry = false) {
|
|
854
|
+
const feature = this.registry.get(id);
|
|
855
|
+
if (!(allowRetry ? [
|
|
856
|
+
FeatureStatus.READY,
|
|
857
|
+
FeatureStatus.DEACTIVATED,
|
|
858
|
+
FeatureStatus.ERROR
|
|
859
|
+
] : [FeatureStatus.READY, FeatureStatus.DEACTIVATED]).includes(feature.status)) return;
|
|
860
|
+
for (const depId of feature.config.dependencies ?? []) if (this.registry.get(depId)?.status === FeatureStatus.ERROR) {
|
|
861
|
+
this.logger.error(id, `cannot activate — dependency "${depId}" is in error state`);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
feature.transition(FeatureStatus.ACTIVATING);
|
|
865
|
+
try {
|
|
866
|
+
await feature.activate();
|
|
867
|
+
feature.transition(FeatureStatus.ACTIVATED);
|
|
868
|
+
} catch (err) {
|
|
869
|
+
feature.transition(FeatureStatus.ERROR);
|
|
870
|
+
this.logger.error(id, `activate failed`, { error: err instanceof Error ? err.message : String(err) });
|
|
871
|
+
if (feature.config.critical) throw new Error(`Critical feature "${id}" failed to activate`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Deactivate a feature.
|
|
876
|
+
* @param id The ID of the feature to deactivate.
|
|
877
|
+
*/
|
|
878
|
+
async deactivate(id) {
|
|
879
|
+
const feature = this.registry.get(id);
|
|
880
|
+
if (![FeatureStatus.ACTIVATED].includes(feature.status)) return;
|
|
881
|
+
feature.transition(FeatureStatus.DEACTIVATING);
|
|
882
|
+
try {
|
|
883
|
+
await feature.deactivate();
|
|
884
|
+
feature.transition(FeatureStatus.DEACTIVATED);
|
|
885
|
+
} catch (err) {
|
|
886
|
+
feature.transition(FeatureStatus.ERROR);
|
|
887
|
+
this.logger.error(id, `deactivate failed`, { error: err instanceof Error ? err.message : String(err) });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
async destroy(id) {
|
|
891
|
+
const feature = this.registry.get(id);
|
|
892
|
+
if (![FeatureStatus.DEACTIVATED, FeatureStatus.ERROR].includes(feature.status)) return;
|
|
893
|
+
feature.transition(FeatureStatus.DESTROYING);
|
|
894
|
+
try {
|
|
895
|
+
await feature.destroy();
|
|
896
|
+
feature.transition(FeatureStatus.DESTROYED);
|
|
897
|
+
} catch (err) {
|
|
898
|
+
feature.transition(FeatureStatus.ERROR);
|
|
899
|
+
this.logger.error(id, `destroy failed`, { error: err instanceof Error ? err.message : String(err) });
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
/** Public: re-activate a DEACTIVATED or ERROR feature. */
|
|
903
|
+
async enable(id) {
|
|
904
|
+
if (!this.registry.get(id)) throw new Error(`Feature "${id}" not found`);
|
|
905
|
+
await this.activate(id, true);
|
|
906
|
+
}
|
|
907
|
+
/** Public: deactivate an ACTIVATED feature. */
|
|
908
|
+
async disable(id) {
|
|
909
|
+
if (!this.registry.get(id)) throw new Error(`Feature "${id}" not found`);
|
|
910
|
+
await this.deactivate(id);
|
|
911
|
+
}
|
|
912
|
+
async shutdown() {
|
|
913
|
+
const order = [...this.reorder()].reverse();
|
|
914
|
+
for (const id of order) await this.deactivate(id);
|
|
915
|
+
for (const id of order) await this.destroy(id);
|
|
916
|
+
}
|
|
917
|
+
reorder() {
|
|
918
|
+
const sorted = [];
|
|
919
|
+
const visited = /* @__PURE__ */ new Set();
|
|
920
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
921
|
+
const visit = (id) => {
|
|
922
|
+
if (visiting.has(id)) throw new Error(`Circular dependency detected: Feature "${id}" depends on itself!`);
|
|
923
|
+
if (visited.has(id)) return;
|
|
924
|
+
visiting.add(id);
|
|
925
|
+
const feature = this.registry.get(id);
|
|
926
|
+
if (!feature) throw new Error(`Missing dependency: Feature "${id}" is not registered.`);
|
|
927
|
+
for (const depId of feature.config.dependencies ?? []) visit(depId);
|
|
928
|
+
visiting.delete(id);
|
|
929
|
+
visited.add(id);
|
|
930
|
+
sorted.push(id);
|
|
931
|
+
};
|
|
932
|
+
for (const id of this.registry.keys()) visit(id);
|
|
933
|
+
return sorted;
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
//#endregion
|
|
938
|
+
//#region src/core/logger/console-handler.ts
|
|
939
|
+
const dim = "\x1B[90m";
|
|
940
|
+
const cyan = "\x1B[36m";
|
|
941
|
+
const green = "\x1B[32m";
|
|
942
|
+
const yellow = "\x1B[33m";
|
|
943
|
+
const magenta = "\x1B[35m";
|
|
944
|
+
const reset = "\x1B[0m";
|
|
945
|
+
function formatTime(ts) {
|
|
946
|
+
const d = new Date(ts);
|
|
947
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
|
|
948
|
+
}
|
|
949
|
+
function colorizeValue(value) {
|
|
950
|
+
if (value === null) return `${magenta}null${reset}`;
|
|
951
|
+
if (value === void 0) return `${dim}undefined${reset}`;
|
|
952
|
+
if (typeof value === "string") return `${green}"${value}"${reset}`;
|
|
953
|
+
if (typeof value === "number") return `${yellow}${value}${reset}`;
|
|
954
|
+
if (typeof value === "boolean") return `${yellow}${value}${reset}`;
|
|
955
|
+
if (Array.isArray(value)) {
|
|
956
|
+
if (value.length === 0) return "[]";
|
|
957
|
+
return `[${value.map(colorizeValue).join(`${dim},${reset} `)}]`;
|
|
958
|
+
}
|
|
959
|
+
if (typeof value === "object") {
|
|
960
|
+
const entries = Object.entries(value);
|
|
961
|
+
if (entries.length === 0) return "{}";
|
|
962
|
+
return `${dim}{${reset} ${entries.map(([k, v]) => `${cyan}${k}${reset}${dim}:${reset} ${colorizeValue(v)}`).join(`${dim},${reset} `)} ${dim}}${reset}`;
|
|
963
|
+
}
|
|
964
|
+
return String(value);
|
|
965
|
+
}
|
|
966
|
+
function createConsoleHandler() {
|
|
967
|
+
return (entry) => {
|
|
968
|
+
const time = formatTime(entry.timestamp);
|
|
969
|
+
const tag = entry.level === "debug" ? "electro" : entry.level;
|
|
970
|
+
const detailsPart = entry.details ? ` ${colorizeValue(entry.details)}` : "";
|
|
971
|
+
const line = `${time} [${tag}] ${entry.code} \u2192 ${entry.message}${detailsPart}`;
|
|
972
|
+
if (entry.level === "error") console.error(line);
|
|
973
|
+
else if (entry.level === "warn") console.warn(line);
|
|
974
|
+
else console.log(line);
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
//#endregion
|
|
979
|
+
//#region src/core/logger/logger.ts
|
|
980
|
+
var Logger = class {
|
|
981
|
+
handlers = /* @__PURE__ */ new Set();
|
|
982
|
+
addHandler(handler) {
|
|
983
|
+
this.handlers.add(handler);
|
|
984
|
+
}
|
|
985
|
+
removeHandler(handler) {
|
|
986
|
+
this.handlers.delete(handler);
|
|
987
|
+
}
|
|
988
|
+
debug(code, message, details) {
|
|
989
|
+
this.emit("debug", code, message, details);
|
|
990
|
+
}
|
|
991
|
+
warn(code, message, details) {
|
|
992
|
+
this.emit("warn", code, message, details);
|
|
993
|
+
}
|
|
994
|
+
error(code, message, details) {
|
|
995
|
+
this.emit("error", code, message, details);
|
|
996
|
+
}
|
|
997
|
+
emit(level, code, message, details) {
|
|
998
|
+
const entry = {
|
|
999
|
+
level,
|
|
1000
|
+
code,
|
|
1001
|
+
message,
|
|
1002
|
+
details,
|
|
1003
|
+
timestamp: Date.now()
|
|
1004
|
+
};
|
|
1005
|
+
for (const handler of this.handlers) handler(entry);
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
//#endregion
|
|
1010
|
+
//#region src/core/runtime/runtime.ts
|
|
1011
|
+
const RUNTIME_TRANSITIONS = {
|
|
1012
|
+
[RuntimeState.CREATED]: [RuntimeState.STARTING],
|
|
1013
|
+
[RuntimeState.STARTING]: [RuntimeState.RUNNING, RuntimeState.FAILED],
|
|
1014
|
+
[RuntimeState.RUNNING]: [RuntimeState.STOPPING],
|
|
1015
|
+
[RuntimeState.STOPPING]: [RuntimeState.STOPPED],
|
|
1016
|
+
[RuntimeState.STOPPED]: [],
|
|
1017
|
+
[RuntimeState.FAILED]: []
|
|
1018
|
+
};
|
|
1019
|
+
var Runtime = class {
|
|
1020
|
+
state;
|
|
1021
|
+
logger;
|
|
1022
|
+
featureManager;
|
|
1023
|
+
eventBus;
|
|
1024
|
+
windowManager = null;
|
|
1025
|
+
constructor(config) {
|
|
1026
|
+
this.state = new StateMachine({
|
|
1027
|
+
transitions: RUNTIME_TRANSITIONS,
|
|
1028
|
+
initial: RuntimeState.CREATED,
|
|
1029
|
+
name: "Runtime"
|
|
1030
|
+
});
|
|
1031
|
+
this.logger = new Logger();
|
|
1032
|
+
this.eventBus = new EventBus();
|
|
1033
|
+
this.featureManager = new FeatureManager(this.logger, this.eventBus);
|
|
1034
|
+
this.logger.addHandler(createConsoleHandler());
|
|
1035
|
+
if (config?.logger?.handlers) for (const handler of config.logger.handlers) this.logger.addHandler(handler);
|
|
1036
|
+
if (config?.features) this.featureManager.register(config.features);
|
|
1037
|
+
}
|
|
1038
|
+
/** @internal Inject window manager (called by Electron layer before start). */
|
|
1039
|
+
_injectWindowManager(windowManager) {
|
|
1040
|
+
this.state.assertState(RuntimeState.CREATED);
|
|
1041
|
+
this.windowManager = windowManager;
|
|
1042
|
+
this.featureManager.setWindowManager(windowManager);
|
|
1043
|
+
}
|
|
1044
|
+
register(features) {
|
|
1045
|
+
this.state.assertState(RuntimeState.CREATED);
|
|
1046
|
+
this.featureManager.register(features);
|
|
1047
|
+
}
|
|
1048
|
+
async start() {
|
|
1049
|
+
if (!this.windowManager && typeof __ELECTRO_WINDOW_DEFINITIONS__ !== "undefined") {
|
|
1050
|
+
const wm = new WindowManager(createDefaultWindowFactory());
|
|
1051
|
+
for (const def of __ELECTRO_WINDOW_DEFINITIONS__) wm.registerDefinition(def);
|
|
1052
|
+
this._injectWindowManager(wm);
|
|
1053
|
+
}
|
|
1054
|
+
this.state.transition(RuntimeState.STARTING);
|
|
1055
|
+
try {
|
|
1056
|
+
await this.featureManager.bootstrap();
|
|
1057
|
+
this.state.transition(RuntimeState.RUNNING);
|
|
1058
|
+
} catch (err) {
|
|
1059
|
+
this.state.transition(RuntimeState.FAILED);
|
|
1060
|
+
throw err;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
async shutdown() {
|
|
1064
|
+
this.state.assertState(RuntimeState.RUNNING);
|
|
1065
|
+
this.state.transition(RuntimeState.STOPPING);
|
|
1066
|
+
await this.featureManager.shutdown();
|
|
1067
|
+
this.windowManager?.destroyAll();
|
|
1068
|
+
this.state.transition(RuntimeState.STOPPED);
|
|
1069
|
+
}
|
|
1070
|
+
async enable(id) {
|
|
1071
|
+
this.state.assertState(RuntimeState.RUNNING);
|
|
1072
|
+
await this.featureManager.enable(id);
|
|
1073
|
+
}
|
|
1074
|
+
async disable(id) {
|
|
1075
|
+
this.state.assertState(RuntimeState.RUNNING);
|
|
1076
|
+
await this.featureManager.disable(id);
|
|
1077
|
+
}
|
|
1078
|
+
isDegraded() {
|
|
1079
|
+
this.state.assertState(RuntimeState.RUNNING);
|
|
1080
|
+
return this.featureManager.list().some((f) => f.status === FeatureStatus.ERROR);
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
//#endregion
|
|
1085
|
+
//#region src/core/runtime/helpers.ts
|
|
1086
|
+
function createRuntime(config) {
|
|
1087
|
+
return new Runtime(config);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
//#endregion
|
|
1091
|
+
//#region src/core/service/service.ts
|
|
1092
|
+
/**
|
|
1093
|
+
* Single service unit: one id, one scope, one factory.
|
|
1094
|
+
*
|
|
1095
|
+
* Lifecycle: Registered → Active (after build) → Destroyed (after destroy).
|
|
1096
|
+
* `build()` is idempotent — calling it when already Active is a no-op.
|
|
1097
|
+
*/
|
|
1098
|
+
var Service = class {
|
|
1099
|
+
_status = ServiceStatus.REGISTERED;
|
|
1100
|
+
_api = null;
|
|
1101
|
+
constructor(config) {
|
|
1102
|
+
this.config = config;
|
|
1103
|
+
}
|
|
1104
|
+
get id() {
|
|
1105
|
+
return this.config.id;
|
|
1106
|
+
}
|
|
1107
|
+
get scope() {
|
|
1108
|
+
return this.config.scope ?? ServiceScope.PRIVATE;
|
|
1109
|
+
}
|
|
1110
|
+
/** Invoke the factory and store the result. Idempotent — no-op if already Active. */
|
|
1111
|
+
build(ctx) {
|
|
1112
|
+
if (this._status === ServiceStatus.ACTIVE) return;
|
|
1113
|
+
if (this._status === ServiceStatus.DESTROYED) throw new Error(`Service "${this.id}" (${this.scope}) is destroyed and cannot be rebuilt`);
|
|
1114
|
+
this._api = this.config.api(ctx);
|
|
1115
|
+
this._status = ServiceStatus.ACTIVE;
|
|
1116
|
+
}
|
|
1117
|
+
/** Clear the factory result and mark as Destroyed. Idempotent. */
|
|
1118
|
+
destroy() {
|
|
1119
|
+
this._api = null;
|
|
1120
|
+
this._status = ServiceStatus.DESTROYED;
|
|
1121
|
+
}
|
|
1122
|
+
/** Return the factory result, or null if not yet built / destroyed. */
|
|
1123
|
+
api() {
|
|
1124
|
+
return this._api;
|
|
1125
|
+
}
|
|
1126
|
+
/** Snapshot of the service's current state. */
|
|
1127
|
+
status() {
|
|
1128
|
+
return {
|
|
1129
|
+
serviceId: this.id,
|
|
1130
|
+
scope: this.scope,
|
|
1131
|
+
state: this._status
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
//#endregion
|
|
1137
|
+
//#region src/core/service/helpers.ts
|
|
1138
|
+
/**
|
|
1139
|
+
* Creates a {@link Service} instance from a configuration object.
|
|
1140
|
+
*
|
|
1141
|
+
* @param config - Service configuration with `id`, `scope`, and `api`.
|
|
1142
|
+
* @returns A new `Service` instance ready for registration.
|
|
1143
|
+
* @throws If `config.id` is empty.
|
|
1144
|
+
*/
|
|
1145
|
+
function createService(config) {
|
|
1146
|
+
if (!config.id) throw new Error("Service must have an id");
|
|
1147
|
+
return new Service(config);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
//#endregion
|
|
1151
|
+
//#region src/core/task/enums.ts
|
|
1152
|
+
let TaskOverlapStrategy = /* @__PURE__ */ function(TaskOverlapStrategy) {
|
|
1153
|
+
TaskOverlapStrategy["Skip"] = "skip";
|
|
1154
|
+
TaskOverlapStrategy["Queue"] = "queue";
|
|
1155
|
+
TaskOverlapStrategy["Parallel"] = "parallel";
|
|
1156
|
+
return TaskOverlapStrategy;
|
|
1157
|
+
}({});
|
|
1158
|
+
let TaskStatus = /* @__PURE__ */ function(TaskStatus) {
|
|
1159
|
+
TaskStatus["Registered"] = "registered";
|
|
1160
|
+
TaskStatus["Scheduled"] = "scheduled";
|
|
1161
|
+
TaskStatus["Running"] = "running";
|
|
1162
|
+
TaskStatus["Stopped"] = "stopped";
|
|
1163
|
+
TaskStatus["Failed"] = "failed";
|
|
1164
|
+
return TaskStatus;
|
|
1165
|
+
}({});
|
|
1166
|
+
let TaskRetryStrategy = /* @__PURE__ */ function(TaskRetryStrategy) {
|
|
1167
|
+
TaskRetryStrategy["Fixed"] = "fixed";
|
|
1168
|
+
TaskRetryStrategy["Exponential"] = "exponential";
|
|
1169
|
+
return TaskRetryStrategy;
|
|
1170
|
+
}({});
|
|
1171
|
+
let TaskTriggerKind = /* @__PURE__ */ function(TaskTriggerKind) {
|
|
1172
|
+
TaskTriggerKind["Cron"] = "cron";
|
|
1173
|
+
TaskTriggerKind["Manual"] = "manual";
|
|
1174
|
+
return TaskTriggerKind;
|
|
1175
|
+
}({});
|
|
1176
|
+
|
|
1177
|
+
//#endregion
|
|
1178
|
+
//#region ../../node_modules/.bun/es-toolkit@github+MyraxByte+es-toolkit+f71db7c/node_modules/es-toolkit/src/error/AbortError.ts
|
|
1179
|
+
/**
|
|
1180
|
+
* An error class representing an aborted operation.
|
|
1181
|
+
* @augments Error
|
|
1182
|
+
*/
|
|
1183
|
+
var AbortError = class extends Error {
|
|
1184
|
+
constructor(message = "The operation was aborted") {
|
|
1185
|
+
super(message);
|
|
1186
|
+
this.name = "AbortError";
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
//#endregion
|
|
1191
|
+
//#region ../../node_modules/.bun/es-toolkit@github+MyraxByte+es-toolkit+f71db7c/node_modules/es-toolkit/src/error/TimeoutError.ts
|
|
1192
|
+
/**
|
|
1193
|
+
* An error class representing an timeout operation.
|
|
1194
|
+
* @augments Error
|
|
1195
|
+
*/
|
|
1196
|
+
var TimeoutError = class extends Error {
|
|
1197
|
+
constructor(message = "The operation was timed out") {
|
|
1198
|
+
super(message);
|
|
1199
|
+
this.name = "TimeoutError";
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
//#endregion
|
|
1204
|
+
//#region ../../node_modules/.bun/es-toolkit@github+MyraxByte+es-toolkit+f71db7c/node_modules/es-toolkit/src/promise/delay.ts
|
|
1205
|
+
/**
|
|
1206
|
+
* Delays the execution of code for a specified number of milliseconds.
|
|
1207
|
+
*
|
|
1208
|
+
* This function returns a Promise that resolves after the specified delay, allowing you to use it
|
|
1209
|
+
* with async/await to pause execution.
|
|
1210
|
+
*
|
|
1211
|
+
* @param {number} ms - The number of milliseconds to delay.
|
|
1212
|
+
* @param {DelayOptions} options - The options object.
|
|
1213
|
+
* @param {AbortSignal} options.signal - An optional AbortSignal to cancel the delay.
|
|
1214
|
+
* @returns {Promise<void>} A Promise that resolves after the specified delay.
|
|
1215
|
+
*
|
|
1216
|
+
* @example
|
|
1217
|
+
* async function foo() {
|
|
1218
|
+
* console.log('Start');
|
|
1219
|
+
* await delay(1000); // Delays execution for 1 second
|
|
1220
|
+
* console.log('End');
|
|
1221
|
+
* }
|
|
1222
|
+
*
|
|
1223
|
+
* foo();
|
|
1224
|
+
*
|
|
1225
|
+
* // With AbortSignal
|
|
1226
|
+
* const controller = new AbortController();
|
|
1227
|
+
* const { signal } = controller;
|
|
1228
|
+
*
|
|
1229
|
+
* setTimeout(() => controller.abort(), 50); // Will cancel the delay after 50ms
|
|
1230
|
+
* try {
|
|
1231
|
+
* await delay(100, { signal });
|
|
1232
|
+
* } catch (error) {
|
|
1233
|
+
* console.error(error); // Will log 'AbortError'
|
|
1234
|
+
* }
|
|
1235
|
+
* }
|
|
1236
|
+
*/
|
|
1237
|
+
function delay(ms, { signal } = {}) {
|
|
1238
|
+
return new Promise((resolve, reject) => {
|
|
1239
|
+
const abortError = () => {
|
|
1240
|
+
reject(new AbortError());
|
|
1241
|
+
};
|
|
1242
|
+
const abortHandler = () => {
|
|
1243
|
+
clearTimeout(timeoutId);
|
|
1244
|
+
abortError();
|
|
1245
|
+
};
|
|
1246
|
+
if (signal?.aborted) return abortError();
|
|
1247
|
+
const timeoutId = setTimeout(() => {
|
|
1248
|
+
signal?.removeEventListener("abort", abortHandler);
|
|
1249
|
+
resolve();
|
|
1250
|
+
}, ms);
|
|
1251
|
+
signal?.addEventListener("abort", abortHandler, { once: true });
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
//#endregion
|
|
1256
|
+
//#region ../../node_modules/.bun/es-toolkit@github+MyraxByte+es-toolkit+f71db7c/node_modules/es-toolkit/src/promise/timeout.ts
|
|
1257
|
+
/**
|
|
1258
|
+
* Returns a promise that rejects with a `TimeoutError` after a specified delay.
|
|
1259
|
+
*
|
|
1260
|
+
* @param {number} ms - The delay duration in milliseconds.
|
|
1261
|
+
* @param {TimeoutOptions} options - The options object.
|
|
1262
|
+
* @param {AbortSignal} options.signal - An optional AbortSignal to cancel the timeout.
|
|
1263
|
+
* @returns {Promise<never>} A promise that rejects with a `TimeoutError` after the specified delay.
|
|
1264
|
+
* @throws {TimeoutError} Throws a `TimeoutError` after the specified delay.
|
|
1265
|
+
*
|
|
1266
|
+
* @example
|
|
1267
|
+
* try {
|
|
1268
|
+
* await timeout(1000); // Timeout exception after 1 second
|
|
1269
|
+
* } catch (error) {
|
|
1270
|
+
* console.error(error); // Will log 'The operation was timed out'
|
|
1271
|
+
* }
|
|
1272
|
+
*
|
|
1273
|
+
* // With AbortSignal
|
|
1274
|
+
* const controller = new AbortController();
|
|
1275
|
+
* const { signal } = controller;
|
|
1276
|
+
* setTimeout(() => controller.abort(), 50);
|
|
1277
|
+
* try {
|
|
1278
|
+
* await timeout(1000, { signal }); // Will be aborted after 50ms
|
|
1279
|
+
* } catch (error) {
|
|
1280
|
+
* console.error(error); // Will log 'The operation was aborted'
|
|
1281
|
+
* }
|
|
1282
|
+
*/
|
|
1283
|
+
async function timeout(ms, { signal } = {}) {
|
|
1284
|
+
await delay(ms, { signal });
|
|
1285
|
+
throw new TimeoutError();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region ../../node_modules/.bun/es-toolkit@github+MyraxByte+es-toolkit+f71db7c/node_modules/es-toolkit/src/promise/withTimeout.ts
|
|
1290
|
+
/**
|
|
1291
|
+
* Executes an async function and enforces a timeout.
|
|
1292
|
+
*
|
|
1293
|
+
* If the promise does not resolve within the specified time,
|
|
1294
|
+
* the timeout will trigger and the returned promise will be rejected.
|
|
1295
|
+
*
|
|
1296
|
+
* @template T
|
|
1297
|
+
* @param {() => Promise<T>} run - A function that returns a promise to be executed.
|
|
1298
|
+
* @param {number} ms - The timeout duration in milliseconds.
|
|
1299
|
+
* @param {WithTimeoutOptions} options - The options object.
|
|
1300
|
+
* @param {AbortSignal} options.signal - An optional AbortSignal to cancel the operation.
|
|
1301
|
+
* @returns {Promise<T>} A promise that resolves with the result of the `run` function or rejects if the timeout is reached.
|
|
1302
|
+
*
|
|
1303
|
+
* @example
|
|
1304
|
+
* async function fetchData() {
|
|
1305
|
+
* const response = await fetch('https://example.com/data');
|
|
1306
|
+
* return response.json();
|
|
1307
|
+
* }
|
|
1308
|
+
*
|
|
1309
|
+
* try {
|
|
1310
|
+
* const data = await withTimeout(fetchData, 1000);
|
|
1311
|
+
* console.log(data); // Logs the fetched data if resolved within 1 second.
|
|
1312
|
+
* } catch (error) {
|
|
1313
|
+
* console.error(error); // Will log 'TimeoutError' if not resolved within 1 second.
|
|
1314
|
+
* }
|
|
1315
|
+
*
|
|
1316
|
+
* // With AbortSignal
|
|
1317
|
+
* const controller = new AbortController();
|
|
1318
|
+
* const { signal } = controller;
|
|
1319
|
+
* const data = await withTimeout(async () => {
|
|
1320
|
+
* const response = await fetch('https://example.com/data', { signal });
|
|
1321
|
+
* return response.json();
|
|
1322
|
+
* }, 5000, { signal });
|
|
1323
|
+
*/
|
|
1324
|
+
async function withTimeout(run, ms, { signal } = {}) {
|
|
1325
|
+
return Promise.race([run(), timeout(ms, { signal })]);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
//#endregion
|
|
1329
|
+
//#region src/core/task/task.ts
|
|
1330
|
+
const noop = () => {};
|
|
1331
|
+
/**
|
|
1332
|
+
* Single executable unit within the Electro task system.
|
|
1333
|
+
*
|
|
1334
|
+
* Supports overlap strategies (skip / queue / parallel), per-payload deduplication,
|
|
1335
|
+
* configurable retry with fixed or exponential backoff, timeouts with abort propagation,
|
|
1336
|
+
* and cron-based scheduling via `croner`.
|
|
1337
|
+
*
|
|
1338
|
+
* State machine: `Registered → Scheduled → Running → Scheduled | Failed | Stopped`
|
|
1339
|
+
*/
|
|
1340
|
+
var Task = class {
|
|
1341
|
+
_status = TaskStatus.Registered;
|
|
1342
|
+
_runningCount = 0;
|
|
1343
|
+
_lastRunAt = null;
|
|
1344
|
+
_lastSuccessAt = null;
|
|
1345
|
+
_lastErrorAt = null;
|
|
1346
|
+
_lastError = null;
|
|
1347
|
+
_queue = [];
|
|
1348
|
+
_stopping = false;
|
|
1349
|
+
_enabled = false;
|
|
1350
|
+
_abortControllers = /* @__PURE__ */ new Set();
|
|
1351
|
+
_cronJob = null;
|
|
1352
|
+
_ctx = null;
|
|
1353
|
+
_activeDedupeKeys = /* @__PURE__ */ new Set();
|
|
1354
|
+
constructor(config) {
|
|
1355
|
+
this.config = config;
|
|
1356
|
+
}
|
|
1357
|
+
get id() {
|
|
1358
|
+
return this.config.id;
|
|
1359
|
+
}
|
|
1360
|
+
/** Activate the task within a feature context. Idempotent. */
|
|
1361
|
+
enable(ctx) {
|
|
1362
|
+
if (this._enabled) return;
|
|
1363
|
+
this._ctx = ctx;
|
|
1364
|
+
this._enabled = true;
|
|
1365
|
+
this._status = TaskStatus.Scheduled;
|
|
1366
|
+
const autoStart = this.config.autoStart !== false;
|
|
1367
|
+
if (this.config.cron && autoStart) this.scheduleCron();
|
|
1368
|
+
else if (!this.config.cron && autoStart) this.execute(TaskTriggerKind.Manual, void 0).catch(noop);
|
|
1369
|
+
}
|
|
1370
|
+
/** Deactivate the task. `"graceful"` lets running executions finish; `"force"` aborts them. */
|
|
1371
|
+
disable(mode = "graceful") {
|
|
1372
|
+
this._enabled = false;
|
|
1373
|
+
this.clear();
|
|
1374
|
+
if (this._cronJob) {
|
|
1375
|
+
this._cronJob.stop();
|
|
1376
|
+
this._cronJob = null;
|
|
1377
|
+
}
|
|
1378
|
+
if (mode === "force") for (const ac of this._abortControllers) ac.abort();
|
|
1379
|
+
if (this._runningCount === 0) this._status = TaskStatus.Stopped;
|
|
1380
|
+
}
|
|
1381
|
+
/** Trigger a manual execution. No-op when the task is not enabled. */
|
|
1382
|
+
async start(payload) {
|
|
1383
|
+
await this.execute(TaskTriggerKind.Manual, payload);
|
|
1384
|
+
}
|
|
1385
|
+
/** Push payload to FIFO queue. Starts processing if idle. */
|
|
1386
|
+
queue(payload) {
|
|
1387
|
+
if (!this._enabled || !this._ctx) return;
|
|
1388
|
+
if (this._runningCount > 0) {
|
|
1389
|
+
this._queue.push(payload);
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
this.execute(TaskTriggerKind.Manual, payload).catch(noop);
|
|
1393
|
+
}
|
|
1394
|
+
/** Abort current execution. Queue continues processing next item. */
|
|
1395
|
+
stop() {
|
|
1396
|
+
this._stopping = true;
|
|
1397
|
+
for (const ac of this._abortControllers) ac.abort();
|
|
1398
|
+
}
|
|
1399
|
+
/** Clear the FIFO queue without stopping current execution. */
|
|
1400
|
+
clear() {
|
|
1401
|
+
this._queue = [];
|
|
1402
|
+
}
|
|
1403
|
+
/** Snapshot of the task's current state and run history. */
|
|
1404
|
+
status() {
|
|
1405
|
+
return {
|
|
1406
|
+
taskId: this.id,
|
|
1407
|
+
state: this._status,
|
|
1408
|
+
running: this._runningCount > 0,
|
|
1409
|
+
queueSize: this._queue.length,
|
|
1410
|
+
lastRunAt: this._lastRunAt,
|
|
1411
|
+
lastSuccessAt: this._lastSuccessAt,
|
|
1412
|
+
lastErrorAt: this._lastErrorAt,
|
|
1413
|
+
lastError: this._lastError
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
async execute(trigger, payload) {
|
|
1417
|
+
if (!this._enabled || !this._ctx) return;
|
|
1418
|
+
const ctx = this._ctx;
|
|
1419
|
+
this._stopping = false;
|
|
1420
|
+
if (this.shouldSkipExecution(payload)) return;
|
|
1421
|
+
const dedupeKey = this.acquireDedupeKey(payload);
|
|
1422
|
+
this._runningCount++;
|
|
1423
|
+
this._status = TaskStatus.Running;
|
|
1424
|
+
let lastError = null;
|
|
1425
|
+
try {
|
|
1426
|
+
lastError = await this.executeWithRetry(ctx, payload);
|
|
1427
|
+
} finally {
|
|
1428
|
+
this._runningCount--;
|
|
1429
|
+
if (dedupeKey) this._activeDedupeKeys.delete(dedupeKey);
|
|
1430
|
+
this.resolveStatus(lastError);
|
|
1431
|
+
}
|
|
1432
|
+
if (lastError) {
|
|
1433
|
+
if (this._stopping) {
|
|
1434
|
+
this._stopping = false;
|
|
1435
|
+
this.drainQueue(trigger);
|
|
1436
|
+
} else {
|
|
1437
|
+
this._queue = [];
|
|
1438
|
+
throw lastError;
|
|
1439
|
+
}
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
this.drainQueue(trigger);
|
|
1443
|
+
}
|
|
1444
|
+
resolveStatus(lastError) {
|
|
1445
|
+
if (this._runningCount > 0) return;
|
|
1446
|
+
if (lastError) this._status = TaskStatus.Failed;
|
|
1447
|
+
else if (this._enabled) this._status = TaskStatus.Scheduled;
|
|
1448
|
+
else this._status = TaskStatus.Stopped;
|
|
1449
|
+
}
|
|
1450
|
+
shouldSkipExecution(payload) {
|
|
1451
|
+
if (this._runningCount === 0) return false;
|
|
1452
|
+
const overlap = this.config.overlap ?? TaskOverlapStrategy.Skip;
|
|
1453
|
+
if (overlap === TaskOverlapStrategy.Skip) return true;
|
|
1454
|
+
if (overlap === TaskOverlapStrategy.Queue) {
|
|
1455
|
+
this._queue.push(payload);
|
|
1456
|
+
return true;
|
|
1457
|
+
}
|
|
1458
|
+
if (this.config.dedupeKey) return this._activeDedupeKeys.has(this.config.dedupeKey(payload));
|
|
1459
|
+
return false;
|
|
1460
|
+
}
|
|
1461
|
+
acquireDedupeKey(payload) {
|
|
1462
|
+
if (!this.config.dedupeKey) return void 0;
|
|
1463
|
+
const key = this.config.dedupeKey(payload);
|
|
1464
|
+
this._activeDedupeKeys.add(key);
|
|
1465
|
+
return key;
|
|
1466
|
+
}
|
|
1467
|
+
async executeWithRetry(ctx, payload) {
|
|
1468
|
+
const retryConfig = this.config.retry;
|
|
1469
|
+
const maxAttempts = retryConfig ? retryConfig.attempts : 1;
|
|
1470
|
+
let lastError = null;
|
|
1471
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1472
|
+
if (!this._enabled) break;
|
|
1473
|
+
const ac = new AbortController();
|
|
1474
|
+
this._abortControllers.add(ac);
|
|
1475
|
+
this._lastRunAt = Date.now();
|
|
1476
|
+
try {
|
|
1477
|
+
const execCtx = {
|
|
1478
|
+
signal: ac.signal,
|
|
1479
|
+
attempt
|
|
1480
|
+
};
|
|
1481
|
+
const promise = Promise.resolve(this.config.execute(ctx, payload, execCtx));
|
|
1482
|
+
if (this.config.timeoutMs && this.config.timeoutMs > 0) await this.executeWithTimeout(promise, this.config.timeoutMs, ac);
|
|
1483
|
+
else await promise;
|
|
1484
|
+
this._lastSuccessAt = Date.now();
|
|
1485
|
+
this._lastError = null;
|
|
1486
|
+
return null;
|
|
1487
|
+
} catch (err) {
|
|
1488
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1489
|
+
lastError = error;
|
|
1490
|
+
this._lastErrorAt = Date.now();
|
|
1491
|
+
this._lastError = error;
|
|
1492
|
+
if (attempt < maxAttempts && retryConfig) await delay(this.computeRetryDelay(retryConfig.strategy, retryConfig.delayMs, attempt));
|
|
1493
|
+
} finally {
|
|
1494
|
+
this._abortControllers.delete(ac);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return lastError;
|
|
1498
|
+
}
|
|
1499
|
+
drainQueue(trigger) {
|
|
1500
|
+
if (this._queue.length === 0 || !this._enabled) return;
|
|
1501
|
+
const payload = this._queue.shift();
|
|
1502
|
+
this.execute(trigger, payload).catch(noop);
|
|
1503
|
+
}
|
|
1504
|
+
scheduleCron() {
|
|
1505
|
+
this._cronJob = new Cron(this.config.cron, () => {
|
|
1506
|
+
this.execute(TaskTriggerKind.Cron, void 0).catch(noop);
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
async executeWithTimeout(promise, timeoutMs, ac) {
|
|
1510
|
+
try {
|
|
1511
|
+
await withTimeout(() => promise, timeoutMs);
|
|
1512
|
+
} catch (err) {
|
|
1513
|
+
if (err instanceof TimeoutError) {
|
|
1514
|
+
ac.abort();
|
|
1515
|
+
throw new Error(`Task "${this.id}" timed out after ${timeoutMs}ms`);
|
|
1516
|
+
}
|
|
1517
|
+
throw err;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
computeRetryDelay(strategy, baseMs, attempt) {
|
|
1521
|
+
if (strategy === TaskRetryStrategy.Exponential) return baseMs * 2 ** (attempt - 1);
|
|
1522
|
+
return baseMs;
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
|
|
1526
|
+
//#endregion
|
|
1527
|
+
//#region src/core/task/helpers.ts
|
|
1528
|
+
/** Create a {@link Task} from a config object. Throws if `id` is empty. */
|
|
1529
|
+
function createTask(config) {
|
|
1530
|
+
if (!config.id || config.id.trim().length === 0) throw new Error("createTask: id is required");
|
|
1531
|
+
return new Task(config);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
//#endregion
|
|
1535
|
+
//#region src/policy/types.ts
|
|
1536
|
+
/** Outcome of a policy check. */
|
|
1537
|
+
let PolicyDecision = /* @__PURE__ */ function(PolicyDecision) {
|
|
1538
|
+
PolicyDecision["ALLOWED"] = "ALLOWED";
|
|
1539
|
+
PolicyDecision["ACCESS_DENIED"] = "ACCESS_DENIED";
|
|
1540
|
+
PolicyDecision["WINDOW_NOT_FOUND"] = "WINDOW_NOT_FOUND";
|
|
1541
|
+
return PolicyDecision;
|
|
1542
|
+
}({});
|
|
1543
|
+
|
|
1544
|
+
//#endregion
|
|
1545
|
+
//#region src/policy/engine.ts
|
|
1546
|
+
/**
|
|
1547
|
+
* Deny-by-default policy engine for window–feature access control.
|
|
1548
|
+
*
|
|
1549
|
+
* A window's renderer can only access exposed services of features
|
|
1550
|
+
* listed in its `features: []` config. Everything else is denied.
|
|
1551
|
+
*
|
|
1552
|
+
* Used at:
|
|
1553
|
+
* - **Build time** (codegen): generate preload stubs only for allowed features
|
|
1554
|
+
* - **Runtime** (IPC routing): gate incoming calls from renderer
|
|
1555
|
+
*/
|
|
1556
|
+
var PolicyEngine = class {
|
|
1557
|
+
policies = /* @__PURE__ */ new Map();
|
|
1558
|
+
constructor(windows) {
|
|
1559
|
+
for (const win of windows) this.policies.set(win.name, new Set(win.features ?? []));
|
|
1560
|
+
}
|
|
1561
|
+
/** Full policy check with decision code and context. */
|
|
1562
|
+
check(windowName, featureId) {
|
|
1563
|
+
const allowed = this.policies.get(windowName);
|
|
1564
|
+
if (!allowed) return {
|
|
1565
|
+
decision: PolicyDecision.WINDOW_NOT_FOUND,
|
|
1566
|
+
windowName,
|
|
1567
|
+
featureId
|
|
1568
|
+
};
|
|
1569
|
+
return {
|
|
1570
|
+
decision: allowed.has(featureId) ? PolicyDecision.ALLOWED : PolicyDecision.ACCESS_DENIED,
|
|
1571
|
+
windowName,
|
|
1572
|
+
featureId
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
/** Convenience: returns true only when access is ALLOWED. */
|
|
1576
|
+
canAccess(windowName, featureId) {
|
|
1577
|
+
return this.check(windowName, featureId).decision === PolicyDecision.ALLOWED;
|
|
1578
|
+
}
|
|
1579
|
+
/** Returns the allowed feature IDs for a window. Throws if window unknown. */
|
|
1580
|
+
getAllowedFeatures(windowName) {
|
|
1581
|
+
const allowed = this.policies.get(windowName);
|
|
1582
|
+
if (!allowed) throw new Error(`Window "${windowName}" is not registered in the policy engine`);
|
|
1583
|
+
return [...allowed];
|
|
1584
|
+
}
|
|
1585
|
+
/** Returns all registered window names. */
|
|
1586
|
+
getWindowNames() {
|
|
1587
|
+
return [...this.policies.keys()];
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
|
|
1591
|
+
//#endregion
|
|
1592
|
+
export { EventAccessor, FeatureStatus, PolicyDecision, PolicyEngine, Runtime, RuntimeState, ServiceScope, ServiceStatus, TaskOverlapStrategy, TaskRetryStrategy, TaskStatus, createEvent, createFeature, createRuntime, createService, createTask, defineConfig, defineRuntime, defineWindow };
|