tosijs-ui 1.9.1 → 1.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/bin/tunnel.ts +40 -4
- package/dist/data-table.js +4 -4
- package/dist/doc-browser.js +26 -4
- package/dist/doc-system/doc-system-styles.js +24 -3
- 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.js +103 -84
- 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/header.js +1 -1
- package/dist/iife.js +69 -69
- package/dist/iife.js.map +10 -10
- package/dist/live-theme.js +4 -4
- package/dist/theme.js +4 -4
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/value-renderer.js +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,114 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.3
|
|
4
|
+
|
|
5
|
+
Two adopter-reported fixes, both with the diagnosis largely done by the reporter.
|
|
6
|
+
|
|
7
|
+
### The dev server crashed instead of exiting (#47)
|
|
8
|
+
|
|
9
|
+
A long-lived `bun start` **segfaulted at the 8-hour idle timeout** rather than exiting —
|
|
10
|
+
so a workspace left up for a working day went offline silently, and the first sign was a
|
|
11
|
+
tunnel link failing. All six exit paths called `server.stop()` immediately before
|
|
12
|
+
`process.exit()`; that buys nothing (the OS closes the sockets) and was exactly where it
|
|
13
|
+
died. They now just exit.
|
|
14
|
+
|
|
15
|
+
Note for anyone hitting this elsewhere: **`try/catch` is not a mitigation** — a segfault
|
|
16
|
+
is not a catchable exception, so the only workaround is not calling the API. Filed
|
|
17
|
+
upstream as [oven-sh/bun#36788](https://github.com/oven-sh/bun/issues/36788).
|
|
18
|
+
|
|
19
|
+
### ePub volumes are now linkable (#46)
|
|
20
|
+
|
|
21
|
+
The build wrote an ePub per volume and linked to none of them, so a reader had no route
|
|
22
|
+
to a book that existed. The filename is *derived* (`<project>-<volume>.epub`), so
|
|
23
|
+
hand-written links rot silently when a volume is renamed.
|
|
24
|
+
|
|
25
|
+
Three ways to surface them, and the ePub build now names its output through the same
|
|
26
|
+
helper, so a link cannot point at a name nothing wrote:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
<!-- epub-downloads --> drop in any page → a link per volume
|
|
30
|
+
/epub-volumes.json manifest: book, title, filename, url
|
|
31
|
+
listEpubVolumes() exported from tosijs-ui/site
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`epub.volumeTitles` renames what humans read without moving what published links point
|
|
35
|
+
at.
|
|
36
|
+
|
|
37
|
+
### Also
|
|
38
|
+
|
|
39
|
+
- **A valid session now trumps a stale invite link** (#45). Clicking an older link while
|
|
40
|
+
already signed in — a second window, a link scrolled back to in chat, a bookmark —
|
|
41
|
+
walled you with "that invite link has been used". The stale token is simply irrelevant
|
|
42
|
+
to someone who already holds a session.
|
|
43
|
+
- **`tosijs-tunnel --close` confirms identity before signalling.** It SIGTERMed every
|
|
44
|
+
`pgrep -f` match, and `pgrep -f` matches an argv *substring* — a shell echoing the
|
|
45
|
+
command or an editor holding a log path both matched. It now checks the process really
|
|
46
|
+
is `ssh`, says what it signalled, and names what it skipped. It also interpolated `host`
|
|
47
|
+
into a REGEX unescaped, so `me@a.b` matched `me@axb` and a host containing `+`, `(` or
|
|
48
|
+
`*` changed the pattern's meaning outright.
|
|
49
|
+
|
|
50
|
+
### Internals, for anyone reading the source
|
|
51
|
+
|
|
52
|
+
- One `shutdown(code)` helper replaces the six copy-pasted exit sites. The 1.9.0 review
|
|
53
|
+
had flagged that duplication as a tidiness follow-up; it turned out not to be untidy but
|
|
54
|
+
to be six copies of the crash above — you cannot fix a bug once if it exists six times.
|
|
55
|
+
- `resolveLinkArrival()` joins `mayReadSite` / `isLockedDown` as a decision extracted from
|
|
56
|
+
the dev server's request closure, where nothing could test it. Each of these was
|
|
57
|
+
extracted only after shipping a bug in it.
|
|
58
|
+
|
|
59
|
+
## 1.9.2
|
|
60
|
+
|
|
61
|
+
**Accessibility: every default color pair now meets WCAG AA.** Found by running
|
|
62
|
+
`hj map` (haltija 1.11) over the doc site, which reports a measured contrast ratio per
|
|
63
|
+
node — none of these were visible as wrong to the eye.
|
|
64
|
+
|
|
65
|
+
| pair | was | now |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| test widget, running | white on `#fa0` — **1.9:1** | `#2b1a00` on `#fa0` — 8.8:1 |
|
|
68
|
+
| test widget, passed | white on `#0a0` — **3.1:1** | white on `#008a00` — 4.5:1 |
|
|
69
|
+
| `--tosi-accent` as text | `#EE257B` — **4.05:1** on white, 4.3:1 on the page | `#d92270` — 4.6:1 |
|
|
70
|
+
| `--tosi-accent` as a fill | white on `#EE257B` — **4.05:1** | white on `#d92270` — 4.8:1 |
|
|
71
|
+
|
|
72
|
+
**The brand pink is 9% darker** (`#EE257B` → `#d92270`). It is imperceptible side by side
|
|
73
|
+
and it was the only value that clears AA in *both* directions — as text on the page, and
|
|
74
|
+
as a fill carrying white text. Override `--tosi-accent` if you want the original.
|
|
75
|
+
|
|
76
|
+
Two more, found by sweeping wider — both in **example code we publish**, which matters
|
|
77
|
+
more than a component default because people copy it: the `.-negative { color: #e44 }` we
|
|
78
|
+
document for negative numbers in tables sat at **3.63:1** (now `#d32f2f`, 4.77:1), and
|
|
79
|
+
`#007AFF` — the accent in every theming example in our docs — was **4.02:1** under white
|
|
80
|
+
text (now `#0064d2`, 5.59:1).
|
|
81
|
+
|
|
82
|
+
Also fixed: the doc-site brand bar's text was a hand-tuned warm off-white
|
|
83
|
+
(`brandColor.rotate(30).brighten(0.9)` → `#fbeae9`) sitting at **4.09:1** on the brand —
|
|
84
|
+
under AA for the normal-size nav links in it. Every brighten value short of pure white
|
|
85
|
+
also misses, so it now uses `contrasting()`, which picks the readable extreme for whatever
|
|
86
|
+
the brand currently is and cannot drift when the brand moves. Three stale `#EE257B` /
|
|
87
|
+
`#007AFF` fallbacks were still live in `header.ts` and `doc-browser.ts` and now match.
|
|
88
|
+
|
|
89
|
+
Verified across 8 pages in both themes on haltija 1.11.2: **0 failures, 0 uncertain**.
|
|
90
|
+
|
|
91
|
+
**The browser-test lane now runs its own private haltija.** It used to reuse any running
|
|
92
|
+
instance and otherwise spawn with `-f`, which reclaims haltija's shared default port and
|
|
93
|
+
kills whatever held it. Both halves were wrong: adopting meant inheriting the *desktop
|
|
94
|
+
app's* window, whose visibility depends on whatever else is on screen — and `hj` rightly
|
|
95
|
+
refuses to drive a hidden tab, since a backgrounded tab throttles `requestAnimationFrame`
|
|
96
|
+
and would produce plausible-but-wrong results. The lane failed roughly two runs in three
|
|
97
|
+
with no programmatic way out. It now takes an isolated instance on an ephemeral port:
|
|
98
|
+
**4/4 runs green**, and a concurrent haltija belonging to someone else survives untouched
|
|
99
|
+
(verified both). Closes #18 and #21.
|
|
100
|
+
|
|
101
|
+
The test widget's text color is now **paired** with its background rather than hardcoded
|
|
102
|
+
to white. Amber is a genuinely light color: darkening it enough for white text turns it
|
|
103
|
+
brown and stops reading as "in progress", so it keeps its hue and takes dark text, which
|
|
104
|
+
is the conventional warning-badge treatment. New `--tosi-test-text-color-{pass,fail,running}`
|
|
105
|
+
variables if you theme these.
|
|
106
|
+
|
|
107
|
+
In dark mode the brand color is **brightened** rather than reused: `invertLuminance` flips
|
|
108
|
+
the neutrals but leaves a saturated hue roughly where it was, so a pink dark enough for a
|
|
109
|
+
near-white page landed at 4.4:1 on a near-black one. The hue is the brand; the luminance
|
|
110
|
+
belongs to whatever it sits on.
|
|
111
|
+
|
|
3
112
|
## 1.9.1
|
|
4
113
|
|
|
5
114
|
**`tosijs-ui` did not import under Node.** Shipped `dist/` used extensionless relative
|
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
|
}
|
package/dist/data-table.js
CHANGED
|
@@ -221,7 +221,7 @@ Numeric types (`number`, `currency`, `fixed`, `percent`, `sci`, `eng`, `bytes`)
|
|
|
221
221
|
by default; `boolean` centers and renders icons. An explicit `align` — or a `dataCell` —
|
|
222
222
|
always wins. Formatting follows the app locale (`setLocale()`). Numeric cells also get a
|
|
223
223
|
`-negative` or `-zero` state class by value sign, so the red "Change" values above come from
|
|
224
|
-
one CSS rule (`.-negative { color: #
|
|
224
|
+
one CSS rule (`.-negative { color: #d32f2f }`), no cell renderer.
|
|
225
225
|
|
|
226
226
|
```js
|
|
227
227
|
import { tosiTable } from 'tosijs-ui'
|
|
@@ -251,7 +251,7 @@ table.value = {
|
|
|
251
251
|
preview.append(table)
|
|
252
252
|
```
|
|
253
253
|
```css
|
|
254
|
-
.preview .-negative { color: #
|
|
254
|
+
.preview .-negative { color: #d32f2f; }
|
|
255
255
|
.preview .-zero { opacity: 0.45; }
|
|
256
256
|
```
|
|
257
257
|
|
|
@@ -687,10 +687,10 @@ export class TosiTable extends WebComponent {
|
|
|
687
687
|
background: varDefault.tosiTableHeaderBg(varDefault.tosiTableBg('var(--tosi-bg, #fff)')),
|
|
688
688
|
},
|
|
689
689
|
':host .tr[aria-selected] .td': {
|
|
690
|
-
background: varDefault.tosiTableSelectedBg('var(--tosi-accent, #
|
|
690
|
+
background: varDefault.tosiTableSelectedBg('var(--tosi-accent, #0064d222)'),
|
|
691
691
|
},
|
|
692
692
|
':host .td:focus, :host .th:focus': {
|
|
693
|
-
outline: '2px solid var(--tosi-accent, #
|
|
693
|
+
outline: '2px solid var(--tosi-accent, #0064d2)',
|
|
694
694
|
outlineOffset: '-2px',
|
|
695
695
|
zIndex: '1',
|
|
696
696
|
},
|
package/dist/doc-browser.js
CHANGED
|
@@ -164,12 +164,29 @@ import { popMenu } from './menu.js';
|
|
|
164
164
|
import { codeEditor } from './code-editor.js';
|
|
165
165
|
import { tosiDiff } from './diff.js';
|
|
166
166
|
const { div, span, a, header, button, template, input, h2, img, details, summary, ul, li, } = elements;
|
|
167
|
-
|
|
167
|
+
/*
|
|
168
|
+
Test colors, and the text color that goes ON each — because contrast is a PAIR.
|
|
169
|
+
|
|
170
|
+
The widget used one hardcoded `color: white` and swapped only the background, so the
|
|
171
|
+
amber running state rendered white-on-#fa0 at **1.9:1** and the green pass state at
|
|
172
|
+
**3.1:1**, both well under WCAG AA's 4.5:1. (Found by `hj map`, which reports a
|
|
173
|
+
`contrast` ratio per node — invisible to the eye until measured.)
|
|
174
|
+
|
|
175
|
+
Amber is a genuinely light color: darkening it far enough for white text turns it brown
|
|
176
|
+
(63% brightness) and stops reading as "in progress". So it keeps its hue and takes dark
|
|
177
|
+
text at 11:1, which is the conventional warning-badge treatment. Green only needed a
|
|
178
|
+
nudge (81% brightness) to carry white.
|
|
179
|
+
*/
|
|
168
180
|
const testColor = {
|
|
169
|
-
pass: varDefault.testColorPass('#
|
|
181
|
+
pass: varDefault.testColorPass('#008a00'),
|
|
170
182
|
fail: varDefault.testColorFail('#c00'),
|
|
171
183
|
running: varDefault.testColorRunning('#fa0'),
|
|
172
184
|
};
|
|
185
|
+
const testTextColor = {
|
|
186
|
+
pass: varDefault.testTextColorPass('#fff'),
|
|
187
|
+
fail: varDefault.testTextColorFail('#fff'),
|
|
188
|
+
running: varDefault.testTextColorRunning('#2b1a00'),
|
|
189
|
+
};
|
|
173
190
|
// Test indicator styles - widget inherits button styles from base stylesheet
|
|
174
191
|
const testIndicatorStyleSpec = {
|
|
175
192
|
'.view-source': { opacity: '0.9' },
|
|
@@ -215,25 +232,30 @@ const testIndicatorStyleSpec = {
|
|
|
215
232
|
// was overlaying this.
|
|
216
233
|
'.test-widget': {
|
|
217
234
|
_testBg: testColor.running,
|
|
235
|
+
_testFg: testTextColor.running,
|
|
218
236
|
position: 'fixed',
|
|
219
237
|
bottom: vars.spacing,
|
|
220
238
|
left: vars.spacing,
|
|
221
239
|
zIndex: '1000',
|
|
222
240
|
background: vars.testBg,
|
|
223
|
-
|
|
241
|
+
// Paired with the background, not fixed to white — see testTextColor above.
|
|
242
|
+
color: vars.testFg,
|
|
224
243
|
gap: vars.spacing50,
|
|
225
244
|
},
|
|
226
245
|
'.test-widget[hidden]': { display: 'none' },
|
|
227
246
|
'.test-widget.-running': {
|
|
228
247
|
_testBg: testColor.running,
|
|
248
|
+
_testFg: testTextColor.running,
|
|
229
249
|
animation: 'test-appear 0.3s ease-out, test-pulse 2s ease-in-out 0.3s infinite',
|
|
230
250
|
},
|
|
231
251
|
'.test-widget.-passed': {
|
|
232
252
|
_testBg: testColor.pass,
|
|
253
|
+
_testFg: testTextColor.pass,
|
|
233
254
|
animation: 'test-fade 3s ease-out forwards',
|
|
234
255
|
},
|
|
235
256
|
'.test-widget.-failed': {
|
|
236
257
|
_testBg: testColor.fail,
|
|
258
|
+
_testFg: testTextColor.fail,
|
|
237
259
|
animation: 'test-pulse 2s ease-in-out infinite',
|
|
238
260
|
},
|
|
239
261
|
// Count badge
|
|
@@ -250,7 +272,7 @@ const testIndicatorStyleSpec = {
|
|
|
250
272
|
},
|
|
251
273
|
// Deep-linked example (e.g. arriving at /slug/#example-2): brief highlight pulse
|
|
252
274
|
'@keyframes example-target-pulse': {
|
|
253
|
-
from: { boxShadow: `0 0 0 3px ${varDefault.accent('#
|
|
275
|
+
from: { boxShadow: `0 0 0 3px ${varDefault.accent('#0064d2')}` },
|
|
254
276
|
to: { boxShadow: '0 0 0 3px transparent' },
|
|
255
277
|
},
|
|
256
278
|
'.example-target': {
|
|
@@ -20,7 +20,7 @@ export const SIDEBAR_WIDTH = 200;
|
|
|
20
20
|
export const SIDEBAR_BREAKPOINT = 600;
|
|
21
21
|
/** Compute the full set of `:root` color variables from a few base colors. */
|
|
22
22
|
export function docSystemColors(theme = {}) {
|
|
23
|
-
const brandColor = Color.fromCss(theme.accent ?? '#
|
|
23
|
+
const brandColor = Color.fromCss(theme.accent ?? '#d92270');
|
|
24
24
|
return {
|
|
25
25
|
_textColor: theme.text ?? '#222',
|
|
26
26
|
_brandColor: brandColor,
|
|
@@ -32,7 +32,18 @@ export function docSystemColors(theme = {}) {
|
|
|
32
32
|
_barColor: brandColor.opacity(0.4),
|
|
33
33
|
_focusColor: brandColor.opacity(0.7),
|
|
34
34
|
_placeholderColor: brandColor.opacity(0.4),
|
|
35
|
-
|
|
35
|
+
/*
|
|
36
|
+
`contrasting()` rather than a hand-tuned tint.
|
|
37
|
+
|
|
38
|
+
`brandColor.rotate(30).brighten(0.9)` produced a warm off-white (#fbeae9) that sat at
|
|
39
|
+
**4.09:1** on the brand bar — under AA for the normal-size nav links in it. Every
|
|
40
|
+
brighten value short of pure white also misses (0.96 lands at 4.49), so the tint had
|
|
41
|
+
to go; it was imperceptible anyway. `contrasting()` picks the readable extreme for
|
|
42
|
+
whatever the brand currently is, so this cannot drift the next time the brand moves.
|
|
43
|
+
Dark mode is unaffected: invertLuminance flips this to near-black on the brightened
|
|
44
|
+
bar, which is already high-contrast.
|
|
45
|
+
*/
|
|
46
|
+
_brandTextColor: brandColor.contrasting(),
|
|
36
47
|
_insetBg: brandColor.rotate(45).brighten(0.8),
|
|
37
48
|
// Neutral off-white (not brand-tinted); dark mode inverts its luminance to
|
|
38
49
|
// off-black, so the editor surface stays a clean neutral in both themes.
|
|
@@ -55,7 +66,7 @@ export function docSystemColors(theme = {}) {
|
|
|
55
66
|
/** Build the full doc-system stylesheet for a given base theme. */
|
|
56
67
|
export function docSystemStyleSpec(theme = {}) {
|
|
57
68
|
const colors = docSystemColors(theme);
|
|
58
|
-
const brandColor = Color.fromCss(theme.accent ?? '#
|
|
69
|
+
const brandColor = Color.fromCss(theme.accent ?? '#d92270');
|
|
59
70
|
return {
|
|
60
71
|
'@import': 'https://fonts.googleapis.com/css2?family=Aleo:ital,wght@0,100..900;1,100..900&famiSpline+Sans+Mono:ital,wght@0,300..700;1,300..700&display=swap',
|
|
61
72
|
':root': {
|
|
@@ -89,6 +100,16 @@ export function docSystemStyleSpec(theme = {}) {
|
|
|
89
100
|
},
|
|
90
101
|
'.darkmode': {
|
|
91
102
|
...invertLuminance(colors),
|
|
103
|
+
/*
|
|
104
|
+
Lift the brand color for dark backgrounds.
|
|
105
|
+
|
|
106
|
+
`invertLuminance` flips the neutrals but leaves a saturated brand hue roughly where
|
|
107
|
+
it was, so one accent has to serve both a near-white and a near-black page — and a
|
|
108
|
+
pink dark enough to clear AA on `#fafafa` (4.56:1) lands at 4.4:1 on `#050505`,
|
|
109
|
+
just under. Brightening it in dark mode is the standard resolution: the hue is the
|
|
110
|
+
brand, the luminance belongs to the background it sits on.
|
|
111
|
+
*/
|
|
112
|
+
_brandColor: brandColor.brighten(0.18),
|
|
92
113
|
_shadowColor: brandColor.opacity(0.5),
|
|
93
114
|
_menuShadow: `0 0 0 2px ${brandColor.opacity(0.75)}`,
|
|
94
115
|
_menuSeparatorColor: brandColor.opacity(0.5),
|
|
@@ -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
|
+
}
|
|
@@ -9,13 +9,14 @@ Build-time only (Bun APIs). Never import this from browser code.
|
|
|
9
9
|
*/
|
|
10
10
|
import * as path from 'path';
|
|
11
11
|
import { statSync } from 'fs';
|
|
12
|
+
import { tmpdir } from 'os';
|
|
12
13
|
import { $, spawn } from 'bun';
|
|
13
14
|
import { buildSite } from './orchestrator.js';
|
|
14
15
|
import { preflight } from './preflight.js';
|
|
15
16
|
import { auditDependencies, reportAudit } from './audit-guard.js';
|
|
16
17
|
import { openDevBrowser } from './open-browser.js';
|
|
17
18
|
import { resolveTunnelLocalPort } from './site-config.js';
|
|
18
|
-
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';
|
|
19
20
|
const TEST_RESULTS_FILE = '.browser-tests.json';
|
|
20
21
|
const DEFAULT_IDLE_HOURS = 8;
|
|
21
22
|
/**
|
|
@@ -940,20 +941,37 @@ export async function devServer(config, opts = {}) {
|
|
|
940
941
|
'Referrer-Policy': 'no-referrer',
|
|
941
942
|
'Cache-Control': 'no-store',
|
|
942
943
|
};
|
|
943
|
-
|
|
944
|
+
const heldCookie = readCookie(request.headers.get('cookie'), SESSION_COOKIE);
|
|
945
|
+
const arrival = resolveLinkArrival({
|
|
946
|
+
redeemed: session,
|
|
947
|
+
hasValidSession: validSession(auth, heldCookie, Date.now()),
|
|
948
|
+
});
|
|
949
|
+
if (arrival === 'issue-session') {
|
|
944
950
|
headers['Set-Cookie'] = sessionCookie(session);
|
|
945
951
|
console.log('🔓 edit link redeemed — session issued');
|
|
946
952
|
return new Response(null, { status: 302, headers });
|
|
947
953
|
}
|
|
948
954
|
/*
|
|
949
|
-
|
|
955
|
+
A VALID SESSION TRUMPS A STALE LINK.
|
|
956
|
+
|
|
957
|
+
The comment that used to sit here asserted that a session holder "never lands
|
|
958
|
+
here, their cookie is sent automatically" — and nothing enforced it. The token is
|
|
959
|
+
read before any session check, so anyone already signed in who clicks an older link
|
|
960
|
+
(a second window, a link scrolled back to in chat, a bookmark) got walled with
|
|
961
|
+
"that invite link has been used" while holding a perfectly good session. The stale
|
|
962
|
+
token is simply irrelevant to them.
|
|
963
|
+
|
|
964
|
+
Strip it and carry on. No new session is issued: they already have one, and
|
|
965
|
+
re-issuing on a spent token would make expiry meaningless.
|
|
966
|
+
*/
|
|
967
|
+
if (arrival === 'already-authenticated') {
|
|
968
|
+
return new Response(null, { status: 302, headers });
|
|
969
|
+
}
|
|
970
|
+
/*
|
|
971
|
+
Genuinely unauthenticated, and the likely culprit is not an attacker but a
|
|
950
972
|
chat-app link-preview bot, whose GET *is* the first use. dev-auth.ts names
|
|
951
973
|
unfurlers as a reason for single-use without handling the consequence: you click
|
|
952
974
|
a dead link, get silently redirected, and the next signal is a save failing.
|
|
953
|
-
|
|
954
|
-
An anonymous reader who already holds a session never lands here (their cookie is
|
|
955
|
-
sent automatically), so saying this out loud costs nothing and saves a confusing
|
|
956
|
-
hunt.
|
|
957
975
|
*/
|
|
958
976
|
const spent = `<!doctype html><meta charset=utf-8><title>Link already used</title>` +
|
|
959
977
|
`<style>body{font:16px/1.6 system-ui;margin:15vh auto;max-width:32rem;padding:0 1.5rem;color:#222}` +
|
|
@@ -1071,7 +1089,12 @@ export async function devServer(config, opts = {}) {
|
|
|
1071
1089
|
}
|
|
1072
1090
|
return new Response('File not found', { status: 404 });
|
|
1073
1091
|
};
|
|
1074
|
-
|
|
1092
|
+
/*
|
|
1093
|
+
Not bound to a name: nothing stops these listeners. `process.exit()` ends the process
|
|
1094
|
+
and the OS closes the sockets, and calling `.stop()` first is what segfaulted on the
|
|
1095
|
+
idle path (#47). A handle we would never legitimately use is an invitation to use it.
|
|
1096
|
+
*/
|
|
1097
|
+
Bun.serve({
|
|
1075
1098
|
port: PORT,
|
|
1076
1099
|
tls: {
|
|
1077
1100
|
key: Bun.file('./tls/key.pem'),
|
|
@@ -1107,10 +1130,9 @@ export async function devServer(config, opts = {}) {
|
|
|
1107
1130
|
? resolveTunnelLocalPort(config, process.env)
|
|
1108
1131
|
: undefined;
|
|
1109
1132
|
const configuredTunnelPort = config.preview?.tunnel?.localPort;
|
|
1110
|
-
let tunnelServer;
|
|
1111
1133
|
if (tunnelPort && !testMode && !process.env.DEV_NO_TUNNEL) {
|
|
1112
1134
|
try {
|
|
1113
|
-
|
|
1135
|
+
Bun.serve({
|
|
1114
1136
|
port: tunnelPort,
|
|
1115
1137
|
hostname: '127.0.0.1',
|
|
1116
1138
|
fetch: (request, srv) => handleRequest(request, srv, true),
|
|
@@ -1131,6 +1153,27 @@ export async function devServer(config, opts = {}) {
|
|
|
1131
1153
|
`${configuredTunnelPort ? '' : ' — it defaults to PORT + 1'}.`);
|
|
1132
1154
|
}
|
|
1133
1155
|
}
|
|
1156
|
+
/*
|
|
1157
|
+
Exit WITHOUT stopping the listeners first.
|
|
1158
|
+
|
|
1159
|
+
Every one of these six sites called `server.stop()` (and `tunnelServer?.stop()`)
|
|
1160
|
+
immediately before `process.exit()`. That buys nothing — `process.exit` terminates the
|
|
1161
|
+
process and the OS closes the listening sockets — and it is where the process was
|
|
1162
|
+
dying: tosijs-3d reported a reproducible **segfault at ~7.95h** against the 8h idle
|
|
1163
|
+
timer, twice, on the shutdown path, with flat 0.34GB RSS (so not the Bun.build native
|
|
1164
|
+
leak) and a faulting address that decodes to ASCII text rather than a null — the shape
|
|
1165
|
+
of a use-after-free inside Bun's own teardown. tosijs-ui#47.
|
|
1166
|
+
|
|
1167
|
+
A `try/catch` around the stops would NOT help: a segfault is not a JS exception. Not
|
|
1168
|
+
making the call is the only fix available to us. Filed upstream separately, because
|
|
1169
|
+
Bun should not crash here either.
|
|
1170
|
+
|
|
1171
|
+
Child processes we spawned are a different matter — those we must still reap, since the
|
|
1172
|
+
OS will not do it for a detached Electron grandchild. Callers do that before calling in.
|
|
1173
|
+
*/
|
|
1174
|
+
const shutdown = (code) => {
|
|
1175
|
+
process.exit(code);
|
|
1176
|
+
};
|
|
1134
1177
|
console.log(`Listening on https://localhost:${PORT}`);
|
|
1135
1178
|
// ── open (or bring to front) this project's browser tab ─────────────────────
|
|
1136
1179
|
//
|
|
@@ -1182,9 +1225,7 @@ export async function devServer(config, opts = {}) {
|
|
|
1182
1225
|
` updates, which a long-running process never does. Disable with\n` +
|
|
1183
1226
|
` DEV_IDLE_TIMEOUT_HOURS=0 or idleTimeoutHours in your site config.\n`);
|
|
1184
1227
|
clearInterval(timer);
|
|
1185
|
-
|
|
1186
|
-
tunnelServer?.stop();
|
|
1187
|
-
process.exit(0);
|
|
1228
|
+
shutdown(0);
|
|
1188
1229
|
}
|
|
1189
1230
|
const ok = await preflight({
|
|
1190
1231
|
label: 'Dev server',
|
|
@@ -1193,9 +1234,7 @@ export async function devServer(config, opts = {}) {
|
|
|
1193
1234
|
});
|
|
1194
1235
|
if (!ok) {
|
|
1195
1236
|
clearInterval(timer);
|
|
1196
|
-
|
|
1197
|
-
tunnelServer?.stop();
|
|
1198
|
-
process.exit(1);
|
|
1237
|
+
shutdown(1);
|
|
1199
1238
|
}
|
|
1200
1239
|
}, TICK_MS);
|
|
1201
1240
|
// Never let the health check itself be the thing keeping the process alive.
|
|
@@ -1210,7 +1249,6 @@ export async function devServer(config, opts = {}) {
|
|
|
1210
1249
|
testReportResolve = resolve;
|
|
1211
1250
|
setTimeout(() => reject(new Error('Browser tests timed out')), testTimeout);
|
|
1212
1251
|
});
|
|
1213
|
-
let haltija;
|
|
1214
1252
|
/**
|
|
1215
1253
|
* Tear down the haltija WE started — including the Electron grandchild. Defined up
|
|
1216
1254
|
* front so EVERY failure path can call it, including the start-timeout below, which
|
|
@@ -1270,72 +1308,54 @@ export async function devServer(config, opts = {}) {
|
|
|
1270
1308
|
|
|
1271
1309
|
Declining to adopt is safe: we spawn with `-f`, which stomps the stale instance.
|
|
1272
1310
|
*/
|
|
1273
|
-
|
|
1311
|
+
/*
|
|
1312
|
+
ALWAYS an isolated instance. Never adopt, never stomp.
|
|
1313
|
+
|
|
1314
|
+
The lane used to reuse any reachable haltija and otherwise spawn with `-f`, which
|
|
1315
|
+
reclaims the shared default port and SIGTERMs whatever held it — often another
|
|
1316
|
+
project's session. Both halves were a problem:
|
|
1317
|
+
|
|
1318
|
+
- adopting meant inheriting the DESKTOP app's window, whose visibility depends on
|
|
1319
|
+
whatever else is on screen. `hj` refuses to drive a hidden tab (rightly — a
|
|
1320
|
+
backgrounded tab throttles rAF, so results are plausible-but-wrong), so the lane
|
|
1321
|
+
failed intermittently, roughly two runs in three, with no programmatic way out.
|
|
1322
|
+
- `-f` made this project's test run destructive to everyone else's.
|
|
1323
|
+
|
|
1324
|
+
`--private` gives us our own server on an EPHEMERAL port, so there is nothing to
|
|
1325
|
+
contend for and nothing to kill. The port is written to a file rather than guessed.
|
|
1326
|
+
(tosijs-ui#18, #21.)
|
|
1327
|
+
*/
|
|
1328
|
+
const portFile = `${tmpdir()}/tosijs-haltija-${process.pid}.json`;
|
|
1329
|
+
console.log('Starting a private haltija…');
|
|
1330
|
+
const haltija = spawn(['bunx', HALTIJA_PKG, '--private', '--port-file', portFile], {
|
|
1331
|
+
// stdout/stderr are NOT inherited: `bunx haltija` launches Electron as a
|
|
1332
|
+
// grandchild, and killing the bunx wrapper does not kill it. An orphan holding
|
|
1333
|
+
// our inherited stdout keeps the pipe open forever, so the command looks hung
|
|
1334
|
+
// long after it exited.
|
|
1335
|
+
stdout: 'ignore',
|
|
1336
|
+
stderr: 'ignore',
|
|
1337
|
+
});
|
|
1338
|
+
// Read the port it actually bound, rather than assuming one.
|
|
1339
|
+
let hjPort = '';
|
|
1340
|
+
for (let i = 0; i < 60; i++) {
|
|
1341
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1274
1342
|
try {
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
`JSON.parse` throws and a `catch` that fell through to `return true` adopted the
|
|
1280
|
-
very dead server this check exists to reject — the gate was inert on every
|
|
1281
|
-
npm-installed CLI. It passed verification here only because this machine's `hj` is
|
|
1282
|
-
a standalone bundle with no `hints.json` beside it, so the hint was never emitted:
|
|
1283
|
-
a real behavioural test, run against an environment adopters do not have.
|
|
1284
|
-
|
|
1285
|
-
Failing closed costs nothing: the spawn below uses `-f`, which stomps a stale
|
|
1286
|
-
instance. Adopting a server we could not read is the expensive direction.
|
|
1287
|
-
*/
|
|
1288
|
-
const out = await $ `hj windows --json`.quiet().text();
|
|
1289
|
-
return haltijaIsDrivable(out);
|
|
1343
|
+
const info = JSON.parse(await Bun.file(portFile).text());
|
|
1344
|
+
hjPort = String(info.port ?? info.address?.port ?? '');
|
|
1345
|
+
if (hjPort)
|
|
1346
|
+
break;
|
|
1290
1347
|
}
|
|
1291
1348
|
catch {
|
|
1292
|
-
|
|
1349
|
+
/* not written yet */
|
|
1293
1350
|
}
|
|
1294
|
-
};
|
|
1295
|
-
const adoptable = await haltijaReachable();
|
|
1296
|
-
if (!adoptable) {
|
|
1297
|
-
/*
|
|
1298
|
-
Leave a receipt: spawning with `-f` reclaims haltija's shared default port, which
|
|
1299
|
-
SIGTERMs whatever was there — frequently another project's session, or the
|
|
1300
|
-
developer's own. This file already sets that norm for killStrayServer; declining to
|
|
1301
|
-
adopt makes the stomp MORE frequent, so it must be at least as loud.
|
|
1302
|
-
*/
|
|
1303
|
-
console.log(`No drivable haltija (server absent, or up with no connected tab).\n` +
|
|
1304
|
-
` Starting one with -f, which reclaims the shared haltija port — any other\n` +
|
|
1305
|
-
` project's session on it will be stopped. (cwd: ${process.cwd()})`);
|
|
1306
1351
|
}
|
|
1307
|
-
if (
|
|
1308
|
-
console.
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
// stdout/stderr are NOT inherited: `bunx haltija` launches an Electron app as a
|
|
1313
|
-
// grandchild, and killing the bunx wrapper does not kill it. An orphaned Electron
|
|
1314
|
-
// holding our inherited stdout keeps the pipe open forever, so a CI job or an
|
|
1315
|
-
// agent capturing this command's output sees it hang long after it exited (0).
|
|
1316
|
-
haltija = spawn(['bunx', HALTIJA_PKG, '-f'], {
|
|
1317
|
-
stdout: 'ignore',
|
|
1318
|
-
stderr: 'ignore',
|
|
1319
|
-
});
|
|
1320
|
-
const waitStarted = Date.now();
|
|
1321
|
-
console.log('Waiting for haltija…');
|
|
1322
|
-
let up = false;
|
|
1323
|
-
for (let i = 0; i < 20; i++) {
|
|
1324
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1325
|
-
if (await haltijaReachable()) {
|
|
1326
|
-
up = true;
|
|
1327
|
-
break;
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
if (!up) {
|
|
1331
|
-
console.error(`haltija did not become drivable within ${Math.round((Date.now() - waitStarted) / 1000)}s.\n` +
|
|
1332
|
-
` A single \`hj windows\` can block for several seconds when a server is up\n` +
|
|
1333
|
-
` with no tab, so this can take much longer than the poll count suggests.`);
|
|
1334
|
-
await stopHaltija();
|
|
1335
|
-
server.stop();
|
|
1336
|
-
process.exit(1);
|
|
1337
|
-
}
|
|
1352
|
+
if (!hjPort) {
|
|
1353
|
+
console.error(`haltija did not report a private port within 30s (${portFile}).\n` +
|
|
1354
|
+
` Is \`bunx ${HALTIJA_PKG}\` able to run? Try it by hand.`);
|
|
1355
|
+
await stopHaltija();
|
|
1356
|
+
shutdown(1);
|
|
1338
1357
|
}
|
|
1358
|
+
console.log(` private haltija on port ${hjPort}`);
|
|
1339
1359
|
console.log('Opening demo site...');
|
|
1340
1360
|
// haltija 1.4: `hj navigate` exits NON-ZERO on failure (no browser reachable, a
|
|
1341
1361
|
// window that didn't take the URL, …). Before 1.4 it exited 0 regardless, so a
|
|
@@ -1343,25 +1363,24 @@ export async function devServer(config, opts = {}) {
|
|
|
1343
1363
|
// timeout with a misleading "Browser tests timed out". Now we can read the exit
|
|
1344
1364
|
// code: fail immediately, say why, and tear down the haltija we spawned so the
|
|
1345
1365
|
// next run isn't inheriting a half-navigated browser.
|
|
1346
|
-
const nav = await $ `hj navigate https://localhost:${PORT}
|
|
1366
|
+
const nav = await $ `hj --port ${hjPort} navigate https://localhost:${PORT}`
|
|
1367
|
+
.nothrow()
|
|
1368
|
+
.quiet();
|
|
1347
1369
|
if (nav.exitCode !== 0) {
|
|
1348
1370
|
console.error(`hj navigate failed (exit ${nav.exitCode}): ${nav.stderr.toString().trim() || 'no browser reachable'}`);
|
|
1349
1371
|
await stopHaltija();
|
|
1350
|
-
|
|
1351
|
-
process.exit(1);
|
|
1372
|
+
shutdown(1);
|
|
1352
1373
|
}
|
|
1353
1374
|
try {
|
|
1354
1375
|
const results = await testResults;
|
|
1355
1376
|
const exitCode = results.failed > 0 ? 1 : 0;
|
|
1356
1377
|
await stopHaltija();
|
|
1357
|
-
|
|
1358
|
-
process.exit(exitCode);
|
|
1378
|
+
shutdown(exitCode);
|
|
1359
1379
|
}
|
|
1360
1380
|
catch (e) {
|
|
1361
1381
|
console.error(e.message);
|
|
1362
1382
|
await stopHaltija();
|
|
1363
|
-
|
|
1364
|
-
process.exit(1);
|
|
1383
|
+
shutdown(1);
|
|
1365
1384
|
}
|
|
1366
1385
|
}
|
|
1367
1386
|
}
|