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/LICENSE +21 -21
- package/README.md +8 -1
- package/cordis.patch.yml +13 -13
- package/lib/client.bundle.js +53 -26
- package/lib/client.js +53 -26
- package/lib/index.js +136 -25
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/endpoints.ts +10 -10
- package/src/client/index.ts +36 -0
- package/src/client/markdown-editor.ts +128 -128
- package/src/client/panel.ts +4 -0
- package/src/client/rightbar-tab.ts +440 -0
- package/src/client/session-summary.ts +279 -279
- package/src/client/settings-page.ts +1 -0
- package/src/client/state.ts +39 -39
- package/src/client/styles.ts +27 -0
- package/src/client/theme-sync.ts +312 -312
- package/src/client/toast.ts +22 -22
- package/src/client/tool-views.ts +604 -604
- package/src/client/ui-config.ts +64 -61
- package/src/host/config.ts +7 -0
- package/src/host/http.ts +57 -57
- package/src/host/routes.ts +2 -0
- package/src/host/seed-home.ts +1 -1
- package/src/host/seed-render.ts +66 -66
- package/src/host/seed-send-to-agent.ts +63 -63
- package/src/host/tools.ts +40 -5
- package/src/host/tw-api.ts +108 -16
- package/src/host/wiki.ts +299 -299
- package/src/index.ts +9 -8
- package/src/sdk.ts +204 -204
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
|
+
}
|