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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +239 -230
- package/dist/client/static/js/{index-1q6165r3.js → index-4qx6m2p7.js} +640 -640
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- package/package.json +4 -3
- package/public/sw.js +54 -5
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +14 -5
- package/src/client/components/settings/Modal.tsx +10 -17
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +17 -11
- package/src/client/components/workspace/editor/index.tsx +15 -7
- package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
- package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
- package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
- package/src/client/data/theme/terminal.ts +25 -8
- package/src/client/hooks/editor/font.ts +53 -0
- package/src/client/hooks/ui/clipboard.ts +17 -0
- package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
- package/src/client/hooks/ui/keyboard-inset.ts +113 -0
- package/src/client/hooks/ui/text-overflow.test.ts +152 -0
- package/src/client/hooks/ui/text-overflow.ts +78 -0
- package/src/client/hooks/ui/touch-device.ts +46 -0
- package/src/client/main.tsx +1 -0
- package/src/server/index.ts +4 -3
- package/src/server/lib/assets/conditional.server.test.ts +74 -0
- package/src/server/lib/assets/conditional.server.ts +54 -0
- package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
- package/src/server/lib/assets/dev-assets.server.ts +121 -0
- package/src/server/lib/assets/fonts.server.ts +9 -4
- package/src/server/lib/bundler/build-errors.server.ts +136 -0
- package/src/server/lib/bundler/build-errors.test.ts +103 -0
- package/src/server/lib/bundler/dependencies.test.ts +148 -0
- package/src/server/lib/lifecycle/listener.ts +29 -0
- package/src/server/plugins/shell.server.ts +52 -13
- package/src/server/plugins/ssr.ts +43 -7
- package/src/server/plugins/static.ts +51 -18
- package/src/shared/lib/code/editor/typography.test.ts +93 -0
- package/src/shared/lib/code/editor/typography.ts +86 -0
- package/src/shared/lib/fonts/NOTICE.md +44 -0
- package/src/shared/lib/fonts/nerd-symbols.css +55 -0
- package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
- package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
- package/src/shared/lib/settings/diff.test.ts +1 -1
- package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
- package/src/shared/lib/workspace/terminal/keys.ts +225 -0
- package/src/shared/types/settings/state.ts +7 -16
- 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
|
+
}
|
package/src/client/main.tsx
CHANGED
|
@@ -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__;
|
package/src/server/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
+
}
|