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,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,148 @@
|
|
|
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, resolve } from 'node:path';
|
|
39
|
+
|
|
40
|
+
/** Files whose imports are not part of any runtime path. */
|
|
41
|
+
const EXCLUDED = /\.test\.|\.test-util\.|test-util\./;
|
|
42
|
+
|
|
43
|
+
/** `@scope/name` keeps two segments; a bare package keeps one. */
|
|
44
|
+
function packageName(specifier: string): string {
|
|
45
|
+
const segments = specifier.split('/');
|
|
46
|
+
return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const BUILTINS = new Set(builtinModules);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The package root, derived from this file's own location (`src/server/lib/
|
|
53
|
+
* bundler` → four levels up) rather than the cwd.
|
|
54
|
+
*
|
|
55
|
+
* `packageRoot()` from `lib/assets/fonts.server.ts` is deliberately NOT used
|
|
56
|
+
* here: it answers "where are the dependencies installed", which is the first
|
|
57
|
+
* directory up from the module that holds `node_modules`. In a published
|
|
58
|
+
* install the package has no `node_modules` of its own — its dependencies are
|
|
59
|
+
* hoisted to the consumer's root — so that helper returns the CONSUMER's
|
|
60
|
+
* project, and the manifest it reads is the consumer's `package.json`, not
|
|
61
|
+
* this package's. Measured: run from an installed copy it resolved
|
|
62
|
+
* `/private/tmp/npmcheck/tsconfig.json` and died with `ENOENT`.
|
|
63
|
+
*
|
|
64
|
+
* The path depth is the same fact `lib/updates/install.ts` encodes in its
|
|
65
|
+
* `DEFAULT_PKG_ROOT`, for the same reason: `src/server/lib/<domain>/<file>`
|
|
66
|
+
* is always four levels below the package.
|
|
67
|
+
*/
|
|
68
|
+
const ROOT = resolve(import.meta.dir, '..', '..', '..', '..');
|
|
69
|
+
|
|
70
|
+
const pkg = (await Bun.file(join(ROOT, 'package.json')).json()) as {
|
|
71
|
+
dependencies?: Record<string, string>;
|
|
72
|
+
devDependencies?: Record<string, string>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const RUNTIME = new Set(Object.keys(pkg.dependencies ?? {}));
|
|
76
|
+
const DEV = new Set(Object.keys(pkg.devDependencies ?? {}));
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Bun's transpiler is the parser here, so the answer matches what the bundler
|
|
80
|
+
* itself sees: `@/` aliases, relative paths and `node:`/`bun:` prefixes are
|
|
81
|
+
* filtered below, and a type-only import never appears at all.
|
|
82
|
+
*
|
|
83
|
+
* `tsconfig.json` ships in the published tarball (it is in `files`, and Bun
|
|
84
|
+
* resolves the `@/` alias from the package's own copy), so it is always
|
|
85
|
+
* readable here.
|
|
86
|
+
*/
|
|
87
|
+
const transpiler = new Bun.Transpiler({
|
|
88
|
+
loader: 'tsx',
|
|
89
|
+
tsconfig: readFileSync(join(ROOT, 'tsconfig.json'), 'utf8'),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const sources = [...new Bun.Glob('src/**/*.{ts,tsx,js}').scanSync({ cwd: ROOT })]
|
|
93
|
+
.filter((file) => !EXCLUDED.test(file));
|
|
94
|
+
|
|
95
|
+
/** Every bare specifier each file imports, keyed by the file that imports it. */
|
|
96
|
+
async function bareImports(file: string): Promise<string[]> {
|
|
97
|
+
let imports: Bun.Import[];
|
|
98
|
+
try {
|
|
99
|
+
imports = transpiler.scanImports(readFileSync(join(ROOT, file), 'utf8'));
|
|
100
|
+
} catch {
|
|
101
|
+
// A shebang line (`src/cli/ompchamber.js`) is not parseable as a module;
|
|
102
|
+
// its imports are covered by the files it imports.
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
return imports
|
|
106
|
+
// `require-call` entries are the JSX runtime Bun injects into the scan, not
|
|
107
|
+
// a specifier the source names; the runtime itself is `preact`, which is
|
|
108
|
+
// declared.
|
|
109
|
+
.filter((entry) => entry.kind !== 'require-call')
|
|
110
|
+
.map((entry) => entry.path)
|
|
111
|
+
.filter((path) => !path.startsWith('.') && !path.startsWith('@/'))
|
|
112
|
+
.filter((path) => !path.startsWith('bun:') && !path.startsWith('node:'))
|
|
113
|
+
.filter((path) => !BUILTINS.has(packageName(path)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe('runtime imports are declared as dependencies', () => {
|
|
117
|
+
test('no shipped module imports a bare specifier the package does not declare', async () => {
|
|
118
|
+
const violations: string[] = [];
|
|
119
|
+
|
|
120
|
+
for (const file of sources) {
|
|
121
|
+
for (const specifier of await bareImports(file)) {
|
|
122
|
+
const name = packageName(specifier);
|
|
123
|
+
if (RUNTIME.has(name)) continue;
|
|
124
|
+
violations.push(`${file} imports '${specifier}' — ${DEV.has(name) ? 'declared in devDependencies' : 'not declared in package.json'}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Printed rather than merely counted: the fix is per-specifier, and the
|
|
129
|
+
// message has to say which list to move it to.
|
|
130
|
+
expect(violations).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('the bundler plugin is a runtime dependency of the server', () => {
|
|
134
|
+
// The specific regression, pinned by name. `bunfig.toml` is what makes the
|
|
135
|
+
// plugin runtime code, so the two are read together: if the plugin ever
|
|
136
|
+
// stops being a `serve.static` entry, this assertion is what says the
|
|
137
|
+
// dependency placement no longer has to hold.
|
|
138
|
+
const bunfig = Bun.TOML.parse(readFileSync(join(ROOT, 'bunfig.toml'), 'utf8')) as {
|
|
139
|
+
serve?: { static?: { plugins?: string[] } };
|
|
140
|
+
};
|
|
141
|
+
expect(bunfig.serve?.static?.plugins).toContain('./src/server/lib/bundler/css.ts');
|
|
142
|
+
|
|
143
|
+
for (const name of ['@tailwindcss/postcss', 'postcss']) {
|
|
144
|
+
expect(RUNTIME.has(name)).toBe(true);
|
|
145
|
+
expect(DEV.has(name)).toBe(false);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -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
|
|
23
|
-
*
|
|
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
|
|
29
|
-
|
|
30
|
-
let source: ShellSource = null;
|
|
30
|
+
export type ShellRender = { ok: true; html: string } | { ok: false; reason: string };
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
50
|
-
if (!response.ok) return { ok: false, reason:
|
|
51
|
-
|
|
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 };
|
|
@@ -1,26 +1,47 @@
|
|
|
1
1
|
import { Elysia } from 'elysia';
|
|
2
2
|
import { getDb } from '@/server/db.server';
|
|
3
3
|
import { tryServeStatic } from '@/server/plugins/static';
|
|
4
|
-
import { renderShell } from '@/server/plugins/shell.server';
|
|
4
|
+
import { renderShell, SHELL_MARKER } from '@/server/plugins/shell.server';
|
|
5
|
+
import {
|
|
6
|
+
DEV_ASSETS_ENABLED,
|
|
7
|
+
devAssetUpstreamPath,
|
|
8
|
+
rewriteDevAssetUrls,
|
|
9
|
+
serveDevAsset,
|
|
10
|
+
} from '@/server/lib/assets/dev-assets.server';
|
|
11
|
+
import { listenerUrl } from '@/server/lib/lifecycle/listener';
|
|
5
12
|
import { FONT_STYLESHEET_ROUTE } from '@/server/lib/assets/font-css.server';
|
|
6
13
|
import { resolveTheme } from '@/shared/lib/theme/catalog';
|
|
7
14
|
import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
|
|
8
15
|
|
|
16
|
+
/** HTML-escape a string for interpolation into the failure page's `<pre>`. */
|
|
17
|
+
function escapeHtmlText(value: string): string {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/&/g, '&')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>')
|
|
22
|
+
.replace(/"/g, '"');
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
/**
|
|
10
26
|
* Actionable message instead of a bare 500 when the shell cannot be rendered.
|
|
11
27
|
*
|
|
12
28
|
* The shell is a Bun HTML route now, not a file on disk, so "not found" is no
|
|
13
29
|
* longer the failure mode — a bundle error is, and Bun renders its own error
|
|
14
|
-
* page for that
|
|
15
|
-
*
|
|
30
|
+
* page for that, with the file and line locked inside a payload only the
|
|
31
|
+
* browser can decode. `plugins/shell.server.ts` rebuilds the entrypoint to get
|
|
32
|
+
* those facts in readable form and passes them here as the reason, so this page
|
|
33
|
+
* names the edit that broke the bundle rather than the status it produced.
|
|
16
34
|
*/
|
|
17
35
|
function shellUnavailableResponse(reason: string): Response {
|
|
36
|
+
// Escaped, not interpolated raw: the reason carries the failed build's own
|
|
37
|
+
// source lines, which are arbitrary code — a `<` in one would otherwise
|
|
38
|
+
// truncate the page at the first tag.
|
|
18
39
|
return new Response(
|
|
19
40
|
`<!doctype html><meta charset="utf-8"><title>OMPChamber</title>
|
|
20
41
|
<body style="font:14px/1.6 ui-monospace,monospace;padding:2rem">
|
|
21
42
|
<h1 style="font-size:1.1rem">Shell not available</h1>
|
|
22
43
|
<p>The server could not render its HTML shell.</p>
|
|
23
|
-
<pre style="background:#f4f1ea;padding:1rem;border-radius:6px">${reason}</pre>
|
|
44
|
+
<pre style="background:#f4f1ea;padding:1rem;border-radius:6px">${escapeHtmlText(reason)}</pre>
|
|
24
45
|
</body>`,
|
|
25
46
|
{ status: 503, headers: HTML_HEADERS },
|
|
26
47
|
);
|
|
@@ -83,7 +104,17 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
83
104
|
});
|
|
84
105
|
}
|
|
85
106
|
|
|
86
|
-
|
|
107
|
+
// The dev asset proxy, before the static lookup: its prefix is this server's
|
|
108
|
+
// own, and Bun's table never sees it. See `lib/assets/dev-assets.server`.
|
|
109
|
+
if (DEV_ASSETS_ENABLED && devAssetUpstreamPath(pathname)) {
|
|
110
|
+
const base = listenerUrl();
|
|
111
|
+
if (base) {
|
|
112
|
+
const proxied = await serveDevAsset(request, pathname, base);
|
|
113
|
+
if (proxied) return proxied;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const asset = await tryServeStatic(pathname, request);
|
|
87
118
|
if (asset) return asset;
|
|
88
119
|
|
|
89
120
|
const settings = await readSettings();
|
|
@@ -110,13 +141,18 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
110
141
|
// loader and fail the same way.
|
|
111
142
|
const fontLink = `<link rel="stylesheet" href="${FONT_STYLESHEET_ROUTE}">`;
|
|
112
143
|
|
|
144
|
+
// Bun's dev asset URLs move to this server's prefix so the proxy above can add
|
|
145
|
+
// the caching headers Bun omits. Rewritten BEFORE the bootstrap is injected,
|
|
146
|
+
// so a `/_bun/` string inside the settings payload is left alone.
|
|
147
|
+
const shellHtml = DEV_ASSETS_ENABLED ? rewriteDevAssetUrls(rendered.html) : rendered.html;
|
|
148
|
+
|
|
113
149
|
return new Response(
|
|
114
|
-
|
|
150
|
+
shellHtml
|
|
115
151
|
.replace('data-theme="paper"', `data-theme="${theme.id}"`)
|
|
116
152
|
.replace('data-theme-variant="light"', `data-theme-variant="${theme.variant}"`)
|
|
117
153
|
.replace('<meta name="theme-color" content="#faf8f3" />', `<meta name="theme-color" content="${theme.canvas}" />`)
|
|
118
154
|
.replace('<!--app-head-->', themeStyle + fontLink)
|
|
119
|
-
.replace(
|
|
155
|
+
.replace(SHELL_MARKER, `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
|
|
120
156
|
{ headers: HTML_HEADERS },
|
|
121
157
|
);
|
|
122
158
|
});
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { extname, join, normalize, resolve, sep } from 'path';
|
|
2
2
|
|
|
3
|
+
import { notModified } from '@/server/lib/assets/conditional.server';
|
|
3
4
|
import { FONT_STYLESHEET_ROUTE, serveFontFile, serveFontStylesheet } from '@/server/lib/assets/font-css.server';
|
|
4
5
|
import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* File serving for `public/` and the font routes.
|
|
8
9
|
*
|
|
9
|
-
* The client bundle is NOT served from here
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The client bundle is NOT served from here. Bun's own routing owns the assets
|
|
11
|
+
* it emits — `/_bun/asset/*` and `/_bun/client/*` in development, `/chunk-*` in
|
|
12
|
+
* production — and answers them from its bundle table before a request reaches
|
|
13
|
+
* Elysia. In development those responses are reached a second way, through
|
|
14
|
+
* `lib/assets/dev-assets.server.ts`: Bun's table wins for the paths it declares,
|
|
15
|
+
* so the shell points at `/_dev-assets/*` and that module fetches the bytes back
|
|
16
|
+
* from the listener to add the `Cache-Control` and `ETag`/`304` handling Bun's
|
|
17
|
+
* dev asset routes lack. `dist/client` no longer exists, so the `/static/`
|
|
13
18
|
* branch and its content-hashed caching went with it.
|
|
14
19
|
*
|
|
15
20
|
* Hand-rolled rather than `@elysiajs/static`: that plugin registers its own
|
|
@@ -61,7 +66,17 @@ function resolveWithin(root: string, pathname: string): string | null {
|
|
|
61
66
|
return candidate;
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The file's own bytes, or null when it cannot be read.
|
|
71
|
+
*
|
|
72
|
+
* `If-None-Match` is answered from a weak validator derived from the file's
|
|
73
|
+
* size and mtime. A content hash would be stronger, but it would mean reading
|
|
74
|
+
* every asset on every request to compute it — the opposite of the point.
|
|
75
|
+
* Size+mtime is what `plugins/static.ts` can produce from the `stat` it already
|
|
76
|
+
* performs, and a wrong `304` from it needs both a same-size edit and a
|
|
77
|
+
* preserved mtime, which is not something a build or an editor does.
|
|
78
|
+
*/
|
|
79
|
+
async function serveFile(filePath: string, cacheControl: string, request?: Request): Promise<Response | null> {
|
|
65
80
|
let stat;
|
|
66
81
|
try {
|
|
67
82
|
stat = await Bun.file(filePath).stat();
|
|
@@ -71,14 +86,24 @@ async function serveFile(filePath: string, cacheControl: string): Promise<Respon
|
|
|
71
86
|
if (!stat.isFile()) return null;
|
|
72
87
|
|
|
73
88
|
const type = MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
const lastModified = stat.mtime.toUTCString();
|
|
90
|
+
// Weak on purpose: the validator identifies the file as the same version, and
|
|
91
|
+
// byte equality is not what it promises.
|
|
92
|
+
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs / 1000)}"`;
|
|
93
|
+
const headers = {
|
|
94
|
+
'content-type': type,
|
|
95
|
+
'cache-control': cacheControl,
|
|
96
|
+
'last-modified': lastModified,
|
|
97
|
+
etag,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (request && notModified(request, etag, stat.mtimeMs)) {
|
|
101
|
+
// No `content-length`: a 304 describes the validator, not a body.
|
|
102
|
+
return new Response(null, { status: 304, headers });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return new Response(Bun.file(filePath).stream(), {
|
|
106
|
+
headers: { ...headers, 'content-length': String(stat.size) },
|
|
82
107
|
});
|
|
83
108
|
}
|
|
84
109
|
|
|
@@ -90,18 +115,22 @@ async function serveFile(filePath: string, cacheControl: string): Promise<Respon
|
|
|
90
115
|
* going through the SSR route that injects the theme and bootstrap, and the
|
|
91
116
|
* cache stays short because these filenames carry no content hash.
|
|
92
117
|
*/
|
|
93
|
-
export async function servePublicAsset(
|
|
118
|
+
export async function servePublicAsset(
|
|
119
|
+
pathname: string,
|
|
120
|
+
roots: readonly string[],
|
|
121
|
+
request?: Request,
|
|
122
|
+
): Promise<Response | null> {
|
|
94
123
|
for (const root of roots) {
|
|
95
124
|
const filePath = resolveWithin(root, pathname);
|
|
96
125
|
if (!filePath || filePath.endsWith('.html')) continue;
|
|
97
|
-
const response = await serveFile(filePath, SHORT);
|
|
126
|
+
const response = await serveFile(filePath, SHORT, request);
|
|
98
127
|
if (response) return response;
|
|
99
128
|
}
|
|
100
129
|
return null;
|
|
101
130
|
}
|
|
102
131
|
|
|
103
132
|
/** A file for `pathname`, or null when the request should reach the app. */
|
|
104
|
-
export async function tryServeStatic(pathname: string): Promise<Response | null> {
|
|
133
|
+
export async function tryServeStatic(pathname: string, request?: Request): Promise<Response | null> {
|
|
105
134
|
// The production bundle's own assets. `scripts/build-client.ts` emits the
|
|
106
135
|
// server into `dist/client` with its chunks under `dist/client/static`, and
|
|
107
136
|
// the generated HTML references them as `./static/...` — a page URL of `/`
|
|
@@ -110,7 +139,7 @@ export async function tryServeStatic(pathname: string): Promise<Response | null>
|
|
|
110
139
|
// `/_bun/client/*`) and never reach here.
|
|
111
140
|
if (pathname.startsWith('/static/')) {
|
|
112
141
|
const filePath = resolveWithin(CLIENT_ROOT, pathname);
|
|
113
|
-
return filePath ? serveFile(filePath, ASSET_CACHE) : null;
|
|
142
|
+
return filePath ? serveFile(filePath, ASSET_CACHE, request) : null;
|
|
114
143
|
}
|
|
115
144
|
// The extracted `@font-face` stylesheet and the files it names. Bun's CSS
|
|
116
145
|
// loader cannot carry either — every local `url()` is resolved, so a bundled
|
|
@@ -118,9 +147,13 @@ export async function tryServeStatic(pathname: string): Promise<Response | null>
|
|
|
118
147
|
// than emitted. Both live outside `public/`: the stylesheet is assembled from
|
|
119
148
|
// the faces `lib/bundler/css.ts` wrote, and the files come from the installed
|
|
120
149
|
// packages.
|
|
150
|
+
//
|
|
151
|
+
// Neither is given a conditional here: the stylesheet is `no-store` (nothing
|
|
152
|
+
// is ever stored to revalidate) and a font file is `immutable` (the browser
|
|
153
|
+
// does not ask again). A validator on either would be dead code.
|
|
121
154
|
if (pathname === FONT_STYLESHEET_ROUTE) return serveFontStylesheet();
|
|
122
155
|
if (pathname.startsWith(FONT_ROUTE_PREFIX)) {
|
|
123
156
|
return serveFontFile(decodeURIComponent(pathname.slice(FONT_ROUTE_PREFIX.length)));
|
|
124
157
|
}
|
|
125
|
-
return servePublicAsset(pathname, [PUBLIC_ROOT]);
|
|
158
|
+
return servePublicAsset(pathname, [PUBLIC_ROOT], request);
|
|
126
159
|
}
|