@linxin666/dsh-usage 0.4.2 → 0.4.4
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 +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +104 -2
- package/lib/client.js.map +1 -1
- package/lib/index.js +252 -45
- package/lib/types/client/body-mutations.d.ts +4 -4
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/host/usage-service.d.ts +4 -0
- package/lib/types/index.d.ts +61 -10
- package/lib/types/mount-once.d.ts +38 -7
- package/package.json +15 -15
- package/src/client/body-mutations.ts +4 -4
- package/src/client/index.ts +17 -7
- package/src/client/settings-entry-form.ts +142 -0
- package/src/host/usage-service.ts +129 -8
- package/src/index.ts +138 -43
- package/src/mount-once.ts +107 -13
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/host/run-guarded.ts +0 -76
package/src/index.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis'
|
|
2
2
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
3
3
|
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
4
|
-
import z from 'schemastery'
|
|
4
|
+
import z from '@deepseek-ai/schemastery'
|
|
5
5
|
import { mountOnce } from './mount-once.ts'
|
|
6
6
|
import { UsageService, type UsageServiceOptions } from './host/usage-service.ts'
|
|
7
7
|
import { makeUsageOverviewRoute, makeUsageRefreshRoute } from './host/routes.ts'
|
|
8
8
|
|
|
9
|
+
declare module '@deepseek-ai/cordis' {
|
|
10
|
+
interface Events {
|
|
11
|
+
/**
|
|
12
|
+
* Volatile config values were committed into the running fiber without a
|
|
13
|
+
* remount; dispatched to the owning fiber only. Spelled here because the
|
|
14
|
+
* Loader package is not a dependency of this plugin, with the Loader's own
|
|
15
|
+
* shape so the two declarations merge when a Host program carries both.
|
|
16
|
+
* @param paths - changed config paths as key arrays; every value is committed before dispatch.
|
|
17
|
+
* @mode emit
|
|
18
|
+
*/
|
|
19
|
+
'loader/volatile-update'(paths: readonly (readonly string[])[]): void
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
export const name = 'dsh-usage'
|
|
10
24
|
export const inject = ['webServer']
|
|
11
25
|
/**
|
|
@@ -16,29 +30,75 @@ export const inject = ['webServer']
|
|
|
16
30
|
*/
|
|
17
31
|
export const USAGE_SETTINGS_NAMESPACE = 'dsh-usage' as SettingsNamespace
|
|
18
32
|
|
|
19
|
-
|
|
20
|
-
|
|
33
|
+
/** The stable reference a `volatile()` config field resolves to; its owner updates it in place. */
|
|
34
|
+
interface ConfigRef<T> {
|
|
35
|
+
/** @returns the value currently committed for the running instance. */
|
|
36
|
+
get(): T | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One config field as the Host hands it over: a live reference, or a plain value (profile patches, tests). */
|
|
40
|
+
type ConfigField<T> = ConfigRef<T> | T | undefined
|
|
41
|
+
|
|
42
|
+
/** The config the Host hands this activation — the runtime face of {@link Config}. */
|
|
43
|
+
export interface UsageConfigFields {
|
|
44
|
+
enabled?: ConfigField<boolean>
|
|
21
45
|
/** Provider probe cycle in seconds; 30-3600. */
|
|
22
|
-
pollIntervalSec?: number
|
|
46
|
+
pollIntervalSec?: ConfigField<number>
|
|
23
47
|
/** Ledger retention in local days. */
|
|
24
|
-
retainDays?: number
|
|
48
|
+
retainDays?: ConfigField<number>
|
|
25
49
|
}
|
|
26
50
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Plugin config schema. Under the 0.1.7 settings model this schema IS the
|
|
53
|
+
* entry's settings page: the Host derives one form per profile entry from it
|
|
54
|
+
* and serves it only when at least one field is `volatile()`. The marker is
|
|
55
|
+
* also what admits a write and what keeps the edit on the live path — the
|
|
56
|
+
* Loader commits the new value into the field's reference and announces
|
|
57
|
+
* `loader/volatile-update` on this fiber instead of remounting the row, so the
|
|
58
|
+
* probe cycle, its ledger and its route registrations survive a settings save.
|
|
59
|
+
*
|
|
60
|
+
* The schema is left to inference rather than annotated with
|
|
61
|
+
* `z<UsageConfigFields>`: a volatile field's parsed output is a reference while
|
|
62
|
+
* its accepted input stays the plain value, so the two sides no longer share
|
|
63
|
+
* one shape and the annotation would reject the schema the Host must be given.
|
|
64
|
+
*/
|
|
65
|
+
export const Config = z.object({
|
|
66
|
+
enabled: z.boolean().default(true).volatile(),
|
|
67
|
+
pollIntervalSec: z.number().min(30).max(3600).default(60).volatile(),
|
|
68
|
+
retainDays: z.number().min(7).max(730).default(180).volatile(),
|
|
31
69
|
})
|
|
32
70
|
|
|
33
71
|
export interface ResolvedConfig extends UsageServiceOptions {
|
|
34
72
|
enabled: boolean
|
|
35
73
|
}
|
|
36
74
|
|
|
37
|
-
|
|
75
|
+
/** Read one config field, following the live reference the schema produces. */
|
|
76
|
+
function readConfigField<T>(field: ConfigField<T>): T | undefined {
|
|
77
|
+
if (field === undefined) return undefined
|
|
78
|
+
if (typeof field === 'object' && field !== null && typeof (field as ConfigRef<T>).get === 'function') {
|
|
79
|
+
return (field as ConfigRef<T>).get()
|
|
80
|
+
}
|
|
81
|
+
return field as T
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read one numeric field, falling back when the value is absent or not a usable number. */
|
|
85
|
+
function readNumber(field: ConfigField<number>, fallback: number): number {
|
|
86
|
+
const value = readConfigField(field)
|
|
87
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the effective settings of one activation. Volatile references are
|
|
92
|
+
* read here, so every re-arm observes the latest committed values rather than
|
|
93
|
+
* whatever the row was activated with.
|
|
94
|
+
* @param config - the config the Host passed to the activation.
|
|
95
|
+
* @returns the resolved settings.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveConfig(config?: UsageConfigFields): ResolvedConfig {
|
|
38
98
|
return {
|
|
39
|
-
enabled: config?.enabled ?? true,
|
|
40
|
-
pollIntervalSec:
|
|
41
|
-
retainDays:
|
|
99
|
+
enabled: readConfigField(config?.enabled) ?? true,
|
|
100
|
+
pollIntervalSec: readNumber(config?.pollIntervalSec, 60),
|
|
101
|
+
retainDays: readNumber(config?.retainDays, 180),
|
|
42
102
|
}
|
|
43
103
|
}
|
|
44
104
|
|
|
@@ -54,31 +114,23 @@ let pendingStop: Promise<unknown> | undefined
|
|
|
54
114
|
/**
|
|
55
115
|
* Host body. The plugin's own Config schema above is the settings document the
|
|
56
116
|
* 0.1.7 Host serves for this row; the effective config arrives here as the
|
|
57
|
-
* `config` argument
|
|
58
|
-
*
|
|
117
|
+
* `config` argument, and a settings save re-enters this body through the
|
|
118
|
+
* volatile path rather than a row reload (see {@link Config}).
|
|
59
119
|
* @param ctx - host root context.
|
|
60
120
|
* @param config - the row's resolved configuration.
|
|
61
121
|
*/
|
|
62
|
-
export const apply = mountOnce('@linxin666/dsh-usage', (ctx: Context, config?:
|
|
63
|
-
const value = resolveConfig(config)
|
|
64
|
-
// A disabled row mounts nothing: no probe cycle, no ledger, no routes. The
|
|
65
|
-
// settings page owns the enable switch, and re-enabling reloads this row.
|
|
66
|
-
if (!value.enabled) return
|
|
67
|
-
|
|
122
|
+
export const apply = mountOnce('@linxin666/dsh-usage', (ctx: Context, config?: UsageConfigFields): void => {
|
|
68
123
|
let live = true
|
|
69
124
|
let service: UsageService | undefined
|
|
70
125
|
let disposeRoutes: (() => void) | undefined
|
|
126
|
+
/** True while this activation has a start queued behind a predecessor's flush. */
|
|
127
|
+
let queued = false
|
|
71
128
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// still pending; only start while this activation is the live one.
|
|
75
|
-
if (!live) return
|
|
76
|
-
const next = new UsageService(ctx, value)
|
|
77
|
-
service = next
|
|
78
|
-
next.start()
|
|
129
|
+
/** Register the service's two routes; the returned disposer unregisters them. */
|
|
130
|
+
const mountRoutes = (next: UsageService): (() => void) => {
|
|
79
131
|
const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageRefreshRoute(ctx, next)]
|
|
80
132
|
.map((route) => ctx.webServer.register(route))
|
|
81
|
-
|
|
133
|
+
return () => {
|
|
82
134
|
for (const dispose of disposers) {
|
|
83
135
|
try {
|
|
84
136
|
dispose()
|
|
@@ -89,25 +141,68 @@ export const apply = mountOnce('@linxin666/dsh-usage', (ctx: Context, config?: C
|
|
|
89
141
|
}
|
|
90
142
|
}
|
|
91
143
|
|
|
92
|
-
|
|
144
|
+
/** Start a fresh activation, unless this fiber is down or one already runs. */
|
|
145
|
+
const launch = (): void => {
|
|
146
|
+
queued = false
|
|
147
|
+
if (!live || service !== undefined) return
|
|
148
|
+
const value = resolveConfig(config)
|
|
149
|
+
if (!value.enabled) return
|
|
150
|
+
const next = new UsageService(ctx, value)
|
|
151
|
+
service = next
|
|
152
|
+
next.start()
|
|
153
|
+
disposeRoutes = mountRoutes(next)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Tear the running activation down, chaining its final flush for any successor. */
|
|
157
|
+
const stop = (): void => {
|
|
158
|
+
disposeRoutes?.()
|
|
159
|
+
disposeRoutes = undefined
|
|
160
|
+
const stopping = service?.stop()
|
|
161
|
+
service = undefined
|
|
162
|
+
if (stopping !== undefined) pendingStop = (pendingStop ?? Promise.resolve()).then(() => stopping)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Apply the activation's current settings: a disabled row mounts nothing (no
|
|
167
|
+
* probe cycle, no ledger, no routes), an enabled one starts on first use and
|
|
168
|
+
* otherwise re-applies its options in place.
|
|
169
|
+
*/
|
|
170
|
+
const rearm = (): void => {
|
|
171
|
+
const value = resolveConfig(config)
|
|
172
|
+
if (!value.enabled) {
|
|
173
|
+
queued = false
|
|
174
|
+
stop()
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (service !== undefined) {
|
|
178
|
+
// Retention shrink prunes now; the poll loop reads the new cadence when
|
|
179
|
+
// its next cycle re-arms.
|
|
180
|
+
service.applyOptions(value)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
if (queued) return
|
|
93
184
|
if (pendingStop === undefined) {
|
|
94
185
|
// No predecessor's flush is in flight: start now.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// Serialize the start behind every predecessor: their final writes must
|
|
98
|
-
// land on disk before this instance loads the file.
|
|
99
|
-
pendingStop = pendingStop.then(begin, begin).then(() => undefined)
|
|
186
|
+
launch()
|
|
187
|
+
return
|
|
100
188
|
}
|
|
189
|
+
// Serialize the start behind every predecessor: their final writes must
|
|
190
|
+
// land on disk before this instance loads the file. `launch` re-reads the
|
|
191
|
+
// settings, so a disable that arrives while this is queued still wins.
|
|
192
|
+
queued = true
|
|
193
|
+
pendingStop = pendingStop.then(launch, launch).then(() => undefined)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The settings write path: the Host committed new values into this row's
|
|
197
|
+
// volatile references without remounting it, so re-arm from them.
|
|
198
|
+
ctx.on('loader/volatile-update', () => { rearm() })
|
|
199
|
+
|
|
200
|
+
ctx.effect(() => {
|
|
201
|
+
rearm()
|
|
101
202
|
return () => {
|
|
102
203
|
live = false
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const stopping = service?.stop()
|
|
106
|
-
service = undefined
|
|
107
|
-
// A successor waits for this instance's final flush, which itself waited
|
|
108
|
-
// for the predecessor's start: a row disposed before it ever started
|
|
109
|
-
// keeps the earlier link instead of dropping it.
|
|
110
|
-
if (stopping !== undefined) pendingStop = (pendingStop ?? Promise.resolve()).then(() => stopping)
|
|
204
|
+
queued = false
|
|
205
|
+
stop()
|
|
111
206
|
}
|
|
112
207
|
}, 'dsh-usage: runtime')
|
|
113
208
|
})
|
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
|
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Async-boundary guard shared by the plugin family's Host halves: every
|
|
3
|
-
* fire-and-forget promise chain and callback the host runtime does not own
|
|
4
|
-
* (route handlers, timers, event listeners, spawned work) funnels through
|
|
5
|
-
* these helpers. The dsh host installs a process-level fail-loud guard that
|
|
6
|
-
* turns ANY unhandled promise rejection into a whole-process exit — one
|
|
7
|
-
* plugin's stray rejection would otherwise take every plugin down. These
|
|
8
|
-
* helpers exist so that failure mode is structurally impossible in family
|
|
9
|
-
* code: the rejection becomes a logged error at the plugin boundary instead.
|
|
10
|
-
*
|
|
11
|
-
* Complements the aggregate's shell isolation (packages/dsh-web-all): the
|
|
12
|
-
* shell contains import/activation failures at boot; runGuarded contains
|
|
13
|
-
* run-time failures after activation.
|
|
14
|
-
* @module dsh-web-shared/host/run-guarded
|
|
15
|
-
*/
|
|
16
|
-
/**
|
|
17
|
-
* Run one async operation, logging (never propagating) a rejection. Use for
|
|
18
|
-
* promises whose lifecycle the caller does not await: spawned work, retry
|
|
19
|
-
* loops, background flushes. Returns the original promise so callers can
|
|
20
|
-
* still chain when they want to.
|
|
21
|
-
* @param promise - the work to shield; any thenable.
|
|
22
|
-
* @param label - log prefix naming the work site.
|
|
23
|
-
* @param log - error sink; defaults to console.error.
|
|
24
|
-
* @returns the input promise (rejection already consumed).
|
|
25
|
-
*/
|
|
26
|
-
export declare function runGuarded<T>(promise: PromiseLike<T>, label: string, log?: (error: unknown) => void): PromiseLike<T>;
|
|
27
|
-
/**
|
|
28
|
-
* Wrap one callback so every invocation is individually guarded: a rejection
|
|
29
|
-
* inside one call is logged and swallowed instead of escaping into whatever
|
|
30
|
-
* infrastructure invoked the callback (HTTP server, EventEmitter, interval).
|
|
31
|
-
* Sync throws are caught identically; a returned promise is replaced by
|
|
32
|
-
* `undefined` after guarding (callers that need the original rejection should
|
|
33
|
-
* await inside their own try/catch instead).
|
|
34
|
-
* @param label - log prefix naming the callback site.
|
|
35
|
-
* @param handler - the work to guard.
|
|
36
|
-
* @param log - error sink; defaults to console.error.
|
|
37
|
-
* @returns a wrapped callback with the same parameter list.
|
|
38
|
-
*/
|
|
39
|
-
export declare function guardedHandler<A extends unknown[]>(label: string, handler: (...args: A) => unknown, log?: (error: unknown) => void): (...args: A) => unknown;
|
package/src/host/run-guarded.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
// Generated by scripts/sync-shared.mjs from shared/host/run-guarded.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
-
/**
|
|
3
|
-
* Async-boundary guard shared by the plugin family's Host halves: every
|
|
4
|
-
* fire-and-forget promise chain and callback the host runtime does not own
|
|
5
|
-
* (route handlers, timers, event listeners, spawned work) funnels through
|
|
6
|
-
* these helpers. The dsh host installs a process-level fail-loud guard that
|
|
7
|
-
* turns ANY unhandled promise rejection into a whole-process exit — one
|
|
8
|
-
* plugin's stray rejection would otherwise take every plugin down. These
|
|
9
|
-
* helpers exist so that failure mode is structurally impossible in family
|
|
10
|
-
* code: the rejection becomes a logged error at the plugin boundary instead.
|
|
11
|
-
*
|
|
12
|
-
* Complements the aggregate's shell isolation (packages/dsh-web-all): the
|
|
13
|
-
* shell contains import/activation failures at boot; runGuarded contains
|
|
14
|
-
* run-time failures after activation.
|
|
15
|
-
* @module dsh-web-shared/host/run-guarded
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/** Format one failure line for logging. */
|
|
19
|
-
function formatFailure(label: string, error: unknown): Error {
|
|
20
|
-
return new Error(`[${label}] unhandled async failure: ${error instanceof Error ? error.stack ?? error.message : String(error)}`)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Run one async operation, logging (never propagating) a rejection. Use for
|
|
25
|
-
* promises whose lifecycle the caller does not await: spawned work, retry
|
|
26
|
-
* loops, background flushes. Returns the original promise so callers can
|
|
27
|
-
* still chain when they want to.
|
|
28
|
-
* @param promise - the work to shield; any thenable.
|
|
29
|
-
* @param label - log prefix naming the work site.
|
|
30
|
-
* @param log - error sink; defaults to console.error.
|
|
31
|
-
* @returns the input promise (rejection already consumed).
|
|
32
|
-
*/
|
|
33
|
-
export function runGuarded<T>(promise: PromiseLike<T>, label: string, log: (error: unknown) => void = console.error): PromiseLike<T> {
|
|
34
|
-
void Promise.resolve(promise).catch(error => {
|
|
35
|
-
log(formatFailure(label, error))
|
|
36
|
-
})
|
|
37
|
-
return promise
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Wrap one callback so every invocation is individually guarded: a rejection
|
|
42
|
-
* inside one call is logged and swallowed instead of escaping into whatever
|
|
43
|
-
* infrastructure invoked the callback (HTTP server, EventEmitter, interval).
|
|
44
|
-
* Sync throws are caught identically; a returned promise is replaced by
|
|
45
|
-
* `undefined` after guarding (callers that need the original rejection should
|
|
46
|
-
* await inside their own try/catch instead).
|
|
47
|
-
* @param label - log prefix naming the callback site.
|
|
48
|
-
* @param handler - the work to guard.
|
|
49
|
-
* @param log - error sink; defaults to console.error.
|
|
50
|
-
* @returns a wrapped callback with the same parameter list.
|
|
51
|
-
*/
|
|
52
|
-
export function guardedHandler<A extends unknown[]>(
|
|
53
|
-
label: string,
|
|
54
|
-
handler: (...args: A) => unknown,
|
|
55
|
-
log: (error: unknown) => void = console.error,
|
|
56
|
-
): (...args: A) => unknown {
|
|
57
|
-
return (...args: A) => {
|
|
58
|
-
try {
|
|
59
|
-
const result = handler(...args)
|
|
60
|
-
if (isPromiseLike(result)) {
|
|
61
|
-
void Promise.resolve(result).catch(error => {
|
|
62
|
-
log(formatFailure(label, error))
|
|
63
|
-
})
|
|
64
|
-
return undefined
|
|
65
|
-
}
|
|
66
|
-
return result
|
|
67
|
-
} catch (error) {
|
|
68
|
-
log(formatFailure(label, error))
|
|
69
|
-
return undefined
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
75
|
-
return typeof value === 'object' && value !== null && typeof (value as PromiseLike<unknown>).then === 'function'
|
|
76
|
-
}
|