ompchamber 3.3.1 → 3.3.3
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 +291 -292
- package/dist/client/static/js/{index-4qx6m2p7.js → index-8ejde0b7.js} +333 -333
- package/package.json +1 -1
- package/src/server/lib/assets/font-css.server.test.ts +87 -0
- package/src/server/lib/assets/font-css.server.ts +46 -20
- package/src/server/lib/assets/font-faces.ts +69 -0
- package/src/server/lib/assets/fonts.server.ts +27 -4
- package/src/server/lib/bundler/build-errors.server.ts +8 -3
- package/src/server/lib/bundler/css.ts +12 -61
- package/src/server/lib/fs/git-repos.ts +45 -25
- package/src/server/lib/lifecycle/proc/darwin.ts +25 -14
- package/src/server/lib/omp/rpc/lines.ts +17 -7
- package/src/server/lib/omp/session/usage/aggregate.ts +41 -7
- package/src/server/lib/terminal/shell.test.ts +20 -1
- package/src/server/lib/terminal/shell.ts +32 -7
- package/src/server/plugins/ssr.ts +4 -4
- package/src/server/plugins/static.ts +14 -10
- package/src/server/routes/fs/read.ts +9 -18
- package/src/shared/lib/code/highlighter.ts +19 -1
- package/src/shared/lib/omp/rpc/frame.ts +21 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ompchamber",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.3",
|
|
4
4
|
"description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `/fonts.css` is derived from the SOURCE stylesheets on every request, and this
|
|
8
|
+
* is the gate that keeps it that way.
|
|
9
|
+
*
|
|
10
|
+
* The sheet used to be assembled from a directory the bundler wrote as a side
|
|
11
|
+
* effect of bundling a CSS module — so it was produced once per process and
|
|
12
|
+
* could never be repaired. An `rm -rf` while the server ran, or a fresh clone
|
|
13
|
+
* that never built, left the route answering 0 bytes for the rest of that
|
|
14
|
+
* process and the terminal's Nerd symbols face vanished: private-use glyphs
|
|
15
|
+
* became tofu boxes with no error anywhere. These assertions fail if the sheet
|
|
16
|
+
* ever depends on a build artifact again.
|
|
17
|
+
*
|
|
18
|
+
* The last case is the end-to-end one: every font basename the sheet names must
|
|
19
|
+
* resolve to a file the server actually serves. A face pointing at a basename
|
|
20
|
+
* no package ships is a 404 the browser swallows, which looks identical to the
|
|
21
|
+
* font never having been declared.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, expect, test } from 'bun:test';
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { fontStylesheet, serveFontFile } from '@/server/lib/assets/font-css.server';
|
|
29
|
+
import { packageDir } from '@/server/lib/assets/fonts.server';
|
|
30
|
+
|
|
31
|
+
const sheet = await fontStylesheet();
|
|
32
|
+
|
|
33
|
+
/** Every `url(...)` target in the sheet. */
|
|
34
|
+
function urls(css: string): string[] {
|
|
35
|
+
return [...css.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)].map(([, , target]) => target);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('/fonts.css is assembled from source, not from a build artifact', () => {
|
|
39
|
+
test('it declares the bundled Nerd symbols face', () => {
|
|
40
|
+
expect(sheet).toContain('OMPChamber Nerd Symbols');
|
|
41
|
+
expect(urls(sheet)).toContain('/fonts/symbols-nerd-font-mono.woff2');
|
|
42
|
+
// The range is what keeps the 1.1 MB face off every machine that has a
|
|
43
|
+
// Nerd Font of its own, so a sheet without it is a different contract.
|
|
44
|
+
expect(sheet).toContain('unicode-range');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('it declares the text faces the bundle cannot carry', () => {
|
|
48
|
+
expect(sheet).toContain('Fira Code');
|
|
49
|
+
expect(sheet).toContain('KaTeX_Main');
|
|
50
|
+
expect(urls(sheet)).toContain('/fonts/fira-code-latin-400-normal.woff2');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('every url is rewritten to the served route — no relative or node_modules path survives', () => {
|
|
54
|
+
const targets = urls(sheet);
|
|
55
|
+
expect(targets.length).toBeGreaterThan(20);
|
|
56
|
+
for (const target of targets) {
|
|
57
|
+
expect(target.startsWith('/fonts/')).toBe(true);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('every basename the sheet names resolves to a served file', async () => {
|
|
62
|
+
for (const target of urls(sheet)) {
|
|
63
|
+
const response = await serveFontFile(target.slice('/fonts/'.length));
|
|
64
|
+
expect(response).not.toBeNull();
|
|
65
|
+
expect(response?.headers.get('content-type')).toMatch(/^font\//);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('the source tree is resolved by the package, not by the dependencies', () => {
|
|
71
|
+
// In a hoisted install the package's own files live under
|
|
72
|
+
// `node_modules/ompchamber/` while its dependencies sit in the parent
|
|
73
|
+
// `node_modules/` — so `packageRoot()` (the first root holding
|
|
74
|
+
// `node_modules`) is the PARENT, and looking for `src/` beside the hoisted
|
|
75
|
+
// packages finds nothing. That served an empty sheet and silently dropped
|
|
76
|
+
// every `@font-face`; the stylesheet has to resolve through `packageDir()`.
|
|
77
|
+
test('the stylesheets resolve through packageDir, and it finds the source tree', () => {
|
|
78
|
+
// This repo is a source checkout, where `packageDir()` and `packageRoot()`
|
|
79
|
+
// coincide. The assertion that matters is that the source tree is found AT
|
|
80
|
+
// ALL — the hoisted layout above is the case that breaks, and it cannot be
|
|
81
|
+
// staged from inside the repo without rewriting the module's own search
|
|
82
|
+
// roots, so the end-to-end proof for it lives in the manual run against a
|
|
83
|
+
// simulated published install.
|
|
84
|
+
expect(existsSync(join(packageDir(), 'src'))).toBe(true);
|
|
85
|
+
expect(existsSync(join(packageDir(), 'src', 'shared', 'lib', 'fonts', 'nerd-symbols.css'))).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -10,32 +10,44 @@
|
|
|
10
10
|
* local `url()`, and a font is always a local file — so a bundled face is either
|
|
11
11
|
* a base64 blob or an absolute filesystem path. `lib/bundler/css.ts` lifts each
|
|
12
12
|
* block out of its stylesheet and rewrites the urls to `<FONT_ROUTE_PREFIX><basename>`;
|
|
13
|
-
* this module
|
|
13
|
+
* this module re-declares them on `/fonts.css`.
|
|
14
14
|
*
|
|
15
15
|
* Two jobs, one route each:
|
|
16
16
|
*
|
|
17
|
-
* - `FONT_STYLESHEET_ROUTE` (`/fonts.css`) concatenates the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* - `FONT_STYLESHEET_ROUTE` (`/fonts.css`) concatenates the faces, read from the
|
|
18
|
+
* source stylesheets under `src/` on every request. It is a handful of
|
|
19
|
+
* kilobytes and four small files, and reading them is what keeps the sheet
|
|
20
|
+
* correct for the process's whole life — the alternative, a directory the
|
|
21
|
+
* bundler wrote, is only ever produced as a side effect of a CSS module being
|
|
22
|
+
* bundled, so it is written once per process and can never be repaired: an
|
|
23
|
+
* `rm -rf` while the server runs (or a fresh clone that never ran a build)
|
|
24
|
+
* leaves `/fonts.css` empty for the rest of that process, and the terminal's
|
|
25
|
+
* Nerd symbols face silently disappears — the glyphs become tofu boxes with no
|
|
26
|
+
* error anywhere.
|
|
27
|
+
*
|
|
28
|
+
* Only a face whose `src` names a local font is re-declared, so a stylesheet
|
|
29
|
+
* carrying a remote face contributes nothing (see `font-faces.ts`). The faces
|
|
30
|
+
* the client bundle imports all live under `src/`, which is what makes the
|
|
31
|
+
* source tree the honest place to read them from: the same files the bundler
|
|
32
|
+
* strips are the files the browser is served.
|
|
22
33
|
*
|
|
23
34
|
* - `FONT_ROUTE_PREFIX` (`/fonts/<basename>`) serves the file itself from the
|
|
24
35
|
* installed packages. The basename is the whole key, so the lookup is an index
|
|
25
36
|
* rather than a path built from the request; see `fonts.server.ts`.
|
|
26
37
|
*/
|
|
27
38
|
|
|
28
|
-
import { join } from 'path';
|
|
39
|
+
import { dirname, join } from 'path';
|
|
29
40
|
import { Glob } from 'bun';
|
|
30
41
|
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
/** Directory holding one extracted face file per source stylesheet. */
|
|
34
|
-
export const FONT_FACE_DIR = join('.ompchamber-build', 'font-faces');
|
|
42
|
+
import { splitFontFaces } from '@/server/lib/assets/font-faces';
|
|
43
|
+
import { packageDir, resolveFontFile } from '@/server/lib/assets/fonts.server';
|
|
35
44
|
|
|
36
45
|
/** The route the assembled stylesheet is served on. */
|
|
37
46
|
export const FONT_STYLESHEET_ROUTE = '/fonts.css';
|
|
38
47
|
|
|
48
|
+
/** Where the stylesheets that carry faces live, relative to the package directory. */
|
|
49
|
+
const STYLE_SOURCE_ROOT = 'src';
|
|
50
|
+
|
|
39
51
|
const FONT_MIME: Record<string, string> = {
|
|
40
52
|
'.woff2': 'font/woff2',
|
|
41
53
|
'.woff': 'font/woff',
|
|
@@ -48,20 +60,34 @@ const STYLESHEET_HEADERS = {
|
|
|
48
60
|
'cache-control': 'no-store',
|
|
49
61
|
} as const;
|
|
50
62
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const glob = new Glob('
|
|
63
|
+
/** The absolute path of every stylesheet under the package's `src/`, sorted. */
|
|
64
|
+
function sourceStylesheets(): string[] {
|
|
65
|
+
const root = join(packageDir(), STYLE_SOURCE_ROOT);
|
|
66
|
+
const glob = new Glob('**/*.css');
|
|
55
67
|
const files: string[] = [];
|
|
56
68
|
try {
|
|
57
|
-
for
|
|
69
|
+
for (const file of glob.scanSync({ cwd: root, onlyFiles: true })) files.push(join(root, file));
|
|
58
70
|
} catch {
|
|
59
|
-
return
|
|
71
|
+
return [];
|
|
60
72
|
}
|
|
61
|
-
// Sorted so the stylesheet is byte-stable between
|
|
62
|
-
// on the ETag, and an unstable order would invalidate it for no change.
|
|
73
|
+
// Sorted so the stylesheet is byte-stable between requests: the browser
|
|
74
|
+
// caches on the ETag, and an unstable order would invalidate it for no change.
|
|
63
75
|
files.sort();
|
|
64
|
-
|
|
76
|
+
return files;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Every `@font-face` block the source stylesheets declare, concatenated. */
|
|
80
|
+
export async function fontStylesheet(): Promise<string> {
|
|
81
|
+
const parts: string[] = [];
|
|
82
|
+
for (const path of sourceStylesheets()) {
|
|
83
|
+
// A read that fails contributes nothing rather than failing the sheet: the
|
|
84
|
+
// shell links this unconditionally, and one unreadable file must not take
|
|
85
|
+
// the faces beside it down with it.
|
|
86
|
+
const css = await Bun.file(path).text().catch(() => '');
|
|
87
|
+
if (!css) continue;
|
|
88
|
+
const { faces } = splitFontFaces(css, dirname(path));
|
|
89
|
+
if (faces.length > 0) parts.push(...faces);
|
|
90
|
+
}
|
|
65
91
|
return parts.join('\n');
|
|
66
92
|
}
|
|
67
93
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pulling `@font-face` blocks out of a stylesheet, and pointing their `url()`s
|
|
8
|
+
* at the served route.
|
|
9
|
+
*
|
|
10
|
+
* Two callers, one reason to share: the bundler's CSS loader strips faces out
|
|
11
|
+
* of every module it bundles (`lib/bundler/css.ts`) because Bun resolves each
|
|
12
|
+
* local `url()` into base64 or an absolute filesystem path, and the server
|
|
13
|
+
* assembles `/fonts.css` from the same stylesheets
|
|
14
|
+
* (`lib/assets/font-css.server.ts`). The two must agree byte for byte — a face
|
|
15
|
+
* stripped from the bundle has to be the one the stylesheet re-declares, or the
|
|
16
|
+
* font silently never loads.
|
|
17
|
+
*
|
|
18
|
+
* Only a block whose `src` names a LOCAL font moves: a face pointing at a
|
|
19
|
+
* remote url is valid CSS the bundle can keep. `rewriteFontUrls` replaces every
|
|
20
|
+
* local font url with `<FONT_ROUTE_PREFIX><basename>`, which is the only form
|
|
21
|
+
* that survives Bun's loader — a same-origin path is not an escape hatch, since
|
|
22
|
+
* `url("/fonts/x.woff2")` fails the build with "Could not resolve".
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { basename, isAbsolute, resolve as resolvePath } from 'path';
|
|
26
|
+
|
|
27
|
+
import { FONT_ROUTE_PREFIX } from '@/server/lib/assets/fonts.server';
|
|
28
|
+
|
|
29
|
+
/** A `url(...)` whose target is a font file. */
|
|
30
|
+
const URL_FUNCTION = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
|
31
|
+
const FONT_FILE = /\.(?:woff2?|ttf|otf)$/i;
|
|
32
|
+
/** Schemes Bun leaves alone, and the prefix this module itself emits. */
|
|
33
|
+
const EXTERNAL = /^(?:data:|https?:|blob:|\/\/)/i;
|
|
34
|
+
|
|
35
|
+
/** One `@font-face` block, braces included. Faces cannot nest. */
|
|
36
|
+
const FONT_FACE = /@font-face\s*\{[^{}]*\}/g;
|
|
37
|
+
|
|
38
|
+
/** Absolute path of a local font url, or null when it is not one. */
|
|
39
|
+
function localFont(specifier: string, cssDir: string): string | null {
|
|
40
|
+
if (EXTERNAL.test(specifier) || specifier.startsWith(FONT_ROUTE_PREFIX)) return null;
|
|
41
|
+
if (!FONT_FILE.test(specifier)) return null;
|
|
42
|
+
return isAbsolute(specifier) ? specifier : resolvePath(cssDir, specifier);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Point every font `url()` at the served path. */
|
|
46
|
+
export function rewriteFontUrls(css: string, cssDir: string): string {
|
|
47
|
+
return css.replace(URL_FUNCTION, (whole, quote: string, specifier: string) => {
|
|
48
|
+
const absolute = localFont(specifier, cssDir);
|
|
49
|
+
if (!absolute) return whole;
|
|
50
|
+
return `url(${quote}${FONT_ROUTE_PREFIX}${basename(absolute)}${quote})`;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Split `@font-face` blocks out of a stylesheet.
|
|
56
|
+
*
|
|
57
|
+
* `cssDir` resolves a relative `src` url, so it must be the directory of the
|
|
58
|
+
* file the text came from — not the bundle's.
|
|
59
|
+
*/
|
|
60
|
+
export function splitFontFaces(css: string, cssDir: string): { rest: string; faces: string[] } {
|
|
61
|
+
const faces: string[] = [];
|
|
62
|
+
const rest = css.replace(FONT_FACE, (block) => {
|
|
63
|
+
const hasLocal = [...block.matchAll(URL_FUNCTION)].some(([, , spec]) => localFont(spec, cssDir));
|
|
64
|
+
if (!hasLocal) return block;
|
|
65
|
+
faces.push(rewriteFontUrls(block, cssDir));
|
|
66
|
+
return '';
|
|
67
|
+
});
|
|
68
|
+
return { rest, faces };
|
|
69
|
+
}
|
|
@@ -77,12 +77,16 @@ let index: Record<string, string> | null = null;
|
|
|
77
77
|
/**
|
|
78
78
|
* The package root — the first search root that holds `node_modules`.
|
|
79
79
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* `dist/client` while a source checkout runs from the repo. Falling back to the
|
|
80
|
+
* The AOT bundle runs from `dist/client` while a source checkout runs from the
|
|
81
|
+
* repo, and both resolve their dependencies through this. Falling back to the
|
|
83
82
|
* cwd keeps a checkout with no `node_modules` (a publish dry-run) working.
|
|
83
|
+
*
|
|
84
|
+
* This answers "where are the DEPENDENCIES", which is not the same question as
|
|
85
|
+
* `packageDir()` below: a hoisted install keeps the package's own files under
|
|
86
|
+
* `node_modules/ompchamber/` while its dependencies sit in the parent
|
|
87
|
+
* `node_modules/`, so the first root holding `node_modules` is the PARENT.
|
|
84
88
|
*/
|
|
85
|
-
|
|
89
|
+
function packageRoot(): string {
|
|
86
90
|
for (const root of searchRoots()) {
|
|
87
91
|
// `existsSync` rather than `Bun.file().exists()`: the latter is async and
|
|
88
92
|
// file-only, and `node_modules` is a directory.
|
|
@@ -91,6 +95,25 @@ export function packageRoot(): string {
|
|
|
91
95
|
return process.cwd();
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The package's own directory — the first search root that holds `src/`.
|
|
100
|
+
*
|
|
101
|
+
* The source stylesheets `lib/assets/font-css.server.ts` reads for `/fonts.css`
|
|
102
|
+
* live there, and they must be found in every install shape. In a source
|
|
103
|
+
* checkout and a non-hoisted install this is the same directory
|
|
104
|
+
* `packageRoot()` returns; in a hoisted install it is the package under
|
|
105
|
+
* `node_modules/`, one level BELOW the root that holds the dependencies.
|
|
106
|
+
* Resolving the source tree through `packageRoot()` there would look for
|
|
107
|
+
* `src/` beside the hoisted packages and find nothing, which serves an empty
|
|
108
|
+
* stylesheet and silently drops every `@font-face`.
|
|
109
|
+
*/
|
|
110
|
+
export function packageDir(): string {
|
|
111
|
+
for (const root of searchRoots()) {
|
|
112
|
+
if (existsSync(join(root, 'src'))) return root;
|
|
113
|
+
}
|
|
114
|
+
return packageRoot();
|
|
115
|
+
}
|
|
116
|
+
|
|
94
117
|
async function buildIndex(): Promise<Record<string, string>> {
|
|
95
118
|
const found: Record<string, string> = {};
|
|
96
119
|
const glob = new Glob('*.{woff2,woff,ttf,otf}');
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
import { join, relative } from 'path';
|
|
34
34
|
import type { BuildOutput, BunPlugin } from 'bun';
|
|
35
35
|
|
|
36
|
-
import {
|
|
36
|
+
import { packageDir } from '@/server/lib/assets/fonts.server';
|
|
37
37
|
|
|
38
|
-
/** The shell bundle's entrypoint, relative to the package
|
|
38
|
+
/** The shell bundle's entrypoint, relative to the package directory. */
|
|
39
39
|
const SHELL_ENTRYPOINT = 'index.html';
|
|
40
40
|
|
|
41
41
|
/** Bun's own config file, and the section naming the bundle's plugins. */
|
|
@@ -109,8 +109,13 @@ function formatLog(log: BundleLog, root: string): string {
|
|
|
109
109
|
/**
|
|
110
110
|
* The bundle errors behind a shell that would not render, or null when this
|
|
111
111
|
* build is not the reason (a clean bundle means the failure lies elsewhere).
|
|
112
|
+
*
|
|
113
|
+
* `root` is the PACKAGE, not `packageRoot()`: the entrypoint is `index.html`
|
|
114
|
+
* beside the source, and in a hoisted install the first root holding
|
|
115
|
+
* `node_modules` is the parent, where `index.html` does not exist — so the probe
|
|
116
|
+
* would report no failure at all and the real build error would never be shown.
|
|
112
117
|
*/
|
|
113
|
-
export async function describeShellBuildFailure(root =
|
|
118
|
+
export async function describeShellBuildFailure(root = packageDir()): Promise<string | null> {
|
|
114
119
|
const entrypoint = join(root, SHELL_ENTRYPOINT);
|
|
115
120
|
if (!(await Bun.file(entrypoint).exists())) return null;
|
|
116
121
|
|
|
@@ -30,18 +30,17 @@
|
|
|
30
30
|
* rewrite inside the bundle can work.
|
|
31
31
|
*
|
|
32
32
|
* The faces therefore leave the bundle. Each block is rewritten to
|
|
33
|
-
* `<FONT_ROUTE_PREFIX><basename>` and
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* that
|
|
37
|
-
* shell links it, so the layout rules stay bundled while the faces stay
|
|
33
|
+
* `<FONT_ROUTE_PREFIX><basename>` and dropped from the module, and the server
|
|
34
|
+
* re-declares them on `/fonts.css` from the SAME source stylesheets
|
|
35
|
+
* (`lib/assets/font-css.server.ts`, `lib/assets/font-faces.ts`). The shell
|
|
36
|
+
* links that route, so the layout rules stay bundled while the faces stay
|
|
38
37
|
* cacheable files.
|
|
39
38
|
*
|
|
40
|
-
*
|
|
41
|
-
* invoked by `serve.static` (verified:
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
39
|
+
* The server derives the faces from source rather than from a directory this
|
|
40
|
+
* plugin wrote, because `onEnd` is NOT invoked by `serve.static` (verified:
|
|
41
|
+
* `onStart` and `onLoad` fire, `onEnd` never does) and a written artifact is
|
|
42
|
+
* produced exactly once per process — a `rm -rf` after boot, or a fresh clone
|
|
43
|
+
* that never built, would leave `/fonts.css` empty for good.
|
|
45
44
|
*
|
|
46
45
|
* 3. **Nested at-rules are flattened** (`flattenNestedAtRules`, registered
|
|
47
46
|
* AFTER Tailwind so it sees the expanded tree). Bun's CSS minifier keeps the
|
|
@@ -53,57 +52,14 @@
|
|
|
53
52
|
* the measurement; this one only has to run it.
|
|
54
53
|
*/
|
|
55
54
|
|
|
56
|
-
import {
|
|
55
|
+
import { dirname } from 'path';
|
|
57
56
|
import postcss from 'postcss';
|
|
58
57
|
import tailwind from '@tailwindcss/postcss';
|
|
59
58
|
import type { BunPlugin } from 'bun';
|
|
60
59
|
|
|
61
|
-
import {
|
|
62
|
-
import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
|
|
60
|
+
import { splitFontFaces } from '@/server/lib/assets/font-faces';
|
|
63
61
|
import { flattenNestedAtRules } from '@/server/lib/bundler/nested-at-rules';
|
|
64
62
|
|
|
65
|
-
/** A `url(...)` whose target is a font file. */
|
|
66
|
-
const URL_FUNCTION = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
|
67
|
-
const FONT_FILE = /\.(?:woff2?|ttf|otf)$/i;
|
|
68
|
-
/** Schemes Bun leaves alone, and the prefix this plugin itself emits. */
|
|
69
|
-
const EXTERNAL = /^(?:data:|https?:|blob:|\/\/)/i;
|
|
70
|
-
|
|
71
|
-
/** One `@font-face` block, braces included. Faces cannot nest. */
|
|
72
|
-
const FONT_FACE = /@font-face\s*\{[^{}]*\}/g;
|
|
73
|
-
|
|
74
|
-
/** Absolute path of a local font url, or null when it is not one. */
|
|
75
|
-
function localFont(specifier: string, cssDir: string): string | null {
|
|
76
|
-
if (EXTERNAL.test(specifier) || specifier.startsWith(FONT_ROUTE_PREFIX)) return null;
|
|
77
|
-
if (!FONT_FILE.test(specifier)) return null;
|
|
78
|
-
return isAbsolute(specifier) ? specifier : resolvePath(cssDir, specifier);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Point every font `url()` at the served path. */
|
|
82
|
-
function rewriteFontUrls(css: string, cssDir: string): string {
|
|
83
|
-
return css.replace(URL_FUNCTION, (whole, quote: string, specifier: string) => {
|
|
84
|
-
const absolute = localFont(specifier, cssDir);
|
|
85
|
-
if (!absolute) return whole;
|
|
86
|
-
return `url(${quote}${FONT_ROUTE_PREFIX}${basename(absolute)}${quote})`;
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Split `@font-face` blocks out of a stylesheet.
|
|
92
|
-
*
|
|
93
|
-
* Only blocks whose `src` names a local font move: a face pointing at a remote
|
|
94
|
-
* url is valid CSS the bundle can keep.
|
|
95
|
-
*/
|
|
96
|
-
function splitFontFaces(css: string, cssDir: string): { rest: string; faces: string[] } {
|
|
97
|
-
const faces: string[] = [];
|
|
98
|
-
const rest = css.replace(FONT_FACE, (block) => {
|
|
99
|
-
const hasLocal = [...block.matchAll(URL_FUNCTION)].some(([, , spec]) => localFont(spec, cssDir));
|
|
100
|
-
if (!hasLocal) return block;
|
|
101
|
-
faces.push(rewriteFontUrls(block, cssDir));
|
|
102
|
-
return '';
|
|
103
|
-
});
|
|
104
|
-
return { rest, faces };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
63
|
const plugin: BunPlugin = {
|
|
108
64
|
name: 'ompchamber-css',
|
|
109
65
|
setup(build) {
|
|
@@ -117,12 +73,7 @@ const plugin: BunPlugin = {
|
|
|
117
73
|
const expanded = await postcss([tailwind(), flattenNestedAtRules()]).process(source, {
|
|
118
74
|
from: args.path,
|
|
119
75
|
});
|
|
120
|
-
const { rest
|
|
121
|
-
if (faces.length > 0) {
|
|
122
|
-
const target = join(packageRoot(), FONT_FACE_DIR, `${basename(args.path)}`);
|
|
123
|
-
const header = `/* GENERATED from ${args.path} — do not edit. */\n`;
|
|
124
|
-
await Bun.write(target, header + faces.join('\n') + '\n');
|
|
125
|
-
}
|
|
76
|
+
const { rest } = splitFontFaces(expanded.css, dirname(args.path));
|
|
126
77
|
return { contents: rest, loader: 'css' };
|
|
127
78
|
});
|
|
128
79
|
},
|
|
@@ -12,39 +12,59 @@
|
|
|
12
12
|
* answered with the root's own status; the client polls until `pending` clears.
|
|
13
13
|
* Results are cached per scoped root for the life of the process.
|
|
14
14
|
*
|
|
15
|
-
* The
|
|
16
|
-
* unrelated `.git` directories, and the one that makes the walk unbounded)
|
|
15
|
+
* The walk prunes `node_modules` (the one directory guaranteed to hold
|
|
16
|
+
* unrelated `.git` directories, and the one that makes the walk unbounded) and
|
|
17
|
+
* stops at `MAX_GIT_DEPTH`.
|
|
18
|
+
*
|
|
19
|
+
* It is a JS walk rather than `find`, and the readdir is `withFileTypes` so a
|
|
20
|
+
* directory test costs no syscall: `find` needed a subprocess and a 1 MB stdout
|
|
21
|
+
* pipe, and measured 302 ms against 142 ms for the walk on a real workspace
|
|
22
|
+
* root, for the identical repo set (verified: 206 == 206, no element in one and
|
|
23
|
+
* not the other). Pre-order traversal in readdir order matches `find`'s, so the
|
|
24
|
+
* picker's list order is unchanged.
|
|
17
25
|
*/
|
|
18
26
|
|
|
19
|
-
import
|
|
27
|
+
import fs from 'fs';
|
|
28
|
+
import path from 'path';
|
|
20
29
|
|
|
21
30
|
const MAX_GIT_DEPTH = 8;
|
|
22
31
|
|
|
23
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Repos under `rootDir`, as root-relative POSIX paths (`.` for the root itself).
|
|
34
|
+
*
|
|
35
|
+
* `depth` is the depth of `dir` itself: the root is 0, so an entry inside it is
|
|
36
|
+
* at depth 1 and `MAX_GIT_DEPTH` bounds an entry's own depth exactly as `find
|
|
37
|
+
* -maxdepth` does.
|
|
38
|
+
*/
|
|
39
|
+
async function findGitDirs(dir: string, rel: string, depth: number, out: string[]): Promise<void> {
|
|
40
|
+
let entries: fs.Dirent[];
|
|
24
41
|
try {
|
|
25
|
-
|
|
26
|
-
// workspace (e.g. projects/<name>/<sub>/.git) while pruning node_modules.
|
|
27
|
-
const result = await runShell(
|
|
28
|
-
`find . -maxdepth ${MAX_GIT_DEPTH} -name node_modules -prune -o -name .git -type d -print`,
|
|
29
|
-
{ cwd: rootDir, timeout: 12000, maxBuffer: 1024 * 1024 }
|
|
30
|
-
);
|
|
31
|
-
const discovered = result.stdout
|
|
32
|
-
.trim()
|
|
33
|
-
.split('\n')
|
|
34
|
-
.filter(Boolean)
|
|
35
|
-
.map(line => {
|
|
36
|
-
const cleaned = line.replace(/^\.\//, '').replace(/\/\.git$/, '');
|
|
37
|
-
return cleaned === '.git' || !cleaned ? '.' : cleaned;
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
const uniqueRepos = Array.from(new Set(discovered));
|
|
41
|
-
if (uniqueRepos.includes('.')) {
|
|
42
|
-
return ['.', ...uniqueRepos.filter(r => r !== '.')];
|
|
43
|
-
}
|
|
44
|
-
return uniqueRepos.length ? uniqueRepos : ['.'];
|
|
42
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
45
43
|
} catch {
|
|
46
|
-
|
|
44
|
+
// Unreadable directory (permissions, a race) — skip it, never fail the walk.
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (!entry.isDirectory()) continue;
|
|
49
|
+
if (entry.name === 'node_modules') continue;
|
|
50
|
+
if (entry.name === '.git') {
|
|
51
|
+
out.push(rel || '.');
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (depth + 1 < MAX_GIT_DEPTH) {
|
|
55
|
+
await findGitDirs(path.join(dir, entry.name), rel ? `${rel}/${entry.name}` : entry.name, depth + 1, out);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function getRepos(rootDir: string): Promise<string[]> {
|
|
61
|
+
const found: string[] = [];
|
|
62
|
+
await findGitDirs(rootDir, '', 0, found);
|
|
63
|
+
const uniqueRepos = Array.from(new Set(found));
|
|
64
|
+
if (uniqueRepos.includes('.')) {
|
|
65
|
+
return ['.', ...uniqueRepos.filter((r) => r !== '.')];
|
|
47
66
|
}
|
|
67
|
+
return uniqueRepos.length ? uniqueRepos : ['.'];
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
interface RepoDiscovery {
|
|
@@ -149,6 +149,20 @@ function procStatus(pid: number): number | 'absent' | null {
|
|
|
149
149
|
const bsdInfo = new Uint8Array(BSDINFO_BYTES);
|
|
150
150
|
const bsdInfoView = new DataView(bsdInfo.buffer);
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Scratch for `executablePath` and `argvOf`, for the same reason as the buffers
|
|
154
|
+
* above: `getProcessState` calls `commandLine`, which falls through argv to the
|
|
155
|
+
* executable path, and each call used to allocate a fresh 256 KiB buffer plus
|
|
156
|
+
* its views — 83 ms per 5000 probes against 24 ms reusing them. The decoder is
|
|
157
|
+
* hoisted for the same reason. Safe because the whole probe is synchronous.
|
|
158
|
+
*/
|
|
159
|
+
const pathBuffer = new Uint8Array(PATH_BUFFER_BYTES);
|
|
160
|
+
const argvMib = new Int32Array([CTL_KERN, KERN_PROCARGS2, 0]);
|
|
161
|
+
const argvSize = new BigUint64Array([BigInt(ARGV_BUFFER_BYTES)]);
|
|
162
|
+
const argvBuffer = new Uint8Array(ARGV_BUFFER_BYTES);
|
|
163
|
+
const argvView = new DataView(argvBuffer.buffer);
|
|
164
|
+
const utf8Decoder = new TextDecoder();
|
|
165
|
+
|
|
152
166
|
/**
|
|
153
167
|
* Executable path, or null. Also answers for root-owned PIDs (verified: pid 1 →
|
|
154
168
|
* `/sbin/launchd`), which is what makes it the identity fallback when argv is
|
|
@@ -157,10 +171,9 @@ const bsdInfoView = new DataView(bsdInfo.buffer);
|
|
|
157
171
|
function executablePath(pid: number): string | null {
|
|
158
172
|
const bound = bindLibs();
|
|
159
173
|
if (bound === null) return null;
|
|
160
|
-
const buffer = new Uint8Array(PATH_BUFFER_BYTES);
|
|
161
174
|
try {
|
|
162
|
-
const written = bound.libproc.symbols.proc_pidpath(pid, ptr(
|
|
163
|
-
return written > 0 ?
|
|
175
|
+
const written = bound.libproc.symbols.proc_pidpath(pid, ptr(pathBuffer), pathBuffer.length);
|
|
176
|
+
return written > 0 ? utf8Decoder.decode(pathBuffer.subarray(0, written)) : null;
|
|
164
177
|
} catch {
|
|
165
178
|
return null;
|
|
166
179
|
}
|
|
@@ -176,28 +189,26 @@ function executablePath(pid: number): string | null {
|
|
|
176
189
|
function argvOf(pid: number): string[] | null {
|
|
177
190
|
const bound = bindLibs();
|
|
178
191
|
if (bound === null) return null;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const buffer = new Uint8Array(ARGV_BUFFER_BYTES);
|
|
192
|
+
argvMib[2] = pid;
|
|
193
|
+
argvSize[0] = BigInt(ARGV_BUFFER_BYTES);
|
|
182
194
|
try {
|
|
183
|
-
if (bound.libc.symbols.sysctl(ptr(
|
|
195
|
+
if (bound.libc.symbols.sysctl(ptr(argvMib), 3, ptr(argvBuffer), ptr(argvSize), null, 0) !== 0) return null;
|
|
184
196
|
} catch {
|
|
185
197
|
return null;
|
|
186
198
|
}
|
|
187
199
|
|
|
188
|
-
const length = Number(
|
|
200
|
+
const length = Number(argvSize[0]);
|
|
189
201
|
if (length <= 4) return null;
|
|
190
|
-
const argc =
|
|
202
|
+
const argc = argvView.getInt32(0, true);
|
|
191
203
|
let cursor = 4;
|
|
192
|
-
while (cursor < length &&
|
|
193
|
-
while (cursor < length &&
|
|
204
|
+
while (cursor < length && argvBuffer[cursor] !== 0) cursor += 1;
|
|
205
|
+
while (cursor < length && argvBuffer[cursor] === 0) cursor += 1;
|
|
194
206
|
|
|
195
|
-
const decoder = new TextDecoder();
|
|
196
207
|
const args: string[] = [];
|
|
197
208
|
for (let index = 0; index < argc && cursor < length; index += 1) {
|
|
198
209
|
let end = cursor;
|
|
199
|
-
while (end < length &&
|
|
200
|
-
args.push(
|
|
210
|
+
while (end < length && argvBuffer[end] !== 0) end += 1;
|
|
211
|
+
args.push(utf8Decoder.decode(argvBuffer.subarray(cursor, end)));
|
|
201
212
|
cursor = end + 1;
|
|
202
213
|
}
|
|
203
214
|
return args;
|
|
@@ -9,25 +9,35 @@
|
|
|
9
9
|
* Incomplete trailing data stays buffered until the next chunk or stream end,
|
|
10
10
|
* matching readline's line semantics. Per-line errors never surface: the
|
|
11
11
|
* callback receiving them is the caller's boundary.
|
|
12
|
+
*
|
|
13
|
+
* The cursor is an offset into `buffer` rather than a re-slice per line:
|
|
14
|
+
* `buffer = buffer.slice(i + 1)` copies the entire remainder once per line,
|
|
15
|
+
* which is quadratic in a chunk carrying many frames. Measured on an 18 MB
|
|
16
|
+
* burst of 200k lines: 3.87 ms against 2.29 ms. The buffer is re-sliced once
|
|
17
|
+
* per chunk, after every complete line in it has been emitted.
|
|
12
18
|
*/
|
|
13
19
|
|
|
14
20
|
export function readLines(stream: ReadableStream<Uint8Array>, onLine: (line: string) => void): Promise<void> {
|
|
15
21
|
const decoder = new TextDecoder();
|
|
16
22
|
let buffer = '';
|
|
23
|
+
let cursor = 0;
|
|
17
24
|
return stream.pipeTo(
|
|
18
25
|
new WritableStream({
|
|
19
26
|
write(chunk) {
|
|
20
27
|
buffer += decoder.decode(chunk, { stream: true });
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
buffer
|
|
25
|
-
|
|
26
|
-
newlineIndex = buffer.indexOf('\n');
|
|
28
|
+
for (;;) {
|
|
29
|
+
const newlineIndex = buffer.indexOf('\n', cursor);
|
|
30
|
+
if (newlineIndex === -1) break;
|
|
31
|
+
onLine(buffer.slice(cursor, newlineIndex));
|
|
32
|
+
cursor = newlineIndex + 1;
|
|
27
33
|
}
|
|
34
|
+
// Drop what has been emitted, so the buffer does not grow with the whole
|
|
35
|
+
// stream. One re-slice per chunk instead of one per line.
|
|
36
|
+
buffer = buffer.slice(cursor);
|
|
37
|
+
cursor = 0;
|
|
28
38
|
},
|
|
29
39
|
close() {
|
|
30
|
-
const rest = buffer + decoder.decode();
|
|
40
|
+
const rest = buffer.slice(cursor) + decoder.decode();
|
|
31
41
|
if (rest) onLine(rest);
|
|
32
42
|
},
|
|
33
43
|
}),
|