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,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
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
7
+ import { mkdtempSync, rmSync } from 'fs';
8
+ import { tmpdir } from 'os';
9
+ import { join } from 'path';
10
+
11
+ import { describeShellBuildFailure } from '@/server/lib/bundler/build-errors.server';
12
+
13
+ let root: string;
14
+
15
+ beforeEach(() => {
16
+ root = mkdtempSync(join(tmpdir(), 'omp-build-errors-'));
17
+ Bun.write(join(root, 'index.html'), '<!doctype html><html><body><script type="module" src="./entry.ts"></script></body></html>');
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(root, { recursive: true, force: true });
22
+ });
23
+
24
+ describe('describeShellBuildFailure', () => {
25
+ test('names the file, position and message of a syntax error', async () => {
26
+ await Bun.write(join(root, 'entry.ts'), 'const a = 1;\nexport const broken = ;\n');
27
+
28
+ const detail = await describeShellBuildFailure(root);
29
+
30
+ expect(detail).toContain('entry.ts:2:23');
31
+ expect(detail).toContain('Unexpected ;');
32
+ // The offending source line travels with the message: a file and column
33
+ // alone still leave the reader hunting for the edit.
34
+ expect(detail).toContain('export const broken = ;');
35
+ });
36
+
37
+ test('reports an unresolved import that the bundle would use', async () => {
38
+ await Bun.write(join(root, 'entry.ts'), 'import { a } from "./nowhere";\nconsole.log(a);\n');
39
+
40
+ expect(await describeShellBuildFailure(root)).toContain('Could not resolve: "./nowhere"');
41
+ });
42
+
43
+ test('reports a missing named export, which the route serves as a reload stub', async () => {
44
+ // The route answers 200 for this, so a status-only check calls it healthy —
45
+ // but Bun replaces the bundle with a 104-byte stub that only calls
46
+ // `location.reload()`, so the page cannot run. It is a real failure of the
47
+ // bundle and is reported as one.
48
+ await Bun.write(join(root, 'dep.ts'), 'export const a = 1;\n');
49
+ await Bun.write(join(root, 'entry.ts'), 'import { zzz } from "./dep";\nconsole.log(zzz);\n');
50
+
51
+ expect(await describeShellBuildFailure(root)).toContain('No matching export');
52
+ });
53
+
54
+ test('stays silent for an unresolved import the bundle would drop anyway', async () => {
55
+ // Tree-shaken away, so the route serves the page fine (verified: 200). A
56
+ // probe that reported it would send the reader after a build error that is
57
+ // not why the page is broken.
58
+ await Bun.write(join(root, 'entry.ts'), 'import x from "./nowhere";\n');
59
+
60
+ expect(await describeShellBuildFailure(root)).toBeNull();
61
+ });
62
+
63
+ test('returns null for a bundle that builds, so the reason stays the status alone', async () => {
64
+ await Bun.write(join(root, 'entry.ts'), 'console.log("ok");\n');
65
+
66
+ expect(await describeShellBuildFailure(root)).toBeNull();
67
+ });
68
+
69
+ test('returns null when there is no shell entrypoint to build', async () => {
70
+ rmSync(join(root, 'index.html'));
71
+
72
+ expect(await describeShellBuildFailure(root)).toBeNull();
73
+ });
74
+
75
+ test('loads the plugins named in bunfig.toml, as the route does', async () => {
76
+ // A plugin that fails a file the route would bundle: if the probe ignored
77
+ // bunfig it would report a clean build and hide the real failure.
78
+ await Bun.write(
79
+ join(root, 'probe-plugin.ts'),
80
+ `import type { BunPlugin } from 'bun';
81
+ const plugin: BunPlugin = {
82
+ name: 'probe',
83
+ setup(build) {
84
+ build.onLoad({ filter: /\\.probe\\.ts$/ }, () => ({ contents: 'export const = ;', loader: 'ts' }));
85
+ },
86
+ };
87
+ export default plugin;
88
+ `,
89
+ );
90
+ await Bun.write(join(root, 'bunfig.toml'), '[serve.static]\nplugins = ["./probe-plugin.ts"]\n');
91
+ await Bun.write(join(root, 'entry.ts'), 'import "./thing.probe";\n');
92
+ await Bun.write(join(root, 'thing.probe.ts'), 'export const ok = 1;\n');
93
+
94
+ expect(await describeShellBuildFailure(root)).not.toBeNull();
95
+ });
96
+
97
+ test('survives a bunfig it cannot parse', async () => {
98
+ await Bun.write(join(root, 'bunfig.toml'), '[serve.static\nplugins = broken\n');
99
+ await Bun.write(join(root, 'entry.ts'), 'console.log("ok");\n');
100
+
101
+ expect(await describeShellBuildFailure(root)).toBeNull();
102
+ });
103
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Every bare import in `src/` must resolve from a published install.
8
+ *
9
+ * The bundler plugin (`css.ts`) is **runtime server code**, not build tooling:
10
+ * `bunfig.toml` names it in `[serve.static].plugins`, so `Bun.serve` loads it
11
+ * whenever an HTML route is bundled — which is every `ompchamber serve` in
12
+ * development and every `bun run start`. It therefore has to be loadable from
13
+ * an install that carries only `dependencies`.
14
+ *
15
+ * It was not. `@tailwindcss/postcss` was declared a devDependency and `postcss`
16
+ * was declared nowhere at all, which no local run could reveal — a source
17
+ * checkout and the developer's own `bun install` both have the whole tree — but
18
+ * the published package does not. Measured against a production-only install of
19
+ * the published tarball: the server came up and answered `503` on every page,
20
+ * with `error: Failed to load plugins for Bun.serve: Cannot find package
21
+ * 'postcss'` in its log and nothing in the shell to say why. The same install
22
+ * from `bun add -g` failed one dependency later on `@tailwindcss/postcss`.
23
+ *
24
+ * The invariant is therefore: a module reachable from a runtime entry point may
25
+ * only import a bare specifier that `dependencies` declares (or a Node/Bun
26
+ * builtin). `devDependencies` is the wrong home for anything the running server
27
+ * loads, however build-shaped it looks.
28
+ *
29
+ * Scope is deliberately `src/**` rather than "what the bundler reaches": the
30
+ * published tarball ships all of `src` and the CLI executes from it too, so a
31
+ * static import anywhere in the tree is a real requirement. Type-only imports
32
+ * are excluded because `scanImports` erases them — they cost nothing at runtime.
33
+ */
34
+
35
+ import { describe, expect, test } from 'bun:test';
36
+ import { readFileSync } from 'node:fs';
37
+ import { builtinModules } from 'node:module';
38
+ import { join } from 'node:path';
39
+
40
+ import { packageRoot } from '@/server/lib/assets/fonts.server';
41
+
42
+ /** Files whose imports are not part of any runtime path. */
43
+ const EXCLUDED = /\.test\.|\.test-util\.|test-util\./;
44
+
45
+ /** `@scope/name` keeps two segments; a bare package keeps one. */
46
+ function packageName(specifier: string): string {
47
+ const segments = specifier.split('/');
48
+ return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0];
49
+ }
50
+
51
+ const BUILTINS = new Set(builtinModules);
52
+
53
+ /**
54
+ * Resolved from the running module, not the cwd — the same helper the bundler
55
+ * plugin uses, so this test answers the same question whether it is run from the
56
+ * repo root, from `dist/client`, or through `bun test <path>` elsewhere.
57
+ */
58
+ const ROOT = packageRoot();
59
+
60
+ const pkg = (await Bun.file(join(ROOT, 'package.json')).json()) as {
61
+ dependencies?: Record<string, string>;
62
+ devDependencies?: Record<string, string>;
63
+ };
64
+
65
+ const RUNTIME = new Set(Object.keys(pkg.dependencies ?? {}));
66
+ const DEV = new Set(Object.keys(pkg.devDependencies ?? {}));
67
+
68
+ /**
69
+ * Bun's transpiler is the parser here, so the answer matches what the bundler
70
+ * itself sees: `@/` aliases, relative paths and `node:`/`bun:` prefixes are
71
+ * filtered below, and a type-only import never appears at all.
72
+ */
73
+ const transpiler = new Bun.Transpiler({
74
+ loader: 'tsx',
75
+ tsconfig: readFileSync(join(ROOT, 'tsconfig.json'), 'utf8'),
76
+ });
77
+
78
+ const sources = [...new Bun.Glob('src/**/*.{ts,tsx,js}').scanSync({ cwd: ROOT })]
79
+ .filter((file) => !EXCLUDED.test(file));
80
+
81
+ /** Every bare specifier each file imports, keyed by the file that imports it. */
82
+ async function bareImports(file: string): Promise<string[]> {
83
+ let imports: Bun.Import[];
84
+ try {
85
+ imports = transpiler.scanImports(readFileSync(join(ROOT, file), 'utf8'));
86
+ } catch {
87
+ // A shebang line (`src/cli/ompchamber.js`) is not parseable as a module;
88
+ // its imports are covered by the files it imports.
89
+ return [];
90
+ }
91
+ return imports
92
+ // `require-call` entries are the JSX runtime Bun injects into the scan, not
93
+ // a specifier the source names; the runtime itself is `preact`, which is
94
+ // declared.
95
+ .filter((entry) => entry.kind !== 'require-call')
96
+ .map((entry) => entry.path)
97
+ .filter((path) => !path.startsWith('.') && !path.startsWith('@/'))
98
+ .filter((path) => !path.startsWith('bun:') && !path.startsWith('node:'))
99
+ .filter((path) => !BUILTINS.has(packageName(path)));
100
+ }
101
+
102
+ describe('runtime imports are declared as dependencies', () => {
103
+ test('no shipped module imports a bare specifier the package does not declare', async () => {
104
+ const violations: string[] = [];
105
+
106
+ for (const file of sources) {
107
+ for (const specifier of await bareImports(file)) {
108
+ const name = packageName(specifier);
109
+ if (RUNTIME.has(name)) continue;
110
+ violations.push(`${file} imports '${specifier}' — ${DEV.has(name) ? 'declared in devDependencies' : 'not declared in package.json'}`);
111
+ }
112
+ }
113
+
114
+ // Printed rather than merely counted: the fix is per-specifier, and the
115
+ // message has to say which list to move it to.
116
+ expect(violations).toEqual([]);
117
+ });
118
+
119
+ test('the bundler plugin is a runtime dependency of the server', () => {
120
+ // The specific regression, pinned by name. `bunfig.toml` is what makes the
121
+ // plugin runtime code, so the two are read together: if the plugin ever
122
+ // stops being a `serve.static` entry, this assertion is what says the
123
+ // dependency placement no longer has to hold.
124
+ const bunfig = Bun.TOML.parse(readFileSync(join(ROOT, 'bunfig.toml'), 'utf8')) as {
125
+ serve?: { static?: { plugins?: string[] } };
126
+ };
127
+ expect(bunfig.serve?.static?.plugins).toContain('./src/server/lib/bundler/css.ts');
128
+
129
+ for (const name of ['@tailwindcss/postcss', 'postcss']) {
130
+ expect(RUNTIME.has(name)).toBe(true);
131
+ expect(DEV.has(name)).toBe(false);
132
+ }
133
+ });
134
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The HTTP listener this process is serving on.
8
+ *
9
+ * Two consumers need it and neither can import it: the shell renderer, which
10
+ * asks the listener for its own `/_shell` route (Bun renders an HTML route only
11
+ * while serving), and the dev asset proxy, which asks it for the assets Bun's
12
+ * own routing table owns. The reference is injected rather than imported because
13
+ * `index.ts` owns the listener and imports both of them — a direct import would
14
+ * be a cycle.
15
+ */
16
+
17
+ type Listener = { url: URL } | null;
18
+
19
+ let listener: Listener = null;
20
+
21
+ /** Called by the server entry once its listener is up. */
22
+ export function setListener(server: { url: URL }): void {
23
+ listener = server;
24
+ }
25
+
26
+ /** The listener's base URL, or null before it is up. */
27
+ export function listenerUrl(): URL | null {
28
+ return listener?.url ?? null;
29
+ }
@@ -19,22 +19,58 @@
19
19
  * own routing table rather than Elysia's (verified against Bun 1.4.2). Fetching
20
20
  * our own listener is the supported way to obtain the markup.
21
21
  *
22
- * The server reference is injected rather than imported to avoid a cycle:
23
- * `index.ts` owns the listener and imports `ssrRoutes`, which reaches here.
22
+ * The listener reference is shared with the dev asset proxy, which needs the
23
+ * same URL to reach the routes Bun's own table owns — see `lifecycle/listener`.
24
24
  */
25
25
 
26
+ import { describeShellBuildFailure } from '@/server/lib/bundler/build-errors.server';
26
27
  import { SHELL_ROUTE } from '@/server/lib/lifecycle/shell-route';
28
+ import { listenerUrl } from '@/server/lib/lifecycle/listener';
27
29
 
28
- type ShellSource = { url: URL } | null;
29
-
30
- let source: ShellSource = null;
30
+ export type ShellRender = { ok: true; html: string } | { ok: false; reason: string };
31
31
 
32
- /** Called by the server entry once its listener is up. */
33
- export function setShellSource(server: { url: URL }): void {
34
- source = server;
32
+ /**
33
+ * Why the route refused, in the terms of the edit that broke it.
34
+ *
35
+ * A status alone is the least useful half of the answer: the route answers 500
36
+ * because Bun could not bundle the page, and the file, line and message are in
37
+ * the response — but only inside Bun's "Build Failed" page, which carries them
38
+ * as a binary payload the page decodes at runtime. So the entrypoint is rebuilt
39
+ * to obtain them in readable form. See `bundler/build-errors.server` for why
40
+ * that probe agrees with the route.
41
+ *
42
+ * Runs only on a failure, on a page that is already broken.
43
+ */
44
+ async function shellFailureReason(status: number, markupInvalid = false): Promise<string> {
45
+ const detail = await describeShellBuildFailure();
46
+ if (detail) {
47
+ const reason = markupInvalid ? 'The shell route answered with markup that is not the shell.' : `Shell route answered ${status}.`;
48
+ return `${reason}\n\nThe client bundle does not build:\n\n${detail}`;
49
+ }
50
+ // Markup that is not the shell while the bundle builds cleanly means the
51
+ // route is serving a CACHED failure: Bun caches the HTML route's output once
52
+ // in production and never rebuilds it, so the empty body a failed first
53
+ // request left behind outlives the fix. There is nothing to point at in the
54
+ // source, and no amount of reloading helps — only a restart does.
55
+ return 'The shell route answered with markup that is not the shell, and the client bundle builds cleanly.\n\n'
56
+ + 'In production Bun caches the HTML route\'s output, so a build failure on the first request is cached as an empty page. Restart the server to rebuild it.';
35
57
  }
36
58
 
37
- export type ShellRender = { ok: true; html: string } | { ok: false; reason: string };
59
+ /**
60
+ * The bootstrap marker the SSR route substitutes, and therefore the one string
61
+ * that proves the markup came from `index.html`.
62
+ *
63
+ * A status check alone is not enough, and the gap is not theoretical: with
64
+ * `development: false` Bun caches the route's output, and a build that failed on
65
+ * the FIRST request leaves it caching an EMPTY body answered `200`. Every later
66
+ * request then looks successful, and the substitution below finds nothing to
67
+ * replace — so the browser gets a blank page with a 200 and no way to tell why.
68
+ * Measured on Bun 1.4.2: request 1 `500` + empty, requests 2..n `200` + empty,
69
+ * and it stayed empty after the source was fixed, because the cached output is
70
+ * never rebuilt in production. The marker turns that into the same actionable
71
+ * page the development path already produces.
72
+ */
73
+ export const SHELL_MARKER = '<!--app-bootstrap-->';
38
74
 
39
75
  /**
40
76
  * The current shell markup, or a reason it could not be produced.
@@ -44,11 +80,14 @@ export type ShellRender = { ok: true; html: string } | { ok: false; reason: stri
44
80
  * thing that could serve markup pointing at a previous build's asset hashes.
45
81
  */
46
82
  export async function renderShell(): Promise<ShellRender> {
47
- if (!source) return { ok: false, reason: 'The HTTP listener is not up yet.' };
83
+ const base = listenerUrl();
84
+ if (!base) return { ok: false, reason: 'The HTTP listener is not up yet.' };
48
85
  try {
49
- const response = await fetch(new URL(SHELL_ROUTE, source.url));
50
- if (!response.ok) return { ok: false, reason: `Shell route answered ${response.status}.` };
51
- return { ok: true, html: await response.text() };
86
+ const response = await fetch(new URL(SHELL_ROUTE, base));
87
+ if (!response.ok) return { ok: false, reason: await shellFailureReason(response.status) };
88
+ const html = await response.text();
89
+ if (!html.includes(SHELL_MARKER)) return { ok: false, reason: await shellFailureReason(response.status, true) };
90
+ return { ok: true, html };
52
91
  } catch (error) {
53
92
  const message = error instanceof Error ? error.message : String(error);
54
93
  return { ok: false, reason: message };