ompchamber 3.2.0 → 3.3.0

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-bsxxt89j.css +1 -0
  4. package/dist/client/static/js/{index-1q6165r3.js → index-qkt4z7x0.js} +640 -640
  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 +134 -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,113 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * How far a bottom-anchored panel must lift so a soft keyboard does not cover
8
+ * it.
9
+ *
10
+ * The measurement is taken from the PANEL'S OWN geometry, not from
11
+ * `window.innerHeight`: the overlap between the panel's bottom edge and the
12
+ * visible bottom edge is what actually matters, and it is self-correcting.
13
+ *
14
+ * The difference matters because `innerHeight` is not a stable reference. A
15
+ * browser that shrinks it when the keyboard opens, a `dvh` container that
16
+ * tracks it, or a panel that already ends above the keyboard all change what
17
+ * "the keyboard height" means — and computing `innerHeight - visualHeight`
18
+ * against a height that already moved lifts the panel by the keyboard's height
19
+ * a second time, which is the too-far margin. Deriving the lift from the
20
+ * overlap yields zero in every one of those cases and the exact height only
21
+ * when the panel genuinely runs under the keyboard.
22
+ *
23
+ * Deliberately local to the panel that uses it. Putting
24
+ * `interactive-widget=resizes-content` in the viewport meta would solve this
25
+ * globally, but it changes how every surface in the app sizes itself when the
26
+ * keyboard opens — a whole-app layout decision, not a terminal one.
27
+ */
28
+
29
+ import { useEffect, useState } from 'preact/hooks';
30
+
31
+ /** Ignore small overlaps: a collapsing URL bar is not a keyboard. */
32
+ const MIN_KEYBOARD_INSET_PX = 80;
33
+
34
+ /** The element shape the hook needs; keeps the signature free of a Preact import. */
35
+ export interface PanelRef {
36
+ readonly current: HTMLElement | null;
37
+ }
38
+
39
+ /**
40
+ * The lift for one measurement pair, in CSS px.
41
+ *
42
+ * `panelBottom` is the panel's bottom edge and `visibleBottom` the visible
43
+ * area's bottom edge, both in the same coordinate space. Pure so the cases
44
+ * below can be pinned without a DOM.
45
+ */
46
+ export function resolveKeyboardInset(panelBottom: number, visibleBottom: number): number {
47
+ // A non-positive visible bottom is a browser reporting nonsense; lifting by
48
+ // it would squeeze the panel to nothing, so the failure mode is "no keyboard".
49
+ if (visibleBottom <= 0) return 0;
50
+ const overlap = panelBottom - visibleBottom;
51
+ if (overlap < MIN_KEYBOARD_INSET_PX) return 0;
52
+ return Math.round(overlap);
53
+ }
54
+
55
+ export function useKeyboardInset(ref: PanelRef): number {
56
+ const [inset, setInset] = useState(0);
57
+
58
+ useEffect(() => {
59
+ const element = ref.current;
60
+ if (!element) return;
61
+
62
+ let frame: number | null = null;
63
+ const measure = () => {
64
+ frame = null;
65
+ const viewport = window.visualViewport;
66
+ if (!viewport) {
67
+ setInset((previous) => (previous === 0 ? previous : 0));
68
+ return;
69
+ }
70
+ // `offsetTop` + `height` is the visible area's bottom edge within the
71
+ // layout viewport, which is the space `getBoundingClientRect` reports in.
72
+ const next = resolveKeyboardInset(
73
+ element.getBoundingClientRect().bottom,
74
+ viewport.offsetTop + viewport.height,
75
+ );
76
+ setInset((previous) => (previous === next ? previous : next));
77
+ };
78
+
79
+ // Deferred by a frame: `visualViewport.resize` fires BEFORE the container
80
+ // reflows for the keyboard, so measuring in the handler reads the old, full
81
+ // height and lifts by the keyboard twice over. One frame later the layout
82
+ // has settled and the panel's own bottom edge is the truth.
83
+ const schedule = () => {
84
+ if (frame !== null) return;
85
+ frame = requestAnimationFrame(measure);
86
+ };
87
+
88
+ // The container can also shrink with no viewport event at all (a `dvh`
89
+ // wrapper tracking the keyboard, a drawer resized by the layout). Watching
90
+ // the panel's BORDER box catches that without a feedback loop: our own
91
+ // padding changes the content box only.
92
+ const observer = new ResizeObserver(schedule);
93
+ observer.observe(element, { box: 'border-box' });
94
+
95
+ const viewport = window.visualViewport;
96
+ viewport?.addEventListener('resize', schedule);
97
+ viewport?.addEventListener('scroll', schedule);
98
+ window.addEventListener('resize', schedule);
99
+ window.addEventListener('orientationchange', schedule);
100
+ schedule();
101
+
102
+ return () => {
103
+ if (frame !== null) cancelAnimationFrame(frame);
104
+ observer.disconnect();
105
+ viewport?.removeEventListener('resize', schedule);
106
+ viewport?.removeEventListener('scroll', schedule);
107
+ window.removeEventListener('resize', schedule);
108
+ window.removeEventListener('orientationchange', schedule);
109
+ };
110
+ }, [ref]);
111
+
112
+ return inset;
113
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Truncation measurement contract. The bug this hook replaces was a character
8
+ * count standing in for a width: 76 characters fit a desktop timeline and were
9
+ * clipped on a 340px phone card, so the notice rendered an ellipsis and shipped
10
+ * a disabled button — the chevron was dropped with it, and the tap did nothing.
11
+ *
12
+ * happy-dom does no layout, so `scrollWidth`/`clientWidth` are faked on the
13
+ * element exactly as the scroll tests fake container geometry.
14
+ */
15
+
16
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
17
+ import { Window } from 'happy-dom';
18
+ import type { h as PreactH, render as PreactRender } from 'preact';
19
+ import type { act as PreactAct } from 'preact/test-utils';
20
+ import type { RefObject } from 'preact';
21
+ import type { useIsTruncated as UseIsTruncated } from '@/client/hooks/ui/text-overflow';
22
+
23
+ let useIsTruncated: typeof UseIsTruncated;
24
+ let h: typeof PreactH;
25
+ let render: typeof PreactRender;
26
+ let act: typeof PreactAct;
27
+
28
+ let win: Window;
29
+
30
+ /** Box the hook measures. `clientWidth` is faked, so no layout is needed. */
31
+ class StubResizeObserver {
32
+ static latest: StubResizeObserver | null = null;
33
+ constructor(private readonly callback: () => void) {
34
+ StubResizeObserver.latest = this;
35
+ }
36
+ observe() {}
37
+ unobserve() {}
38
+ disconnect() {}
39
+ fire() { this.callback(); }
40
+ }
41
+
42
+ function installBox(el: HTMLElement, scrollWidth: number, clientWidth: number) {
43
+ Object.defineProperty(el, 'scrollWidth', { get: () => scrollWidth, configurable: true });
44
+ Object.defineProperty(el, 'clientWidth', { get: () => clientWidth, configurable: true });
45
+ }
46
+
47
+ interface ProbeProps {
48
+ content: string;
49
+ seen: { current: boolean | null };
50
+ /** Not named `ref`: Preact strips that key from props. */
51
+ targetRef: RefObject<HTMLSpanElement | null>;
52
+ /** When false the truncated row is not rendered — the expanded state. */
53
+ show?: boolean;
54
+ }
55
+
56
+ function Probe(props: ProbeProps) {
57
+ props.seen.current = useIsTruncated(props.targetRef, props.content);
58
+ if (props.show === false) return h('span', null, 'expanded body');
59
+ return h('span', { ref: props.targetRef, className: 'truncate' }, props.content);
60
+ }
61
+
62
+ async function mount(content: string): Promise<{
63
+ seen: { current: boolean | null };
64
+ ref: RefObject<HTMLSpanElement | null>;
65
+ node: HTMLDivElement;
66
+ el: HTMLSpanElement;
67
+ }> {
68
+ const seen: { current: boolean | null } = { current: null };
69
+ const ref: RefObject<HTMLSpanElement | null> = { current: null };
70
+ const node = document.createElement('div');
71
+ document.body.appendChild(node);
72
+ await act(async () => { render(h(Probe, { content, seen, targetRef: ref }), node); });
73
+ return { seen, ref, node, el: ref.current! };
74
+ }
75
+
76
+ beforeAll(async () => {
77
+ win = new Window({ url: 'http://localhost' });
78
+ for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'ResizeObserver'] as const) {
79
+ (globalThis as Record<string, unknown>)[key] = (win as unknown as Record<string, unknown>)[key];
80
+ }
81
+ (globalThis as Record<string, unknown>).ResizeObserver = StubResizeObserver;
82
+ // Static imports cannot work here: Preact binds its environment at module
83
+ // evaluation time, so the modules below must load after those DOM globals.
84
+ ({ useIsTruncated } = await import('@/client/hooks/ui/text-overflow'));
85
+ ({ h, render } = await import('preact'));
86
+ ({ act } = await import('preact/test-utils'));
87
+ });
88
+
89
+ afterAll(() => {
90
+ for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'ResizeObserver']) {
91
+ delete (globalThis as Record<string, unknown>)[key];
92
+ }
93
+ });
94
+
95
+ describe('useIsTruncated', () => {
96
+ test('reports a clipped line even when it is short', async () => {
97
+ // The regression: 76 characters, clipped on a phone card. A character
98
+ // threshold called this "fits" and the expander was never offered.
99
+ const probe = await mount('Multi-step reasoning: think carefully through the problem before responding.');
100
+ installBox(probe.el, 480, 254);
101
+ await act(async () => { StubResizeObserver.latest!.fire(); });
102
+
103
+ expect(probe.seen.current).toBe(true);
104
+ render(null, probe.node);
105
+ probe.node.remove();
106
+ });
107
+
108
+ test('reports a fitting line as not truncated', async () => {
109
+ const probe = await mount('Late LSP diagnostics arrived after the edit returned:');
110
+ installBox(probe.el, 335, 335);
111
+ await act(async () => { StubResizeObserver.latest!.fire(); });
112
+
113
+ expect(probe.seen.current).toBe(false);
114
+ render(null, probe.node);
115
+ probe.node.remove();
116
+ });
117
+
118
+ test('keeps the last answer when the element has no layout to measure', async () => {
119
+ // An unmeasured element reports 0 for both values, which is not "fits" —
120
+ // treating it as one would retract the expander mid-interaction.
121
+ const probe = await mount('clipped');
122
+ installBox(probe.el, 500, 200);
123
+ await act(async () => { StubResizeObserver.latest!.fire(); });
124
+ expect(probe.seen.current).toBe(true);
125
+
126
+ installBox(probe.el, 0, 0);
127
+ await act(async () => { StubResizeObserver.latest!.fire(); });
128
+ expect(probe.seen.current).toBe(true);
129
+
130
+ render(null, probe.node);
131
+ probe.node.remove();
132
+ });
133
+
134
+ test('keeps its answer when the truncated row is replaced by the expanded body', async () => {
135
+ // Expanding swaps the truncated row out for the body, so the ref detaches
136
+ // while the answer is still the reason the expander is on screen. A reset
137
+ // there would collapse the card the user just opened.
138
+ const probe = await mount('clipped');
139
+ installBox(probe.el, 500, 200);
140
+ await act(async () => { StubResizeObserver.latest!.fire(); });
141
+ expect(probe.seen.current).toBe(true);
142
+
143
+ // Detached AND with new content, so the effect re-runs against a null ref.
144
+ await act(async () => {
145
+ render(h(Probe, { content: 'clipped and expanded', seen: probe.seen, targetRef: probe.ref, show: false }), probe.node);
146
+ });
147
+ expect(probe.seen.current).toBe(true);
148
+
149
+ render(null, probe.node);
150
+ probe.node.remove();
151
+ });
152
+ });
@@ -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
+ });