tosijs-ui 1.9.2 → 1.9.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/CHANGELOG.md +78 -0
- package/bin/tunnel.ts +40 -4
- package/dist/doc-system/site/dev-auth.d.ts +6 -0
- package/dist/doc-system/site/dev-auth.js +7 -0
- package/dist/doc-system/site/dev-server.d.ts +4 -0
- package/dist/doc-system/site/dev-server.js +122 -37
- package/dist/doc-system/site/epub-volumes.d.ts +49 -0
- package/dist/doc-system/site/epub-volumes.js +88 -0
- package/dist/doc-system/site/epub.js +4 -3
- package/dist/doc-system/site/index.d.ts +1 -0
- package/dist/doc-system/site/index.js +1 -0
- package/dist/doc-system/site/orchestrator.js +37 -0
- package/dist/iife.js +2 -2
- package/dist/iife.js.map +2 -2
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,83 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.4
|
|
4
|
+
|
|
5
|
+
**The dev server now uses your own installed `haltija`** (#48, reported by tosijs-3d).
|
|
6
|
+
|
|
7
|
+
It spawned its own channel via `bunx haltija@^1.6.1` and ignored the project's dependency
|
|
8
|
+
entirely — and because **bunx caches the resolution**, a range that resolves forward never
|
|
9
|
+
*re*-resolves once its cache key exists. So an adopter who bumped `haltija` to `^1.11.2`
|
|
10
|
+
for a fix, restarted, and still didn't have the fix was running a cached 1.11.0: new
|
|
11
|
+
enough to look current, old enough to lack it. `hj where` reports the *spawned* server, so
|
|
12
|
+
the version indicator agreed with them.
|
|
13
|
+
|
|
14
|
+
- Your `node_modules/.bin/haltija` is preferred when present. `HALTIJA_VERSION` still
|
|
15
|
+
overrides everything.
|
|
16
|
+
- The fallback floor moves `^1.6.1` → `^1.11.2`. Its practical job is to make bunx
|
|
17
|
+
re-resolve, not to express a minimum — a stale cache key was half the bug.
|
|
18
|
+
- **Both spawn sites now print which channel they used and where it came from** —
|
|
19
|
+
`1.11.2 (this project's dependency)`, `haltija@^1.11.2 (bunx)`,
|
|
20
|
+
`haltija@beta (HALTIJA_VERSION)`. The failure was never that a wrong version ran; it
|
|
21
|
+
was that nothing said so.
|
|
22
|
+
|
|
23
|
+
If you were working around this with `HALTIJA_VERSION`, you can drop it.
|
|
24
|
+
|
|
25
|
+
## 1.9.3
|
|
26
|
+
|
|
27
|
+
Two adopter-reported fixes, both with the diagnosis largely done by the reporter.
|
|
28
|
+
|
|
29
|
+
### The dev server crashed instead of exiting (#47)
|
|
30
|
+
|
|
31
|
+
A long-lived `bun start` **segfaulted at the 8-hour idle timeout** rather than exiting —
|
|
32
|
+
so a workspace left up for a working day went offline silently, and the first sign was a
|
|
33
|
+
tunnel link failing. All six exit paths called `server.stop()` immediately before
|
|
34
|
+
`process.exit()`; that buys nothing (the OS closes the sockets) and was exactly where it
|
|
35
|
+
died. They now just exit.
|
|
36
|
+
|
|
37
|
+
Note for anyone hitting this elsewhere: **`try/catch` is not a mitigation** — a segfault
|
|
38
|
+
is not a catchable exception, so the only workaround is not calling the API. Filed
|
|
39
|
+
upstream as [oven-sh/bun#36788](https://github.com/oven-sh/bun/issues/36788).
|
|
40
|
+
|
|
41
|
+
### ePub volumes are now linkable (#46)
|
|
42
|
+
|
|
43
|
+
The build wrote an ePub per volume and linked to none of them, so a reader had no route
|
|
44
|
+
to a book that existed. The filename is *derived* (`<project>-<volume>.epub`), so
|
|
45
|
+
hand-written links rot silently when a volume is renamed.
|
|
46
|
+
|
|
47
|
+
Three ways to surface them, and the ePub build now names its output through the same
|
|
48
|
+
helper, so a link cannot point at a name nothing wrote:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
<!-- epub-downloads --> drop in any page → a link per volume
|
|
52
|
+
/epub-volumes.json manifest: book, title, filename, url
|
|
53
|
+
listEpubVolumes() exported from tosijs-ui/site
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`epub.volumeTitles` renames what humans read without moving what published links point
|
|
57
|
+
at.
|
|
58
|
+
|
|
59
|
+
### Also
|
|
60
|
+
|
|
61
|
+
- **A valid session now trumps a stale invite link** (#45). Clicking an older link while
|
|
62
|
+
already signed in — a second window, a link scrolled back to in chat, a bookmark —
|
|
63
|
+
walled you with "that invite link has been used". The stale token is simply irrelevant
|
|
64
|
+
to someone who already holds a session.
|
|
65
|
+
- **`tosijs-tunnel --close` confirms identity before signalling.** It SIGTERMed every
|
|
66
|
+
`pgrep -f` match, and `pgrep -f` matches an argv *substring* — a shell echoing the
|
|
67
|
+
command or an editor holding a log path both matched. It now checks the process really
|
|
68
|
+
is `ssh`, says what it signalled, and names what it skipped. It also interpolated `host`
|
|
69
|
+
into a REGEX unescaped, so `me@a.b` matched `me@axb` and a host containing `+`, `(` or
|
|
70
|
+
`*` changed the pattern's meaning outright.
|
|
71
|
+
|
|
72
|
+
### Internals, for anyone reading the source
|
|
73
|
+
|
|
74
|
+
- One `shutdown(code)` helper replaces the six copy-pasted exit sites. The 1.9.0 review
|
|
75
|
+
had flagged that duplication as a tidiness follow-up; it turned out not to be untidy but
|
|
76
|
+
to be six copies of the crash above — you cannot fix a bug once if it exists six times.
|
|
77
|
+
- `resolveLinkArrival()` joins `mayReadSite` / `isLockedDown` as a decision extracted from
|
|
78
|
+
the dev server's request closure, where nothing could test it. Each of these was
|
|
79
|
+
extracted only after shipping a bug in it.
|
|
80
|
+
|
|
3
81
|
## 1.9.2
|
|
4
82
|
|
|
5
83
|
**Accessibility: every default color pair now meets WCAG AA.** Found by running
|
package/bin/tunnel.ts
CHANGED
|
@@ -108,8 +108,17 @@ if (!host) {
|
|
|
108
108
|
process.exit(1)
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
/*
|
|
112
|
+
pgrep pattern that matches only OUR forward, so --close can't kill someone else's.
|
|
113
|
+
|
|
114
|
+
`host` is interpolated into a REGEX, so it must be escaped: it is user config
|
|
115
|
+
(`user@box.example.com`), and an unescaped `.` matches any character while `+`, `(` or
|
|
116
|
+
`*` change the pattern's meaning outright. Unescaped, `me@a.b` also matches `me@axb`.
|
|
117
|
+
*/
|
|
118
|
+
const rx = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
119
|
+
const pattern = `ssh .*-R ${remotePort}:localhost:${tunnelLocalPort} ${rx(
|
|
120
|
+
String(host)
|
|
121
|
+
)}`
|
|
113
122
|
|
|
114
123
|
async function running(): Promise<number[]> {
|
|
115
124
|
const out = await $`pgrep -f ${pattern}`.nothrow().quiet().text()
|
|
@@ -175,16 +184,43 @@ if (has('status')) {
|
|
|
175
184
|
}
|
|
176
185
|
|
|
177
186
|
if (has('close')) {
|
|
187
|
+
/*
|
|
188
|
+
Confirm identity before signalling, and leave a receipt.
|
|
189
|
+
|
|
190
|
+
`pgrep -f` matches an argv SUBSTRING, so anything whose command line happens to contain
|
|
191
|
+
this pattern matches — a shell running an `echo` of the command, an editor holding a log
|
|
192
|
+
path, another agent tailing output. Signalling on a substring match alone is how a tool
|
|
193
|
+
kills something it never started.
|
|
194
|
+
|
|
195
|
+
`killStrayServer` in dev-server.ts already set this norm ("reclaiming a port is
|
|
196
|
+
destructive, so the log says exactly which pid/comm"), but the discipline lived in
|
|
197
|
+
another file and did not travel. Same rule here: check `comm` is really ssh, say what
|
|
198
|
+
was signalled, and name what was skipped and why.
|
|
199
|
+
*/
|
|
178
200
|
const pids = await running()
|
|
201
|
+
const closed: number[] = []
|
|
179
202
|
for (const pid of pids) {
|
|
203
|
+
const comm = (
|
|
204
|
+
await $`ps -p ${pid} -o comm=`.nothrow().quiet().text()
|
|
205
|
+
).trim()
|
|
206
|
+
if (!/(^|\/)ssh$/.test(comm)) {
|
|
207
|
+
console.warn(
|
|
208
|
+
`⚠️ pid ${pid} matched the tunnel pattern but is ${
|
|
209
|
+
comm || 'unknown'
|
|
210
|
+
}, not ssh — leaving it alone.`
|
|
211
|
+
)
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
180
214
|
try {
|
|
181
215
|
process.kill(pid, 'SIGTERM')
|
|
216
|
+
closed.push(pid)
|
|
217
|
+
console.log(` SIGTERM → pid ${pid} (${comm})`)
|
|
182
218
|
} catch {
|
|
183
|
-
/* already gone */
|
|
219
|
+
/* already gone between pgrep and here */
|
|
184
220
|
}
|
|
185
221
|
}
|
|
186
222
|
console.log(
|
|
187
|
-
|
|
223
|
+
closed.length ? `closed (pid ${closed.join(', ')})` : 'nothing to close'
|
|
188
224
|
)
|
|
189
225
|
process.exit(0)
|
|
190
226
|
}
|
|
@@ -83,3 +83,9 @@ export declare function hasTunnel(config: {
|
|
|
83
83
|
tunnel?: unknown;
|
|
84
84
|
};
|
|
85
85
|
}): boolean;
|
|
86
|
+
export type LinkArrival = 'issue-session' | 'already-authenticated' | 'reject';
|
|
87
|
+
export declare function resolveLinkArrival(opts: {
|
|
88
|
+
/** the session redeemLink() minted, or null if the token was invalid/spent */
|
|
89
|
+
redeemed: string | null;
|
|
90
|
+
hasValidSession: boolean;
|
|
91
|
+
}): LinkArrival;
|
|
@@ -266,3 +266,10 @@ export function isLockedDown(config) {
|
|
|
266
266
|
export function hasTunnel(config) {
|
|
267
267
|
return Boolean(config.preview?.tunnel);
|
|
268
268
|
}
|
|
269
|
+
export function resolveLinkArrival(opts) {
|
|
270
|
+
if (opts.redeemed)
|
|
271
|
+
return 'issue-session';
|
|
272
|
+
if (opts.hasValidSession)
|
|
273
|
+
return 'already-authenticated';
|
|
274
|
+
return 'reject';
|
|
275
|
+
}
|
|
@@ -3,6 +3,10 @@ import { isLoopbackAddressForAuth as isLoopbackAddress } from './dev-auth.js';
|
|
|
3
3
|
declare global {
|
|
4
4
|
var Bun: any;
|
|
5
5
|
}
|
|
6
|
+
export declare function resolveHaltijaChannel(cwd?: string, env?: Record<string, string | undefined>): {
|
|
7
|
+
argv: string[];
|
|
8
|
+
describe: string;
|
|
9
|
+
};
|
|
6
10
|
/**
|
|
7
11
|
* Resolve the idle-exit timeout to milliseconds (0 = disabled).
|
|
8
12
|
*
|
|
@@ -8,7 +8,7 @@ headless browser through the inline doc tests and exits with their pass/fail.
|
|
|
8
8
|
Build-time only (Bun APIs). Never import this from browser code.
|
|
9
9
|
*/
|
|
10
10
|
import * as path from 'path';
|
|
11
|
-
import { statSync } from 'fs';
|
|
11
|
+
import { statSync, existsSync, readFileSync } from 'fs';
|
|
12
12
|
import { tmpdir } from 'os';
|
|
13
13
|
import { $, spawn } from 'bun';
|
|
14
14
|
import { buildSite } from './orchestrator.js';
|
|
@@ -16,7 +16,7 @@ import { preflight } from './preflight.js';
|
|
|
16
16
|
import { auditDependencies, reportAudit } from './audit-guard.js';
|
|
17
17
|
import { openDevBrowser } from './open-browser.js';
|
|
18
18
|
import { resolveTunnelLocalPort } from './site-config.js';
|
|
19
|
-
import { TUNNEL_LINK_CMD, isLockedDown, hasTunnel, isLoopbackAddressForAuth as isLoopbackAddress, mayReadSite, shouldInterceptLinkToken, createAuthState, issueLink, readCookie, redeemLink, sessionCookie, urlWithoutToken, validSession, mayWriteSource, isProxiedRequest, SESSION_COOKIE, } from './dev-auth.js';
|
|
19
|
+
import { TUNNEL_LINK_CMD, resolveLinkArrival, isLockedDown, hasTunnel, isLoopbackAddressForAuth as isLoopbackAddress, mayReadSite, shouldInterceptLinkToken, createAuthState, issueLink, readCookie, redeemLink, sessionCookie, urlWithoutToken, validSession, mayWriteSource, isProxiedRequest, SESSION_COOKIE, } from './dev-auth.js';
|
|
20
20
|
const TEST_RESULTS_FILE = '.browser-tests.json';
|
|
21
21
|
const DEFAULT_IDLE_HOURS = 8;
|
|
22
22
|
/**
|
|
@@ -45,15 +45,62 @@ const DEFAULT_IDLE_HOURS = 8;
|
|
|
45
45
|
* test lane below can trust an exit code instead of racing to a timeout.
|
|
46
46
|
*/
|
|
47
47
|
/*
|
|
48
|
-
|
|
49
|
-
tell "server up" from "there is a tab to drive".
|
|
48
|
+
The FALLBACK channel, used only when the project has no haltija of its own.
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
`^1.11.2` because that is where the agent-facing fixes people upgrade for currently live;
|
|
51
|
+
the floor's practical job is to make bunx re-resolve rather than to express a minimum. The
|
|
52
|
+
floor before this was `^1.6.1`, and an adopter with `haltija@^1.11.2` in their own
|
|
53
|
+
devDependencies still got **1.11.0** — new enough to look current, old enough to lack the
|
|
54
|
+
fix they had just upgraded for (tosijs-ui#48).
|
|
55
|
+
|
|
56
|
+
Two things conspired, and the second is the nasty one:
|
|
57
|
+
|
|
58
|
+
- we spawned our own channel and ignored theirs entirely, and
|
|
59
|
+
- **`bunx` caches the resolution**, so a range that resolves forward does not RE-resolve
|
|
60
|
+
once its cache key is populated. `^1.6.1` froze at whatever satisfied it first.
|
|
61
|
+
|
|
62
|
+
That is the stale-but-satisfying-cache hazard this project filed against haltija (#11) and
|
|
63
|
+
then shipped an instance of. A range is not a pin, and a cached range is not even a range.
|
|
64
|
+
|
|
65
|
+
`resolveHaltijaChannel()` below prefers the project's own installed haltija, so the version
|
|
66
|
+
is something an adopter controls with the tool they already use. `HALTIJA_VERSION` still
|
|
67
|
+
overrides everything.
|
|
55
68
|
*/
|
|
56
|
-
const HALTIJA_PKG = process.env.HALTIJA_VERSION ?? 'haltija@^1.
|
|
69
|
+
const HALTIJA_PKG = process.env.HALTIJA_VERSION ?? 'haltija@^1.11.2';
|
|
70
|
+
/*
|
|
71
|
+
Which haltija do we actually run, and can we say so out loud?
|
|
72
|
+
|
|
73
|
+
Prefer the one in the project's own node_modules. An adopter who bumps their haltija
|
|
74
|
+
devDependency to get a fix expects that to be the haltija they get; anything else makes
|
|
75
|
+
the upgrade look like it did nothing — with `hj where` agreeing, since it reports the
|
|
76
|
+
SPAWNED server's version and therefore looks authoritative.
|
|
77
|
+
|
|
78
|
+
Returns the argv prefix plus a human description, because an invisible divergence is the
|
|
79
|
+
whole bug: one startup line naming the channel and where it came from turns "the fix
|
|
80
|
+
doesn't work" into "oh, it's running a different one".
|
|
81
|
+
*/
|
|
82
|
+
export function resolveHaltijaChannel(cwd = process.cwd(), env = process.env) {
|
|
83
|
+
// An explicit override always wins, and is worth saying so.
|
|
84
|
+
if (env.HALTIJA_VERSION) {
|
|
85
|
+
return {
|
|
86
|
+
argv: ['bunx', env.HALTIJA_VERSION],
|
|
87
|
+
describe: `${env.HALTIJA_VERSION} (HALTIJA_VERSION)`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const local = `${cwd}/node_modules/.bin/haltija`;
|
|
91
|
+
if (existsSync(local)) {
|
|
92
|
+
let version = 'unknown version';
|
|
93
|
+
try {
|
|
94
|
+
version =
|
|
95
|
+
JSON.parse(readFileSync(`${cwd}/node_modules/haltija/package.json`, 'utf8')).version ?? version;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* installed but unreadable manifest — still prefer it, just unnamed */
|
|
99
|
+
}
|
|
100
|
+
return { argv: [local], describe: `${version} (this project's dependency)` };
|
|
101
|
+
}
|
|
102
|
+
return { argv: ['bunx', HALTIJA_PKG], describe: `${HALTIJA_PKG} (bunx)` };
|
|
103
|
+
}
|
|
57
104
|
/**
|
|
58
105
|
* Resolve the idle-exit timeout to milliseconds (0 = disabled).
|
|
59
106
|
*
|
|
@@ -256,8 +303,11 @@ async function ensureHaltijaChannel(port) {
|
|
|
256
303
|
// HTTPS dev page has no mixed-content), while the `hj` CLI drives over its
|
|
257
304
|
// default HTTP 8700 — one server, both transports, shared state. HTTPS cert
|
|
258
305
|
// is mkcert-trusted. Output quiet so it doesn't drown the dev log.
|
|
259
|
-
|
|
260
|
-
|
|
306
|
+
const channel = resolveHaltijaChannel();
|
|
307
|
+
// Name the channel: an adopter who upgraded their own haltija and got ours instead
|
|
308
|
+
// sees the divergence here rather than concluding the fix does not work (#48).
|
|
309
|
+
console.log(`haltija channel: ${channel.describe}`);
|
|
310
|
+
spawn([...channel.argv, '--server', '--both'], {
|
|
261
311
|
stdout: 'ignore',
|
|
262
312
|
stderr: 'ignore',
|
|
263
313
|
});
|
|
@@ -941,20 +991,37 @@ export async function devServer(config, opts = {}) {
|
|
|
941
991
|
'Referrer-Policy': 'no-referrer',
|
|
942
992
|
'Cache-Control': 'no-store',
|
|
943
993
|
};
|
|
944
|
-
|
|
994
|
+
const heldCookie = readCookie(request.headers.get('cookie'), SESSION_COOKIE);
|
|
995
|
+
const arrival = resolveLinkArrival({
|
|
996
|
+
redeemed: session,
|
|
997
|
+
hasValidSession: validSession(auth, heldCookie, Date.now()),
|
|
998
|
+
});
|
|
999
|
+
if (arrival === 'issue-session') {
|
|
945
1000
|
headers['Set-Cookie'] = sessionCookie(session);
|
|
946
1001
|
console.log('🔓 edit link redeemed — session issued');
|
|
947
1002
|
return new Response(null, { status: 302, headers });
|
|
948
1003
|
}
|
|
949
1004
|
/*
|
|
950
|
-
|
|
1005
|
+
A VALID SESSION TRUMPS A STALE LINK.
|
|
1006
|
+
|
|
1007
|
+
The comment that used to sit here asserted that a session holder "never lands
|
|
1008
|
+
here, their cookie is sent automatically" — and nothing enforced it. The token is
|
|
1009
|
+
read before any session check, so anyone already signed in who clicks an older link
|
|
1010
|
+
(a second window, a link scrolled back to in chat, a bookmark) got walled with
|
|
1011
|
+
"that invite link has been used" while holding a perfectly good session. The stale
|
|
1012
|
+
token is simply irrelevant to them.
|
|
1013
|
+
|
|
1014
|
+
Strip it and carry on. No new session is issued: they already have one, and
|
|
1015
|
+
re-issuing on a spent token would make expiry meaningless.
|
|
1016
|
+
*/
|
|
1017
|
+
if (arrival === 'already-authenticated') {
|
|
1018
|
+
return new Response(null, { status: 302, headers });
|
|
1019
|
+
}
|
|
1020
|
+
/*
|
|
1021
|
+
Genuinely unauthenticated, and the likely culprit is not an attacker but a
|
|
951
1022
|
chat-app link-preview bot, whose GET *is* the first use. dev-auth.ts names
|
|
952
1023
|
unfurlers as a reason for single-use without handling the consequence: you click
|
|
953
1024
|
a dead link, get silently redirected, and the next signal is a save failing.
|
|
954
|
-
|
|
955
|
-
An anonymous reader who already holds a session never lands here (their cookie is
|
|
956
|
-
sent automatically), so saying this out loud costs nothing and saves a confusing
|
|
957
|
-
hunt.
|
|
958
1025
|
*/
|
|
959
1026
|
const spent = `<!doctype html><meta charset=utf-8><title>Link already used</title>` +
|
|
960
1027
|
`<style>body{font:16px/1.6 system-ui;margin:15vh auto;max-width:32rem;padding:0 1.5rem;color:#222}` +
|
|
@@ -1072,7 +1139,12 @@ export async function devServer(config, opts = {}) {
|
|
|
1072
1139
|
}
|
|
1073
1140
|
return new Response('File not found', { status: 404 });
|
|
1074
1141
|
};
|
|
1075
|
-
|
|
1142
|
+
/*
|
|
1143
|
+
Not bound to a name: nothing stops these listeners. `process.exit()` ends the process
|
|
1144
|
+
and the OS closes the sockets, and calling `.stop()` first is what segfaulted on the
|
|
1145
|
+
idle path (#47). A handle we would never legitimately use is an invitation to use it.
|
|
1146
|
+
*/
|
|
1147
|
+
Bun.serve({
|
|
1076
1148
|
port: PORT,
|
|
1077
1149
|
tls: {
|
|
1078
1150
|
key: Bun.file('./tls/key.pem'),
|
|
@@ -1108,10 +1180,9 @@ export async function devServer(config, opts = {}) {
|
|
|
1108
1180
|
? resolveTunnelLocalPort(config, process.env)
|
|
1109
1181
|
: undefined;
|
|
1110
1182
|
const configuredTunnelPort = config.preview?.tunnel?.localPort;
|
|
1111
|
-
let tunnelServer;
|
|
1112
1183
|
if (tunnelPort && !testMode && !process.env.DEV_NO_TUNNEL) {
|
|
1113
1184
|
try {
|
|
1114
|
-
|
|
1185
|
+
Bun.serve({
|
|
1115
1186
|
port: tunnelPort,
|
|
1116
1187
|
hostname: '127.0.0.1',
|
|
1117
1188
|
fetch: (request, srv) => handleRequest(request, srv, true),
|
|
@@ -1132,6 +1203,27 @@ export async function devServer(config, opts = {}) {
|
|
|
1132
1203
|
`${configuredTunnelPort ? '' : ' — it defaults to PORT + 1'}.`);
|
|
1133
1204
|
}
|
|
1134
1205
|
}
|
|
1206
|
+
/*
|
|
1207
|
+
Exit WITHOUT stopping the listeners first.
|
|
1208
|
+
|
|
1209
|
+
Every one of these six sites called `server.stop()` (and `tunnelServer?.stop()`)
|
|
1210
|
+
immediately before `process.exit()`. That buys nothing — `process.exit` terminates the
|
|
1211
|
+
process and the OS closes the listening sockets — and it is where the process was
|
|
1212
|
+
dying: tosijs-3d reported a reproducible **segfault at ~7.95h** against the 8h idle
|
|
1213
|
+
timer, twice, on the shutdown path, with flat 0.34GB RSS (so not the Bun.build native
|
|
1214
|
+
leak) and a faulting address that decodes to ASCII text rather than a null — the shape
|
|
1215
|
+
of a use-after-free inside Bun's own teardown. tosijs-ui#47.
|
|
1216
|
+
|
|
1217
|
+
A `try/catch` around the stops would NOT help: a segfault is not a JS exception. Not
|
|
1218
|
+
making the call is the only fix available to us. Filed upstream separately, because
|
|
1219
|
+
Bun should not crash here either.
|
|
1220
|
+
|
|
1221
|
+
Child processes we spawned are a different matter — those we must still reap, since the
|
|
1222
|
+
OS will not do it for a detached Electron grandchild. Callers do that before calling in.
|
|
1223
|
+
*/
|
|
1224
|
+
const shutdown = (code) => {
|
|
1225
|
+
process.exit(code);
|
|
1226
|
+
};
|
|
1135
1227
|
console.log(`Listening on https://localhost:${PORT}`);
|
|
1136
1228
|
// ── open (or bring to front) this project's browser tab ─────────────────────
|
|
1137
1229
|
//
|
|
@@ -1183,9 +1275,7 @@ export async function devServer(config, opts = {}) {
|
|
|
1183
1275
|
` updates, which a long-running process never does. Disable with\n` +
|
|
1184
1276
|
` DEV_IDLE_TIMEOUT_HOURS=0 or idleTimeoutHours in your site config.\n`);
|
|
1185
1277
|
clearInterval(timer);
|
|
1186
|
-
|
|
1187
|
-
tunnelServer?.stop();
|
|
1188
|
-
process.exit(0);
|
|
1278
|
+
shutdown(0);
|
|
1189
1279
|
}
|
|
1190
1280
|
const ok = await preflight({
|
|
1191
1281
|
label: 'Dev server',
|
|
@@ -1194,9 +1284,7 @@ export async function devServer(config, opts = {}) {
|
|
|
1194
1284
|
});
|
|
1195
1285
|
if (!ok) {
|
|
1196
1286
|
clearInterval(timer);
|
|
1197
|
-
|
|
1198
|
-
tunnelServer?.stop();
|
|
1199
|
-
process.exit(1);
|
|
1287
|
+
shutdown(1);
|
|
1200
1288
|
}
|
|
1201
1289
|
}, TICK_MS);
|
|
1202
1290
|
// Never let the health check itself be the thing keeping the process alive.
|
|
@@ -1288,8 +1376,9 @@ export async function devServer(config, opts = {}) {
|
|
|
1288
1376
|
(tosijs-ui#18, #21.)
|
|
1289
1377
|
*/
|
|
1290
1378
|
const portFile = `${tmpdir()}/tosijs-haltija-${process.pid}.json`;
|
|
1291
|
-
|
|
1292
|
-
|
|
1379
|
+
const channel = resolveHaltijaChannel();
|
|
1380
|
+
console.log(`Starting a private haltija… (${channel.describe})`);
|
|
1381
|
+
const haltija = spawn([...channel.argv, '--private', '--port-file', portFile], {
|
|
1293
1382
|
// stdout/stderr are NOT inherited: `bunx haltija` launches Electron as a
|
|
1294
1383
|
// grandchild, and killing the bunx wrapper does not kill it. An orphan holding
|
|
1295
1384
|
// our inherited stdout keeps the pipe open forever, so the command looks hung
|
|
@@ -1313,10 +1402,9 @@ export async function devServer(config, opts = {}) {
|
|
|
1313
1402
|
}
|
|
1314
1403
|
if (!hjPort) {
|
|
1315
1404
|
console.error(`haltija did not report a private port within 30s (${portFile}).\n` +
|
|
1316
|
-
` Is
|
|
1405
|
+
` Is \`${channel.argv.join(' ')}\` able to run? Try it by hand.`);
|
|
1317
1406
|
await stopHaltija();
|
|
1318
|
-
|
|
1319
|
-
process.exit(1);
|
|
1407
|
+
shutdown(1);
|
|
1320
1408
|
}
|
|
1321
1409
|
console.log(` private haltija on port ${hjPort}`);
|
|
1322
1410
|
console.log('Opening demo site...');
|
|
@@ -1332,21 +1420,18 @@ export async function devServer(config, opts = {}) {
|
|
|
1332
1420
|
if (nav.exitCode !== 0) {
|
|
1333
1421
|
console.error(`hj navigate failed (exit ${nav.exitCode}): ${nav.stderr.toString().trim() || 'no browser reachable'}`);
|
|
1334
1422
|
await stopHaltija();
|
|
1335
|
-
|
|
1336
|
-
process.exit(1);
|
|
1423
|
+
shutdown(1);
|
|
1337
1424
|
}
|
|
1338
1425
|
try {
|
|
1339
1426
|
const results = await testResults;
|
|
1340
1427
|
const exitCode = results.failed > 0 ? 1 : 0;
|
|
1341
1428
|
await stopHaltija();
|
|
1342
|
-
|
|
1343
|
-
process.exit(exitCode);
|
|
1429
|
+
shutdown(exitCode);
|
|
1344
1430
|
}
|
|
1345
1431
|
catch (e) {
|
|
1346
1432
|
console.error(e.message);
|
|
1347
1433
|
await stopHaltija();
|
|
1348
|
-
|
|
1349
|
-
process.exit(1);
|
|
1434
|
+
shutdown(1);
|
|
1350
1435
|
}
|
|
1351
1436
|
}
|
|
1352
1437
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface EpubVolume {
|
|
2
|
+
/** the `book` value, or '' for the default volume */
|
|
3
|
+
book: string;
|
|
4
|
+
/** human title — "<project>" or "<project> — <volume>", or an explicit override */
|
|
5
|
+
title: string;
|
|
6
|
+
/** output filename, e.g. `my-project-appendices.epub` */
|
|
7
|
+
filename: string;
|
|
8
|
+
/** served URL, honouring basePath */
|
|
9
|
+
url: string;
|
|
10
|
+
}
|
|
11
|
+
export interface VolumeNamingConfig {
|
|
12
|
+
name?: string;
|
|
13
|
+
basePath?: string;
|
|
14
|
+
epub?: boolean | {
|
|
15
|
+
title?: string;
|
|
16
|
+
volumeTitles?: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The identity of one volume. `bookTarget` undefined means the default volume.
|
|
21
|
+
*
|
|
22
|
+
* Kept deliberately pure and dependency-free so the ePub builder, the site build and an
|
|
23
|
+
* adopter's own script all agree by construction rather than by comment.
|
|
24
|
+
*/
|
|
25
|
+
export declare function epubVolumeIdentity(config: VolumeNamingConfig, bookTarget?: string): EpubVolume;
|
|
26
|
+
/**
|
|
27
|
+
* Every volume this corpus will produce, default first.
|
|
28
|
+
*
|
|
29
|
+
* Empty when the corpus has no publishable docs at all — which is a real state (everything
|
|
30
|
+
* hidden, or everything `book: "none"`) and must not be reported as one nameless volume.
|
|
31
|
+
*/
|
|
32
|
+
export declare function listEpubVolumes(corpus: Array<{
|
|
33
|
+
filename: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
parent?: string;
|
|
36
|
+
book?: string | string[];
|
|
37
|
+
hidden?: boolean;
|
|
38
|
+
}>, config: VolumeNamingConfig): EpubVolume[];
|
|
39
|
+
/** The marker a consumer drops into a page to get the download list. */
|
|
40
|
+
export declare const EPUB_DOWNLOADS_MARKER: RegExp;
|
|
41
|
+
/**
|
|
42
|
+
* Replace `<!-- epub-downloads -->` with a markdown list of the built volumes.
|
|
43
|
+
*
|
|
44
|
+
* Substituted at BUILD time, into the doc text, so the statically-rendered page and the
|
|
45
|
+
* hydrated SPA show the same thing and the client needs no new data source. A marker in a
|
|
46
|
+
* corpus that builds no ePub renders as nothing rather than an empty list or a stray
|
|
47
|
+
* comment.
|
|
48
|
+
*/
|
|
49
|
+
export declare function renderEpubDownloads(text: string, volumes: EpubVolume[]): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Which ePubs does this build produce, and where do they land?
|
|
3
|
+
|
|
4
|
+
The output filename is DERIVED — from the project name and, for a named volume, the
|
|
5
|
+
`book` value (`foresight-rpg` + `book: "foresight-1986"` →
|
|
6
|
+
`foresight-rpg-foresight-1986.epub`). Nothing published that derivation, so a consumer
|
|
7
|
+
wanting to link their own book had to reverse-engineer it from the source and re-derive it
|
|
8
|
+
by hand every time a volume was renamed.
|
|
9
|
+
|
|
10
|
+
That is not a documentation gap, it is a missing seam: foresight-rpg shipped a valid ePub
|
|
11
|
+
that **nobody could download**, because the site built the file and then linked to nothing.
|
|
12
|
+
It was caught on review rather than by anyone noticing — which is the tell. A build that
|
|
13
|
+
produces an artifact should be able to say where it is.
|
|
14
|
+
|
|
15
|
+
One definition, used by three callers: the ePub build names its output with it, the
|
|
16
|
+
orchestrator writes a manifest from it, and `<!-- epub-downloads -->` in any page renders
|
|
17
|
+
from it. Consumers can import it directly rather than hard-coding a filename.
|
|
18
|
+
*/
|
|
19
|
+
import { DEFAULT_BOOK, namedBooks, partitionByBook } from '../book-target.js';
|
|
20
|
+
import { buildSlugMap } from '../routing.js';
|
|
21
|
+
/** Lowercase, hyphenated — the same shape the ePub build uses for filenames. */
|
|
22
|
+
function slugify(text) {
|
|
23
|
+
return text
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
26
|
+
.replace(/^-|-$/g, '');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The identity of one volume. `bookTarget` undefined means the default volume.
|
|
30
|
+
*
|
|
31
|
+
* Kept deliberately pure and dependency-free so the ePub builder, the site build and an
|
|
32
|
+
* adopter's own script all agree by construction rather than by comment.
|
|
33
|
+
*/
|
|
34
|
+
export function epubVolumeIdentity(config, bookTarget) {
|
|
35
|
+
const epub = typeof config.epub === 'object' ? config.epub : {};
|
|
36
|
+
const baseTitle = epub.title ?? config.name ?? 'book';
|
|
37
|
+
const title = bookTarget
|
|
38
|
+
? (epub.volumeTitles?.[bookTarget] ?? `${baseTitle} — ${bookTarget}`)
|
|
39
|
+
: baseTitle;
|
|
40
|
+
const filename = bookTarget
|
|
41
|
+
? `${slugify(baseTitle)}-${slugify(bookTarget)}.epub`
|
|
42
|
+
: `${slugify(baseTitle)}.epub`;
|
|
43
|
+
const base = (config.basePath ?? '/').replace(/\/+$/, '');
|
|
44
|
+
return { book: bookTarget ?? DEFAULT_BOOK, title, filename, url: `${base}/${filename}` };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Every volume this corpus will produce, default first.
|
|
48
|
+
*
|
|
49
|
+
* Empty when the corpus has no publishable docs at all — which is a real state (everything
|
|
50
|
+
* hidden, or everything `book: "none"`) and must not be reported as one nameless volume.
|
|
51
|
+
*/
|
|
52
|
+
export function listEpubVolumes(corpus, config) {
|
|
53
|
+
const slugs = buildSlugMap(corpus);
|
|
54
|
+
const partitioned = partitionByBook(corpus, slugs);
|
|
55
|
+
const books = [
|
|
56
|
+
...(partitioned.get(DEFAULT_BOOK)?.length ? [undefined] : []),
|
|
57
|
+
...namedBooks(corpus, slugs),
|
|
58
|
+
];
|
|
59
|
+
return books.map((b) => epubVolumeIdentity(config, b));
|
|
60
|
+
}
|
|
61
|
+
/** The marker a consumer drops into a page to get the download list. */
|
|
62
|
+
export const EPUB_DOWNLOADS_MARKER = /<!--\s*epub-downloads\s*-->/g;
|
|
63
|
+
/**
|
|
64
|
+
* Replace `<!-- epub-downloads -->` with a markdown list of the built volumes.
|
|
65
|
+
*
|
|
66
|
+
* Substituted at BUILD time, into the doc text, so the statically-rendered page and the
|
|
67
|
+
* hydrated SPA show the same thing and the client needs no new data source. A marker in a
|
|
68
|
+
* corpus that builds no ePub renders as nothing rather than an empty list or a stray
|
|
69
|
+
* comment.
|
|
70
|
+
*/
|
|
71
|
+
export function renderEpubDownloads(text, volumes) {
|
|
72
|
+
/*
|
|
73
|
+
No `.test()` guard — but as simplification, not a bug fix.
|
|
74
|
+
|
|
75
|
+
A `/g` regex carries `lastIndex`, and `.test()` advances it, which LOOKS like it should
|
|
76
|
+
make a `test`-then-`replace` pair stateful. It does not: `String.replace` with `/g`
|
|
77
|
+
resets `lastIndex` to 0, and a failing `.test()` resets it too, so the pair is
|
|
78
|
+
self-correcting. (Verified rather than reasoned — an earlier version of this comment
|
|
79
|
+
confidently described an alternating no-op that mutation testing showed does not exist.)
|
|
80
|
+
|
|
81
|
+
The guard is dropped because `replace` already returns the input unchanged when nothing
|
|
82
|
+
matches, so it only bought a redundant scan and a subtlety to explain.
|
|
83
|
+
*/
|
|
84
|
+
const list = volumes
|
|
85
|
+
.map((v) => `- [${v.title}](${v.url}) *(ePub)*`)
|
|
86
|
+
.join('\n');
|
|
87
|
+
return text.replace(EPUB_DOWNLOADS_MARKER, list);
|
|
88
|
+
}
|
|
@@ -20,6 +20,7 @@ import { renderDocMarkdown } from '../render.js';
|
|
|
20
20
|
import { buildSlugMap, pathForSlug, slugForPath } from '../routing.js';
|
|
21
21
|
import { buildNavTree } from '../nav-tree.js';
|
|
22
22
|
import { partitionByBook, DEFAULT_BOOK } from '../book-target.js';
|
|
23
|
+
import { epubVolumeIdentity } from './epub-volumes.js';
|
|
23
24
|
import { DEFAULT_BOOK_CSS, stripDocMeta, flatten, slugify } from '../book-html.js';
|
|
24
25
|
import { selectBookDocs } from '../book-manifest.js';
|
|
25
26
|
// Re-exported for back-compat (tosijs-ui/site's public surface + tests).
|
|
@@ -637,9 +638,9 @@ export async function buildEpub(config, opts = {}) {
|
|
|
637
638
|
// An explicit `output` names ONE file, so it can only apply to the default volume —
|
|
638
639
|
// otherwise every volume writes to it and you ship whichever bound last.
|
|
639
640
|
(opts.bookTarget ? undefined : opts.output) ??
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
641
|
+
// Named through the SHARED helper, so the manifest, the download links and the file
|
|
642
|
+
// on disk all derive from one function. A link cannot point at a name nothing wrote.
|
|
643
|
+
path.join(outDir, epubVolumeIdentity({ name: baseTitle, epub: { title: baseTitle } }, opts.bookTarget).filename));
|
|
643
644
|
await zipEpub(buildDir, output);
|
|
644
645
|
fs.rmSync(buildDir, { recursive: true, force: true });
|
|
645
646
|
// Release the parser window: an unclosed happy-dom Window holds its whole
|
|
@@ -14,3 +14,4 @@ export { buildEpub, DEFAULT_BOOK_CSS } from './epub.js';
|
|
|
14
14
|
export type { BuildEpubOptions, BookMeta } from './epub.js';
|
|
15
15
|
export { selectBookDocs } from '../book-manifest.js';
|
|
16
16
|
export type { BookManifest } from '../book-manifest.js';
|
|
17
|
+
export { listEpubVolumes, epubVolumeIdentity, renderEpubDownloads, type EpubVolume, } from './epub-volumes.js';
|
|
@@ -12,3 +12,4 @@ export { extractDocs, saveDocsJSON } from './docs.js';
|
|
|
12
12
|
export { generateSite } from './generate-site.js';
|
|
13
13
|
export { buildEpub, DEFAULT_BOOK_CSS } from './epub.js';
|
|
14
14
|
export { selectBookDocs } from '../book-manifest.js';
|
|
15
|
+
export { listEpubVolumes, epubVolumeIdentity, renderEpubDownloads, } from './epub-volumes.js';
|