ompchamber 3.2.0 → 3.3.1

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.
Files changed (52) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +239 -230
  3. package/dist/client/static/js/{index-1q6165r3.js → index-4qx6m2p7.js} +640 -640
  4. package/dist/client/static/js/index-bsxxt89j.css +1 -0
  5. package/package.json +4 -3
  6. package/public/sw.js +54 -5
  7. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
  8. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -0
  9. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
  10. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +14 -5
  11. package/src/client/components/settings/Modal.tsx +10 -17
  12. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  13. package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
  14. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +17 -11
  15. package/src/client/components/workspace/editor/index.tsx +15 -7
  16. package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
  17. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
  18. package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
  19. package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
  20. package/src/client/data/theme/terminal.ts +25 -8
  21. package/src/client/hooks/editor/font.ts +53 -0
  22. package/src/client/hooks/ui/clipboard.ts +17 -0
  23. package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
  24. package/src/client/hooks/ui/keyboard-inset.ts +113 -0
  25. package/src/client/hooks/ui/text-overflow.test.ts +152 -0
  26. package/src/client/hooks/ui/text-overflow.ts +78 -0
  27. package/src/client/hooks/ui/touch-device.ts +46 -0
  28. package/src/client/main.tsx +1 -0
  29. package/src/server/index.ts +4 -3
  30. package/src/server/lib/assets/conditional.server.test.ts +74 -0
  31. package/src/server/lib/assets/conditional.server.ts +54 -0
  32. package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
  33. package/src/server/lib/assets/dev-assets.server.ts +121 -0
  34. package/src/server/lib/assets/fonts.server.ts +9 -4
  35. package/src/server/lib/bundler/build-errors.server.ts +136 -0
  36. package/src/server/lib/bundler/build-errors.test.ts +103 -0
  37. package/src/server/lib/bundler/dependencies.test.ts +148 -0
  38. package/src/server/lib/lifecycle/listener.ts +29 -0
  39. package/src/server/plugins/shell.server.ts +52 -13
  40. package/src/server/plugins/ssr.ts +43 -7
  41. package/src/server/plugins/static.ts +51 -18
  42. package/src/shared/lib/code/editor/typography.test.ts +93 -0
  43. package/src/shared/lib/code/editor/typography.ts +86 -0
  44. package/src/shared/lib/fonts/NOTICE.md +44 -0
  45. package/src/shared/lib/fonts/nerd-symbols.css +55 -0
  46. package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
  47. package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
  48. package/src/shared/lib/settings/diff.test.ts +1 -1
  49. package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
  50. package/src/shared/lib/workspace/terminal/keys.ts +225 -0
  51. package/src/shared/types/settings/state.ts +7 -16
  52. package/dist/client/static/js/index-01dv383b.css +0 -1
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * True when an element's content is visually cut off by CSS truncation.
8
+ *
9
+ * `text-overflow: ellipsis` hides content at a WIDTH, not at a character count,
10
+ * and the two are unrelated: the same 76-character line fits a 970px desktop
11
+ * timeline and is cut on a 340px phone card. A caller that decides "is there
12
+ * more to show?" from `text.length` therefore renders an ellipsis the user can
13
+ * see and no way to reveal it — the exact failure this hook exists to prevent.
14
+ *
15
+ * `scrollWidth > clientWidth` is the measurement the browser itself uses to
16
+ * decide whether to draw the ellipsis, so it cannot disagree with what is on
17
+ * screen. Three details are load-bearing:
18
+ *
19
+ * - A zero-width element reports 0 for BOTH values, which reads as "fits". That
20
+ * is not a measurement, it is the absence of one (a `display: none` ancestor,
21
+ * a detached node), so it is skipped and the previous answer stands.
22
+ * - The measurement is retained when the ref detaches. Callers unmount the
23
+ * truncated row as soon as it expands, and forgetting the answer there would
24
+ * retract the very control the user just used.
25
+ * - A webfont that lands after first paint changes the text's width without
26
+ * resizing the box, so `ResizeObserver` alone never fires. `document.fonts`
27
+ * is the one signal for it.
28
+ */
29
+
30
+ import { useLayoutEffect, useRef, useState } from 'preact/hooks';
31
+ import type { RefObject } from 'preact';
32
+
33
+ /**
34
+ * @param ref Element carrying the truncating class.
35
+ * @param content Deps that change the rendered text; the element's own resize
36
+ * is observed, but new text in an unchanged box is not.
37
+ */
38
+ export function useIsTruncated(
39
+ ref: RefObject<HTMLElement | null>,
40
+ content: unknown,
41
+ ): boolean {
42
+ const [truncated, setTruncated] = useState(false);
43
+ const measuredRef = useRef(false);
44
+
45
+ useLayoutEffect(() => {
46
+ const element = ref.current;
47
+ if (!element) return;
48
+
49
+ let cancelled = false;
50
+ const measure = () => {
51
+ if (cancelled) return;
52
+ // No layout to measure — keep the last real answer rather than report "fits".
53
+ if (element.clientWidth === 0) return;
54
+ const next = element.scrollWidth > element.clientWidth;
55
+ if (next === measuredRef.current) return;
56
+ measuredRef.current = next;
57
+ setTruncated(next);
58
+ };
59
+
60
+ measure();
61
+
62
+ // Fonts swap after first paint and move the text inside a box that never
63
+ // resizes, so no observer fires; `fonts.ready` is the only signal. Absent
64
+ // in a DOM without font loading (a test environment), hence the guard.
65
+ document?.fonts?.ready?.then(measure).catch(() => {});
66
+
67
+ if (typeof ResizeObserver === 'undefined') return;
68
+ const observer = new ResizeObserver(measure);
69
+ observer.observe(element);
70
+
71
+ return () => {
72
+ cancelled = true;
73
+ observer.disconnect();
74
+ };
75
+ }, [ref, content]);
76
+
77
+ return truncated;
78
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Whether this device is typed into with a soft keyboard — the only condition
8
+ * under which a terminal needs on-screen keys.
9
+ *
10
+ * `(pointer: coarse)` is the signal: a phone or tablet reports it, and a
11
+ * touchscreen laptop does not, because its primary pointer is the trackpad.
12
+ * The fallback covers the devices that report a fine pointer anyway (some
13
+ * Android desktop modes): touch points plus `(hover: none)`, which is the pair
14
+ * that means "there is no pointer at all" rather than "there is a screen that
15
+ * can be touched".
16
+ *
17
+ * The queries are subscribed, not read once: a hybrid device that docks a
18
+ * keyboard or leaves tablet mode changes the answer, and a bar of keys that
19
+ * cannot react to that is stuck on a desktop or missing on a phone.
20
+ */
21
+
22
+ import { useEffect, useState } from 'preact/hooks';
23
+
24
+ const COARSE_POINTER_QUERY = '(pointer: coarse)';
25
+ const NO_HOVER_QUERY = '(hover: none)';
26
+
27
+ function detectSoftKeyboardDevice(): boolean {
28
+ if (typeof window === 'undefined') return false;
29
+ if (window.matchMedia(COARSE_POINTER_QUERY).matches) return true;
30
+ return navigator.maxTouchPoints > 0 && window.matchMedia(NO_HOVER_QUERY).matches;
31
+ }
32
+
33
+ export function useTouchDevice(): boolean {
34
+ const [isTouch, setIsTouch] = useState(detectSoftKeyboardDevice);
35
+
36
+ useEffect(() => {
37
+ const queries = [COARSE_POINTER_QUERY, NO_HOVER_QUERY].map((q) => window.matchMedia(q));
38
+ const update = () => setIsTouch(detectSoftKeyboardDevice());
39
+ for (const query of queries) query.addEventListener('change', update);
40
+ return () => {
41
+ for (const query of queries) query.removeEventListener('change', update);
42
+ };
43
+ }, []);
44
+
45
+ return isTouch;
46
+ }
@@ -7,6 +7,7 @@ import { initDocumentTheme } from '@/client/hooks/ui/theme';
7
7
  import '@/client/tailwind.css';
8
8
  import '@/shared/lib/markdown/katex-fonts.css';
9
9
  import '@/shared/lib/markdown/fira-code-fonts.css';
10
+ import '@/shared/lib/fonts/nerd-symbols.css';
10
11
 
11
12
  const root = document.getElementById('app');
12
13
  const bootstrap = window.__OMP_BOOTSTRAP__;
@@ -3,7 +3,7 @@ import pkg from '@/../package.json';
3
3
  import shell from '@/../index.html';
4
4
  import { apiRoutes } from '@/server/routes';
5
5
  import { ssrRoutes } from '@/server/plugins/ssr';
6
- import { setShellSource } from '@/server/plugins/shell.server';
6
+ import { setListener } from '@/server/lib/lifecycle/listener';
7
7
  import { getDatabasePath } from '@/server/db.server';
8
8
  import { fetchOmpRegistrySnapshot } from '@/server/lib/models/provider-registry.server';
9
9
  import { ompStartupError, ompStartupLogLines } from '@/server/lib/omp/core/startup';
@@ -67,8 +67,9 @@ try {
67
67
  // an HTML route only while serving, and there is no in-process API for an
68
68
  // `HTMLBundle` (`new Response(bundle)` is "[object HTMLBundle]",
69
69
  // `app.handle` answers 404). Handing the listener over is what makes
70
- // `renderShell()` possible.
71
- setShellSource(app.server);
70
+ // `renderShell()` possible — and the dev asset proxy reaches Bun's own
71
+ // asset routes through it for the same reason.
72
+ setListener(app.server);
72
73
  },
73
74
  });
74
75
  } catch (error) {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { notModified } from '@/server/lib/assets/conditional.server';
9
+
10
+ const ETAG = '"abc123"';
11
+ const MTIME_MS = Date.parse('2026-01-02T03:04:05.000Z');
12
+
13
+ /** A GET carrying the given conditional headers. */
14
+ const request = (headers: Record<string, string> = {}) => new Request('http://localhost/asset.js', { headers });
15
+
16
+ describe('notModified', () => {
17
+ test('a plain request is never a match', () => {
18
+ expect(notModified(request(), ETAG, MTIME_MS)).toBe(false);
19
+ });
20
+
21
+ test('a matching validator answers 304', () => {
22
+ expect(notModified(request({ 'if-none-match': ETAG }), ETAG, MTIME_MS)).toBe(true);
23
+ });
24
+
25
+ test('a stale validator does not', () => {
26
+ expect(notModified(request({ 'if-none-match': '"other"' }), ETAG, MTIME_MS)).toBe(false);
27
+ });
28
+
29
+ test('a weak or list-form validator still matches', () => {
30
+ expect(notModified(request({ 'if-none-match': `W/${ETAG}` }), ETAG, MTIME_MS)).toBe(true);
31
+ expect(notModified(request({ 'if-none-match': `"other", ${ETAG}` }), ETAG, MTIME_MS)).toBe(true);
32
+ expect(notModified(request({ 'if-none-match': '"other", W/' + ETAG }), ETAG, MTIME_MS)).toBe(true);
33
+ });
34
+
35
+ test('a date at or after the mtime matches, an older one does not', () => {
36
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS).toUTCString() }), ETAG, MTIME_MS)).toBe(true);
37
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS + 60_000).toUTCString() }), ETAG, MTIME_MS)).toBe(true);
38
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS - 1000).toUTCString() }), ETAG, MTIME_MS)).toBe(false);
39
+ });
40
+
41
+ // HTTP dates carry one-second resolution. A file whose mtime has a fractional
42
+ // part must still match the `Last-Modified` this server sent for it, or no
43
+ // request could ever revalidate.
44
+ test('a fractional mtime is floored before comparing', () => {
45
+ const fractional = MTIME_MS + 742;
46
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS).toUTCString() }), ETAG, fractional)).toBe(true);
47
+ });
48
+
49
+ // RFC 9110 §13.1.3: `If-Modified-Since` MUST be ignored when `If-None-Match`
50
+ // is present. A stale validator beside a fresh date is a stale copy.
51
+ test('If-None-Match wins over If-Modified-Since', () => {
52
+ const fresh = new Date(MTIME_MS + 60_000).toUTCString();
53
+ expect(notModified(request({ 'if-none-match': '"stale"', 'if-modified-since': fresh }), ETAG, MTIME_MS)).toBe(false);
54
+ });
55
+
56
+ test('an unparseable date is not a match', () => {
57
+ expect(notModified(request({ 'if-modified-since': 'not-a-date' }), ETAG, MTIME_MS)).toBe(false);
58
+ });
59
+
60
+ // A server that offers only `Last-Modified` revalidates on the date alone —
61
+ // the standard fallback. Neither caller here takes this shape (the file route
62
+ // always mints a validator, the dev proxy never has a date), but the rule is
63
+ // the general one and the branch is reachable if either changes.
64
+ test('without a validator, the date decides', () => {
65
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS).toUTCString() }), null, MTIME_MS)).toBe(true);
66
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS - 1000).toUTCString() }), null, MTIME_MS)).toBe(false);
67
+ });
68
+
69
+ test('with a validator but no mtime, only the validator can match', () => {
70
+ expect(notModified(request({ 'if-none-match': ETAG }), ETAG, null)).toBe(true);
71
+ expect(notModified(request({ 'if-none-match': '"other"' }), ETAG, null)).toBe(false);
72
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS).toUTCString() }), ETAG, null)).toBe(false);
73
+ });
74
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Conditional requests for the assets this server serves itself.
8
+ *
9
+ * Two routes hand out bytes and both need the same answer to "is the client's
10
+ * copy still good?" — `plugins/static.ts`, from a file on disk, and
11
+ * `lib/assets/dev-assets.server.ts`, from bytes it just read back through Bun's
12
+ * dev asset routes. They must agree: a rule applied to one and not the other
13
+ * would make one asset kind revalidate differently from another for no reason a
14
+ * caller could see, so the decision lives here rather than in either caller.
15
+ *
16
+ * The precedence is the load-bearing part. RFC 9110 §13.1.3: a recipient MUST
17
+ * ignore `If-Modified-Since` when the request carries `If-None-Match`. So the
18
+ * presence of `If-None-Match` settles the response on its own — a non-match
19
+ * means "the client's copy is stale" (200), never a reason to consult the date.
20
+ *
21
+ * Every path here errs toward `false`. A `false` costs one full response; a
22
+ * wrong `true` hands the client a stale asset with no way to notice.
23
+ */
24
+
25
+ /**
26
+ * Whether the client's stored copy is still current, so the caller can answer
27
+ * `304` and skip the body.
28
+ *
29
+ * `lastModifiedMs` is the file's own mtime; pass null when there is no file
30
+ * behind the bytes (the dev proxy hashes what it fetched instead).
31
+ */
32
+ export function notModified(request: Request, etag: string | null, lastModifiedMs: number | null): boolean {
33
+ const ifNoneMatch = request.headers.get('if-none-match');
34
+ if (ifNoneMatch !== null) {
35
+ if (!etag) return false;
36
+ // A validator survives a `W/` prefix and arrives as a candidate list.
37
+ const wanted = etag.trim().replace(/^W\//, '');
38
+ return ifNoneMatch.split(',').some((candidate) => candidate.trim().replace(/^W\//, '') === wanted);
39
+ }
40
+
41
+ const ifModifiedSince = request.headers.get('if-modified-since');
42
+ if (ifModifiedSince && lastModifiedMs !== null) {
43
+ const since = Date.parse(ifModifiedSince);
44
+ if (!Number.isNaN(since)) {
45
+ // HTTP dates carry one-second resolution, so the mtime is floored to match
46
+ // before comparing. Without the floor a file whose mtime has a fractional
47
+ // part is always "newer" than the `Last-Modified` this server just sent
48
+ // for it, and no request could ever revalidate.
49
+ return Math.floor(lastModifiedMs / 1000) * 1000 <= since;
50
+ }
51
+ }
52
+
53
+ return false;
54
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { devAssetUpstreamPath, rewriteDevAssetUrls } from '@/server/lib/assets/dev-assets.server';
9
+
10
+ describe('rewriteDevAssetUrls', () => {
11
+ test('moves the bundle and the assets to the proxied prefix', () => {
12
+ const html = '<script src="/_bun/client/index-abc.js"></script><link href="/_bun/asset/x.css">';
13
+ expect(rewriteDevAssetUrls(html)).toBe(
14
+ '<script src="/_dev-assets/client/index-abc.js"></script><link href="/_dev-assets/asset/x.css">',
15
+ );
16
+ });
17
+
18
+ // The HMR client dials `/_bun/hmr` from the bundle and the beacon posts to
19
+ // `/_bun/unref`; both are Bun's own routes and must stay where Bun serves them.
20
+ test('leaves the HMR socket and the unref beacon on Bun', () => {
21
+ const html = '<script>new WebSocket("/_bun/hmr");navigator.sendBeacon("/_bun/unref")</script>';
22
+ expect(rewriteDevAssetUrls(html)).toBe(html);
23
+ });
24
+ });
25
+
26
+ describe('devAssetUpstreamPath', () => {
27
+ test('maps a proxied asset back to the path Bun serves', () => {
28
+ expect(devAssetUpstreamPath('/_dev-assets/client/index-abc.js')).toBe('/_bun/client/index-abc.js');
29
+ expect(devAssetUpstreamPath('/_dev-assets/asset/x.css')).toBe('/_bun/asset/x.css');
30
+ });
31
+
32
+ test('claims nothing outside its prefix', () => {
33
+ expect(devAssetUpstreamPath('/_bun/client/index-abc.js')).toBeNull();
34
+ expect(devAssetUpstreamPath('/fonts.css')).toBeNull();
35
+ expect(devAssetUpstreamPath('/')).toBeNull();
36
+ });
37
+
38
+ // A prefix match alone is not enough: `/_bun/hmr` is a WebSocket upgrade and
39
+ // must never be proxied through a normal request handler.
40
+ test('refuses a Bun path that is not a page-load asset', () => {
41
+ expect(devAssetUpstreamPath('/_dev-assets/hmr')).toBeNull();
42
+ expect(devAssetUpstreamPath('/_dev-assets/unref')).toBeNull();
43
+ });
44
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Dev asset proxy — the one place Bun's own asset routes fall short.
8
+ *
9
+ * In development Bun bundles `index.html` itself and answers the assets it emits
10
+ * from its own routing table (`/_bun/client/*`, `/_bun/asset/*`), before a
11
+ * request can reach Elysia. Those responses carry an `ETag` but ignore
12
+ * `If-None-Match`, carry no `Cache-Control`, and are never compressed — so every
13
+ * page load re-downloads the whole unminified bundle. Measured on Bun 1.4.2, a
14
+ * 19.7 MB dev bundle answered `200` with all 19,755,962 bytes to a request that
15
+ * sent its own ETag back, and again with `Accept-Encoding: gzip, br, zstd` (no
16
+ * `content-encoding`). The same server with `development: false` answers `304`
17
+ * and `Cache-Control: public, max-age=31536000, immutable`, which is exactly why
18
+ * production is cheap and development was not.
19
+ *
20
+ * Bun's table wins for the paths it declares, so a route on `/_bun/*` cannot
21
+ * shadow them (verified: a `routes` entry for `/_bun/client/*` is never
22
+ * reached). The shell is therefore rewritten to a prefix this server owns, and
23
+ * this module serves the bytes back from the listener — adding the two headers
24
+ * Bun omits.
25
+ *
26
+ * Why `no-cache` rather than `immutable`: the JS bundle's name is a build
27
+ * generation id that changes on every rebuild, but the CSS asset names are NOT
28
+ * content-addressed — appending a rule to `tailwind.css` changed the bytes
29
+ * served at the same `/_bun/asset/<hash>.css` URL (verified). `immutable` there
30
+ * would pin stale styles for a year. `no-cache` plus a content `ETag` is correct
31
+ * for both: the browser stores the body and revalidates, so an unchanged asset
32
+ * costs one 304 instead of a re-download.
33
+ *
34
+ * Development only. Production assets are content-hashed and already answered
35
+ * with a working `304` and `immutable` — by Bun's own table in runtime mode and
36
+ * by `plugins/static.ts` from a build — so neither needs this.
37
+ */
38
+
39
+ import { notModified } from '@/server/lib/assets/conditional.server';
40
+ import { maybeCompress } from '@/server/plugins/compress';
41
+
42
+ /** Whether the proxy is active at all. See the module comment. */
43
+ export const DEV_ASSETS_ENABLED = Bun.env.NODE_ENV !== 'production';
44
+
45
+ /** Bun's own dev asset prefix — its routing table owns every path under it. */
46
+ const BUN_ASSET_PREFIX = '/_bun/';
47
+
48
+ /**
49
+ * The asset kinds worth proxying.
50
+ *
51
+ * `/_bun/hmr` (the WebSocket) and `/_bun/unref` (a visibility beacon) stay on
52
+ * Bun's table: neither is a page-load byte, and proxying them would put a socket
53
+ * upgrade and a POST through here for nothing.
54
+ */
55
+ const ASSET_KINDS = ['/_bun/client/', '/_bun/asset/'] as const;
56
+
57
+ /** The prefix this server answers instead. Underscored, so it cannot collide. */
58
+ export const DEV_ASSET_PREFIX = '/_dev-assets/';
59
+
60
+ /** Cache policy: store the body, revalidate every time. See the module comment. */
61
+ const DEV_ASSET_CACHE = 'no-cache';
62
+
63
+ /** `/_bun/client/x.js` -> `/_dev-assets/client/x.js`, in the shell markup. */
64
+ export function rewriteDevAssetUrls(html: string): string {
65
+ let out = html;
66
+ for (const kind of ASSET_KINDS) {
67
+ out = out.replaceAll(kind, DEV_ASSET_PREFIX + kind.slice(BUN_ASSET_PREFIX.length));
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** `/_dev-assets/client/x.js` -> `/_bun/client/x.js`, or null when not ours. */
73
+ export function devAssetUpstreamPath(pathname: string): string | null {
74
+ if (!pathname.startsWith(DEV_ASSET_PREFIX)) return null;
75
+ const upstream = BUN_ASSET_PREFIX + pathname.slice(DEV_ASSET_PREFIX.length);
76
+ return ASSET_KINDS.some((kind) => upstream.startsWith(kind)) ? upstream : null;
77
+ }
78
+
79
+ /**
80
+ * The response for a dev asset request, or null when the path is not one.
81
+ *
82
+ * `base` is the listener's own URL: the bytes are fetched back from it because
83
+ * Bun's bundle is in memory and its routing table — not Elysia's — owns the
84
+ * `/_bun/*` paths.
85
+ */
86
+ export async function serveDevAsset(request: Request, pathname: string, base: URL): Promise<Response | null> {
87
+ const upstream = devAssetUpstreamPath(pathname);
88
+ if (!upstream) return null;
89
+
90
+ const response = await fetch(new URL(upstream, base));
91
+ const type = response.headers.get('content-type') ?? '';
92
+ // An asset name that no longer exists falls through Bun's table to the SSR
93
+ // catch-all and comes back as the shell markup. Serving that as JavaScript
94
+ // would fail far from its cause, so it is reported here instead.
95
+ if (!response.ok || type.startsWith('text/html')) {
96
+ return new Response(`No dev asset at ${upstream}\n`, {
97
+ status: 404,
98
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
99
+ });
100
+ }
101
+
102
+ const bytes = new Uint8Array(await response.arrayBuffer());
103
+ const etag = `"${Bun.hash(bytes).toString(16)}"`;
104
+ // The same decision the file route makes, so a dev asset and a built one
105
+ // revalidate identically. `lastModifiedMs` is null: there is no file behind
106
+ // these bytes, only the hash above, so the date branch does not apply.
107
+ if (notModified(request, etag, null)) {
108
+ return new Response(null, { status: 304, headers: { etag, 'cache-control': DEV_ASSET_CACHE } });
109
+ }
110
+
111
+ return maybeCompress(
112
+ new Response(bytes, {
113
+ headers: {
114
+ 'content-type': type || 'application/octet-stream',
115
+ 'cache-control': DEV_ASSET_CACHE,
116
+ etag,
117
+ },
118
+ }),
119
+ request.headers.get('accept-encoding'),
120
+ );
121
+ }
@@ -4,13 +4,14 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Font files served straight from the installed packages.
7
+ * Font files served straight from the installed packages — plus the one the
8
+ * chamber vendors itself (`src/shared/lib/fonts`, the Nerd Font symbols face).
8
9
  *
9
10
  * The client stylesheet reaches its `@font-face` sources through the bundler's
10
11
  * CSS loader (`lib/bundler/css.ts`), which rewrites every one of them to
11
- * `<FONT_ROUTE_PREFIX><basename>`. Nothing copies them into the repository: a
12
- * font is addressed by its own basename, and this module resolves that basename
13
- * back to the package that ships it.
12
+ * `<FONT_ROUTE_PREFIX><basename>`. Nothing is copied at build time: a font is
13
+ * addressed by its own basename, and this module resolves that basename back to
14
+ * the directory that ships it.
14
15
  *
15
16
  * A directory index rather than a path built from the request, because the
16
17
  * request must never choose a directory. The index is built once, lazily, by
@@ -35,6 +36,10 @@ export const FONT_ROUTE_PREFIX = '/fonts/';
35
36
  const FONT_SOURCES = [
36
37
  'node_modules/katex/dist/fonts',
37
38
  'node_modules/@fontsource/fira-code/files',
39
+ // The one font the chamber vendors itself rather than borrowing from a
40
+ // package: the Nerd Font symbols face the terminal falls back to. See
41
+ // `src/shared/lib/fonts/nerd-symbols.css`.
42
+ 'src/shared/lib/fonts',
38
43
  ] as const;
39
44
 
40
45
  /**
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Why the shell's bundle failed, in the terms the person who broke it needs.
8
+ *
9
+ * Bun renders an HTML route by bundling it. When the bundle fails, the route
10
+ * answers with Bun's own "Build Failed" page, which holds the file, line,
11
+ * message and offending source line — but only inside a JavaScript payload the
12
+ * page decodes at runtime (`Uint8Array.from(atob(…))`, a private binary format
13
+ * with no server-side reader). Scraping that would be a bet on Bun's internals.
14
+ *
15
+ * `Bun.build` is the supported way to the same facts: a probe over the same
16
+ * entrypoint with the same plugins returns `logs` carrying `message` and
17
+ * `position {file,line,column,lineText}`. What it reports is what `Bun.build`
18
+ * rejects, and that set covers every shape the route 500s on — measured against
19
+ * the route on Bun 1.4.2, an unresolved import that survives tree shaking and a
20
+ * syntax error both fail the route and are both reported here. It is not a
21
+ * subset of the route's failures, and deliberately so: a missing named export is
22
+ * answered `200` by the route while Bun serves a 104-byte stub that only calls
23
+ * `location.reload()` in place of the bundle, so the page cannot run. That is a
24
+ * real failure of the bundle and is reported as one.
25
+ *
26
+ * `target: 'browser'` because that is the bundle the page is served; `bun`
27
+ * agreed with it on every case measured.
28
+ *
29
+ * Runs only after the shell has already failed, so its cost lands on a page that
30
+ * is broken anyway and never on a healthy request.
31
+ */
32
+
33
+ import { join, relative } from 'path';
34
+ import type { BuildOutput, BunPlugin } from 'bun';
35
+
36
+ import { packageRoot } from '@/server/lib/assets/fonts.server';
37
+
38
+ /** The shell bundle's entrypoint, relative to the package root. */
39
+ const SHELL_ENTRYPOINT = 'index.html';
40
+
41
+ /** Bun's own config file, and the section naming the bundle's plugins. */
42
+ const BUNFIG = 'bunfig.toml';
43
+
44
+ type BundleLog = BuildOutput['logs'][number];
45
+
46
+ /**
47
+ * The plugin list Bun uses for this bundle, read from the file Bun reads.
48
+ *
49
+ * The probe has to bundle what the route bundles: a plugin missing here would
50
+ * make it describe a build nobody is served — or succeed where the route fails,
51
+ * which reports nothing at all.
52
+ */
53
+ async function bundlePlugins(root: string): Promise<BunPlugin[]> {
54
+ const path = join(root, BUNFIG);
55
+ if (!(await Bun.file(path).exists())) return [];
56
+
57
+ let configured: unknown;
58
+ try {
59
+ const config = Bun.TOML.parse(await Bun.file(path).text()) as {
60
+ serve?: { static?: { plugins?: unknown } };
61
+ };
62
+ configured = config.serve?.static?.plugins;
63
+ } catch {
64
+ // A config Bun cannot parse is the build's own error to report.
65
+ return [];
66
+ }
67
+ if (!Array.isArray(configured)) return [];
68
+
69
+ const plugins: BunPlugin[] = [];
70
+ for (const entry of configured) {
71
+ if (typeof entry !== 'string') continue;
72
+ // A relative path is resolved from the package root; a bare specifier is
73
+ // left to the resolver, exactly as Bun reads it. Dynamic because the
74
+ // specifier is whatever the config names, not a module known here.
75
+ const specifier = entry.startsWith('.') ? join(root, entry) : entry;
76
+ try {
77
+ const module = (await import(specifier)) as { default?: BunPlugin };
78
+ if (module.default) plugins.push(module.default);
79
+ } catch {
80
+ // Same: an unimportable plugin surfaces through the build, not here.
81
+ }
82
+ }
83
+ return plugins;
84
+ }
85
+
86
+ /**
87
+ * A path as the reader knows it: relative to the package root, falling back to
88
+ * the absolute path when the file is not under it. The fallback matters on
89
+ * macOS, where `tmpdir()` is `/var/…` while the same file resolves to
90
+ * `/private/var/…` — a lexically-relative path there is `../../../private/var/…`,
91
+ * which is longer than what it replaces.
92
+ */
93
+ function displayPath(file: string, root: string): string {
94
+ const relativePath = relative(root, file);
95
+ return relativePath && !relativePath.startsWith('..') ? relativePath : file;
96
+ }
97
+
98
+ /** One error as `file:line:column message` plus the offending source line. */
99
+ function formatLog(log: BundleLog, root: string): string {
100
+ const position = log.position;
101
+ if (!position?.file) return log.message;
102
+ const where = `${displayPath(position.file, root)}:${position.line}:${position.column}`;
103
+ const source = position.lineText.trim();
104
+ // The source line travels with the position: a file and column alone still
105
+ // leave the reader hunting for the edit.
106
+ return source ? `${where} ${log.message}\n ${source}` : `${where} ${log.message}`;
107
+ }
108
+
109
+ /**
110
+ * The bundle errors behind a shell that would not render, or null when this
111
+ * build is not the reason (a clean bundle means the failure lies elsewhere).
112
+ */
113
+ export async function describeShellBuildFailure(root = packageRoot()): Promise<string | null> {
114
+ const entrypoint = join(root, SHELL_ENTRYPOINT);
115
+ if (!(await Bun.file(entrypoint).exists())) return null;
116
+
117
+ let result: BuildOutput;
118
+ try {
119
+ result = await Bun.build({
120
+ entrypoints: [entrypoint],
121
+ target: 'browser',
122
+ plugins: await bundlePlugins(root),
123
+ // `throw: false` is what makes the failures readable: it returns them as
124
+ // `logs` instead of rejecting with an AggregateError, and keeps the probe
125
+ // from printing a second copy of an error Bun has already printed.
126
+ throw: false,
127
+ });
128
+ } catch (error) {
129
+ return `Rebuilding the client bundle failed: ${error instanceof Error ? error.message : String(error)}`;
130
+ }
131
+ if (result.success) return null;
132
+
133
+ const errors = result.logs.filter((log) => log.level === 'error');
134
+ if (errors.length === 0) return null;
135
+ return errors.map((log) => formatLog(log, root)).join('\n');
136
+ }