@zenbujs/core 0.0.8 → 0.0.12
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/advice-config-BiYhyeTz.d.mts +41 -0
- package/dist/advice.d.mts +2 -36
- package/dist/advice.mjs +2 -2
- package/dist/{base-window-BxBZ2md_.mjs → base-window-4P-fVvC_.mjs} +37 -26
- package/dist/{build-config-Dzg2frpk.d.mts → build-config-GF0XzR_Y.d.mts} +42 -18
- package/dist/{build-config-pWdmLnrk.mjs → build-config-HMMqpXI1.mjs} +0 -8
- package/dist/{build-electron-Dsbb1EMl.mjs → build-electron-Di_FE62r.mjs} +10 -6
- package/dist/{build-source-d1J3shV8.mjs → build-source-BIaWpaxE.mjs} +2 -2
- package/dist/cli/bin.mjs +7 -7
- package/dist/cli/build.d.mts +1 -1
- package/dist/cli/build.mjs +1 -1
- package/dist/cli/resolve-config.mjs +6 -1
- package/dist/{cli-kL6mPgBE.mjs → cli-5jFDJWM4.mjs} +4 -4
- package/dist/config.d.mts +3 -3
- package/dist/config.mjs +2 -2
- package/dist/{db-Bc292RYo.mjs → db-MkOccvBS.mjs} +2 -2
- package/dist/db.d.mts +3 -2
- package/dist/db.mjs +2 -10
- package/dist/{dev-B2emj0HZ.mjs → dev-BSDyzO4j.mjs} +3 -9
- package/dist/env-bootstrap.d.mts +1 -1
- package/dist/events.d.mts +0 -9
- package/dist/{host-version-BIrF8tX7.mjs → host-version-Cog_odmD.mjs} +4 -3
- package/dist/{index-w5QyDjuf.d.mts → index-C0mXKol5.d.mts} +189 -141
- package/dist/{index-DeDxePAa.d.mts → index-FaexRVl_.d.mts} +13 -1
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +2 -2
- package/dist/launcher.mjs +64 -6
- package/dist/link-Bt3LB_NW.mjs +586 -0
- package/dist/{load-config-C4Oe2qZO.mjs → load-config-C2XloBaQ.mjs} +68 -5
- package/dist/node-loader.mjs +1 -1
- package/dist/{publish-source-Dq2c0iOw.mjs → publish-source-v93eB9kA.mjs} +6 -2
- package/dist/react.d.mts +6 -6
- package/dist/react.mjs +4 -4
- package/dist/registry-generated.d.mts +19 -14
- package/dist/registry-saQDMUhT.d.mts +13 -0
- package/dist/registry.d.mts +1 -1
- package/dist/{reloader-B22UiNA2.mjs → reloader-CFzxYa67.mjs} +3 -3
- package/dist/{renderer-host-DD16MXhI.mjs → renderer-host-Cw38dSDe.mjs} +35 -24
- package/dist/{rpc-C4_NQmpT.mjs → rpc-Dg9zwZ33.mjs} +4 -4
- package/dist/rpc.d.mts +1 -1
- package/dist/rpc.mjs +1 -1
- package/dist/runtime-DYUONc3S.mjs +861 -0
- package/dist/{runtime-BQWntcOb.d.mts → runtime-fnPDZFYM.d.mts} +100 -3
- package/dist/runtime.d.mts +2 -2
- package/dist/runtime.mjs +2 -578
- package/dist/{schema-CjrMVk36.d.mts → schema-brYpUjYO.d.mts} +13 -25
- package/dist/schema.d.mts +2 -2
- package/dist/schema.mjs +9 -2
- package/dist/{server-CZLMF8Dj.mjs → server-BJ2ZC2z2.mjs} +2 -2
- package/dist/services/default.d.mts +1 -5
- package/dist/services/default.mjs +12 -16
- package/dist/services/index.d.mts +1 -1
- package/dist/services/index.mjs +7 -7
- package/dist/setup-gate.d.mts +1 -1
- package/dist/setup-gate.mjs +25 -11
- package/dist/{transport-F2hv_OEm.mjs → transport-Bqlv9pmJ.mjs} +1 -1
- package/dist/updater-Bs1Jtem6.mjs +480 -0
- package/dist/{vite-plugins-tt6KAtyE.mjs → vite-plugins-Df-cfldF.mjs} +2 -49
- package/dist/vite.d.mts +0 -5
- package/dist/vite.mjs +1 -1
- package/dist/{window-YFKvAM0l.mjs → window-DgB70qeZ.mjs} +113 -22
- package/dist/{write-DgIRjo23.mjs → write-7IfKa_nq.mjs} +1 -1
- package/dist/zenbu-bg-parse-CIyPkJOY.mjs +46 -0
- package/package.json +19 -18
- package/LICENSE +0 -11
- package/dist/advice-config-DXSIo0sg.mjs +0 -154
- package/dist/link-glX89NV5.mjs +0 -673
- package/dist/registry-CMp8FYgS.d.mts +0 -47
- package/dist/updater-DCkz9M1c.mjs +0 -1008
- /package/dist/{config-BK78JDRI.mjs → config-DfciRzDu.mjs} +0 -0
- /package/dist/{env-bootstrap-rTs8KR3-.d.mts → env-bootstrap-UBug-4Kw.d.mts} +0 -0
- /package/dist/{index-C-ALz_SH.d.mts → index-CSMHYi3u.d.mts} +0 -0
- /package/dist/{index-ClXLQ1fw.d.mts → index-DJOHDG5e.d.mts} +0 -0
- /package/dist/{log-6rzaCV0I.mjs → log-BkRqDwwB.mjs} +0 -0
- /package/dist/{mirror-sync-pYU6f3-c.mjs → mirror-sync-snqh9kEp.mjs} +0 -0
- /package/dist/{monorepo-Dct-kkbQ.mjs → monorepo-CBzK3l2i.mjs} +0 -0
- /package/dist/{node-BhfLKYCi.mjs → node-BuHlEsE4.mjs} +0 -0
- /package/dist/{schema-Ca7SxXgS.mjs → schema-C6k0SroY.mjs} +0 -0
- /package/dist/{setup-gate-BQq0QgZH.d.mts → setup-gate-DkysEZQO.d.mts} +0 -0
- /package/dist/{src-Cven45mq.mjs → src-BpZAt9zL.mjs} +0 -0
- /package/dist/{trace-BaVg0rnY.mjs → trace-BVcQSD59.mjs} +0 -0
- /package/dist/{transform-BzrwkEdf.mjs → transform-czrcGnVV.mjs} +0 -0
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
import { n as __exportAll } from "./chunk-DsiFFCwN.mjs";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
//#region src/services/advice-config.ts
|
|
6
|
+
var advice_config_exports = /* @__PURE__ */ __exportAll({
|
|
7
|
+
addAdvice: () => addAdvice,
|
|
8
|
+
addContentScript: () => addContentScript,
|
|
9
|
+
getAdvice: () => getAdvice,
|
|
10
|
+
getAllAdviceTypes: () => getAllAdviceTypes,
|
|
11
|
+
getAllContentScriptPaths: () => getAllContentScriptPaths,
|
|
12
|
+
getAllTypes: () => getAllTypes,
|
|
13
|
+
getContentScripts: () => getContentScripts
|
|
14
|
+
});
|
|
15
|
+
const RESOLVED_PREFIX = "\0@advice-prelude/";
|
|
16
|
+
const APP_RENDERER_RELOADER_ID = "app";
|
|
17
|
+
const adviceEntries = /* @__PURE__ */ new Map();
|
|
18
|
+
const contentScripts = /* @__PURE__ */ new Map();
|
|
19
|
+
function resolveAgainstPlugin(modulePath, pluginDir) {
|
|
20
|
+
if (path.isAbsolute(modulePath)) return modulePath;
|
|
21
|
+
return path.resolve(pluginDir, modulePath);
|
|
22
|
+
}
|
|
23
|
+
function invalidatePrelude(type) {
|
|
24
|
+
try {
|
|
25
|
+
const reloader = runtime.getSlot("reloader")?.instance;
|
|
26
|
+
if (!reloader) return;
|
|
27
|
+
const coreEntry = reloader.get(APP_RENDERER_RELOADER_ID);
|
|
28
|
+
if (!coreEntry?.viteServer) return;
|
|
29
|
+
const graph = coreEntry.viteServer.moduleGraph;
|
|
30
|
+
const invalidateMatching = (test) => {
|
|
31
|
+
const ids = [];
|
|
32
|
+
for (const id of graph.idToModuleMap.keys()) {
|
|
33
|
+
if (typeof id !== "string") continue;
|
|
34
|
+
if (test(id)) ids.push(id);
|
|
35
|
+
}
|
|
36
|
+
for (const id of ids) {
|
|
37
|
+
const mod = graph.getModuleById(id);
|
|
38
|
+
if (mod) graph.invalidateModule(mod);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
if (type === "*") invalidateMatching((id) => id.startsWith(RESOLVED_PREFIX));
|
|
42
|
+
else {
|
|
43
|
+
const prefix = RESOLVED_PREFIX + type;
|
|
44
|
+
invalidateMatching((id) => id === prefix || id.startsWith(prefix + "?"));
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
function emitReload(type) {
|
|
49
|
+
invalidatePrelude(type);
|
|
50
|
+
try {
|
|
51
|
+
const rpc = runtime.getSlot("rpc")?.instance;
|
|
52
|
+
if (!rpc) return;
|
|
53
|
+
rpc.emit.core.advice.reload({ type });
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Internal advice registrar. Called by `Service#advise` after the runtime
|
|
58
|
+
* has resolved the calling plugin's root directory from its service slot.
|
|
59
|
+
* User code does not call this directly; use `service.advise({...})`
|
|
60
|
+
* instead.
|
|
61
|
+
*/
|
|
62
|
+
function addAdvice(pluginDir, spec) {
|
|
63
|
+
const { view, ...entry } = spec;
|
|
64
|
+
const resolvedEntry = {
|
|
65
|
+
...entry,
|
|
66
|
+
modulePath: resolveAgainstPlugin(entry.modulePath, pluginDir)
|
|
67
|
+
};
|
|
68
|
+
const list = adviceEntries.get(view) ?? [];
|
|
69
|
+
list.push(resolvedEntry);
|
|
70
|
+
adviceEntries.set(view, list);
|
|
71
|
+
emitReload(view);
|
|
72
|
+
return () => {
|
|
73
|
+
const current = adviceEntries.get(view);
|
|
74
|
+
if (!current) return;
|
|
75
|
+
const idx = current.indexOf(resolvedEntry);
|
|
76
|
+
if (idx >= 0) current.splice(idx, 1);
|
|
77
|
+
if (current.length === 0) adviceEntries.delete(view);
|
|
78
|
+
emitReload(view);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function getAdvice(type) {
|
|
82
|
+
return adviceEntries.get(type) ?? [];
|
|
83
|
+
}
|
|
84
|
+
function getAllAdviceTypes() {
|
|
85
|
+
return [...adviceEntries.keys()];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Internal content-script registrar. Called by `Service#contentScript`
|
|
89
|
+
* after the runtime has resolved the calling plugin's root directory.
|
|
90
|
+
* User code uses `service.contentScript({...})`.
|
|
91
|
+
*/
|
|
92
|
+
function addContentScript(pluginDir, spec) {
|
|
93
|
+
const { view, modulePath } = spec;
|
|
94
|
+
const entry = { path: resolveAgainstPlugin(modulePath, pluginDir) };
|
|
95
|
+
const list = contentScripts.get(view) ?? [];
|
|
96
|
+
list.push(entry);
|
|
97
|
+
contentScripts.set(view, list);
|
|
98
|
+
emitReload(view === "*" ? "*" : view);
|
|
99
|
+
return () => {
|
|
100
|
+
const current = contentScripts.get(view);
|
|
101
|
+
if (!current) return;
|
|
102
|
+
const idx = current.indexOf(entry);
|
|
103
|
+
if (idx >= 0) current.splice(idx, 1);
|
|
104
|
+
if (current.length === 0) contentScripts.delete(view);
|
|
105
|
+
emitReload(view === "*" ? "*" : view);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function getContentScripts(type) {
|
|
109
|
+
const scoped = (contentScripts.get(type) ?? []).map((e) => e.path);
|
|
110
|
+
return [...type !== "*" ? (contentScripts.get("*") ?? []).map((e) => e.path) : [], ...scoped];
|
|
111
|
+
}
|
|
112
|
+
function getAllContentScriptPaths() {
|
|
113
|
+
const paths = [];
|
|
114
|
+
for (const list of contentScripts.values()) for (const entry of list) paths.push(entry.path);
|
|
115
|
+
return paths;
|
|
116
|
+
}
|
|
117
|
+
function getAllTypes() {
|
|
118
|
+
const types = /* @__PURE__ */ new Set();
|
|
119
|
+
for (const k of adviceEntries.keys()) types.add(k);
|
|
120
|
+
for (const k of contentScripts.keys()) if (k !== "*") types.add(k);
|
|
121
|
+
return [...types];
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/runtime.ts
|
|
125
|
+
/**
|
|
126
|
+
* The synthetic plugin name used for everything that ships inside
|
|
127
|
+
* `@zenbujs/core`. Mirrors the `core` DB section so RPC, events, and the
|
|
128
|
+
* database all use the same top-level namespace.
|
|
129
|
+
*/
|
|
130
|
+
const CORE_PLUGIN_NAME = "core";
|
|
131
|
+
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
132
|
+
function readShutdownTimeoutMs() {
|
|
133
|
+
const raw = process.env.ZENBU_SHUTDOWN_TIMEOUT_MS;
|
|
134
|
+
if (!raw) return DEFAULT_SHUTDOWN_TIMEOUT_MS;
|
|
135
|
+
const value = Number(raw);
|
|
136
|
+
return Number.isFinite(value) && value > 0 ? value : DEFAULT_SHUTDOWN_TIMEOUT_MS;
|
|
137
|
+
}
|
|
138
|
+
function resolveDepKey(entry) {
|
|
139
|
+
if (typeof entry === "string") return entry;
|
|
140
|
+
return entry.key;
|
|
141
|
+
}
|
|
142
|
+
var Service = class {
|
|
143
|
+
static deps = {};
|
|
144
|
+
/**
|
|
145
|
+
* Define a Service base class. The returned abstract class has
|
|
146
|
+
* `static key`, `static deps`, and a typed `this.ctx` already set up;
|
|
147
|
+
* extend it and add your `evaluate()` body.
|
|
148
|
+
*
|
|
149
|
+
* export class WindowService extends Service.create({
|
|
150
|
+
* key: "window",
|
|
151
|
+
* deps: {
|
|
152
|
+
* baseWindow: BaseWindowService,
|
|
153
|
+
* http: HttpService,
|
|
154
|
+
* },
|
|
155
|
+
* }) {
|
|
156
|
+
* evaluate() {
|
|
157
|
+
* this.ctx.baseWindow // BaseWindowService
|
|
158
|
+
* this.ctx.http // HttpService
|
|
159
|
+
* }
|
|
160
|
+
* }
|
|
161
|
+
*
|
|
162
|
+
* `key` is a required field on the config object, so TypeScript errors
|
|
163
|
+
* if you forget it. `deps` is optional (defaults to no deps).
|
|
164
|
+
*
|
|
165
|
+
* For dynamic / optional access to another service, use
|
|
166
|
+
* `runtime.get(SomeService, cb)` instead of declaring it in `deps`.
|
|
167
|
+
*/
|
|
168
|
+
static create(config) {
|
|
169
|
+
const { key, deps } = config;
|
|
170
|
+
const resolvedDeps = deps ?? {};
|
|
171
|
+
class ConfiguredService extends Service {
|
|
172
|
+
static key = key;
|
|
173
|
+
static deps = resolvedDeps;
|
|
174
|
+
}
|
|
175
|
+
return ConfiguredService;
|
|
176
|
+
}
|
|
177
|
+
ctx;
|
|
178
|
+
/** @internal */
|
|
179
|
+
__setupCleanups = /* @__PURE__ */ new Map();
|
|
180
|
+
evaluate() {}
|
|
181
|
+
/**
|
|
182
|
+
* Register advice (component wrap/replace, function before/after/around)
|
|
183
|
+
* targeting another plugin's exports. The owning plugin's root directory
|
|
184
|
+
* is resolved automatically from this service's slot, so relative
|
|
185
|
+
* `modulePath` values just work.
|
|
186
|
+
*
|
|
187
|
+
* this.setup("wrap-counter", () =>
|
|
188
|
+
* this.advise({
|
|
189
|
+
* view: "app",
|
|
190
|
+
* moduleId: "App.tsx",
|
|
191
|
+
* name: "Counter",
|
|
192
|
+
* type: "around",
|
|
193
|
+
* modulePath: "src/content/wrap-counter.tsx",
|
|
194
|
+
* exportName: "WrapCounter",
|
|
195
|
+
* }),
|
|
196
|
+
* )
|
|
197
|
+
*
|
|
198
|
+
* Returns an unregister function — wrap the call in `this.setup(...)`
|
|
199
|
+
* so cleanup runs on hot reload.
|
|
200
|
+
*/
|
|
201
|
+
advise(spec) {
|
|
202
|
+
return addAdvice(this.__getPluginDir("advise"), spec);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Inject a content script into a view. Same plugin-root resolution
|
|
206
|
+
* rules as `advise`.
|
|
207
|
+
*
|
|
208
|
+
* this.setup("inject", () =>
|
|
209
|
+
* this.injectContentScript({
|
|
210
|
+
* view: "*",
|
|
211
|
+
* modulePath: "src/content/toolbar.tsx",
|
|
212
|
+
* }),
|
|
213
|
+
* )
|
|
214
|
+
*/
|
|
215
|
+
injectContentScript(spec) {
|
|
216
|
+
return addContentScript(this.__getPluginDir("injectContentScript"), spec);
|
|
217
|
+
}
|
|
218
|
+
/** @internal */
|
|
219
|
+
__getPluginDir(method) {
|
|
220
|
+
const ctor = this.constructor;
|
|
221
|
+
const key = ctor.key;
|
|
222
|
+
const dir = (key ? runtime.getSlot(key) : void 0)?.pluginDir ?? null;
|
|
223
|
+
if (!dir) {
|
|
224
|
+
const label = ctor.name ?? key ?? "<anonymous>";
|
|
225
|
+
throw new Error(`[runtime] Service "${label}" called this.${method}() but its slot has no pluginDir. This service was registered from a file outside any plugin's directory; ${method}() is only valid for services declared inside a plugin.`);
|
|
226
|
+
}
|
|
227
|
+
return dir;
|
|
228
|
+
}
|
|
229
|
+
setup(key, fn) {
|
|
230
|
+
const existing = this.__setupCleanups.get(key);
|
|
231
|
+
if (existing) try {
|
|
232
|
+
existing("reload");
|
|
233
|
+
} catch (e) {
|
|
234
|
+
console.error(`[hot] setup cleanup "${key}" failed:`, e);
|
|
235
|
+
}
|
|
236
|
+
const cleanup = fn();
|
|
237
|
+
if (cleanup) this.__setupCleanups.set(key, cleanup);
|
|
238
|
+
else this.__setupCleanups.delete(key);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Run `fn` and return its result. Historically reported a boot-trace span;
|
|
242
|
+
* now a thin wrapper preserved for caller ergonomics inside service
|
|
243
|
+
* `evaluate()` bodies.
|
|
244
|
+
*/
|
|
245
|
+
trace(_name, fn, _meta) {
|
|
246
|
+
return Promise.resolve(fn());
|
|
247
|
+
}
|
|
248
|
+
traceSync(_name, fn, _meta) {
|
|
249
|
+
return fn();
|
|
250
|
+
}
|
|
251
|
+
/** @internal */
|
|
252
|
+
async __cleanupAllSetups(reason = "shutdown") {
|
|
253
|
+
for (const [key, cleanup] of this.__setupCleanups) try {
|
|
254
|
+
await cleanup(reason);
|
|
255
|
+
} catch (e) {
|
|
256
|
+
console.error(`[hot] setup cleanup "${key}" failed:`, e);
|
|
257
|
+
}
|
|
258
|
+
this.__setupCleanups.clear();
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const SERVICE_BASE_METHODS = new Set(Object.getOwnPropertyNames(Service.prototype));
|
|
262
|
+
var ServiceRuntime = class {
|
|
263
|
+
definitions = /* @__PURE__ */ new Map();
|
|
264
|
+
dependentsIndex = /* @__PURE__ */ new Map();
|
|
265
|
+
dirtyKeys = /* @__PURE__ */ new Set();
|
|
266
|
+
drainError = null;
|
|
267
|
+
draining = null;
|
|
268
|
+
registrationTokens = /* @__PURE__ */ new Map();
|
|
269
|
+
slots = /* @__PURE__ */ new Map();
|
|
270
|
+
onReconciledCallbacks = [];
|
|
271
|
+
subscribers = /* @__PURE__ */ new Map();
|
|
272
|
+
register(ServiceClass, importMeta) {
|
|
273
|
+
const hot = importMeta?.hot ?? null;
|
|
274
|
+
const slotKey = ServiceClass.key;
|
|
275
|
+
if (typeof slotKey !== "string" || slotKey.length === 0) {
|
|
276
|
+
const name = ServiceClass.name ?? "<anonymous>";
|
|
277
|
+
throw new Error(`[runtime] service "${name}" is missing \`static key\`. Define it via \`Service.create({ key: "...", ... })\`.`);
|
|
278
|
+
}
|
|
279
|
+
const metaUrl = importMeta?.url;
|
|
280
|
+
const pluginDir = metaUrl ? findOwningPluginDir(metaUrl) : null;
|
|
281
|
+
this.definitions.set(slotKey, ServiceClass);
|
|
282
|
+
const slot = this.slots.get(slotKey);
|
|
283
|
+
if (slot) {
|
|
284
|
+
slot.ServiceClass = ServiceClass;
|
|
285
|
+
slot.pluginDir = pluginDir;
|
|
286
|
+
} else this.slots.set(slotKey, {
|
|
287
|
+
error: null,
|
|
288
|
+
instance: null,
|
|
289
|
+
ServiceClass,
|
|
290
|
+
status: "blocked",
|
|
291
|
+
pluginDir
|
|
292
|
+
});
|
|
293
|
+
this.rebuildDependentsIndex();
|
|
294
|
+
hot?.accept();
|
|
295
|
+
const token = Symbol(slotKey);
|
|
296
|
+
this.registrationTokens.set(slotKey, token);
|
|
297
|
+
hot?.prune?.(() => {
|
|
298
|
+
this.unregister(slotKey, token);
|
|
299
|
+
});
|
|
300
|
+
this.scheduleReconcile([slotKey]);
|
|
301
|
+
}
|
|
302
|
+
getAllKeys() {
|
|
303
|
+
return [...this.slots.keys()];
|
|
304
|
+
}
|
|
305
|
+
getSlot(key) {
|
|
306
|
+
return this.slots.get(key);
|
|
307
|
+
}
|
|
308
|
+
async whenIdle() {
|
|
309
|
+
while (this.draining) await this.draining;
|
|
310
|
+
if (this.drainError) {
|
|
311
|
+
const error = this.drainError;
|
|
312
|
+
this.drainError = null;
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async reloadAll() {
|
|
317
|
+
const keys = [...this.slots.keys()];
|
|
318
|
+
if (keys.length === 0) return;
|
|
319
|
+
await this.scheduleReconcile(keys);
|
|
320
|
+
await this.whenIdle();
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Reload a single service by key. No-op if the key is not registered.
|
|
324
|
+
* Used by infrastructure that watches resources outside dynohot's
|
|
325
|
+
* import-graph (e.g. the migrations directory watcher in DbService) and
|
|
326
|
+
* needs to nudge a specific service to re-evaluate without leaning on
|
|
327
|
+
* the devtools-only `__zenbu_dev__.reloadService` hook.
|
|
328
|
+
*/
|
|
329
|
+
async reload(key) {
|
|
330
|
+
if (!this.slots.has(key)) return;
|
|
331
|
+
await this.scheduleReconcile([key]);
|
|
332
|
+
await this.whenIdle();
|
|
333
|
+
}
|
|
334
|
+
async shutdown() {
|
|
335
|
+
try {
|
|
336
|
+
await this.whenIdle();
|
|
337
|
+
} catch (error) {
|
|
338
|
+
console.error("[hot] runtime idle wait failed during shutdown:", error);
|
|
339
|
+
}
|
|
340
|
+
const keys = [...this.slots.keys()].reverse();
|
|
341
|
+
for (const key of keys) await this.teardownService(key, { removeSlot: true });
|
|
342
|
+
this.definitions.clear();
|
|
343
|
+
this.dependentsIndex.clear();
|
|
344
|
+
this.dirtyKeys.clear();
|
|
345
|
+
this.registrationTokens.clear();
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Subscribe to a service. Behavior-subject style: the callback fires
|
|
349
|
+
* synchronously once with the current value (the live instance if ready,
|
|
350
|
+
* `undefined` otherwise), then again on every reconcile of that service —
|
|
351
|
+
* so you see the new instance after each HMR. Pass through `undefined`
|
|
352
|
+
* when the service tears down or unregisters.
|
|
353
|
+
*
|
|
354
|
+
* Returns an unsubscribe function. Always call it from a `setup()` cleanup
|
|
355
|
+
* (or wherever you'd otherwise leak callbacks across reloads).
|
|
356
|
+
*/
|
|
357
|
+
get(ref, cb) {
|
|
358
|
+
const key = ref.key;
|
|
359
|
+
let subs = this.subscribers.get(key);
|
|
360
|
+
if (!subs) {
|
|
361
|
+
subs = /* @__PURE__ */ new Set();
|
|
362
|
+
this.subscribers.set(key, subs);
|
|
363
|
+
}
|
|
364
|
+
const wrapped = cb;
|
|
365
|
+
subs.add(wrapped);
|
|
366
|
+
const slot = this.slots.get(key);
|
|
367
|
+
const current = slot?.status === "ready" && slot.instance ? slot.instance : void 0;
|
|
368
|
+
try {
|
|
369
|
+
cb(current);
|
|
370
|
+
} catch (e) {
|
|
371
|
+
console.error(`[hot] runtime.get subscriber for "${key}" threw:`, e);
|
|
372
|
+
}
|
|
373
|
+
return () => {
|
|
374
|
+
const set = this.subscribers.get(key);
|
|
375
|
+
if (!set) return;
|
|
376
|
+
set.delete(wrapped);
|
|
377
|
+
if (set.size === 0) this.subscribers.delete(key);
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
fireSubscribers(key) {
|
|
381
|
+
const subs = this.subscribers.get(key);
|
|
382
|
+
if (!subs || subs.size === 0) return;
|
|
383
|
+
const slot = this.slots.get(key);
|
|
384
|
+
const instance = slot?.status === "ready" && slot.instance ? slot.instance : void 0;
|
|
385
|
+
for (const cb of [...subs]) try {
|
|
386
|
+
cb(instance);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
console.error(`[hot] runtime.get subscriber for "${key}" threw:`, e);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Build a 3-level router keyed by plugin name → service key → method.
|
|
393
|
+
* Mirrors how the database is sectioned (`root.<plugin>.<field>`) so a
|
|
394
|
+
* service's RPC surface lives under its owning plugin's namespace
|
|
395
|
+
* instead of polluting the top level. Core services bucket under
|
|
396
|
+
* `"core"` because the core package is registered as a synthetic
|
|
397
|
+
* plugin (see `getPluginRegistry`).
|
|
398
|
+
*/
|
|
399
|
+
buildRouter() {
|
|
400
|
+
const router = {};
|
|
401
|
+
for (const [slotKey, slot] of this.slots) {
|
|
402
|
+
if (slot.status !== "ready" || !slot.instance) continue;
|
|
403
|
+
const proto = Object.getPrototypeOf(slot.instance);
|
|
404
|
+
const methods = {};
|
|
405
|
+
for (const name of Object.getOwnPropertyNames(proto)) {
|
|
406
|
+
if (SERVICE_BASE_METHODS.has(name)) continue;
|
|
407
|
+
if (name.startsWith("_")) continue;
|
|
408
|
+
const desc = Object.getOwnPropertyDescriptor(proto, name);
|
|
409
|
+
if (!desc || typeof desc.value !== "function") continue;
|
|
410
|
+
const instance = slot.instance;
|
|
411
|
+
methods[name] = (...args) => instance[name](...args);
|
|
412
|
+
}
|
|
413
|
+
if (Object.keys(methods).length === 0) continue;
|
|
414
|
+
const pluginName = pluginNameForDir(slot.pluginDir);
|
|
415
|
+
const bucket = router[pluginName] ??= {};
|
|
416
|
+
bucket[slotKey] = methods;
|
|
417
|
+
}
|
|
418
|
+
return router;
|
|
419
|
+
}
|
|
420
|
+
onReconciled(cb) {
|
|
421
|
+
this.onReconciledCallbacks.push(cb);
|
|
422
|
+
return () => {
|
|
423
|
+
const idx = this.onReconciledCallbacks.indexOf(cb);
|
|
424
|
+
if (idx >= 0) this.onReconciledCallbacks.splice(idx, 1);
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
resolveDepSlot(depKey) {
|
|
428
|
+
return this.slots.get(depKey);
|
|
429
|
+
}
|
|
430
|
+
injectCtx(instance, ServiceClass) {
|
|
431
|
+
const deps = ServiceClass.deps ?? {};
|
|
432
|
+
const ctx = {};
|
|
433
|
+
for (const [name, entry] of Object.entries(deps)) {
|
|
434
|
+
const key = resolveDepKey(entry);
|
|
435
|
+
const slot = this.resolveDepSlot(key);
|
|
436
|
+
if (!slot || slot.status !== "ready" || !slot.instance) throw new Error(`Dependency "${key}" not ready for "${ServiceClass.key}"`);
|
|
437
|
+
ctx[name] = slot.instance;
|
|
438
|
+
}
|
|
439
|
+
instance.ctx = ctx;
|
|
440
|
+
}
|
|
441
|
+
rebuildDependentsIndex() {
|
|
442
|
+
const next = /* @__PURE__ */ new Map();
|
|
443
|
+
for (const [slotKey, ServiceClass] of this.definitions) {
|
|
444
|
+
const deps = ServiceClass.deps ?? {};
|
|
445
|
+
for (const entry of Object.values(deps)) {
|
|
446
|
+
const depKey = resolveDepKey(entry);
|
|
447
|
+
const dependents = next.get(depKey) ?? /* @__PURE__ */ new Set();
|
|
448
|
+
dependents.add(slotKey);
|
|
449
|
+
next.set(depKey, dependents);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
this.dependentsIndex = next;
|
|
453
|
+
}
|
|
454
|
+
getAffectedKeys(changedKeys) {
|
|
455
|
+
const affected = /* @__PURE__ */ new Set();
|
|
456
|
+
const visit = (key) => {
|
|
457
|
+
if (affected.has(key)) return;
|
|
458
|
+
affected.add(key);
|
|
459
|
+
for (const dependent of this.dependentsIndex.get(key) ?? []) visit(dependent);
|
|
460
|
+
};
|
|
461
|
+
for (const key of changedKeys) visit(key);
|
|
462
|
+
return [...affected].filter((key) => this.definitions.has(key));
|
|
463
|
+
}
|
|
464
|
+
listMissingDeps(ServiceClass) {
|
|
465
|
+
const deps = ServiceClass.deps ?? {};
|
|
466
|
+
const missing = [];
|
|
467
|
+
for (const entry of Object.values(deps)) {
|
|
468
|
+
const key = resolveDepKey(entry);
|
|
469
|
+
const slot = this.resolveDepSlot(key);
|
|
470
|
+
if (!slot || slot.status !== "ready" || !slot.instance) missing.push(key);
|
|
471
|
+
}
|
|
472
|
+
return missing;
|
|
473
|
+
}
|
|
474
|
+
ensureSlot(key, ServiceClass) {
|
|
475
|
+
const existing = this.slots.get(key);
|
|
476
|
+
if (existing) return existing;
|
|
477
|
+
const slot = {
|
|
478
|
+
error: null,
|
|
479
|
+
instance: null,
|
|
480
|
+
ServiceClass,
|
|
481
|
+
status: "blocked",
|
|
482
|
+
pluginDir: null
|
|
483
|
+
};
|
|
484
|
+
this.slots.set(key, slot);
|
|
485
|
+
return slot;
|
|
486
|
+
}
|
|
487
|
+
async teardownService(key, options = {}) {
|
|
488
|
+
const slot = this.slots.get(key);
|
|
489
|
+
if (!slot) return;
|
|
490
|
+
const instance = slot.instance;
|
|
491
|
+
slot.instance = null;
|
|
492
|
+
slot.status = "blocked";
|
|
493
|
+
slot.error = null;
|
|
494
|
+
if (instance) {
|
|
495
|
+
const reason = options.reason ?? "shutdown";
|
|
496
|
+
const timeoutMs = readShutdownTimeoutMs();
|
|
497
|
+
const cleanup = instance.__cleanupAllSetups(reason);
|
|
498
|
+
let timer = null;
|
|
499
|
+
const timeout = new Promise((resolve) => {
|
|
500
|
+
timer = setTimeout(() => {
|
|
501
|
+
console.error(`[hot] ${key} ${reason} cleanup timed out after ${timeoutMs}ms; forcing teardown`);
|
|
502
|
+
resolve();
|
|
503
|
+
}, timeoutMs);
|
|
504
|
+
timer.unref?.();
|
|
505
|
+
});
|
|
506
|
+
try {
|
|
507
|
+
await Promise.race([cleanup, timeout]);
|
|
508
|
+
} finally {
|
|
509
|
+
if (timer) clearTimeout(timer);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (options.removeSlot) this.slots.delete(key);
|
|
513
|
+
}
|
|
514
|
+
async unregister(key, token) {
|
|
515
|
+
if (this.registrationTokens.get(key) !== token) return;
|
|
516
|
+
this.registrationTokens.delete(key);
|
|
517
|
+
this.definitions.delete(key);
|
|
518
|
+
await this.teardownService(key, { removeSlot: true });
|
|
519
|
+
this.rebuildDependentsIndex();
|
|
520
|
+
this.fireSubscribers(key);
|
|
521
|
+
await this.scheduleReconcile([key]);
|
|
522
|
+
}
|
|
523
|
+
async scheduleReconcile(keys) {
|
|
524
|
+
for (const key of keys) this.dirtyKeys.add(key);
|
|
525
|
+
if (this.draining) return this.draining;
|
|
526
|
+
this.draining = (async () => {
|
|
527
|
+
try {
|
|
528
|
+
while (this.dirtyKeys.size > 0) {
|
|
529
|
+
const batch = [...this.dirtyKeys];
|
|
530
|
+
this.dirtyKeys.clear();
|
|
531
|
+
await this.reconcileBatch(batch);
|
|
532
|
+
}
|
|
533
|
+
} catch (error) {
|
|
534
|
+
this.drainError = error;
|
|
535
|
+
console.error("[hot] runtime reconcile failed:", error);
|
|
536
|
+
} finally {
|
|
537
|
+
this.draining = null;
|
|
538
|
+
if (this.dirtyKeys.size > 0) this.scheduleReconcile([]);
|
|
539
|
+
}
|
|
540
|
+
})();
|
|
541
|
+
return this.draining;
|
|
542
|
+
}
|
|
543
|
+
async reconcileBatch(changedKeys) {
|
|
544
|
+
const affectedKeys = this.getAffectedKeys(changedKeys);
|
|
545
|
+
if (affectedKeys.length === 0) return;
|
|
546
|
+
const affected = /* @__PURE__ */ new Map();
|
|
547
|
+
for (const key of affectedKeys) {
|
|
548
|
+
const ServiceClass = this.definitions.get(key);
|
|
549
|
+
if (ServiceClass) affected.set(key, ServiceClass);
|
|
550
|
+
}
|
|
551
|
+
const levels = this.topologicalLevels(affected);
|
|
552
|
+
const wasReady = /* @__PURE__ */ new Set();
|
|
553
|
+
for (const key of affectedKeys) if (this.slots.get(key)?.status === "ready") wasReady.add(key);
|
|
554
|
+
for (const level of [...levels].reverse()) await Promise.all(level.map((key) => this.teardownService(key, { reason: "reload" })));
|
|
555
|
+
for (const level of levels) await Promise.all(level.map((key) => this.reconcileKey(key, wasReady)));
|
|
556
|
+
for (const key of affectedKeys) this.fireSubscribers(key);
|
|
557
|
+
if (affectedKeys.length > 0) for (const cb of this.onReconciledCallbacks) try {
|
|
558
|
+
cb(affectedKeys);
|
|
559
|
+
} catch (e) {
|
|
560
|
+
console.error("[hot] onReconciled callback failed:", e);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async reconcileKey(key, wasReady) {
|
|
564
|
+
const ServiceClass = this.definitions.get(key);
|
|
565
|
+
if (!ServiceClass) return;
|
|
566
|
+
const slot = this.ensureSlot(key, ServiceClass);
|
|
567
|
+
slot.ServiceClass = ServiceClass;
|
|
568
|
+
const missingDeps = this.listMissingDeps(ServiceClass);
|
|
569
|
+
if (missingDeps.length > 0) {
|
|
570
|
+
await this.teardownService(key, { reason: "reload" });
|
|
571
|
+
if (wasReady.has(key)) console.log(`[hot] ${key} waiting on: ${missingDeps.join(", ")}`);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const instance = new ServiceClass();
|
|
575
|
+
slot.instance = instance;
|
|
576
|
+
slot.status = "evaluating";
|
|
577
|
+
slot.error = null;
|
|
578
|
+
try {
|
|
579
|
+
this.injectCtx(instance, ServiceClass);
|
|
580
|
+
await instance.evaluate();
|
|
581
|
+
slot.status = "ready";
|
|
582
|
+
} catch (e) {
|
|
583
|
+
slot.status = "failed";
|
|
584
|
+
slot.error = e;
|
|
585
|
+
console.error(`[hot] ${key} failed to evaluate:`, e);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
topologicalLevels(services) {
|
|
589
|
+
const keys = new Set(services.keys());
|
|
590
|
+
const inDegree = /* @__PURE__ */ new Map();
|
|
591
|
+
const dependents = /* @__PURE__ */ new Map();
|
|
592
|
+
for (const key of keys) {
|
|
593
|
+
inDegree.set(key, 0);
|
|
594
|
+
dependents.set(key, []);
|
|
595
|
+
}
|
|
596
|
+
for (const [slotKey, ServiceClass] of services) {
|
|
597
|
+
const deps = ServiceClass.deps ?? {};
|
|
598
|
+
let degree = 0;
|
|
599
|
+
for (const entry of Object.values(deps)) {
|
|
600
|
+
const depKey = resolveDepKey(entry);
|
|
601
|
+
if (keys.has(depKey)) {
|
|
602
|
+
degree++;
|
|
603
|
+
dependents.get(depKey).push(slotKey);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
inDegree.set(slotKey, degree);
|
|
607
|
+
}
|
|
608
|
+
const levels = [];
|
|
609
|
+
let queue = [...keys].filter((k) => inDegree.get(k) === 0);
|
|
610
|
+
while (queue.length > 0) {
|
|
611
|
+
levels.push(queue);
|
|
612
|
+
const next = [];
|
|
613
|
+
for (const key of queue) for (const dep of dependents.get(key)) {
|
|
614
|
+
const d = inDegree.get(dep) - 1;
|
|
615
|
+
inDegree.set(dep, d);
|
|
616
|
+
if (d === 0) next.push(dep);
|
|
617
|
+
}
|
|
618
|
+
queue = next;
|
|
619
|
+
}
|
|
620
|
+
const resolved = levels.flat();
|
|
621
|
+
if (resolved.length !== keys.size) {
|
|
622
|
+
const missing = [...keys].filter((k) => !resolved.includes(k));
|
|
623
|
+
throw new Error(`Circular dependency detected involving: ${missing.join(", ")}`);
|
|
624
|
+
}
|
|
625
|
+
return levels;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
/**
|
|
629
|
+
* fixme: i don't think these commments make sense
|
|
630
|
+
*/
|
|
631
|
+
/**
|
|
632
|
+
* Devtools-only handles, attached to the global so they aren't part of the
|
|
633
|
+
* public `ServiceRuntime` autocomplete surface. Same shape React DevTools
|
|
634
|
+
* uses (`__REACT_DEVTOOLS_GLOBAL_HOOK__`): user code reading
|
|
635
|
+
* `runtime.<...>` never lands on these methods; you only find them if you
|
|
636
|
+
* specifically type `globalThis.__zenbu_dev__`. Kept here (next to the
|
|
637
|
+
* runtime that owns the state) because the hook reaches into private
|
|
638
|
+
* implementation details — `scheduleReconcile` stays `private` on the
|
|
639
|
+
* class; the cast lives inside the encapsulation boundary.
|
|
640
|
+
*/
|
|
641
|
+
function installDevHook(rt) {
|
|
642
|
+
const internals = rt;
|
|
643
|
+
globalThis.__zenbu_dev__ = { reloadService: async (key) => {
|
|
644
|
+
if (!internals.definitions.has(key)) throw new Error(`No service registered for key "${key}"`);
|
|
645
|
+
await internals.scheduleReconcile([key]);
|
|
646
|
+
await rt.whenIdle();
|
|
647
|
+
} };
|
|
648
|
+
}
|
|
649
|
+
const runtime = (() => {
|
|
650
|
+
const existing = globalThis.__zenbu_service_runtime__;
|
|
651
|
+
if (existing) {
|
|
652
|
+
Object.setPrototypeOf(existing, ServiceRuntime.prototype);
|
|
653
|
+
installDevHook(existing);
|
|
654
|
+
return existing;
|
|
655
|
+
}
|
|
656
|
+
const fresh = new ServiceRuntime();
|
|
657
|
+
globalThis.__zenbu_service_runtime__ = fresh;
|
|
658
|
+
installDevHook(fresh);
|
|
659
|
+
return fresh;
|
|
660
|
+
})();
|
|
661
|
+
function getPluginRegistry() {
|
|
662
|
+
const slot = globalThis;
|
|
663
|
+
if (!slot.__zenbu_plugin_registry__) {
|
|
664
|
+
slot.__zenbu_plugin_registry__ = {
|
|
665
|
+
plugins: /* @__PURE__ */ new Map(),
|
|
666
|
+
appEntrypoint: null,
|
|
667
|
+
splashPath: null,
|
|
668
|
+
subscribers: /* @__PURE__ */ new Set()
|
|
669
|
+
};
|
|
670
|
+
seedCorePlugin(slot.__zenbu_plugin_registry__);
|
|
671
|
+
} else if (!slot.__zenbu_plugin_registry__.subscribers) slot.__zenbu_plugin_registry__.subscribers = /* @__PURE__ */ new Set();
|
|
672
|
+
return slot.__zenbu_plugin_registry__;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Walk up from this module's location until we hit the `@zenbujs/core`
|
|
676
|
+
* package.json. Cached at first call. Same trick as `findCorePackageRoot`
|
|
677
|
+
* in `services/db.ts`; consolidated here so the runtime can register
|
|
678
|
+
* itself without round-tripping through a service file.
|
|
679
|
+
*/
|
|
680
|
+
let cachedCorePluginDir = null;
|
|
681
|
+
function getCorePluginDir() {
|
|
682
|
+
if (cachedCorePluginDir) return cachedCorePluginDir;
|
|
683
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
684
|
+
let dir = here;
|
|
685
|
+
while (dir !== path.dirname(dir)) {
|
|
686
|
+
const pkgPath = path.join(dir, "package.json");
|
|
687
|
+
try {
|
|
688
|
+
if (JSON.parse(fs.readFileSync(pkgPath, "utf8"))?.name === "@zenbujs/core") {
|
|
689
|
+
cachedCorePluginDir = dir;
|
|
690
|
+
return dir;
|
|
691
|
+
}
|
|
692
|
+
} catch {}
|
|
693
|
+
dir = path.dirname(dir);
|
|
694
|
+
}
|
|
695
|
+
cachedCorePluginDir = path.resolve(here, "..");
|
|
696
|
+
return cachedCorePluginDir;
|
|
697
|
+
}
|
|
698
|
+
function seedCorePlugin(reg) {
|
|
699
|
+
if (reg.plugins.has("core")) return;
|
|
700
|
+
reg.plugins.set(CORE_PLUGIN_NAME, {
|
|
701
|
+
name: CORE_PLUGIN_NAME,
|
|
702
|
+
dir: getCorePluginDir(),
|
|
703
|
+
services: []
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Resolve the plugin namespace that owns a given service slot's
|
|
708
|
+
* `pluginDir`. Core services (and anything not declared inside a plugin)
|
|
709
|
+
* fall back to `"core"`. Used by `buildRouter` to nest the runtime
|
|
710
|
+
* router by plugin name.
|
|
711
|
+
*/
|
|
712
|
+
function pluginNameForDir(dir) {
|
|
713
|
+
if (!dir) return CORE_PLUGIN_NAME;
|
|
714
|
+
for (const plugin of getPluginRegistry().plugins.values()) if (plugin.dir === dir) return plugin.name;
|
|
715
|
+
return CORE_PLUGIN_NAME;
|
|
716
|
+
}
|
|
717
|
+
function snapshotConfig(reg) {
|
|
718
|
+
return {
|
|
719
|
+
plugins: [...reg.plugins.values()],
|
|
720
|
+
appEntrypoint: reg.appEntrypoint,
|
|
721
|
+
splashPath: reg.splashPath
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
function notifySubscribers(reg) {
|
|
725
|
+
if (reg.subscribers.size === 0) return;
|
|
726
|
+
const snapshot = snapshotConfig(reg);
|
|
727
|
+
for (const cb of reg.subscribers) try {
|
|
728
|
+
cb(snapshot);
|
|
729
|
+
} catch (err) {
|
|
730
|
+
console.error("[zenbu config subscriber] threw:", err);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Register a plugin's resolved manifest. Idempotent — replaces any existing
|
|
735
|
+
* entry for the same `name`. Called by the loader-emitted barrel; user code
|
|
736
|
+
* normally does not call this directly.
|
|
737
|
+
*/
|
|
738
|
+
function registerPlugin(record) {
|
|
739
|
+
const reg = getPluginRegistry();
|
|
740
|
+
if (record.name === "core") {
|
|
741
|
+
console.warn(`[zenbu] plugin name "${CORE_PLUGIN_NAME}" is reserved for the core package; ignoring registerPlugin`);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
reg.plugins.set(record.name, record);
|
|
745
|
+
notifySubscribers(reg);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Drop a plugin from the registry. Used when the loader regenerates the
|
|
749
|
+
* barrel and needs to clear stale entries.
|
|
750
|
+
*/
|
|
751
|
+
function unregisterPlugin(name) {
|
|
752
|
+
const reg = getPluginRegistry();
|
|
753
|
+
if (!reg.plugins.delete(name)) return;
|
|
754
|
+
notifySubscribers(reg);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Replace the entire plugin set in one shot. The loader uses this on every
|
|
758
|
+
* barrel regeneration so removed plugins disappear cleanly. The synthetic
|
|
759
|
+
* `core` plugin is always reseeded after the clear so user records cannot
|
|
760
|
+
* accidentally evict it (and a user record named `"core"` is rejected so
|
|
761
|
+
* the namespace stays unambiguous).
|
|
762
|
+
*/
|
|
763
|
+
function replacePlugins(records) {
|
|
764
|
+
const reg = getPluginRegistry();
|
|
765
|
+
reg.plugins.clear();
|
|
766
|
+
seedCorePlugin(reg);
|
|
767
|
+
for (const record of records) {
|
|
768
|
+
if (record.name === "core") {
|
|
769
|
+
console.warn(`[zenbu] plugin name "${CORE_PLUGIN_NAME}" is reserved for the core package; ignoring user plugin record`);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
reg.plugins.set(record.name, record);
|
|
773
|
+
}
|
|
774
|
+
notifySubscribers(reg);
|
|
775
|
+
}
|
|
776
|
+
function getPlugins() {
|
|
777
|
+
return [...getPluginRegistry().plugins.values()];
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Find the plugin whose `dir` contains the file at `metaUrl`. The plugin
|
|
781
|
+
* registry (populated by the loader-emitted barrel before any service
|
|
782
|
+
* file imports) is the source of truth — no filesystem walking. Returns
|
|
783
|
+
* the plugin's absolute directory, or `null` if the file lives outside
|
|
784
|
+
* every registered plugin (the framework's own `@zenbujs/core/services/*`
|
|
785
|
+
* files take this branch and end up with `slot.pluginDir = null`).
|
|
786
|
+
*
|
|
787
|
+
* Picks the deepest matching plugin so a nested workspace plugin wins
|
|
788
|
+
* over its parent.
|
|
789
|
+
*/
|
|
790
|
+
function findOwningPluginDir(metaUrl) {
|
|
791
|
+
let file;
|
|
792
|
+
try {
|
|
793
|
+
file = fileURLToPath(metaUrl);
|
|
794
|
+
} catch {
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
let bestMatch = null;
|
|
798
|
+
for (const plugin of getPluginRegistry().plugins.values()) {
|
|
799
|
+
const rel = path.relative(plugin.dir, file);
|
|
800
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) continue;
|
|
801
|
+
const depth = plugin.dir.split(path.sep).length;
|
|
802
|
+
if (!bestMatch || depth > bestMatch.depth) bestMatch = {
|
|
803
|
+
dir: plugin.dir,
|
|
804
|
+
depth
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
return bestMatch?.dir ?? null;
|
|
808
|
+
}
|
|
809
|
+
function getPlugin(name) {
|
|
810
|
+
return getPluginRegistry().plugins.get(name);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Set the renderer entrypoint directory + the absolute path to splash.html
|
|
814
|
+
* inside it. Called once by the loader-emitted barrel. Consumers
|
|
815
|
+
* (`view-registry`, `vite-plugins`, `setup-gate`'s splash window) read
|
|
816
|
+
* via `getAppEntrypoint()` / `getSplashPath()`.
|
|
817
|
+
*/
|
|
818
|
+
function registerAppEntrypoint(rendererDir, splashPath) {
|
|
819
|
+
const reg = getPluginRegistry();
|
|
820
|
+
reg.appEntrypoint = rendererDir;
|
|
821
|
+
reg.splashPath = splashPath;
|
|
822
|
+
notifySubscribers(reg);
|
|
823
|
+
}
|
|
824
|
+
function getAppEntrypoint() {
|
|
825
|
+
return getPluginRegistry().appEntrypoint;
|
|
826
|
+
}
|
|
827
|
+
function getSplashPath() {
|
|
828
|
+
return getPluginRegistry().splashPath;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Snapshot of the current resolved config — plugins + entrypoints. Cheap;
|
|
832
|
+
* just walks the in-memory registry. The returned object is a fresh copy,
|
|
833
|
+
* safe to retain or pass through serialization boundaries.
|
|
834
|
+
*/
|
|
835
|
+
function getConfig() {
|
|
836
|
+
return snapshotConfig(getPluginRegistry());
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Subscribe to config changes. The callback fires:
|
|
840
|
+
* - immediately on subscription (with the current snapshot),
|
|
841
|
+
* - after each `replacePlugins(...)` / `registerPlugin(...)` /
|
|
842
|
+
* `registerAppEntrypoint(...)` call — i.e. every time the loader
|
|
843
|
+
* regenerates the plugin barrel after a `zenbu.config.ts` edit.
|
|
844
|
+
*
|
|
845
|
+
* Callback exceptions are logged and swallowed so one buggy subscriber
|
|
846
|
+
* can't break others. The returned function unsubscribes.
|
|
847
|
+
*/
|
|
848
|
+
function subscribeConfig(callback) {
|
|
849
|
+
const reg = getPluginRegistry();
|
|
850
|
+
reg.subscribers.add(callback);
|
|
851
|
+
try {
|
|
852
|
+
callback(snapshotConfig(reg));
|
|
853
|
+
} catch (err) {
|
|
854
|
+
console.error("[zenbu config subscriber] initial fire threw:", err);
|
|
855
|
+
}
|
|
856
|
+
return () => {
|
|
857
|
+
reg.subscribers.delete(callback);
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
//#endregion
|
|
861
|
+
export { getAllAdviceTypes as _, getConfig as a, getContentScripts as b, getSplashPath as c, replacePlugins as d, runtime as f, getAdvice as g, advice_config_exports as h, getAppEntrypoint as i, registerAppEntrypoint as l, unregisterPlugin as m, Service as n, getPlugin as o, subscribeConfig as p, ServiceRuntime as r, getPlugins as s, CORE_PLUGIN_NAME as t, registerPlugin as u, getAllContentScriptPaths as v, getAllTypes as y };
|