dsh-tiddlywiki 0.16.19 → 0.16.21

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/src/host/wiki.ts CHANGED
@@ -1,299 +1,299 @@
1
- /**
2
- * WikiServer — the TiddlyWiki 5 child-process lifecycle (design doc §9, D3).
3
- *
4
- * Zero-friction rules:
5
- * - ensure the wiki folder exists (scaffold with `--init server` once)
6
- * - git bootstrap is NOT this class's job (index.ts owns the GitFace)
7
- * - auto-detect a free loopback port unless one is pinned in config
8
- * - spawn `node <tw>/tiddlywiki.js <wiki> --listen host=127.0.0.1 ...`
9
- * and poll /status until it answers 200
10
- * - the TW child serves at the ROOT of its own dedicated loopback port (no
11
- * `path-prefix`): TW's browser frontend builds its API URLs from
12
- * `$protocol$//$host$/` only, so any path-prefix makes every frontend call
13
- * ../../status → 404 (verified against tiddlywiki 5.4.1). Namespacing lives
14
- * on the DSH webserver side (/dsh-tiddlywiki/* routes), never in TW itself.
15
- * - crash → restart with exponential backoff (1s,2s,4s… cap 30s), reset on
16
- * a successful readiness
17
- * - stop() is deterministic: SIGTERM, escalate to SIGKILL after a grace
18
- * period, and never leave a timer that would respawn during teardown
19
- *
20
- * @module dsh-tiddlywiki/host/wiki
21
- */
22
- import { spawn, execFile, type ChildProcessByStdio } from 'node:child_process'
23
- import { existsSync } from 'node:fs'
24
- import { mkdir } from 'node:fs/promises'
25
- import { createRequire } from 'node:module'
26
- import { createServer } from 'node:net'
27
- import { join, resolve } from 'node:path'
28
- import type { Readable } from 'node:stream'
29
-
30
- /** The DSH webserver route prefix (NOT a TW path-prefix; see module header). */
31
- export const PATH_PREFIX = '/dsh-tiddlywiki'
32
-
33
- /**
34
- * Same-origin TW proxy route on the DSH webserver (remote-access mode, R1).
35
- * The browser only ever talks to the DSH origin — which it already reaches
36
- * over loopback, LAN, Tailscale, a domain or HTTPS — and DSH proxies to the
37
- * loopback TW child. TW's frontend is pointed at this prefix via the
38
- * `$:/config/tiddlyweb/host` tiddler so every API call stays same-origin.
39
- */
40
- export const TW_PROXY_PREFIX = `${PATH_PREFIX}/tw`
41
-
42
- /** The proxy base path (trailing slash) handed to browsers / TW's frontend. */
43
- export const TW_PROXY_PATH = `${TW_PROXY_PREFIX}/`
44
-
45
- /** How long to wait for the wiki to answer /status. */
46
- const READY_TIMEOUT_MS = 20_000
47
-
48
- /** Poll cadence while waiting for readiness. */
49
- const READY_POLL_MS = 500
50
-
51
- /** Backoff ceiling for crash restarts. */
52
- const MAX_RESTART_BACKOFF_MS = 30_000
53
-
54
- /** SIGTERM → SIGKILL escalation grace. */
55
- const KILL_GRACE_MS = 3_000
56
-
57
- /** Ring-buffer cap for the stdout/stderr log. */
58
- const LOG_BUFFER_LIMIT = 200
59
-
60
- /** One-shot scaffold timeout for `--init server`. */
61
- const INIT_TIMEOUT_MS = 30_000
62
-
63
- export interface WikiServerOptions {
64
- /** Root that holds one folder per wiki (default $DSH_HOME/tiddlywiki). */
65
- wikiRoot: string
66
- /** Wiki folder name under wikiRoot (default "main"). */
67
- wiki: string
68
- /** Port; 0 = auto-detect a free loopback port. */
69
- port: number
70
- /** Optional Basic Auth (loopback anonymous by default). */
71
- username?: string
72
- password?: string
73
- logBufferLimit?: number
74
- }
75
-
76
- export type WikiHealth = 'starting' | 'running' | 'stopped' | 'failed'
77
-
78
- export interface WikiStatusView {
79
- status: WikiHealth
80
- url?: string
81
- port?: number
82
- wikiPath: string
83
- pid?: number
84
- lastStartedAt?: number
85
- error?: string
86
- logs: string[]
87
- }
88
-
89
- /** Resolve the absolute entry of the installed `tiddlywiki` package. */
90
- function resolveTwEntry(): string {
91
- const require = createRequire(import.meta.url)
92
- return require.resolve('tiddlywiki/tiddlywiki.js')
93
- }
94
-
95
- export class WikiServer {
96
- private child: ChildProcessByStdio<null, Readable, Readable> | undefined
97
- private readonly wikiPath: string
98
- private readonly logs: string[] = []
99
- private readonly logLimit: number
100
- private health: WikiHealth = 'stopped'
101
- private port: number | undefined
102
- private stopping = false
103
- private restartTimer: NodeJS.Timeout | undefined
104
- private restartDelay = 1_000
105
- private lastStartedAt: number | undefined
106
- private error: string | undefined
107
-
108
- constructor(private readonly options: WikiServerOptions) {
109
- this.wikiPath = resolve(options.wikiRoot, options.wiki)
110
- this.logLimit = options.logBufferLimit ?? LOG_BUFFER_LIMIT
111
- }
112
-
113
- /** Base URL of the TW service, once a port is bound (root, no path prefix). */
114
- get url(): string | undefined {
115
- return this.port === undefined ? undefined : `http://127.0.0.1:${this.port}`
116
- }
117
-
118
- /** The currently bound port (undefined until first spawn). */
119
- get currentPort(): number | undefined {
120
- return this.port
121
- }
122
-
123
- private log(line: string): void {
124
- const ts = new Date().toISOString()
125
- this.logs.push(`[${ts}] ${line}`)
126
- if (this.logs.length > this.logLimit) this.logs.splice(0, this.logs.length - this.logLimit)
127
- }
128
-
129
- /** Scaffold the wiki folder with `--init server` when it is absent. */
130
- async ensureWiki(): Promise<void> {
131
- await mkdir(this.wikiPath, { recursive: true })
132
- if (existsSync(join(this.wikiPath, 'tiddlywiki.info'))) return
133
- const tw = resolveTwEntry()
134
- this.log(`init: ${process.execPath} ${tw} ${this.wikiPath} --init server`)
135
- await new Promise<void>((resolveP, rejectP) => {
136
- execFile(process.execPath, [tw, this.wikiPath, '--init', 'server'], { timeout: INIT_TIMEOUT_MS, windowsHide: true }, (err) => {
137
- if (err) rejectP(err as Error)
138
- else resolveP()
139
- })
140
- })
141
- }
142
-
143
- /** Probe a free loopback port. */
144
- private async findFreePort(): Promise<number> {
145
- return new Promise<number>((resolveP, rejectP) => {
146
- const server = createServer()
147
- server.unref()
148
- server.once('error', rejectP)
149
- server.listen(0, '127.0.0.1', () => {
150
- const address = server.address()
151
- if (address === null || typeof address === 'string') {
152
- server.close()
153
- rejectP(new Error('cannot resolve a free port'))
154
- return
155
- }
156
- const port = address.port
157
- server.close(() => resolveP(port))
158
- })
159
- })
160
- }
161
-
162
- /**
163
- * Start (or restart) the TW child. Resolves once `/status` answers 200 or
164
- * the readiness deadline passes. Never throws on a crash — the exit handler
165
- * schedules a self-healing restart unless we are stopping.
166
- */
167
- async start(): Promise<WikiStatusView> {
168
- this.stopping = false
169
- this.restartDelay = 1_000
170
- await this.ensureWiki()
171
- if (this.child !== undefined) return this.status()
172
- this.health = 'starting'
173
- // Reuse an existing auto port across restarts (restart() → stop() → start())
174
- // so a fixed-baseUrl TiddlyWebClient stays valid and iframe src is stable.
175
- const port = this.options.port > 0 ? this.options.port : ((this.port ?? 0) > 0 ? this.port as number : await this.findFreePort())
176
- this.port = port
177
- const tw = resolveTwEntry()
178
- const args = [tw, this.wikiPath, '--listen', 'host=127.0.0.1', `port=${port}`]
179
- if (this.options.username) {
180
- // Locked-down mode for non-loopback exposure: Basic Auth + access lists.
181
- args.push(`username=${this.options.username}`)
182
- args.push(`password=${this.options.password ?? ''}`)
183
- args.push(`readers=${this.options.username}`)
184
- args.push(`writers=${this.options.username}`)
185
- }
186
- // Anonymous loopback mode carries NO auth args: TW's defaults open the
187
- // wiki to anonymous read/write on the bound (loopback) address. Passing
188
- // anon-username/readers/writers here was verified to 401 every request
189
- // ('undefined' is not authorized), so the anonymous branch stays bare.
190
- this.log(`spawn: ${process.execPath} ${args.join(' ')}`)
191
- const child = spawn(process.execPath, args, { cwd: this.wikiPath, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
192
- this.child = child
193
- child.stdout.on('data', (chunk: Buffer) => this.log(`[out] ${String(chunk).trimEnd()}`))
194
- child.stderr.on('data', (chunk: Buffer) => this.log(`[err] ${String(chunk).trimEnd()}`))
195
- child.once('exit', (code, signal) => {
196
- this.log(`exit code=${code} signal=${signal ?? ''} stopping=${this.stopping}`)
197
- this.child = undefined
198
- this.health = 'stopped'
199
- if (!this.stopping) this.scheduleRestart()
200
- })
201
- child.once('error', (err) => {
202
- this.log(`spawn error: ${err.message}`)
203
- this.error = err.message
204
- this.child = undefined
205
- this.health = 'failed'
206
- if (!this.stopping) this.scheduleRestart()
207
- })
208
- this.lastStartedAt = Date.now()
209
- await this.waitReady()
210
- return this.status()
211
- }
212
-
213
- /** Poll /status until 200 or the deadline; throws only on deadline/crash. */
214
- private async waitReady(): Promise<void> {
215
- const deadline = Date.now() + READY_TIMEOUT_MS
216
- for (;;) {
217
- if (this.child === undefined) throw new Error('wiki process exited before ready')
218
- try {
219
- const res = await fetch(`${this.url}/status`, { signal: AbortSignal.timeout(2_000) })
220
- if (res.ok) {
221
- this.health = 'running'
222
- this.log('ready: /status 200')
223
- return
224
- }
225
- } catch {
226
- /* not ready yet */
227
- }
228
- if (Date.now() > deadline) {
229
- this.health = 'failed'
230
- this.error = 'wiki server did not become ready in time'
231
- this.log(this.error)
232
- throw new Error(this.error)
233
- }
234
- await new Promise<void>((r) => setTimeout(r, READY_POLL_MS))
235
- }
236
- }
237
-
238
- private scheduleRestart(): void {
239
- if (this.stopping || this.restartTimer !== undefined) return
240
- const delay = this.restartDelay
241
- this.restartDelay = Math.min(this.restartDelay * 2, MAX_RESTART_BACKOFF_MS)
242
- this.log(`restart scheduled in ${delay}ms`)
243
- this.health = 'starting'
244
- this.restartTimer = setTimeout(() => {
245
- this.restartTimer = undefined
246
- void this.start().catch((err) => {
247
- this.health = 'failed'
248
- this.error = err instanceof Error ? err.message : String(err)
249
- this.log(`restart failed: ${this.error}`)
250
- })
251
- }, delay)
252
- }
253
-
254
- /** One-click restart (route /dsh-tiddlywiki/restart, panel retry button). */
255
- async restart(): Promise<WikiStatusView> {
256
- await this.stop()
257
- return this.start()
258
- }
259
-
260
- /** Deterministic teardown: cancel timers, SIGTERM, escalate to SIGKILL. */
261
- async stop(): Promise<void> {
262
- this.stopping = true
263
- if (this.restartTimer !== undefined) {
264
- clearTimeout(this.restartTimer)
265
- this.restartTimer = undefined
266
- }
267
- const child = this.child
268
- this.child = undefined
269
- if (child !== undefined && child.exitCode === null && child.signalCode === null) {
270
- try {
271
- child.kill('SIGTERM')
272
- } catch { /* already gone */ }
273
- await Promise.race([
274
- new Promise<void>((r) => child.once('exit', () => r())),
275
- new Promise<void>((r) => {
276
- setTimeout(() => {
277
- try { child.kill('SIGKILL') } catch { /* already gone */ }
278
- r()
279
- }, KILL_GRACE_MS).unref?.()
280
- }),
281
- ])
282
- }
283
- this.health = 'stopped'
284
- }
285
-
286
- /** Live status view (health, url, git-independent, recent logs). */
287
- status(): WikiStatusView {
288
- return {
289
- status: this.health,
290
- url: this.url,
291
- port: this.port,
292
- wikiPath: this.wikiPath,
293
- pid: this.child?.pid,
294
- lastStartedAt: this.lastStartedAt,
295
- ...(this.error !== undefined ? { error: this.error } : {}),
296
- logs: [...this.logs],
297
- }
298
- }
299
- }
1
+ /**
2
+ * WikiServer — the TiddlyWiki 5 child-process lifecycle (design doc §9, D3).
3
+ *
4
+ * Zero-friction rules:
5
+ * - ensure the wiki folder exists (scaffold with `--init server` once)
6
+ * - git bootstrap is NOT this class's job (index.ts owns the GitFace)
7
+ * - auto-detect a free loopback port unless one is pinned in config
8
+ * - spawn `node <tw>/tiddlywiki.js <wiki> --listen host=127.0.0.1 ...`
9
+ * and poll /status until it answers 200
10
+ * - the TW child serves at the ROOT of its own dedicated loopback port (no
11
+ * `path-prefix`): TW's browser frontend builds its API URLs from
12
+ * `$protocol$//$host$/` only, so any path-prefix makes every frontend call
13
+ * ../../status → 404 (verified against tiddlywiki 5.4.1). Namespacing lives
14
+ * on the DSH webserver side (/dsh-tiddlywiki/* routes), never in TW itself.
15
+ * - crash → restart with exponential backoff (1s,2s,4s… cap 30s), reset on
16
+ * a successful readiness
17
+ * - stop() is deterministic: SIGTERM, escalate to SIGKILL after a grace
18
+ * period, and never leave a timer that would respawn during teardown
19
+ *
20
+ * @module dsh-tiddlywiki/host/wiki
21
+ */
22
+ import { spawn, execFile, type ChildProcessByStdio } from 'node:child_process'
23
+ import { existsSync } from 'node:fs'
24
+ import { mkdir } from 'node:fs/promises'
25
+ import { createRequire } from 'node:module'
26
+ import { createServer } from 'node:net'
27
+ import { join, resolve } from 'node:path'
28
+ import type { Readable } from 'node:stream'
29
+
30
+ /** The DSH webserver route prefix (NOT a TW path-prefix; see module header). */
31
+ export const PATH_PREFIX = '/dsh-tiddlywiki'
32
+
33
+ /**
34
+ * Same-origin TW proxy route on the DSH webserver (remote-access mode, R1).
35
+ * The browser only ever talks to the DSH origin — which it already reaches
36
+ * over loopback, LAN, Tailscale, a domain or HTTPS — and DSH proxies to the
37
+ * loopback TW child. TW's frontend is pointed at this prefix via the
38
+ * `$:/config/tiddlyweb/host` tiddler so every API call stays same-origin.
39
+ */
40
+ export const TW_PROXY_PREFIX = `${PATH_PREFIX}/tw`
41
+
42
+ /** The proxy base path (trailing slash) handed to browsers / TW's frontend. */
43
+ export const TW_PROXY_PATH = `${TW_PROXY_PREFIX}/`
44
+
45
+ /** How long to wait for the wiki to answer /status. */
46
+ const READY_TIMEOUT_MS = 20_000
47
+
48
+ /** Poll cadence while waiting for readiness. */
49
+ const READY_POLL_MS = 500
50
+
51
+ /** Backoff ceiling for crash restarts. */
52
+ const MAX_RESTART_BACKOFF_MS = 30_000
53
+
54
+ /** SIGTERM → SIGKILL escalation grace. */
55
+ const KILL_GRACE_MS = 3_000
56
+
57
+ /** Ring-buffer cap for the stdout/stderr log. */
58
+ const LOG_BUFFER_LIMIT = 200
59
+
60
+ /** One-shot scaffold timeout for `--init server`. */
61
+ const INIT_TIMEOUT_MS = 30_000
62
+
63
+ export interface WikiServerOptions {
64
+ /** Root that holds one folder per wiki (default $DSH_HOME/tiddlywiki). */
65
+ wikiRoot: string
66
+ /** Wiki folder name under wikiRoot (default "main"). */
67
+ wiki: string
68
+ /** Port; 0 = auto-detect a free loopback port. */
69
+ port: number
70
+ /** Optional Basic Auth (loopback anonymous by default). */
71
+ username?: string
72
+ password?: string
73
+ logBufferLimit?: number
74
+ }
75
+
76
+ export type WikiHealth = 'starting' | 'running' | 'stopped' | 'failed'
77
+
78
+ export interface WikiStatusView {
79
+ status: WikiHealth
80
+ url?: string
81
+ port?: number
82
+ wikiPath: string
83
+ pid?: number
84
+ lastStartedAt?: number
85
+ error?: string
86
+ logs: string[]
87
+ }
88
+
89
+ /** Resolve the absolute entry of the installed `tiddlywiki` package. */
90
+ function resolveTwEntry(): string {
91
+ const require = createRequire(import.meta.url)
92
+ return require.resolve('tiddlywiki/tiddlywiki.js')
93
+ }
94
+
95
+ export class WikiServer {
96
+ private child: ChildProcessByStdio<null, Readable, Readable> | undefined
97
+ private readonly wikiPath: string
98
+ private readonly logs: string[] = []
99
+ private readonly logLimit: number
100
+ private health: WikiHealth = 'stopped'
101
+ private port: number | undefined
102
+ private stopping = false
103
+ private restartTimer: NodeJS.Timeout | undefined
104
+ private restartDelay = 1_000
105
+ private lastStartedAt: number | undefined
106
+ private error: string | undefined
107
+
108
+ constructor(private readonly options: WikiServerOptions) {
109
+ this.wikiPath = resolve(options.wikiRoot, options.wiki)
110
+ this.logLimit = options.logBufferLimit ?? LOG_BUFFER_LIMIT
111
+ }
112
+
113
+ /** Base URL of the TW service, once a port is bound (root, no path prefix). */
114
+ get url(): string | undefined {
115
+ return this.port === undefined ? undefined : `http://127.0.0.1:${this.port}`
116
+ }
117
+
118
+ /** The currently bound port (undefined until first spawn). */
119
+ get currentPort(): number | undefined {
120
+ return this.port
121
+ }
122
+
123
+ private log(line: string): void {
124
+ const ts = new Date().toISOString()
125
+ this.logs.push(`[${ts}] ${line}`)
126
+ if (this.logs.length > this.logLimit) this.logs.splice(0, this.logs.length - this.logLimit)
127
+ }
128
+
129
+ /** Scaffold the wiki folder with `--init server` when it is absent. */
130
+ async ensureWiki(): Promise<void> {
131
+ await mkdir(this.wikiPath, { recursive: true })
132
+ if (existsSync(join(this.wikiPath, 'tiddlywiki.info'))) return
133
+ const tw = resolveTwEntry()
134
+ this.log(`init: ${process.execPath} ${tw} ${this.wikiPath} --init server`)
135
+ await new Promise<void>((resolveP, rejectP) => {
136
+ execFile(process.execPath, [tw, this.wikiPath, '--init', 'server'], { timeout: INIT_TIMEOUT_MS, windowsHide: true }, (err) => {
137
+ if (err) rejectP(err as Error)
138
+ else resolveP()
139
+ })
140
+ })
141
+ }
142
+
143
+ /** Probe a free loopback port. */
144
+ private async findFreePort(): Promise<number> {
145
+ return new Promise<number>((resolveP, rejectP) => {
146
+ const server = createServer()
147
+ server.unref()
148
+ server.once('error', rejectP)
149
+ server.listen(0, '127.0.0.1', () => {
150
+ const address = server.address()
151
+ if (address === null || typeof address === 'string') {
152
+ server.close()
153
+ rejectP(new Error('cannot resolve a free port'))
154
+ return
155
+ }
156
+ const port = address.port
157
+ server.close(() => resolveP(port))
158
+ })
159
+ })
160
+ }
161
+
162
+ /**
163
+ * Start (or restart) the TW child. Resolves once `/status` answers 200 or
164
+ * the readiness deadline passes. Never throws on a crash — the exit handler
165
+ * schedules a self-healing restart unless we are stopping.
166
+ */
167
+ async start(): Promise<WikiStatusView> {
168
+ this.stopping = false
169
+ this.restartDelay = 1_000
170
+ await this.ensureWiki()
171
+ if (this.child !== undefined) return this.status()
172
+ this.health = 'starting'
173
+ // Reuse an existing auto port across restarts (restart() → stop() → start())
174
+ // so a fixed-baseUrl TiddlyWebClient stays valid and iframe src is stable.
175
+ const port = this.options.port > 0 ? this.options.port : ((this.port ?? 0) > 0 ? this.port as number : await this.findFreePort())
176
+ this.port = port
177
+ const tw = resolveTwEntry()
178
+ const args = [tw, this.wikiPath, '--listen', 'host=127.0.0.1', `port=${port}`]
179
+ if (this.options.username) {
180
+ // Locked-down mode for non-loopback exposure: Basic Auth + access lists.
181
+ args.push(`username=${this.options.username}`)
182
+ args.push(`password=${this.options.password ?? ''}`)
183
+ args.push(`readers=${this.options.username}`)
184
+ args.push(`writers=${this.options.username}`)
185
+ }
186
+ // Anonymous loopback mode carries NO auth args: TW's defaults open the
187
+ // wiki to anonymous read/write on the bound (loopback) address. Passing
188
+ // anon-username/readers/writers here was verified to 401 every request
189
+ // ('undefined' is not authorized), so the anonymous branch stays bare.
190
+ this.log(`spawn: ${process.execPath} ${args.join(' ')}`)
191
+ const child = spawn(process.execPath, args, { cwd: this.wikiPath, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
192
+ this.child = child
193
+ child.stdout.on('data', (chunk: Buffer) => this.log(`[out] ${String(chunk).trimEnd()}`))
194
+ child.stderr.on('data', (chunk: Buffer) => this.log(`[err] ${String(chunk).trimEnd()}`))
195
+ child.once('exit', (code, signal) => {
196
+ this.log(`exit code=${code} signal=${signal ?? ''} stopping=${this.stopping}`)
197
+ this.child = undefined
198
+ this.health = 'stopped'
199
+ if (!this.stopping) this.scheduleRestart()
200
+ })
201
+ child.once('error', (err) => {
202
+ this.log(`spawn error: ${err.message}`)
203
+ this.error = err.message
204
+ this.child = undefined
205
+ this.health = 'failed'
206
+ if (!this.stopping) this.scheduleRestart()
207
+ })
208
+ this.lastStartedAt = Date.now()
209
+ await this.waitReady()
210
+ return this.status()
211
+ }
212
+
213
+ /** Poll /status until 200 or the deadline; throws only on deadline/crash. */
214
+ private async waitReady(): Promise<void> {
215
+ const deadline = Date.now() + READY_TIMEOUT_MS
216
+ for (;;) {
217
+ if (this.child === undefined) throw new Error('wiki process exited before ready')
218
+ try {
219
+ const res = await fetch(`${this.url}/status`, { signal: AbortSignal.timeout(2_000) })
220
+ if (res.ok) {
221
+ this.health = 'running'
222
+ this.log('ready: /status 200')
223
+ return
224
+ }
225
+ } catch {
226
+ /* not ready yet */
227
+ }
228
+ if (Date.now() > deadline) {
229
+ this.health = 'failed'
230
+ this.error = 'wiki server did not become ready in time'
231
+ this.log(this.error)
232
+ throw new Error(this.error)
233
+ }
234
+ await new Promise<void>((r) => setTimeout(r, READY_POLL_MS))
235
+ }
236
+ }
237
+
238
+ private scheduleRestart(): void {
239
+ if (this.stopping || this.restartTimer !== undefined) return
240
+ const delay = this.restartDelay
241
+ this.restartDelay = Math.min(this.restartDelay * 2, MAX_RESTART_BACKOFF_MS)
242
+ this.log(`restart scheduled in ${delay}ms`)
243
+ this.health = 'starting'
244
+ this.restartTimer = setTimeout(() => {
245
+ this.restartTimer = undefined
246
+ void this.start().catch((err) => {
247
+ this.health = 'failed'
248
+ this.error = err instanceof Error ? err.message : String(err)
249
+ this.log(`restart failed: ${this.error}`)
250
+ })
251
+ }, delay)
252
+ }
253
+
254
+ /** One-click restart (route /dsh-tiddlywiki/restart, panel retry button). */
255
+ async restart(): Promise<WikiStatusView> {
256
+ await this.stop()
257
+ return this.start()
258
+ }
259
+
260
+ /** Deterministic teardown: cancel timers, SIGTERM, escalate to SIGKILL. */
261
+ async stop(): Promise<void> {
262
+ this.stopping = true
263
+ if (this.restartTimer !== undefined) {
264
+ clearTimeout(this.restartTimer)
265
+ this.restartTimer = undefined
266
+ }
267
+ const child = this.child
268
+ this.child = undefined
269
+ if (child !== undefined && child.exitCode === null && child.signalCode === null) {
270
+ try {
271
+ child.kill('SIGTERM')
272
+ } catch { /* already gone */ }
273
+ await Promise.race([
274
+ new Promise<void>((r) => child.once('exit', () => r())),
275
+ new Promise<void>((r) => {
276
+ setTimeout(() => {
277
+ try { child.kill('SIGKILL') } catch { /* already gone */ }
278
+ r()
279
+ }, KILL_GRACE_MS).unref?.()
280
+ }),
281
+ ])
282
+ }
283
+ this.health = 'stopped'
284
+ }
285
+
286
+ /** Live status view (health, url, git-independent, recent logs). */
287
+ status(): WikiStatusView {
288
+ return {
289
+ status: this.health,
290
+ url: this.url,
291
+ port: this.port,
292
+ wikiPath: this.wikiPath,
293
+ pid: this.child?.pid,
294
+ lastStartedAt: this.lastStartedAt,
295
+ ...(this.error !== undefined ? { error: this.error } : {}),
296
+ logs: [...this.logs],
297
+ }
298
+ }
299
+ }