webspresso 0.0.110 → 0.0.112

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.
@@ -0,0 +1,588 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso Client Services Registry & Lifecycle Manager (`core/client/services.js`)
5
+ *
6
+ * Provides runtime-scoped, lazily-initialized, strongly-typed shared client services
7
+ * with optional lazy UI host mounting.
8
+ *
9
+ * Principles:
10
+ * - Agnostic core: Core knows nothing about specific application services (toast, modal, etc.).
11
+ * - Explicit lazy resolution: `const svc = await services.get('name')` — no magic Proxy
12
+ * hiding sync/async boundaries.
13
+ * - Runtime-scoped isolation: Shared across controllers/forms within the same runtime;
14
+ * isolated across separate runtimes and SSR requests.
15
+ * - Single-flight initialization: Concurrent `services.get('name')` calls share one promise.
16
+ * - Failure recovery: Failed initialization cleans up partial resources/hosts and evicts
17
+ * the failed promise from cache so subsequent calls can retry.
18
+ * - Deterministic teardown: Controller disconnect does NOT dispose shared services;
19
+ * `runtime.destroy()` disposes all initialized services, in-flight initializations, and UI hosts.
20
+ */
21
+
22
+ const { createClientHostManager } = require('./host');
23
+ const { defaultStorage } = require('./storage');
24
+
25
+ function createServiceError(message, code, serviceName, cause) {
26
+ const err = new Error(message);
27
+ err.name = 'ClientServiceError';
28
+ err.code = code;
29
+ if (serviceName) {
30
+ err.serviceName = serviceName;
31
+ }
32
+ if (cause !== undefined) {
33
+ err.cause = cause;
34
+ }
35
+ return err;
36
+ }
37
+
38
+ /**
39
+ * Define a client service for registration in `bootClient({ services })` or `createClientRuntime({ services })`.
40
+ *
41
+ * Supports:
42
+ * - `defineClientService({ load: () => import('./services/toast') })`
43
+ * - `defineClientService({ create(ctx) { ... }, dispose?(instance, ctx) { ... } })`
44
+ * - `defineClientService((ctx) => ({ ... }))`
45
+ *
46
+ * @template TService
47
+ * @param {object | ((ctx: object) => TService | Promise<TService>)} definition
48
+ * @returns {object}
49
+ */
50
+ function defineClientService(definition) {
51
+ if (typeof definition === 'function') {
52
+ return Object.freeze({
53
+ __isWebspressoClientService: true,
54
+ load: null,
55
+ create: definition,
56
+ dispose: null,
57
+ });
58
+ }
59
+
60
+ if (!definition || typeof definition !== 'object') {
61
+ throw new TypeError('defineClientService() requires a configuration object or factory function.');
62
+ }
63
+
64
+ const load = typeof definition.load === 'function' ? definition.load : null;
65
+ const create =
66
+ typeof definition.create === 'function'
67
+ ? definition.create
68
+ : typeof definition.setup === 'function'
69
+ ? definition.setup
70
+ : null;
71
+ const dispose = typeof definition.dispose === 'function' ? definition.dispose : null;
72
+
73
+ if (!load && !create) {
74
+ throw new TypeError(
75
+ 'defineClientService() requires either a `load` function (`() => import(...)`) or a `create` / `setup` factory function.'
76
+ );
77
+ }
78
+
79
+ return Object.freeze({
80
+ __isWebspressoClientService: true,
81
+ name: definition.name ? String(definition.name).trim() : null,
82
+ load,
83
+ create,
84
+ dispose,
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Resolve a service instance from a dynamically loaded module or factory descriptor.
90
+ */
91
+ async function instantiateFromLoadedModule(loaded, outerDef, serviceCtx, registerDispose) {
92
+ if (outerDef.dispose) registerDispose(outerDef.dispose);
93
+
94
+ if (outerDef.load && outerDef.create) {
95
+ return await outerDef.create(serviceCtx, loaded);
96
+ }
97
+ if (!outerDef.load && outerDef.create) {
98
+ const res = await outerDef.create(serviceCtx);
99
+ return res && typeof res === 'object' && res.__isWebspressoClientService
100
+ ? instantiateFromLoadedModule(res, {}, serviceCtx, registerDispose)
101
+ : res;
102
+ }
103
+ if (loaded == null) return loaded;
104
+
105
+ let target = loaded;
106
+ if (typeof loaded === 'object') {
107
+ if (typeof loaded.dispose === 'function') registerDispose(loaded.dispose);
108
+ if ('default' in loaded && loaded.default !== undefined) {
109
+ target = loaded.default;
110
+ }
111
+ }
112
+
113
+ if (target && typeof target === 'object' && target.__isWebspressoClientService) {
114
+ if (typeof target.dispose === 'function') registerDispose(target.dispose);
115
+ if (typeof target.load === 'function') {
116
+ const nested = await target.load(serviceCtx);
117
+ return instantiateFromLoadedModule(nested, { create: target.create }, serviceCtx, registerDispose);
118
+ }
119
+ return typeof target.create === 'function' ? await target.create(serviceCtx) : target;
120
+ }
121
+
122
+ if (typeof target === 'function') {
123
+ const produced = await target(serviceCtx);
124
+ return produced && typeof produced === 'object' && produced.__isWebspressoClientService
125
+ ? instantiateFromLoadedModule(produced, {}, serviceCtx, registerDispose)
126
+ : produced;
127
+ }
128
+
129
+ if (target && typeof target === 'object') {
130
+ if (typeof target.dispose === 'function') registerDispose(target.dispose);
131
+ const fn = target.createService || target.create || target.setup;
132
+ if (typeof fn === 'function') return await fn.call(target, serviceCtx);
133
+ }
134
+
135
+ return target;
136
+ }
137
+
138
+ /**
139
+ * Create a runtime-scoped Client Service Registry.
140
+ *
141
+ * @param {object} [options]
142
+ * @param {Document|Element|null} [options.root]
143
+ * @param {object} [options.api]
144
+ * @param {object} [options.storage]
145
+ * @param {object} [options.debug]
146
+ * @param {() => object} [options.getRuntime]
147
+ * @param {Record<string, object|Function>} [options.services]
148
+ */
149
+ function createClientServiceRegistry(options = {}) {
150
+ const root = options.root || null;
151
+ const api = options.api || null;
152
+ const baseStorage = options.storage || defaultStorage;
153
+ const debug = options.debug || null;
154
+ const getRuntime = typeof options.getRuntime === 'function' ? options.getRuntime : () => null;
155
+
156
+ // Registered service definitions: name -> normalized definition
157
+ const definitions = new Map();
158
+ // Runtime state per service: name -> ServiceEntry
159
+ const entries = new Map();
160
+ // Initialization order for deterministic reverse disposal
161
+ const initOrder = [];
162
+ // Lazy host manager (only allocated if a service requests a UI host)
163
+ let sharedHostManager = null;
164
+ let disposed = false;
165
+
166
+ function ensureHostManager() {
167
+ if (!sharedHostManager) {
168
+ sharedHostManager = createClientHostManager({ root, debug });
169
+ }
170
+ return sharedHostManager;
171
+ }
172
+
173
+ /**
174
+ * Register a single client service definition.
175
+ * Does NOT import or execute the service implementation at registration time.
176
+ *
177
+ * @param {string} name
178
+ * @param {object|Function} rawDef
179
+ */
180
+ function register(name, rawDef) {
181
+ if (disposed) {
182
+ throw createServiceError(
183
+ `Cannot register client service "${name}" on a disposed runtime.`,
184
+ 'CLIENT_RUNTIME_DISPOSED',
185
+ name
186
+ );
187
+ }
188
+ const cleanName = String(name || '').trim();
189
+ if (!cleanName) {
190
+ throw new TypeError('Service registration requires a non-empty service name.');
191
+ }
192
+
193
+ const normalized =
194
+ rawDef && rawDef.__isWebspressoClientService ? rawDef : defineClientService(rawDef);
195
+
196
+ const existingEntry = entries.get(cleanName);
197
+ if (existingEntry && existingEntry.definition !== normalized) {
198
+ // If HMR or re-registration replaces an uninitialized or existing definition
199
+ if (existingEntry.status === 'ready' || existingEntry.status === 'initializing') {
200
+ disposeEntry(existingEntry);
201
+ entries.delete(cleanName);
202
+ }
203
+ }
204
+
205
+ definitions.set(cleanName, normalized);
206
+ return normalized;
207
+ }
208
+
209
+ function registerAll(servicesMap) {
210
+ if (!servicesMap || typeof servicesMap !== 'object') return;
211
+ for (const [name, def] of Object.entries(servicesMap)) {
212
+ if (def !== undefined && def !== null) {
213
+ register(name, def);
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Clean up all resources, callbacks, and UI hosts associated with a single service attempt/entry.
220
+ */
221
+ function cleanupAttemptResources(attempt, serviceName, phase = 'service:dispose') {
222
+ if (attempt.cleanedUp) return;
223
+ attempt.cleanedUp = true;
224
+
225
+ try {
226
+ attempt.abortController.abort();
227
+ } catch (_e) {}
228
+
229
+ // 1. Run explicit dispose hook(s)
230
+ const ranDisposeFns = new Set();
231
+ for (let i = attempt.disposeFns.length - 1; i >= 0; i--) {
232
+ const fn = attempt.disposeFns[i];
233
+ if (typeof fn !== 'function' || ranDisposeFns.has(fn)) continue;
234
+ ranDisposeFns.add(fn);
235
+ try {
236
+ fn(attempt.instance, attempt.serviceCtx);
237
+ } catch (err) {
238
+ if (debug && typeof debug.trackCleanupError === 'function') {
239
+ debug.trackCleanupError(`service_${serviceName}`, serviceName, phase, err);
240
+ }
241
+ }
242
+ }
243
+
244
+ // 2. If instance itself has a `.dispose()` method not already in disposeFns, invoke it when no explicit dispose hook was provided
245
+ if (
246
+ attempt.disposeFns.length === 0 &&
247
+ attempt.instance &&
248
+ typeof attempt.instance === 'object' &&
249
+ typeof attempt.instance.dispose === 'function' &&
250
+ !ranDisposeFns.has(attempt.instance.dispose)
251
+ ) {
252
+ try {
253
+ attempt.instance.dispose();
254
+ } catch (err) {
255
+ if (debug && typeof debug.trackCleanupError === 'function') {
256
+ debug.trackCleanupError(`service_${serviceName}`, serviceName, phase, err);
257
+ }
258
+ }
259
+ }
260
+
261
+ // 3. Run registered `onCleanup` callbacks in LIFO order
262
+ for (let i = attempt.cleanups.length - 1; i >= 0; i--) {
263
+ try {
264
+ attempt.cleanups[i]();
265
+ } catch (err) {
266
+ if (debug && typeof debug.trackCleanupError === 'function') {
267
+ debug.trackCleanupError(`service_${serviceName}`, serviceName, `${phase}:cleanup`, err);
268
+ }
269
+ }
270
+ }
271
+ attempt.cleanups.length = 0;
272
+
273
+ // 4. Unmount any UI hosts mounted by this service
274
+ const hosts = Array.from(attempt.hosts);
275
+ attempt.hosts.clear();
276
+ for (let i = hosts.length - 1; i >= 0; i--) {
277
+ try {
278
+ hosts[i].unmount();
279
+ } catch (err) {
280
+ if (debug && typeof debug.trackCleanupError === 'function') {
281
+ debug.trackCleanupError(`service_${serviceName}`, serviceName, `${phase}:host`, err);
282
+ }
283
+ }
284
+ }
285
+ }
286
+
287
+ function disposeEntry(entry) {
288
+ if (!entry || entry.disposed) return;
289
+ entry.disposed = true;
290
+ const wasReady = entry.status === 'ready';
291
+ entry.status = 'disposed';
292
+ entry.initPromise = null;
293
+ entry.instance = undefined;
294
+
295
+ if (entry.activeAttempt) {
296
+ cleanupAttemptResources(entry.activeAttempt, entry.name, 'service:dispose');
297
+ entry.activeAttempt = null;
298
+ }
299
+
300
+ if (wasReady && debug && typeof debug.recordEvent === 'function') {
301
+ debug.recordEvent('service:dispose', { name: entry.name });
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Lazily resolve and initialize a registered service by name.
307
+ *
308
+ * @template K
309
+ * @param {K} name
310
+ * @returns {Promise<any>}
311
+ */
312
+ async function get(name) {
313
+ const cleanName = String(name || '').trim();
314
+
315
+ if (disposed) {
316
+ throw createServiceError(
317
+ `Cannot resolve client service "${cleanName}": client runtime has been disposed.`,
318
+ 'CLIENT_RUNTIME_DISPOSED',
319
+ cleanName
320
+ );
321
+ }
322
+
323
+ const def = definitions.get(cleanName);
324
+ if (!def) {
325
+ throw createServiceError(
326
+ `Unknown client service "${cleanName}". Register it in bootClient({ services }) or createClientRuntime({ services }).`,
327
+ 'UNKNOWN_CLIENT_SERVICE',
328
+ cleanName
329
+ );
330
+ }
331
+
332
+ let entry = entries.get(cleanName);
333
+ if (entry) {
334
+ if (entry.status === 'ready') {
335
+ return entry.instance;
336
+ }
337
+ if (entry.status === 'initializing' && entry.initPromise) {
338
+ return entry.initPromise;
339
+ }
340
+ }
341
+
342
+ const attempt = {
343
+ abortController: new AbortController(),
344
+ cleanups: [],
345
+ disposeFns: [],
346
+ hosts: new Set(),
347
+ instance: undefined,
348
+ serviceCtx: null,
349
+ cleanedUp: false,
350
+ };
351
+
352
+ const scopedStorage =
353
+ baseStorage && typeof baseStorage.scoped === 'function'
354
+ ? baseStorage.scoped(`service:${cleanName}`)
355
+ : defaultStorage.scoped(`service:${cleanName}`);
356
+
357
+ function onCleanup(fn) {
358
+ if (typeof fn !== 'function') return () => {};
359
+ if (disposed || attempt.cleanedUp) {
360
+ try {
361
+ fn();
362
+ } catch (_e) {}
363
+ return () => {};
364
+ }
365
+ attempt.cleanups.push(fn);
366
+ return () => {
367
+ const idx = attempt.cleanups.indexOf(fn);
368
+ if (idx !== -1) attempt.cleanups.splice(idx, 1);
369
+ };
370
+ }
371
+
372
+ function mountServiceHost(keyOrOptions = {}, maybeOptions = {}) {
373
+ if (disposed || attempt.cleanedUp) {
374
+ throw createServiceError(
375
+ `Cannot mount UI host for service "${cleanName}": runtime or service is disposed.`,
376
+ 'CLIENT_RUNTIME_DISPOSED',
377
+ cleanName
378
+ );
379
+ }
380
+ const hostMgr = ensureHostManager();
381
+ const handle = hostMgr.mount(keyOrOptions, maybeOptions, cleanName);
382
+ attempt.hosts.add(handle);
383
+ return handle;
384
+ }
385
+
386
+ const hostHelper = Object.freeze({
387
+ mount: mountServiceHost,
388
+ get: (hostKey = cleanName) => (sharedHostManager ? sharedHostManager.get(hostKey) : null),
389
+ has: (hostKey = cleanName) => (sharedHostManager ? sharedHostManager.has(hostKey) : false),
390
+ unmount: (hostKey = cleanName) => {
391
+ if (!sharedHostManager) return false;
392
+ const h = sharedHostManager.get(hostKey);
393
+ if (h) attempt.hosts.delete(h);
394
+ return sharedHostManager.unmount(hostKey);
395
+ },
396
+ });
397
+
398
+ const serviceCtx = Object.freeze({
399
+ name: cleanName,
400
+ api,
401
+ storage: Object.freeze({
402
+ local: scopedStorage.local,
403
+ session: scopedStorage.session,
404
+ memory: scopedStorage.memory,
405
+ scoped: (sub) => scopedStorage.scoped(sub),
406
+ global: baseStorage,
407
+ root: baseStorage,
408
+ }),
409
+ signal: attempt.abortController.signal,
410
+ root,
411
+ get runtime() {
412
+ return getRuntime();
413
+ },
414
+ services: registryApi,
415
+ host: hostHelper,
416
+ mountHost: mountServiceHost,
417
+ onCleanup,
418
+ onDispose: onCleanup,
419
+ isDisposed: () => disposed || attempt.cleanedUp,
420
+ });
421
+
422
+ attempt.serviceCtx = serviceCtx;
423
+
424
+ entry = {
425
+ name: cleanName,
426
+ definition: def,
427
+ status: 'initializing',
428
+ instance: undefined,
429
+ initPromise: null,
430
+ activeAttempt: attempt,
431
+ disposed: false,
432
+ };
433
+ entries.set(cleanName, entry);
434
+
435
+ if (debug && typeof debug.recordEvent === 'function') {
436
+ debug.recordEvent('service:init', { name: cleanName });
437
+ }
438
+
439
+ const initPromise = (async () => {
440
+ try {
441
+ const rawLoaded = def.load ? await def.load(serviceCtx) : null;
442
+
443
+ // Check if runtime or entry was disposed while `load()` was in flight
444
+ if (disposed || entry.disposed) {
445
+ cleanupAttemptResources(attempt, cleanName, 'service:late-init-dispose');
446
+ throw createServiceError(
447
+ `Client service "${cleanName}" initialization aborted because runtime was disposed.`,
448
+ 'CLIENT_RUNTIME_DISPOSED',
449
+ cleanName
450
+ );
451
+ }
452
+
453
+ const instance = await instantiateFromLoadedModule(
454
+ rawLoaded,
455
+ def,
456
+ serviceCtx,
457
+ (disposeFn) => {
458
+ if (typeof disposeFn === 'function') {
459
+ attempt.disposeFns.push(disposeFn);
460
+ }
461
+ }
462
+ );
463
+
464
+ attempt.instance = instance;
465
+
466
+ // Check again if runtime or entry was disposed while `create()` / `setup()` was in flight
467
+ if (disposed || entry.disposed) {
468
+ cleanupAttemptResources(attempt, cleanName, 'service:late-init-dispose');
469
+ throw createServiceError(
470
+ `Client service "${cleanName}" initialization aborted because runtime was disposed.`,
471
+ 'CLIENT_RUNTIME_DISPOSED',
472
+ cleanName
473
+ );
474
+ }
475
+
476
+ entry.status = 'ready';
477
+ entry.instance = instance;
478
+ entry.initPromise = null;
479
+ if (!initOrder.includes(cleanName)) {
480
+ initOrder.push(cleanName);
481
+ }
482
+
483
+ if (debug && typeof debug.recordEvent === 'function') {
484
+ debug.recordEvent('service:ready', {
485
+ name: cleanName,
486
+ hasHost: attempt.hosts.size > 0,
487
+ });
488
+ }
489
+
490
+ return instance;
491
+ } catch (err) {
492
+ // Clean up any partial resources or hosts created before the error
493
+ cleanupAttemptResources(attempt, cleanName, 'service:init-error-cleanup');
494
+
495
+ // Evict failed initialization from cache so subsequent calls can retry (unless runtime is disposed)
496
+ if (!disposed && !entry.disposed && entries.get(cleanName) === entry) {
497
+ entries.delete(cleanName);
498
+ }
499
+
500
+ if (debug && typeof debug.recordEvent === 'function') {
501
+ debug.recordEvent('service:error', {
502
+ name: cleanName,
503
+ code: err && err.code ? err.code : null,
504
+ message: err && err.message ? String(err.message) : String(err),
505
+ });
506
+ }
507
+
508
+ throw err;
509
+ }
510
+ })();
511
+
512
+ entry.initPromise = initPromise;
513
+ return initPromise;
514
+ }
515
+
516
+ function has(name) {
517
+ return definitions.has(String(name || '').trim());
518
+ }
519
+
520
+ function isInitialized(name) {
521
+ const entry = entries.get(String(name || '').trim());
522
+ return Boolean(entry && entry.status === 'ready');
523
+ }
524
+
525
+ function peek(name) {
526
+ const entry = entries.get(String(name || '').trim());
527
+ return entry && entry.status === 'ready' ? entry.instance : undefined;
528
+ }
529
+
530
+ /**
531
+ * Idempotently dispose all initialized and in-flight services and their UI hosts.
532
+ */
533
+ function dispose() {
534
+ if (disposed) return;
535
+ disposed = true;
536
+
537
+ // 1. Dispose in reverse initialization order first
538
+ for (let i = initOrder.length - 1; i >= 0; i--) {
539
+ const name = initOrder[i];
540
+ const entry = entries.get(name);
541
+ if (entry) {
542
+ disposeEntry(entry);
543
+ }
544
+ }
545
+ initOrder.length = 0;
546
+
547
+ // 2. Dispose any remaining in-flight entries
548
+ for (const entry of Array.from(entries.values())) {
549
+ disposeEntry(entry);
550
+ }
551
+ entries.clear();
552
+
553
+ // 3. Dispose shared host manager if allocated
554
+ if (sharedHostManager) {
555
+ sharedHostManager.dispose();
556
+ sharedHostManager = null;
557
+ }
558
+ }
559
+
560
+ if (options.services && typeof options.services === 'object') {
561
+ registerAll(options.services);
562
+ }
563
+
564
+ const registryApi = {
565
+ register,
566
+ registerAll,
567
+ get,
568
+ has,
569
+ isInitialized,
570
+ peek,
571
+ dispose,
572
+ destroy: dispose,
573
+ get isDisposed() {
574
+ return disposed;
575
+ },
576
+ get hostManager() {
577
+ return sharedHostManager;
578
+ },
579
+ };
580
+
581
+ return registryApi;
582
+ }
583
+
584
+ module.exports = {
585
+ defineClientService,
586
+ createClientServiceRegistry,
587
+ createServiceError,
588
+ };