tosijs-ui 1.16.28 → 1.16.30

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 CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.30
4
+
5
+ ### Tunnel
6
+
7
+ - **A dev-server restart no longer costs a key holder their next save.** `bun start` restarts
8
+ the server whenever a file in its import graph changes, and that includes every component
9
+ source, so editing an example restarts it. Save to source writes such a file, so the save
10
+ itself restarted the server, and the NEXT save from the same page was told "This workspace
11
+ needs an invite link". Reloading recovered the session but lost the unsaved edit. Now a
12
+ request from the workspace's own page that holds the global key cookie is given a fresh
13
+ session in place, and the haltija agent bridge's reconnection is too. "Own page" means
14
+ `Sec-Fetch-Site: same-origin` or an `Origin` that is this host; a sibling workspace's page,
15
+ which receives the same cookie, gets nothing, nor does a request with neither header. A
16
+ session opened from a plain `--link` holds no key, so a restart still ends it.
17
+ - **For a key holder, the key cookie now bounds access, not the session.** A key opens a
18
+ one-day session, but the cookie renews it, so access lasts as long as the cookie (30 days for
19
+ the edit key) or until you rotate the key and restart the dev servers.
20
+
21
+ ## 1.16.29
22
+
23
+ ### Doc system
24
+
25
+ - **A wikilink can name a page by its title.** `[[The feature registry]]` now reaches the page
26
+ at `/registry/`. Before, a target was only ever slugified, so it went to
27
+ `/the-feature-registry/` and 404'd. The slug (file name) still wins when a page has one, so
28
+ no link that worked changes target. A title two pages share reaches neither.
29
+ Reported by tosijs-3d-ensemble (#234).
30
+ - **The build reports a wikilink that reaches no doc page**, naming the file and the target,
31
+ and fails under `strict`. One site had nine of these, live 404s from as far back as its 0.3,
32
+ and no build had said a word. **If you build with `strict: true`:** a wikilink to something
33
+ that is not a doc page, such as a hand-written page in your static folder, now fails the
34
+ build even though the link works. Write it as an ordinary `[label](/path/)`.
35
+ - **The test-status widget sits in the header bar.** It was fixed at the bottom left over the
36
+ page, so a full-page component's last list row lost clicks to it (#233). A page with
37
+ `{"header": false}` still floats it there, having nowhere else to put it.
38
+
39
+ ### Tunnel
40
+
41
+ - **The "needs an invite link" page offers the lasting link too.** When the dev server takes
42
+ the preview box's global keys, it now names `tosijs-tunnel --link --global` beside the
43
+ per-visit `--link`. That link keeps the browser in for 30 days across restarts (#232).
44
+
3
45
  ## 1.16.28
4
46
 
5
47
  ### Tunnel
@@ -95,7 +95,7 @@ history, for a browser embedded in another page: drive it with `initialRoute`,
95
95
  */
96
96
  /*{ "parent": "Documentation System", "order": 50 }*/
97
97
  import { elements, vars, varDefault, bindings, touch, getListItem, debounce, tosi, StyleSheet, } from 'tosijs';
98
- import { buildSlugMap, pathForSlug, filenameForPath, homeDoc, } from './doc-system/routing.js';
98
+ import { buildSlugMap, pathForSlug, filenameForPath, homeDoc, wikilinkTargets, } from './doc-system/routing.js';
99
99
  import { buildNavTree } from './doc-system/nav-tree.js';
100
100
  import { renderDocMarkdown } from './doc-system/render.js';
101
101
  import { pageTitle } from './doc-system/doc-title.js';
@@ -202,21 +202,28 @@ const testIndicatorStyleSpec = {
202
202
  background: testColor.fail,
203
203
  animation: 'test-pulse 2s ease-in-out infinite',
204
204
  },
205
- // Floating widget - position and colors only, inherits button structure.
206
- // Bottom-LEFT: bottom-right is where the injected haltija dev widget sits, and it
207
- // was overlaying this.
205
+ /*
206
+ The widget sits in the header bar, not over the page (GitHub #233).
207
+
208
+ It used to be `position: fixed` at the bottom left, on top of whatever the page drew there:
209
+ a full-page component's list ran under it and a click on its last row landed on the widget
210
+ instead. Only a page with no header (`{"header": false}`) still floats it, because there is
211
+ nowhere else to put it. Bottom-LEFT, because the haltija dev widget takes bottom-right.
212
+ */
208
213
  '.test-widget': {
209
214
  _testBg: testColor.running,
210
215
  _testFg: testTextColor.running,
211
- position: 'fixed',
212
- bottom: vars.spacing,
213
- left: vars.spacing,
214
- zIndex: '1000',
215
216
  background: vars.testBg,
216
217
  // Paired with the background, not fixed to white — see testTextColor above.
217
218
  color: vars.testFg,
218
219
  gap: vars.spacing50,
219
220
  },
221
+ '.test-widget.-floating': {
222
+ position: 'fixed',
223
+ bottom: vars.spacing,
224
+ left: vars.spacing,
225
+ zIndex: '1000',
226
+ },
220
227
  '.test-widget[hidden]': { display: 'none' },
221
228
  '.test-widget.-running': {
222
229
  _testBg: testColor.running,
@@ -368,6 +375,7 @@ export function createDocBrowser(options) {
368
375
  // while letting <tosi-doc-system> drive clean `/slug/` URLs off the same docs.
369
376
  // Both path and memory routing key off slugs; only legacy query routing doesn't.
370
377
  const slugMap = routing === 'query' ? {} : buildSlugMap(docs);
378
+ const wikilinks = wikilinkTargets(docs);
371
379
  const slugFor = (filename) => slugMap[filename] ?? filename;
372
380
  const filenameForSlug = (slug) => {
373
381
  for (const d of docs)
@@ -974,6 +982,7 @@ export function createDocBrowser(options) {
974
982
  // without loading the tjs transpiler. See self-contained-examples-plan.md.
975
983
  docContent.innerHTML = renderDocMarkdown(doc.text, {
976
984
  bakes: doc.bakes ? new Map(doc.bakes) : undefined,
985
+ wikilinks,
977
986
  });
978
987
  }
979
988
  /*
@@ -1029,6 +1038,7 @@ export function createDocBrowser(options) {
1029
1038
  // to follow the doc, so this does not ride on that path succeeding.
1030
1039
  refreshNavToggle();
1031
1040
  }
1041
+ placeTestWidget(doc);
1032
1042
  rewriteContentLinks();
1033
1043
  // Stamp each example with its source file (for the source↔doc map). doc.path
1034
1044
  // is the extracted file (.md, or a source file with doc comments).
@@ -1262,7 +1272,7 @@ export function createDocBrowser(options) {
1262
1272
  return;
1263
1273
  editUI.view = view;
1264
1274
  if (view === 'preview') {
1265
- docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path));
1275
+ docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path), { wikilinks });
1266
1276
  rewriteContentLinks();
1267
1277
  LiveExample.insertExamples(docContent, context, editUI.doc.path || undefined);
1268
1278
  }
@@ -1615,6 +1625,21 @@ export function createDocBrowser(options) {
1615
1625
  height: '100%',
1616
1626
  },
1617
1627
  }, docContent)));
1628
+ // Test status widget
1629
+ const testWidget = button({
1630
+ class: 'test-widget',
1631
+ hidden: true,
1632
+ onClick: showTestMenu,
1633
+ }, span({ part: 'label' }, 'Tests'), span({ class: 'count', part: 'count' }, '0'));
1634
+ const headerBar = container.querySelector('header');
1635
+ // In the header unless this doc hides the header; see `.test-widget` in the styles.
1636
+ const placeTestWidget = (doc) => {
1637
+ const floating = doc.header === false;
1638
+ testWidget.classList.toggle('-floating', floating);
1639
+ const home = floating ? container : headerBar;
1640
+ if (testWidget.parentElement !== home)
1641
+ home.appendChild(testWidget);
1642
+ };
1618
1643
  // Render the landing doc (adopts pre-rendered HTML when hydrating).
1619
1644
  showDoc(currentDoc);
1620
1645
  // Inject test indicator styles
@@ -1622,13 +1647,6 @@ export function createDocBrowser(options) {
1622
1647
  // Header brand-mark sizing (icon / <img> / inline-<svg>), one CSS var each.
1623
1648
  StyleSheet('doc-logo-mark', logoMarkStyleSpec);
1624
1649
  StyleSheet('doc-nav-search', navSearchStyleSpec);
1625
- // Floating widget for test status
1626
- const testWidget = button({
1627
- class: 'test-widget',
1628
- hidden: true,
1629
- onClick: showTestMenu,
1630
- }, span({ part: 'label' }, 'Tests'), span({ class: 'count', part: 'count' }, '0'));
1631
- container.appendChild(testWidget);
1632
1650
  /*
1633
1651
  The far end has something to tell you.
1634
1652
 
@@ -7,7 +7,7 @@ Node-only APIs — so it bundles into the iife. Live examples are pretty-printed
7
7
  force-wrapped code listings, not executed.
8
8
  */
9
9
  import { renderDocMarkdown } from './render.js';
10
- import { buildSlugMap } from './routing.js';
10
+ import { buildSlugMap, wikilinkTargets } from './routing.js';
11
11
  import { buildNavTree } from './nav-tree.js';
12
12
  import { bookTextOf } from './book-target.js';
13
13
  // Clean book typography; force-wraps code so listings never overflow a page.
@@ -149,10 +149,13 @@ function tocHtml(nodes) {
149
149
  export function buildBookHtml(docs, opts) {
150
150
  const visible = docs.filter((d) => !d.hidden);
151
151
  const slugMap = buildSlugMap(visible);
152
+ const wikilinks = wikilinkTargets(visible);
152
153
  const roots = buildNavTree(visible, slugMap);
153
154
  const css = opts.css ?? `${DEFAULT_BOOK_CSS}\n${PRINT_CSS}`;
154
155
  const chapters = flatten(roots)
155
- .map((node) => `<section class="chapter" id="${slugify(node.doc.filename)}">\n${renderDocMarkdown(stripDocMeta(bookTextOf(node.doc)))}\n</section>`)
156
+ .map((node) => `<section class="chapter" id="${slugify(node.doc.filename)}">\n${renderDocMarkdown(stripDocMeta(bookTextOf(node.doc)), {
157
+ wikilinks,
158
+ })}\n</section>`)
156
159
  .join('\n');
157
160
  return `<!DOCTYPE html>
158
161
  <html lang="${opts.lang ?? 'en'}">
@@ -6,6 +6,8 @@ export type ExampleBakes = Map<string, ExampleBake>;
6
6
  export declare function unwrapLoneCustomElements(html: string): string;
7
7
  export declare function renderDocMarkdown(text: string, opts?: {
8
8
  bakes?: ExampleBakes;
9
+ /** the corpus's `wikilinkTargets`; without it a `[[target]]` is slugified as written */
10
+ wikilinks?: Map<string, string>;
9
11
  }): string;
10
12
  /**
11
13
  * Derive a clean <meta name="description"> from a doc's first prose paragraph.
@@ -11,8 +11,10 @@ give that example a stable anchor — see the docMarked renderer below.
11
11
  import { Marked, Renderer } from 'marked';
12
12
  import { parseFenceInfo } from './example-policy.js';
13
13
  import { HeadingIds, headingSlug } from './heading-id.js';
14
+ import { pathForSlug, slugify } from './routing.js';
14
15
  const baseRenderer = new Renderer();
15
16
  let currentBakes;
17
+ let currentWikilinks;
16
18
  // Per-parse, reset in `preprocess` below (same pattern as the footnote state).
17
19
  let headingIds = new HeadingIds();
18
20
  const docMarked = new Marked();
@@ -69,12 +71,6 @@ docMarked.use({
69
71
  // Enabled by default because each activates ONLY on its own syntax ([[…]] /
70
72
  // [^id]) — a doc that doesn't use them renders byte-identically (tosijs-ui's own
71
73
  // docs use neither). marked core supports neither; both are common in prose.
72
- function slugify(s) {
73
- return s
74
- .toLowerCase()
75
- .replace(/[^a-z0-9]+/g, '-')
76
- .replace(/^-+|-+$/g, '');
77
- }
78
74
  function escapeHtml(s) {
79
75
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
80
76
  }
@@ -112,7 +108,9 @@ docMarked.use({
112
108
  extensions: [
113
109
  {
114
110
  // [[slug]] / [[slug|label]] → link to /slug/. Not matched inside code spans
115
- // (Marked tokenizes those first). Resolves by slugifying the target.
111
+ // (Marked tokenizes those first). Given the corpus's `wikilinkTargets`, a target is
112
+ // looked up there (by slug, then title) and one that reaches no page is marked
113
+ // `-missing` for the build to report; without them it is slugified as written.
116
114
  name: 'wikilink',
117
115
  level: 'inline',
118
116
  start(src) {
@@ -130,7 +128,12 @@ docMarked.use({
130
128
  };
131
129
  },
132
130
  renderer(token) {
133
- return `<a href="/${slugify(token.target)}/" class="wikilink">${escapeHtml(token.label)}</a>`;
131
+ const key = slugify(token.target);
132
+ const slug = currentWikilinks ? currentWikilinks.get(key) : key;
133
+ const missing = slug === undefined
134
+ ? ` -missing" data-target="${escapeAttr(token.target)}`
135
+ : '';
136
+ return `<a href="${pathForSlug(slug ?? key)}" class="wikilink${missing}">${escapeHtml(token.label)}</a>`;
134
137
  },
135
138
  },
136
139
  {
@@ -205,11 +208,13 @@ export function unwrapLoneCustomElements(html) {
205
208
  }
206
209
  export function renderDocMarkdown(text, opts = {}) {
207
210
  currentBakes = opts.bakes;
211
+ currentWikilinks = opts.wikilinks;
208
212
  try {
209
213
  return unwrapLoneCustomElements(docMarked.parse(text));
210
214
  }
211
215
  finally {
212
216
  currentBakes = undefined;
217
+ currentWikilinks = undefined;
213
218
  }
214
219
  }
215
220
  /**
@@ -19,6 +19,18 @@ export declare function homeDoc(docs: DocLike[]): string | null;
19
19
  * on array order.
20
20
  */
21
21
  export declare function buildSlugMap(docs: DocLike[]): Record<string, string>;
22
+ /**
23
+ * What each `[[target]]` in a corpus can reach: { slugify(target) -> slug }.
24
+ *
25
+ * A page is reached by its slug, and also by its title when no page has that slug and no
26
+ * other page has the same title (GitHub #234). Authors write a page's TITLE —
27
+ * `[[The feature registry]]` — while the page lives at its file name, `/registry/`; before
28
+ * this, every such link went to `/the-feature-registry/` and 404'd without a word. A slug
29
+ * always wins over a title, so no link that already worked changes target.
30
+ */
31
+ export declare function wikilinkTargets(docs: Array<DocLike & {
32
+ title?: string;
33
+ }>): Map<string, string>;
22
34
  /** site-root-relative path for a slug: '' -> '/', 'button' -> '/button/' */
23
35
  export declare function pathForSlug(slug: string): string;
24
36
  /**
@@ -56,6 +56,36 @@ export function buildSlugMap(docs) {
56
56
  }
57
57
  return map;
58
58
  }
59
+ /**
60
+ * What each `[[target]]` in a corpus can reach: { slugify(target) -> slug }.
61
+ *
62
+ * A page is reached by its slug, and also by its title when no page has that slug and no
63
+ * other page has the same title (GitHub #234). Authors write a page's TITLE —
64
+ * `[[The feature registry]]` — while the page lives at its file name, `/registry/`; before
65
+ * this, every such link went to `/the-feature-registry/` and 404'd without a word. A slug
66
+ * always wins over a title, so no link that already worked changes target.
67
+ */
68
+ export function wikilinkTargets(docs) {
69
+ const slugs = buildSlugMap(docs);
70
+ const targets = new Map();
71
+ for (const doc of docs) {
72
+ const slug = slugs[doc.filename];
73
+ if (slug !== '')
74
+ targets.set(slugify(slug), slug);
75
+ }
76
+ const byTitle = new Map();
77
+ for (const doc of docs) {
78
+ const key = doc.title ? slugify(doc.title) : '';
79
+ if (!key || targets.has(key))
80
+ continue;
81
+ // Two pages with one title: neither is reachable by it, so the link is reported.
82
+ byTitle.set(key, byTitle.has(key) ? null : slugs[doc.filename]);
83
+ }
84
+ for (const [key, slug] of byTitle)
85
+ if (slug !== null)
86
+ targets.set(key, slug);
87
+ return targets;
88
+ }
59
89
  /** site-root-relative path for a slug: '' -> '/', 'button' -> '/button/' */
60
90
  export function pathForSlug(slug) {
61
91
  return slug === '' ? '/' : `/${slug}/`;
@@ -32,3 +32,11 @@ export declare function deadInPageAnchors(html: string): string[];
32
32
  * (`diagram.svg`, `img/x.png`) is left alone. The fix is the root-relative `/value-renderer/`.
33
33
  */
34
34
  export declare function bareNameLinks(html: string): string[];
35
+ /**
36
+ * The `[[wikilinks]]` in a RENDERED page that reach no page, as their authors wrote them.
37
+ *
38
+ * The renderer marks one `-missing` (with its target in `data-target`) when it was given the
39
+ * corpus's `wikilinkTargets` and neither a slug nor a title matched. Nine of these sat in one
40
+ * site for five releases, every one a live 404, because nothing said so (GitHub #234).
41
+ */
42
+ export declare function missingWikilinks(html: string): string[];
@@ -128,3 +128,30 @@ export function bareNameLinks(html) {
128
128
  }
129
129
  return [...found];
130
130
  }
131
+ /**
132
+ * The `[[wikilinks]]` in a RENDERED page that reach no page, as their authors wrote them.
133
+ *
134
+ * The renderer marks one `-missing` (with its target in `data-target`) when it was given the
135
+ * corpus's `wikilinkTargets` and neither a slug nor a title matched. Nine of these sat in one
136
+ * site for five releases, every one a live 404, because nothing said so (GitHub #234).
137
+ */
138
+ export function missingWikilinks(html) {
139
+ const found = new Set();
140
+ for (const tag of html.matchAll(START_TAG)) {
141
+ if (tag[1].toLowerCase() !== 'a')
142
+ continue;
143
+ let missing = false;
144
+ let target = '';
145
+ for (const attr of tag[2].matchAll(ATTRIBUTE)) {
146
+ const key = attr[1].toLowerCase();
147
+ const value = unescapeAttr(attr[2] ?? attr[3] ?? attr[4] ?? '');
148
+ if (key === 'class')
149
+ missing = value.split(/\s+/).includes('-missing');
150
+ else if (key === 'data-target')
151
+ target = value;
152
+ }
153
+ if (missing && target)
154
+ found.add(target);
155
+ }
156
+ return [...found];
157
+ }
@@ -422,13 +422,16 @@ export declare const GLOBAL_EDIT_COOKIE = "tosi_edit";
422
422
  export declare const GLOBAL_KEY_MIN_LENGTH = 24;
423
423
  /**
424
424
  * A session minted from a global key lasts a day, or until this dev server stops (sessions
425
- * live in memory). The key is NOT re-checked while the session lives, so rotating a key ends
426
- * the sessions it already opened only when each workspace restarts.
425
+ * live in memory). While the key cookie lasts, the next page load or same-origin request mints
426
+ * another, so for a key holder it is the COOKIE's lifetime that ends access, not this. The key
427
+ * is NOT re-checked while a session lives, so rotating a key ends the sessions it already opened
428
+ * only when each workspace restarts.
427
429
  */
428
430
  export declare const GLOBAL_KEY_SESSION_TTL_MS: number;
429
431
  /**
430
432
  * The most live sessions one server holds; past it the oldest goes. Only a key holder can mint
431
- * one per page load, so this bounds a browser stuck reloading, not an attacker.
433
+ * one: per page load, and per request from a page left open across a restart until its first
434
+ * renewed cookie lands. So this bounds a browser stuck reloading, not an attacker.
432
435
  */
433
436
  export declare const MAX_SESSIONS = 1000;
434
437
  export interface GlobalKeys {
@@ -483,6 +486,41 @@ export declare function globalKeyExchange(request: {
483
486
  keys: GlobalKeys;
484
487
  now: number;
485
488
  }): 'edit' | 'view' | null;
489
+ /**
490
+ * Should a request from this workspace's OWN page, that is not a page load, trade a global key
491
+ * for a session in place?
492
+ *
493
+ * Sessions live in memory, and `bun start` restarts the process whenever a file in its import
494
+ * graph changes, which includes every component source and so every doc-comment example. Save
495
+ * to source writes exactly such a file. So the save restarts the server, and the next save from
496
+ * the same page arrived holding a dead session and was told "This workspace needs an invite
497
+ * link". A reload would have exchanged the key, and lost the unsaved edit (the maintainer,
498
+ * 2026-10-20: a restart must not reset a key holder).
499
+ *
500
+ * The answer is the page-load exchange's, with two extra conditions, because no redirect is
501
+ * involved: the session is issued and used on this same request.
502
+ * - Positive evidence that the request comes from this origin: `Sec-Fetch-Site: same-origin`,
503
+ * or, from a browser that sends no fetch metadata, an `Origin` that is this host. Absence of
504
+ * both is NOT enough here (unlike `isSameOriginRequest`, which allows it for curl and our
505
+ * own CLI, whose requests are not a browser spending an ambient cookie): every host under the preview domain
506
+ * is the same site and receives the key cookie, so a sibling workspace's page must not be
507
+ * able to spend it here.
508
+ *
509
+ * A WebSocket upgrade (the haltija bridge) qualifies too; browsers send `Origin` and
510
+ * `Sec-Fetch-Site` on the handshake, and the 101 carries the cookie.
511
+ */
512
+ export declare function globalKeyRefresh(request: {
513
+ method: string;
514
+ headers: {
515
+ get(name: string): string | null;
516
+ };
517
+ }, opts: {
518
+ viaTunnel: boolean;
519
+ host: string;
520
+ auth: AuthState;
521
+ keys: GlobalKeys;
522
+ now: number;
523
+ }): 'edit' | 'view' | null;
486
524
  /**
487
525
  * The origin allowed to read this workspace's liveness answer: the box's own list.
488
526
  *
@@ -867,13 +867,16 @@ export const GLOBAL_EDIT_COOKIE = 'tosi_edit';
867
867
  export const GLOBAL_KEY_MIN_LENGTH = 24;
868
868
  /**
869
869
  * A session minted from a global key lasts a day, or until this dev server stops (sessions
870
- * live in memory). The key is NOT re-checked while the session lives, so rotating a key ends
871
- * the sessions it already opened only when each workspace restarts.
870
+ * live in memory). While the key cookie lasts, the next page load or same-origin request mints
871
+ * another, so for a key holder it is the COOKIE's lifetime that ends access, not this. The key
872
+ * is NOT re-checked while a session lives, so rotating a key ends the sessions it already opened
873
+ * only when each workspace restarts.
872
874
  */
873
875
  export const GLOBAL_KEY_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
874
876
  /**
875
877
  * The most live sessions one server holds; past it the oldest goes. Only a key holder can mint
876
- * one per page load, so this bounds a browser stuck reloading, not an attacker.
878
+ * one: per page load, and per request from a page left open across a restart until its first
879
+ * renewed cookie lands. So this bounds a browser stuck reloading, not an attacker.
877
880
  */
878
881
  export const MAX_SESSIONS = 1000;
879
882
  /**
@@ -944,7 +947,10 @@ export function isNavigation(request) {
944
947
  export function globalKeyExchange(request, opts) {
945
948
  if (!opts.viaTunnel || !isNavigation(request))
946
949
  return null;
947
- const header = request.headers.get('cookie');
950
+ return keyAddsAccess(request.headers.get('cookie'), opts);
951
+ }
952
+ /** What a global key would add to this request's session: nothing, or a session of a kind. */
953
+ function keyAddsAccess(header, opts) {
948
954
  const access = globalKeyAccess(header, opts.keys);
949
955
  if (!access)
950
956
  return null;
@@ -955,6 +961,41 @@ export function globalKeyExchange(request, opts) {
955
961
  }
956
962
  return access;
957
963
  }
964
+ /**
965
+ * Should a request from this workspace's OWN page, that is not a page load, trade a global key
966
+ * for a session in place?
967
+ *
968
+ * Sessions live in memory, and `bun start` restarts the process whenever a file in its import
969
+ * graph changes, which includes every component source and so every doc-comment example. Save
970
+ * to source writes exactly such a file. So the save restarts the server, and the next save from
971
+ * the same page arrived holding a dead session and was told "This workspace needs an invite
972
+ * link". A reload would have exchanged the key, and lost the unsaved edit (the maintainer,
973
+ * 2026-10-20: a restart must not reset a key holder).
974
+ *
975
+ * The answer is the page-load exchange's, with two extra conditions, because no redirect is
976
+ * involved: the session is issued and used on this same request.
977
+ * - Positive evidence that the request comes from this origin: `Sec-Fetch-Site: same-origin`,
978
+ * or, from a browser that sends no fetch metadata, an `Origin` that is this host. Absence of
979
+ * both is NOT enough here (unlike `isSameOriginRequest`, which allows it for curl and our
980
+ * own CLI, whose requests are not a browser spending an ambient cookie): every host under the preview domain
981
+ * is the same site and receives the key cookie, so a sibling workspace's page must not be
982
+ * able to spend it here.
983
+ *
984
+ * A WebSocket upgrade (the haltija bridge) qualifies too; browsers send `Origin` and
985
+ * `Sec-Fetch-Site` on the handshake, and the 101 carries the cookie.
986
+ */
987
+ export function globalKeyRefresh(request, opts) {
988
+ if (!opts.viaTunnel || isNavigation(request))
989
+ return null;
990
+ const site = request.headers.get('sec-fetch-site');
991
+ const origin = request.headers.get('origin');
992
+ const ours = site !== null
993
+ ? site === 'same-origin'
994
+ : origin !== null && origin === `https://${opts.host.toLowerCase()}`;
995
+ if (!ours)
996
+ return null;
997
+ return keyAddsAccess(request.headers.get('cookie'), opts);
998
+ }
958
999
  /**
959
1000
  * The origin allowed to read this workspace's liveness answer: the box's own list.
960
1001
  *
@@ -150,6 +150,22 @@ export declare function haltijaTunnelLoaderSnippet(): string;
150
150
  /** Same-origin paths the tunnel bridge serves. One place, so the loader and the routes agree. */
151
151
  export declare const HALTIJA_BRIDGE_PREFIX = "/__haltija/";
152
152
  export declare const HALTIJA_BRIDGE_WS = "/__haltija/ws";
153
+ /** The slice of a `Bun.Server` the request handler uses. */
154
+ export type Upgrader = {
155
+ requestIP?: (r: Request) => {
156
+ address?: string;
157
+ } | null;
158
+ upgrade?: (r: Request, opts?: {
159
+ data?: unknown;
160
+ headers?: Record<string, string>;
161
+ }) => boolean;
162
+ };
163
+ /**
164
+ * The same server, except that an accepted WebSocket upgrade also sets `setCookie` on its 101.
165
+ * How a renewed session reaches a browser whose request was a handshake (the haltija bridge),
166
+ * which has no Response of ours to carry it.
167
+ */
168
+ export declare function upgraderSettingCookie(srv: Upgrader, setCookie: string): Upgrader;
153
169
  export declare const HALTIJA_BRIDGE_COMPONENT = "/__haltija/component.js";
154
170
  /** br if the client takes it, else gzip, else nothing. */
155
171
  export declare function negotiateEncoding(accept: string | null): 'br' | 'gzip' | null;
@@ -22,7 +22,7 @@ import { resolveTunnelLocalPort } from './site-config.js';
22
22
  import { acquireBuildLock, describeHolder } from './build-lock.js';
23
23
  import { sinkPathFor, appendToSink } from './debug-sink.js';
24
24
  import { invitePageHtml } from './invite-page.js';
25
- import { TUNNEL_LINK_CMD, resolveLinkArrival, isLockedDown, hasTunnel, isLoopbackAddressForAuth as isLoopbackAddress, mayReadSite, shouldInterceptLinkToken, createAuthState, issueLink, globalKeyCookie, grantDomainFor, takeSessionGrant, outstandingGrants, MAX_OUTSTANDING_GRANTS, resolveShareTtlMs, readCookie, redeemThroughGate, createRedemptionGate, resolveLinkSettings, sessionCookie, urlWithoutToken, sameOriginPath, isSameOriginRequest, validSessionCookie, sessionRejection, haltijaBridgeAdmission, isProxiedRequest, sourceRequestAdmission, trustLanRefusalHint, trustLanWriteRecord, resolveLanTrust, describeLanTrust, resolveGlobalKeys, globalKeyAccess, globalKeyExchange, liveProbeOrigin, issueSession, cookieMayWrite, GLOBAL_KEY_SESSION_TTL_MS, SESSION_COOKIE, } from './dev-auth.js';
25
+ import { TUNNEL_LINK_CMD, resolveLinkArrival, isLockedDown, hasTunnel, isLoopbackAddressForAuth as isLoopbackAddress, mayReadSite, shouldInterceptLinkToken, createAuthState, issueLink, globalKeyCookie, grantDomainFor, takeSessionGrant, outstandingGrants, MAX_OUTSTANDING_GRANTS, resolveShareTtlMs, readCookie, redeemThroughGate, createRedemptionGate, resolveLinkSettings, sessionCookie, urlWithoutToken, sameOriginPath, isSameOriginRequest, validSessionCookie, sessionRejection, haltijaBridgeAdmission, isProxiedRequest, sourceRequestAdmission, trustLanRefusalHint, trustLanWriteRecord, resolveLanTrust, describeLanTrust, resolveGlobalKeys, globalKeyAccess, globalKeyExchange, globalKeyRefresh, liveProbeOrigin, issueSession, cookieMayWrite, GLOBAL_KEY_SESSION_TTL_MS, SESSION_COOKIE, } from './dev-auth.js';
26
26
  import { readLocalSecret } from './local-secrets.js';
27
27
  import { resolveDataStore, resolveDataFile, dataContentProblem, listDataFiles, saveDataFile, dataHistory, readDataAt, isGitWorkTree, } from './data-store.js';
28
28
  import { SOURCE_ACCESS_HEADER, SOURCE_ENDPOINT_SNIPPET, } from '../../live-example/source-access.js';
@@ -361,6 +361,20 @@ export function haltijaTunnelLoaderSnippet() {
361
361
  /** Same-origin paths the tunnel bridge serves. One place, so the loader and the routes agree. */
362
362
  export const HALTIJA_BRIDGE_PREFIX = '/__haltija/';
363
363
  export const HALTIJA_BRIDGE_WS = '/__haltija/ws';
364
+ /**
365
+ * The same server, except that an accepted WebSocket upgrade also sets `setCookie` on its 101.
366
+ * How a renewed session reaches a browser whose request was a handshake (the haltija bridge),
367
+ * which has no Response of ours to carry it.
368
+ */
369
+ export function upgraderSettingCookie(srv, setCookie) {
370
+ return {
371
+ requestIP: (r) => srv.requestIP?.(r) ?? null,
372
+ upgrade: (r, opts) => srv.upgrade?.(r, {
373
+ ...opts,
374
+ headers: { ...opts?.headers, 'Set-Cookie': setCookie },
375
+ }) ?? false,
376
+ };
377
+ }
364
378
  export const HALTIJA_BRIDGE_COMPONENT = '/__haltija/component.js';
365
379
  /**
366
380
  * Reclaim the port we are about to bind, from the process LISTENING on it.
@@ -1725,6 +1739,46 @@ export async function devServer(config, opts = {}) {
1725
1739
  outright. Source and data writes check same-origin on both paths (sourceRequestAdmission).
1726
1740
  */
1727
1741
  const csrfOk = (request, viaTunnel) => viaTunnel || isSameOriginRequest(request);
1742
+ /*
1743
+ A request from this workspace's own page that holds a global key but a dead session (the
1744
+ server restarted under it) is served with a fresh session, which its response hands back.
1745
+ See `globalKeyRefresh`. The session goes into the request's cookie header, so every gate in
1746
+ handleRequest sees a session and none of them has to know about keys. A WebSocket upgrade
1747
+ (the haltija bridge) carries the cookie on its 101, through `upgrade`'s headers.
1748
+ */
1749
+ // Said once per process: a page left open across a restart renews on every request it makes.
1750
+ let renewalLogged = false;
1751
+ const withKeySession = async (request, srv, handle) => {
1752
+ const grant = globalKeyRefresh(request, {
1753
+ viaTunnel: true,
1754
+ host: request.headers.get('host') ?? '',
1755
+ auth,
1756
+ keys: globalKeys.keys,
1757
+ now: Date.now(),
1758
+ });
1759
+ if (!grant)
1760
+ return handle(request, srv);
1761
+ const session = issueSession(auth, Date.now(), GLOBAL_KEY_SESSION_TTL_MS, grant === 'view');
1762
+ const setCookie = sessionCookie(session, GLOBAL_KEY_SESSION_TTL_MS);
1763
+ const headers = new Headers(request.headers);
1764
+ const others = (headers.get('cookie') ?? '')
1765
+ .split(';')
1766
+ .map((c) => c.trim())
1767
+ .filter((c) => c && !c.startsWith(`${SESSION_COOKIE}=`));
1768
+ // The `name=value` pair the browser will send from now on.
1769
+ headers.set('cookie', [...others, setCookie.split(';')[0]].join('; '));
1770
+ if (!renewalLogged) {
1771
+ renewalLogged = true;
1772
+ console.log(`🔓 global ${grant} key — session renewed after a restart`);
1773
+ }
1774
+ const response = await handle(new Request(request, { headers }), srv && upgraderSettingCookie(srv, setCookie));
1775
+ // An accepted upgrade answers with nothing; its cookie went out on the 101.
1776
+ if (!response)
1777
+ return response;
1778
+ const refreshed = new Response(response.body, response);
1779
+ refreshed.headers.append('Set-Cookie', setCookie);
1780
+ return refreshed;
1781
+ };
1728
1782
  const handleRequest = async (request, srv, viaTunnel) => {
1729
1783
  /*
1730
1784
  A REQUEST is activity. This was lost in the two-listener refactor, leaving the only
@@ -1904,7 +1958,7 @@ export async function devServer(config, opts = {}) {
1904
1958
  sentence differs, and that sentence was the entire report.
1905
1959
  */
1906
1960
  const rejection = sessionRejection(readCookie(request.headers.get('cookie'), SESSION_COOKIE), Date.now());
1907
- return new Response(invitePageHtml(rejection, TUNNEL_LINK_CMD), {
1961
+ return new Response(invitePageHtml(rejection, TUNNEL_LINK_CMD, globalKeys.keys.edit ? `${TUNNEL_LINK_CMD} --global` : undefined), {
1908
1962
  status: 401,
1909
1963
  headers: {
1910
1964
  'Content-Type': 'text/html; charset=utf-8',
@@ -2490,7 +2544,7 @@ export async function devServer(config, opts = {}) {
2490
2544
  // Same reasoning as the main listener — a tunnelled load is slower, not faster.
2491
2545
  idleTimeout: DEV_IDLE_TIMEOUT_SECONDS,
2492
2546
  hostname: '127.0.0.1',
2493
- fetch: (request, srv) => handleRequest(request, srv, true),
2547
+ fetch: (request, srv) => withKeySession(request, srv, (r, s) => handleRequest(r, s, true)),
2494
2548
  websocket: haltijaWebSocketHandlers,
2495
2549
  });
2496
2550
  console.log(`Tunnel listener on http://127.0.0.1:${tunnelPort} (loopback only; writes require a session)`);
@@ -18,7 +18,7 @@ import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import { renderDocMarkdown } from '../render.js';
20
20
  import { highlightHtml } from '../highlight.js';
21
- import { buildSlugMap, pathForSlug, resolveParent, slugForPath, withBase, } from '../routing.js';
21
+ import { buildSlugMap, pathForSlug, resolveParent, slugForPath, withBase, wikilinkTargets, } from '../routing.js';
22
22
  import { buildNavTree } from '../nav-tree.js';
23
23
  import { partitionByBook, placeInBook, bookTextOf, DEFAULT_BOOK, } from '../book-target.js';
24
24
  import { COVER_MEDIA_TYPES, coverStem, epubVolumeIdentity, } from './epub-volumes.js';
@@ -621,6 +621,7 @@ export async function buildEpub(config, opts = {}) {
621
621
  into the default one just by not naming a book.
622
622
  */
623
623
  const siteSlugs = buildSlugMap(corpus);
624
+ const wikilinks = wikilinkTargets(corpus);
624
625
  const volumes = partitionByBook(corpus, siteSlugs);
625
626
  const visible = volumes.get(opts.bookTarget ?? DEFAULT_BOOK) ?? [];
626
627
  /*
@@ -750,7 +751,7 @@ export async function buildEpub(config, opts = {}) {
750
751
  `injectExampleLinks` matches on the `language-*` class, never on the code text, so
751
752
  highlighting cannot break the "Run this example live" links.
752
753
  */
753
- await highlightHtml(renderDocMarkdown(stripDocMeta(bookTextOf(doc))), 'none'), bookFiles, slugMap, config.basePath);
754
+ await highlightHtml(renderDocMarkdown(stripDocMeta(bookTextOf(doc)), { wikilinks }), 'none'), bookFiles, slugMap, config.basePath);
754
755
  // happy-dom occasionally throws on exotic content (e.g. an internal selector
755
756
  // bug); fall back to the regex pass for that doc rather than aborting.
756
757
  let bodyHtml;