@linxin666/dsh-pet 0.3.25 → 0.4.3
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/README.i18n.yaml +2 -2
- package/README.md +16 -28
- package/README.zh.md +16 -28
- package/lib/client.js +74 -65
- package/lib/client.js.map +1 -1
- package/lib/index.js +85 -12
- package/lib/types/client/PetSettingsCard.d.ts +16 -12
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +77 -49
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +0 -2
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/mount-once.d.ts.map +1 -1
- package/lib/types/mount-once.js +96 -12
- package/package.json +1 -1
- package/src/client/PetSettingsCard.test.tsx +115 -4
- package/src/client/PetSettingsCard.tsx +76 -66
- package/src/client/locales.ts +0 -2
- package/src/client/pet-css.test.ts +8 -0
- package/src/client/settings-section.module.css +0 -23
- package/src/mount-once.ts +107 -13
package/src/mount-once.ts
CHANGED
|
@@ -5,44 +5,138 @@
|
|
|
5
5
|
* the loader accepts a standalone install of the same package side by side;
|
|
6
6
|
* without this guard the second instance would still re-register the same
|
|
7
7
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
-
* and fail the boot. mountOnce makes
|
|
9
|
-
*
|
|
10
|
-
* package name in the client module host).
|
|
8
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
9
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
10
|
+
* deduped by package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
13
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
14
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
15
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
16
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
17
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
18
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
19
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
20
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
21
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
22
|
+
* restart recovered it).
|
|
23
|
+
*
|
|
24
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
25
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
26
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
27
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
28
|
+
* dedupes against it.
|
|
11
29
|
*
|
|
12
30
|
* The registry rides a global symbol so two module instances of the same
|
|
13
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
14
|
-
*
|
|
31
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
32
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
33
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
34
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
35
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
36
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
37
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
38
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
39
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
40
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
41
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
42
|
+
* mounted after it failed with "claims.get is not a function".
|
|
43
|
+
*
|
|
44
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
45
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
46
|
*/
|
|
17
47
|
|
|
48
|
+
/** Published cross-repository contract: package names currently mounted. */
|
|
18
49
|
const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
|
|
19
50
|
|
|
51
|
+
/** Additive key this guard owns: refused mounts waiting for the name. */
|
|
52
|
+
const WAITERS = Symbol.for('dsh-web.mounted-plugins.waiters')
|
|
53
|
+
|
|
54
|
+
/** The slice of a cordis context this guard touches. */
|
|
55
|
+
interface MountContext {
|
|
56
|
+
effect?: (effect: () => unknown) => unknown
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One mount that was refused while another fiber owned the package name. */
|
|
60
|
+
interface PendingMount {
|
|
61
|
+
/** Replay the refused mount; a no-op once its own fiber has been disposed. */
|
|
62
|
+
run(): void
|
|
63
|
+
}
|
|
64
|
+
|
|
20
65
|
interface MountRegistry {
|
|
21
|
-
[MOUNTED]?:
|
|
66
|
+
[MOUNTED]?: unknown
|
|
67
|
+
[WAITERS]?: Map<string, PendingMount[]>
|
|
22
68
|
}
|
|
23
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The shared name registry, always a `Set` whatever another build stored here:
|
|
72
|
+
* a foreign value (an interim shape, a hand-written global) must not take every
|
|
73
|
+
* family plugin down with it.
|
|
74
|
+
* @returns the process-wide set of mounted package names.
|
|
75
|
+
*/
|
|
24
76
|
function mountedSet(): Set<string> {
|
|
25
77
|
const registry = globalThis as MountRegistry
|
|
26
|
-
|
|
78
|
+
const existing = registry[MOUNTED]
|
|
79
|
+
if (existing instanceof Set) return existing as Set<string>
|
|
80
|
+
const created = new Set<string>()
|
|
81
|
+
registry[MOUNTED] = created
|
|
82
|
+
return created
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Queue per package name for mounts refused while a holder was alive. */
|
|
86
|
+
function mountWaiters(): Map<string, PendingMount[]> {
|
|
87
|
+
const registry = globalThis as MountRegistry
|
|
88
|
+
return (registry[WAITERS] ??= new Map())
|
|
27
89
|
}
|
|
28
90
|
|
|
29
91
|
/**
|
|
30
92
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
-
* The first mount registers normally and
|
|
32
|
-
*
|
|
93
|
+
* The first mount registers normally and releases the name when its fiber
|
|
94
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
95
|
+
* then runs, unless its own fiber disposes first.
|
|
33
96
|
* @param packageName - npm package identity shared by every install source.
|
|
34
97
|
* @param fn - the original plugin apply.
|
|
35
98
|
* @returns an apply of the same shape.
|
|
36
99
|
*/
|
|
37
100
|
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
-
|
|
101
|
+
const mount = (...args: unknown[]): unknown => {
|
|
39
102
|
const mounted = mountedSet()
|
|
40
|
-
|
|
103
|
+
const ctx = args[0] as MountContext | undefined
|
|
104
|
+
if (mounted.has(packageName)) {
|
|
105
|
+
// Another fiber (the aggregate row, a standalone install, or a copy built
|
|
106
|
+
// from an older revision of this guard) owns the name. Queue instead of
|
|
107
|
+
// dropping: dropping lost the plugin for the rest of the process when the
|
|
108
|
+
// holder was disposed by a reload.
|
|
109
|
+
const waiters = mountWaiters()
|
|
110
|
+
const queue = waiters.get(packageName) ?? []
|
|
111
|
+
let alive = true
|
|
112
|
+
const pending: PendingMount = {
|
|
113
|
+
run: () => {
|
|
114
|
+
if (alive) mount(...args)
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
// A fiber that dies while it waits must not be replayed afterwards: its
|
|
118
|
+
// context is disposed and registering effects on it would throw.
|
|
119
|
+
ctx?.effect?.(() => () => {
|
|
120
|
+
alive = false
|
|
121
|
+
const index = queue.indexOf(pending)
|
|
122
|
+
if (index >= 0) queue.splice(index, 1)
|
|
123
|
+
})
|
|
124
|
+
queue.push(pending)
|
|
125
|
+
waiters.set(packageName, queue)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
41
128
|
mounted.add(packageName)
|
|
42
|
-
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
129
|
ctx?.effect?.(() => () => {
|
|
44
130
|
mounted.delete(packageName)
|
|
131
|
+
const waiters = mountWaiters()
|
|
132
|
+
const queue = waiters.get(packageName)
|
|
133
|
+
if (queue === undefined) return
|
|
134
|
+
waiters.delete(packageName)
|
|
135
|
+
// Defer the handover one microtask so the disposing mount's own effects
|
|
136
|
+
// (routes, ledger, timers) are torn down before the next one registers.
|
|
137
|
+
for (const waiter of queue.splice(0)) queueMicrotask(() => { waiter.run() })
|
|
45
138
|
})
|
|
46
139
|
return fn(...args)
|
|
47
|
-
}
|
|
140
|
+
}
|
|
141
|
+
return mount as T
|
|
48
142
|
}
|