ompchamber 3.1.0 → 3.1.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/README.md +6 -3
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +138 -138
- package/dist/client/static/js/index-5yfre1kw.css +1 -0
- package/dist/client/static/js/{index-8t70jhyj.js → index-qhzsybkn.js} +1 -1
- package/package.json +1 -1
- package/src/cli/lib/args.js +4 -3
- package/src/cli/lib/commands/restart.js +7 -6
- package/src/cli/lib/commands/stop.js +38 -31
- package/src/cli/lib/commands/update.js +3 -2
- package/src/cli/lib/stop-scope.js +23 -0
- package/src/cli/lib/stop-scope.test.js +41 -0
- package/src/server/lib/bundler/css.test.ts +87 -0
- package/src/server/lib/bundler/css.ts +17 -2
- package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
- package/src/server/lib/bundler/nested-at-rules.ts +146 -0
- package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
- package/src/server/lib/lifecycle/launch-mode.ts +26 -0
- package/src/server/lib/lifecycle/restart.ts +9 -14
- package/dist/client/static/js/index-n4wkqj7t.css +0 -1
- package/src/server/lib/lifecycle/restart.test.ts +0 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ompchamber",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.1",
|
|
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>",
|
package/src/cli/lib/args.js
CHANGED
|
@@ -188,7 +188,7 @@ USAGE:
|
|
|
188
188
|
COMMANDS:
|
|
189
189
|
serve Start the OMPChamber web server (default when no command is given)
|
|
190
190
|
update Update OMPChamber to the latest GitHub release
|
|
191
|
-
stop Stop
|
|
191
|
+
stop Stop the instances the CLI started (--port/--all to stop any)
|
|
192
192
|
restart Stop and start again (every CLI-started instance, or --port <port>)
|
|
193
193
|
status Show running instances (every one, or --port <port>)
|
|
194
194
|
logs Print or follow server logs (every instance, or --port <port>)
|
|
@@ -201,7 +201,7 @@ OPTIONS:
|
|
|
201
201
|
--prod Run the production build instead of the dev server
|
|
202
202
|
--foreground Run the server in the foreground (no daemon)
|
|
203
203
|
--no-daemon Alias for --foreground
|
|
204
|
-
--all
|
|
204
|
+
--all Every instance, including ones started outside the CLI
|
|
205
205
|
-c, --check Report whether a newer release exists without installing it
|
|
206
206
|
--force Reinstall even when already up to date
|
|
207
207
|
--no-restart Do not restart a running instance after updating
|
|
@@ -226,7 +226,8 @@ EXAMPLES:
|
|
|
226
226
|
ompchamber serve --prod # Serve the production build
|
|
227
227
|
ompchamber update --check # Is a newer release available?
|
|
228
228
|
ompchamber update # Update, then restart a running instance
|
|
229
|
-
ompchamber stop # Stop the
|
|
229
|
+
ompchamber stop # Stop the instances the CLI started
|
|
230
|
+
ompchamber stop --port 3000 # Stop the instance on port 3000, whoever started it
|
|
230
231
|
ompchamber restart # Restart the server
|
|
231
232
|
ompchamber status # Show whether the server is running
|
|
232
233
|
ompchamber logs -f -n 200 # Follow the last 200 log lines
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
// `ompchamber restart` — stop live instances, then serve again.
|
|
2
2
|
//
|
|
3
3
|
// Scoping matches `status` and `stop`: without `--port` every live instance is
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// considered, and `--port <port>` restricts the restart to that one. With
|
|
5
|
+
// nothing running, the command is simply `serve`.
|
|
6
6
|
//
|
|
7
7
|
// Instances the CLI did not start are left alone, exactly as `ompchamber update`
|
|
8
8
|
// leaves them: `bun run dev` and `bun run start` own their own process, so
|
|
9
|
-
// replacing it would break a loop OMPChamber does not manage. `ompchamber stop
|
|
10
|
-
// is
|
|
9
|
+
// replacing it would break a loop OMPChamber does not manage. `ompchamber stop
|
|
10
|
+
// --port <port>` (or `--all`) is the deliberate way to end one.
|
|
11
11
|
|
|
12
12
|
import { findLiveInstance, listLiveInstances, stopInstance } from '@/cli/lib/runtime.js';
|
|
13
13
|
import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
|
|
14
14
|
import { log, isJson, isQuiet } from '@/cli/lib/output.js';
|
|
15
15
|
import { run as runServe } from '@/cli/lib/commands/serve.js';
|
|
16
|
-
import {
|
|
16
|
+
import { skipRestartNote } from '@/server/lib/lifecycle/restart';
|
|
17
|
+
import { isCliManaged } from '@/server/lib/lifecycle/launch-mode';
|
|
17
18
|
|
|
18
19
|
function explicitPort(options) {
|
|
19
20
|
const raw = options?.port;
|
|
@@ -34,7 +35,7 @@ export async function run(options, ctx) {
|
|
|
34
35
|
if (targets.length === 0) return runServe(options, ctx);
|
|
35
36
|
|
|
36
37
|
for (const live of targets) {
|
|
37
|
-
if (!
|
|
38
|
+
if (!isCliManaged(live.launchMode)) {
|
|
38
39
|
if (!json && !quiet) log(`Skipping OMPChamber on port ${live.port}: ${skipRestartNote(live.launchMode)}.`);
|
|
39
40
|
continue;
|
|
40
41
|
}
|
|
@@ -1,22 +1,19 @@
|
|
|
1
|
-
// `ompchamber stop` — stop
|
|
1
|
+
// `ompchamber stop` — stop OMPChamber instances.
|
|
2
|
+
//
|
|
3
|
+
// Scope follows OWNERSHIP, the same rule `restart` and `update` apply. By
|
|
4
|
+
// default only the instances the CLI started are stopped, because a server
|
|
5
|
+
// running from source is supervised by a launcher the CLI does not own and
|
|
6
|
+
// that launcher exits with the child it spawned: `bun run dev` is a process
|
|
7
|
+
// whose whole job is to mirror the script under it, so signalling the server
|
|
8
|
+
// takes the user's dev loop down with it. `--port <port>` and `--all` are the
|
|
9
|
+
// explicit forms that do end one — naming an instance is a deliberate call,
|
|
10
|
+
// not a default.
|
|
2
11
|
|
|
3
12
|
import { listLiveInstances, findLiveInstance, stopInstance } from '@/cli/lib/runtime.js';
|
|
4
13
|
import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
|
|
5
|
-
import { ok, warn, printJson, isJson } from '@/cli/lib/output.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const raw = options?.port;
|
|
9
|
-
if (raw !== null && raw !== undefined) {
|
|
10
|
-
const parsed = Number(raw);
|
|
11
|
-
if (Number.isFinite(parsed)) return parsed;
|
|
12
|
-
}
|
|
13
|
-
const env = Bun.env.OMPCHAMBER_PORT;
|
|
14
|
-
if (typeof env === 'string' && env.trim().length > 0) {
|
|
15
|
-
const parsed = Number(env.trim());
|
|
16
|
-
if (Number.isFinite(parsed)) return parsed;
|
|
17
|
-
}
|
|
18
|
-
return 3000;
|
|
19
|
-
}
|
|
14
|
+
import { ok, warn, log, printJson, isJson, isQuiet } from '@/cli/lib/output.js';
|
|
15
|
+
import { unmanagedReason } from '@/server/lib/lifecycle/launch-mode';
|
|
16
|
+
import { partitionStopTargets } from '@/cli/lib/stop-scope.js';
|
|
20
17
|
|
|
21
18
|
function explicitPort(options) {
|
|
22
19
|
const raw = options?.port;
|
|
@@ -25,30 +22,40 @@ function explicitPort(options) {
|
|
|
25
22
|
return Number.isFinite(parsed) ? parsed : null;
|
|
26
23
|
}
|
|
27
24
|
|
|
25
|
+
/** How to end an instance the default scope leaves alone. */
|
|
26
|
+
function stopHint(port) {
|
|
27
|
+
return `stop it with \`ompchamber stop --port ${port}\` or \`ompchamber stop --all\``;
|
|
28
|
+
}
|
|
29
|
+
|
|
28
30
|
export async function run(options) {
|
|
29
31
|
const json = isJson();
|
|
32
|
+
const quiet = isQuiet();
|
|
30
33
|
const requested = explicitPort(options);
|
|
34
|
+
// `--all` is the explicit spelling of "every instance, whoever started it".
|
|
35
|
+
const explicitScope = requested !== null || Boolean(options?.all);
|
|
36
|
+
|
|
37
|
+
const live = requested !== null
|
|
38
|
+
? [await findLiveInstance(requested)].filter(Boolean)
|
|
39
|
+
: await listLiveInstances();
|
|
40
|
+
const { targets, skipped } = partitionStopTargets(live, explicitScope);
|
|
41
|
+
const skippedReport = skipped.map((entry) => ({ port: entry.port, pid: entry.pid, launchMode: entry.launchMode }));
|
|
31
42
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
let targets;
|
|
37
|
-
if (requested !== null) {
|
|
38
|
-
const live = await findLiveInstance(requested);
|
|
39
|
-
targets = live ? [live] : [];
|
|
40
|
-
} else {
|
|
41
|
-
targets = await listLiveInstances();
|
|
43
|
+
if (!json && !quiet) {
|
|
44
|
+
for (const entry of skipped) {
|
|
45
|
+
log(`Skipping OMPChamber on port ${entry.port} (pid ${entry.pid}): it ${unmanagedReason(entry.launchMode)} — ${stopHint(entry.port)}.`);
|
|
46
|
+
}
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
if (targets.length === 0) {
|
|
45
50
|
if (json) {
|
|
46
|
-
printJson({ stopped: [] });
|
|
51
|
+
printJson({ stopped: [], skipped: skippedReport });
|
|
47
52
|
return;
|
|
48
53
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
if (skipped.length === 0) {
|
|
55
|
+
warn(requested === null
|
|
56
|
+
? 'No running OMPChamber instances found.'
|
|
57
|
+
: `No running OMPChamber instance on port ${requested}.`);
|
|
58
|
+
}
|
|
52
59
|
return;
|
|
53
60
|
}
|
|
54
61
|
|
|
@@ -64,5 +71,5 @@ export async function run(options) {
|
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
if (json) printJson({ stopped });
|
|
74
|
+
if (json) printJson({ stopped, skipped: skippedReport });
|
|
68
75
|
}
|
|
@@ -10,7 +10,8 @@ import { color, configure, error, log, ok, warn, printJson, isJson, isQuiet } fr
|
|
|
10
10
|
import { listLiveInstances, findLiveInstance, stopInstance } from '@/cli/lib/runtime.js';
|
|
11
11
|
import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
|
|
12
12
|
import { run as runServe } from '@/cli/lib/commands/serve.js';
|
|
13
|
-
import {
|
|
13
|
+
import { skipRestartNote } from '@/server/lib/lifecycle/restart';
|
|
14
|
+
import { isCliManaged } from '@/server/lib/lifecycle/launch-mode';
|
|
14
15
|
import { resolveInstallContext, resolveOmpChamberVersion, updateOmpChamber } from '@/server/lib/updates/install';
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -30,7 +31,7 @@ async function restartInstances(options, ctx) {
|
|
|
30
31
|
const restarted = [];
|
|
31
32
|
const skipped = [];
|
|
32
33
|
for (const entry of await liveInstances(options)) {
|
|
33
|
-
if (!
|
|
34
|
+
if (!isCliManaged(entry.launchMode)) {
|
|
34
35
|
skipped.push({ port: entry.port, pid: entry.pid, launchMode: entry.launchMode });
|
|
35
36
|
log(`Skipping OMPChamber on port ${entry.port}: ${skipRestartNote(entry.launchMode)}.`);
|
|
36
37
|
continue;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Ownership scope for `ompchamber stop`.
|
|
2
|
+
//
|
|
3
|
+
// Kept separate from the command so the rule is testable without a live
|
|
4
|
+
// server: the default scope is the instances the CLI started, and only an
|
|
5
|
+
// explicit `--port <port>` / `--all` widens it to the ones it did not. That
|
|
6
|
+
// distinction is the whole reason this module exists — a server running from
|
|
7
|
+
// source sits under a launcher that exits with its child, so stopping it by
|
|
8
|
+
// default takes a `bun run dev` loop down with it.
|
|
9
|
+
|
|
10
|
+
import { isCliManaged } from '@/server/lib/lifecycle/launch-mode';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Split the live instances into the ones `stop` acts on and the ones it leaves
|
|
14
|
+
* alone. `explicit` is true when the user named a port or passed `--all`:
|
|
15
|
+
* naming an instance is a deliberate call, so ownership no longer gates it.
|
|
16
|
+
*/
|
|
17
|
+
export function partitionStopTargets(instances, explicit) {
|
|
18
|
+
if (explicit) return { targets: instances, skipped: [] };
|
|
19
|
+
return {
|
|
20
|
+
targets: instances.filter((entry) => isCliManaged(entry.launchMode)),
|
|
21
|
+
skipped: instances.filter((entry) => !isCliManaged(entry.launchMode)),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { partitionStopTargets } from '@/cli/lib/stop-scope.js';
|
|
9
|
+
|
|
10
|
+
const daemon = { port: 3000, pid: 11, launchMode: 'daemon' };
|
|
11
|
+
const foreground = { port: 3001, pid: 12, launchMode: 'foreground' };
|
|
12
|
+
const dev = { port: 3002, pid: 13, launchMode: 'direct' };
|
|
13
|
+
const unknown = { port: 3003, pid: 14, launchMode: 'unknown' };
|
|
14
|
+
|
|
15
|
+
describe('partitionStopTargets', () => {
|
|
16
|
+
// The default scope is ownership, not "everything that answers /api/health".
|
|
17
|
+
// `bun run dev` is a supervisor whose only job is to mirror the script under
|
|
18
|
+
// it, so stopping that server exits the user's dev loop too — the bug this
|
|
19
|
+
// rule exists to prevent.
|
|
20
|
+
test('stops only the instances the CLI started when the scope is implicit', () => {
|
|
21
|
+
const { targets, skipped } = partitionStopTargets([daemon, dev, foreground, unknown], false);
|
|
22
|
+
expect(targets).toEqual([daemon, foreground]);
|
|
23
|
+
expect(skipped).toEqual([dev, unknown]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Naming an instance — a port or `--all` — is a deliberate call, so the
|
|
27
|
+
// ownership rule no longer gates it.
|
|
28
|
+
test('widens to every instance when the scope is explicit', () => {
|
|
29
|
+
const { targets, skipped } = partitionStopTargets([daemon, dev, unknown], true);
|
|
30
|
+
expect(targets).toEqual([daemon, dev, unknown]);
|
|
31
|
+
expect(skipped).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('an unreadable launch mode is never stopped implicitly', () => {
|
|
35
|
+
// A record whose launchMode could not be read must not authorise a kill:
|
|
36
|
+
// the safe reading is "not ours".
|
|
37
|
+
const { targets, skipped } = partitionStopTargets([{ port: 4000, pid: 99, launchMode: undefined }], false);
|
|
38
|
+
expect(targets).toEqual([]);
|
|
39
|
+
expect(skipped.length).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { mkdtemp, rm } from 'fs/promises';
|
|
8
|
+
import { tmpdir } from 'os';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
import cssPlugin from '@/server/lib/bundler/css';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build one stylesheet through the REAL plugin with the options the client
|
|
15
|
+
* build uses (`target: 'bun'`, `minify: true`), and return the emitted CSS.
|
|
16
|
+
*
|
|
17
|
+
* This is the end-to-end gate for the nesting bug: Bun's minifier re-nests a
|
|
18
|
+
* rule's nested at-rule as `@supports (…) { & { … } }` for a non-browser
|
|
19
|
+
* target, and Chrome then drops it whenever the parent selector names a
|
|
20
|
+
* pseudo-element. Asserting on the flattener's own output cannot catch that —
|
|
21
|
+
* only the bundle the browser is actually served can.
|
|
22
|
+
*/
|
|
23
|
+
async function buildThroughPlugin(css: string): Promise<string> {
|
|
24
|
+
const dir = await mkdtemp(join(tmpdir(), 'ompchamber-css-'));
|
|
25
|
+
try {
|
|
26
|
+
await Bun.write(join(dir, 'probe.css'), css);
|
|
27
|
+
const result = await Bun.build({
|
|
28
|
+
entrypoints: [join(dir, 'probe.css')],
|
|
29
|
+
outdir: join(dir, 'out'),
|
|
30
|
+
target: 'bun',
|
|
31
|
+
minify: true,
|
|
32
|
+
plugins: [cssPlugin],
|
|
33
|
+
});
|
|
34
|
+
if (!result.success) throw new Error(result.logs.map((log) => log.message).join('\n'));
|
|
35
|
+
return await Bun.file(join(dir, 'out', 'probe.css')).text();
|
|
36
|
+
} finally {
|
|
37
|
+
await rm(dir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('css plugin — nested at-rules reach the browser flattened', () => {
|
|
42
|
+
test('a placeholder colour refinement survives as a top-level @supports', async () => {
|
|
43
|
+
const out = await buildThroughPlugin(`.probe::placeholder {
|
|
44
|
+
color: var(--color-ink);
|
|
45
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
46
|
+
color: color-mix(in oklab, var(--color-ink) 40%, transparent);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
`);
|
|
50
|
+
|
|
51
|
+
// No `&` anywhere: a nested rule under `::placeholder` is inert in Chrome,
|
|
52
|
+
// which leaves the rule's full-opacity fallback as the final colour.
|
|
53
|
+
expect(out).not.toMatch(/(?<!\\)&/);
|
|
54
|
+
expect(out).toContain('@supports');
|
|
55
|
+
expect(out).toContain('::placeholder');
|
|
56
|
+
expect(out).toContain('40%');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a scrollbar thumb keeps its translucent refinement', async () => {
|
|
60
|
+
const out = await buildThroughPlugin(`::-webkit-scrollbar-thumb {
|
|
61
|
+
background: var(--theme-ink);
|
|
62
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
63
|
+
background: color-mix(in srgb, var(--theme-ink) 20%, transparent);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
`);
|
|
67
|
+
|
|
68
|
+
expect(out).not.toMatch(/(?<!\\)&/);
|
|
69
|
+
expect(out).toContain('20%');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('declarations written after the at-rule keep their order in the bundle', async () => {
|
|
73
|
+
const out = await buildThroughPlugin(`.probe {
|
|
74
|
+
background: var(--theme-ink);
|
|
75
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
76
|
+
background: color-mix(in srgb, var(--theme-ink) 20%, transparent);
|
|
77
|
+
}
|
|
78
|
+
border-radius: 3px;
|
|
79
|
+
}
|
|
80
|
+
`);
|
|
81
|
+
|
|
82
|
+
// The trailing declaration must not be hoisted above the at-rule: that
|
|
83
|
+
// would let the at-rule's `background` win over a later one.
|
|
84
|
+
expect(out.indexOf('border-radius')).toBeGreaterThan(out.indexOf('@supports'));
|
|
85
|
+
expect(out).not.toMatch(/(?<!\\)&/);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -42,6 +42,15 @@
|
|
|
42
42
|
* never does), so there is no "build finished" moment to flush at. Writing
|
|
43
43
|
* each source's faces as its `onLoad` returns is order-independent and works
|
|
44
44
|
* identically under both entry points.
|
|
45
|
+
*
|
|
46
|
+
* 3. **Nested at-rules are flattened** (`flattenNestedAtRules`, registered
|
|
47
|
+
* AFTER Tailwind so it sees the expanded tree). Bun's CSS minifier keeps the
|
|
48
|
+
* nesting it is handed and, for `target: 'bun'`, re-nests a rule's nested
|
|
49
|
+
* at-rule as `@supports (…) { & { … } }` — and Chrome drops a nested rule
|
|
50
|
+
* whose parent selector names a pseudo-element. Tailwind emits its colour
|
|
51
|
+
* fallbacks in exactly that shape, so the whole app's placeholder and
|
|
52
|
+
* scrollbar colours silently collapsed to their fallback. See that module for
|
|
53
|
+
* the measurement; this one only has to run it.
|
|
45
54
|
*/
|
|
46
55
|
|
|
47
56
|
import { basename, dirname, isAbsolute, join, resolve as resolvePath } from 'path';
|
|
@@ -51,6 +60,7 @@ import type { BunPlugin } from 'bun';
|
|
|
51
60
|
|
|
52
61
|
import { FONT_FACE_DIR } from '@/server/lib/assets/font-css.server';
|
|
53
62
|
import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
|
|
63
|
+
import { flattenNestedAtRules } from '@/server/lib/bundler/nested-at-rules';
|
|
54
64
|
|
|
55
65
|
/** A `url(...)` whose target is a font file. */
|
|
56
66
|
const URL_FUNCTION = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
|
@@ -100,8 +110,13 @@ const plugin: BunPlugin = {
|
|
|
100
110
|
build.onLoad({ filter: /\.css$/ }, async (args) => {
|
|
101
111
|
const source = await Bun.file(args.path).text();
|
|
102
112
|
// Tailwind first: it parses `@import "tailwindcss"` and `@theme`, and the
|
|
103
|
-
// urls it emits are the ones the extraction below has to recognise.
|
|
104
|
-
|
|
113
|
+
// urls it emits are the ones the extraction below has to recognise. The
|
|
114
|
+
// flattener runs after it, because the nesting it removes is Tailwind's
|
|
115
|
+
// own output — and before the font extraction, so a hoisted face is still
|
|
116
|
+
// recognised as a face.
|
|
117
|
+
const expanded = await postcss([tailwind(), flattenNestedAtRules()]).process(source, {
|
|
118
|
+
from: args.path,
|
|
119
|
+
});
|
|
105
120
|
const { rest, faces } = splitFontFaces(expanded.css, dirname(args.path));
|
|
106
121
|
if (faces.length > 0) {
|
|
107
122
|
const target = join(packageRoot(), FONT_FACE_DIR, `${basename(args.path)}`);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import postcss from 'postcss';
|
|
8
|
+
|
|
9
|
+
import { flattenNestedAtRules } from '@/server/lib/bundler/nested-at-rules';
|
|
10
|
+
|
|
11
|
+
/** Run the flattener and return the CSS it produced. */
|
|
12
|
+
async function flatten(css: string): Promise<string> {
|
|
13
|
+
const result = await postcss([flattenNestedAtRules()]).process(css, { from: undefined });
|
|
14
|
+
return result.css;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Every `&` left outside an escaped class name — an unflattened nesting marker. */
|
|
18
|
+
const unescapedAmpersands = (css: string): number => (css.match(/(?<!\\)&/g) ?? []).length;
|
|
19
|
+
|
|
20
|
+
describe('flattenNestedAtRules', () => {
|
|
21
|
+
test('hoists a nested @supports out of a pseudo-element rule', async () => {
|
|
22
|
+
// Tailwind's shape: a fallback declaration, then the color-mix refinement.
|
|
23
|
+
const out = await flatten(
|
|
24
|
+
`.placeholder-ink\\/40::placeholder {
|
|
25
|
+
color: var(--color-ink);
|
|
26
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
27
|
+
color: color-mix(in oklab, var(--color-ink) 40%, transparent);
|
|
28
|
+
}
|
|
29
|
+
}`,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// The at-rule is at the top level and carries the resolved selector itself:
|
|
33
|
+
// a nested rule whose parent names a pseudo-element is dropped by Chrome.
|
|
34
|
+
expect(unescapedAmpersands(out)).toBe(0);
|
|
35
|
+
expect(out).not.toMatch(/::placeholder\s*\{[^}]*@supports/);
|
|
36
|
+
const root = postcss.parse(out);
|
|
37
|
+
const atRules = root.nodes.filter((n) => n.type === 'atrule');
|
|
38
|
+
expect(atRules).toHaveLength(1);
|
|
39
|
+
expect(atRules[0].name).toBe('supports');
|
|
40
|
+
expect(atRules[0].toString()).toContain('.placeholder-ink\\/40::placeholder');
|
|
41
|
+
expect(atRules[0].toString()).toContain('color-mix');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('keeps declaration order, so the cascade is unchanged', async () => {
|
|
45
|
+
const out = await flatten(
|
|
46
|
+
`.a { color: red; @supports (display: block) { color: blue; } border-radius: 3px; }`,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const root = postcss.parse(out);
|
|
50
|
+
const order = root.nodes.map((node) =>
|
|
51
|
+
node.toString().replace(/\s+/g, ' '),
|
|
52
|
+
);
|
|
53
|
+
// `border-radius` was written AFTER the at-rule and must still follow it —
|
|
54
|
+
// moving it before would let the at-rule's `color` be overridden.
|
|
55
|
+
expect(order).toHaveLength(3);
|
|
56
|
+
expect(order[0]).toBe('.a { color: red; }');
|
|
57
|
+
expect(order[1]).toContain('@supports');
|
|
58
|
+
expect(order[2]).toBe('.a { border-radius: 3px; }');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('hoists inside the layer that contained the rule, not to the root', async () => {
|
|
62
|
+
const out = await flatten(
|
|
63
|
+
`@layer base { .b::placeholder { color: red; @supports (display: block) { color: blue; } } }`,
|
|
64
|
+
);
|
|
65
|
+
expect(out).not.toMatch(/::placeholder\s*\{[^}]*@supports/);
|
|
66
|
+
expect(unescapedAmpersands(out)).toBe(0);
|
|
67
|
+
// The refinement stays inside `@layer base` — hoisting it past the layer
|
|
68
|
+
// boundary would change which layer wins.
|
|
69
|
+
const layer = postcss.parse(out).nodes.find((n) => n.type === 'atrule' && n.name === 'layer');
|
|
70
|
+
expect(layer?.toString()).toContain('@supports');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('resolves an explicit & and leaves other rules alone', async () => {
|
|
74
|
+
const out = await flatten(
|
|
75
|
+
`.card { color: red; @supports (display: block) { & { color: blue; } } }
|
|
76
|
+
.plain { color: green; }`,
|
|
77
|
+
);
|
|
78
|
+
expect(out).not.toContain('&');
|
|
79
|
+
const root = postcss.parse(out);
|
|
80
|
+
const supports = root.nodes.find((n) => n.type === 'atrule');
|
|
81
|
+
expect(supports?.toString()).toContain('.card');
|
|
82
|
+
expect(root.toString()).toContain('.plain');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('flattens a rule nested inside the hoisted at-rule on the next pass', async () => {
|
|
86
|
+
const out = await flatten(
|
|
87
|
+
`.outer { @supports (display: block) { .inner { color: blue; } } }`,
|
|
88
|
+
);
|
|
89
|
+
expect(unescapedAmpersands(out)).toBe(0);
|
|
90
|
+
// The inner rule's selector is resolved against its parent, not left bare.
|
|
91
|
+
expect(postcss.parse(out).toString()).toContain('.outer .inner');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('leaves an already-flat stylesheet byte-identical', async () => {
|
|
95
|
+
const flat = `.a { color: red; }
|
|
96
|
+
@supports (display: block) { .a { color: blue; } }
|
|
97
|
+
@layer base { .b { color: green; } }`;
|
|
98
|
+
expect(await flatten(flat)).toBe(flat);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hoist every nested at-rule out of the style rule that contains it.
|
|
8
|
+
*
|
|
9
|
+
* Bun's CSS minifier keeps the nesting it is handed, and for a target that is
|
|
10
|
+
* not the browser (`target: 'bun'`, which this build needs because its
|
|
11
|
+
* entrypoint is the SERVER) it re-nests a rule's nested at-rule into
|
|
12
|
+
* `@supports (…) { & { … } }`. Chrome does not apply a nested rule whose parent
|
|
13
|
+
* selector names a pseudo-element, so `&` under `::placeholder` or
|
|
14
|
+
* `::-webkit-scrollbar-thumb` is dropped and the rule's earlier fallback
|
|
15
|
+
* declaration wins instead — measured on the built bundle: the composer
|
|
16
|
+
* placeholder painted at full ink rather than 40%, every scrollbar thumb opaque
|
|
17
|
+
* rather than 20%.
|
|
18
|
+
*
|
|
19
|
+
* Tailwind v4 emits its colour fallbacks in exactly that shape (`color:
|
|
20
|
+
* var(--x); @supports (color: color-mix(…)) { color: color-mix(…) }`), so one
|
|
21
|
+
* minifier decision reached 16 rules: every placeholder utility in the app and
|
|
22
|
+
* every scrollbar rule, base and xterm included. The rsbuild pipeline this
|
|
23
|
+
* replaced compiled for browsers and flattened the nesting itself.
|
|
24
|
+
*
|
|
25
|
+
* Flattening here is what makes the bundle immune: the emitted CSS is
|
|
26
|
+
* `@supports (…) { .sel::placeholder { … } }` at the top level, which no
|
|
27
|
+
* minifier has anything left to re-nest and every browser applies. Declaration
|
|
28
|
+
* order is preserved exactly — a rule's declarations stay in the segments they
|
|
29
|
+
* were written in, and each at-rule is hoisted to the position it occupied — so
|
|
30
|
+
* the cascade is unchanged.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import postcss from 'postcss';
|
|
34
|
+
import type { AtRule, ChildNode, Container, Plugin, Rule } from 'postcss';
|
|
35
|
+
|
|
36
|
+
/** Resolve a nested selector against the rule it was written inside. */
|
|
37
|
+
function resolveSelector(parentSelector: string, nestedSelector: string): string {
|
|
38
|
+
if (nestedSelector.includes('&')) return nestedSelector.replaceAll('&', parentSelector);
|
|
39
|
+
return `${parentSelector} ${nestedSelector}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve `&` in the rules directly inside `container`, descending through
|
|
44
|
+
* at-rules only: `&` still refers to the nearest enclosing STYLE rule, and an
|
|
45
|
+
* intermediate one is hoisted on a later pass.
|
|
46
|
+
*
|
|
47
|
+
* Runs BEFORE `wrapBareDeclarations` — the rule that wraps bare declarations
|
|
48
|
+
* already carries the parent's resolved selector, and resolving it again would
|
|
49
|
+
* turn `::placeholder` into `::placeholder ::placeholder`.
|
|
50
|
+
*/
|
|
51
|
+
function resolveAmpersands(container: Container, parentSelector: string): void {
|
|
52
|
+
for (const node of container.nodes ?? []) {
|
|
53
|
+
if (node.type === 'rule') node.selector = resolveSelector(parentSelector, node.selector);
|
|
54
|
+
else if (node.type === 'atrule') resolveAmpersands(node, parentSelector);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Wrap an at-rule's bare declarations in `parentSelector`, keeping them in place. */
|
|
59
|
+
function wrapBareDeclarations(atRule: AtRule, parentSelector: string): void {
|
|
60
|
+
const nodes = [...(atRule.nodes ?? [])];
|
|
61
|
+
if (!nodes.some((node) => node.type === 'decl')) return;
|
|
62
|
+
const rebuilt: ChildNode[] = [];
|
|
63
|
+
let declarations: ChildNode[] = [];
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (declarations.length === 0) return;
|
|
66
|
+
const rule = postcss.rule({ selector: parentSelector });
|
|
67
|
+
rule.append(declarations);
|
|
68
|
+
rebuilt.push(rule);
|
|
69
|
+
declarations = [];
|
|
70
|
+
};
|
|
71
|
+
for (const node of nodes) {
|
|
72
|
+
if (node.type === 'decl') declarations.push(node);
|
|
73
|
+
else {
|
|
74
|
+
flush();
|
|
75
|
+
rebuilt.push(node);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
flush();
|
|
79
|
+
atRule.removeAll();
|
|
80
|
+
atRule.append(rebuilt);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Hoist `rule`'s nested at-rules into its own container. Reports whether it moved any. */
|
|
84
|
+
function hoistNestedAtRules(rule: Rule): boolean {
|
|
85
|
+
const container = rule.parent;
|
|
86
|
+
// Snapshot: reparenting the declarations empties `rule.nodes` mid-iteration.
|
|
87
|
+
const nodes = [...(rule.nodes ?? [])];
|
|
88
|
+
if (!container || !nodes.some((node) => node.type === 'atrule')) return false;
|
|
89
|
+
|
|
90
|
+
const replacement: ChildNode[] = [];
|
|
91
|
+
let declarations: ChildNode[] = [];
|
|
92
|
+
const flush = () => {
|
|
93
|
+
if (declarations.length === 0) return;
|
|
94
|
+
const segment = postcss.rule({ selector: rule.selector, raws: { ...rule.raws } });
|
|
95
|
+
segment.append(declarations);
|
|
96
|
+
replacement.push(segment);
|
|
97
|
+
declarations = [];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
for (const node of nodes) {
|
|
101
|
+
if (node.type !== 'atrule') {
|
|
102
|
+
declarations.push(node);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
flush();
|
|
106
|
+
const hoisted = node.clone();
|
|
107
|
+
resolveAmpersands(hoisted, rule.selector);
|
|
108
|
+
wrapBareDeclarations(hoisted, rule.selector);
|
|
109
|
+
replacement.push(hoisted);
|
|
110
|
+
}
|
|
111
|
+
flush();
|
|
112
|
+
|
|
113
|
+
container.insertBefore(rule, replacement);
|
|
114
|
+
rule.remove();
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* PostCSS plugin: hoist nested at-rules out of the rules containing them.
|
|
120
|
+
*
|
|
121
|
+
* `OnceExit` rather than `Once` so Tailwind's own pass has already expanded
|
|
122
|
+
* `@theme` and every utility before there is a rule to flatten; register it
|
|
123
|
+
* LAST for the same reason.
|
|
124
|
+
*/
|
|
125
|
+
export function flattenNestedAtRules(): Plugin {
|
|
126
|
+
return {
|
|
127
|
+
postcssPlugin: 'ompchamber-flatten-nested-at-rules',
|
|
128
|
+
OnceExit(root) {
|
|
129
|
+
// A hoist can expose another nested at-rule in the segment that received
|
|
130
|
+
// it, so repeat until a full pass moves nothing.
|
|
131
|
+
let hoisted = true;
|
|
132
|
+
while (hoisted) {
|
|
133
|
+
hoisted = false;
|
|
134
|
+
const rules: Rule[] = [];
|
|
135
|
+
root.walkRules((rule) => {
|
|
136
|
+
rules.push(rule);
|
|
137
|
+
});
|
|
138
|
+
// Reverse document order: a nested rule is walked after its parent, so
|
|
139
|
+
// the inner one is flattened first and its parent is then still intact.
|
|
140
|
+
for (let index = rules.length - 1; index >= 0; index -= 1) {
|
|
141
|
+
if (hoistNestedAtRules(rules[index])) hoisted = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import { launchModeArg, resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
|
|
3
|
+
import { isCliManaged, launchModeArg, resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
|
|
4
4
|
|
|
5
5
|
const ENTRY = '/repo/src/server/index.ts';
|
|
6
6
|
|
|
@@ -30,3 +30,22 @@ describe('resolveLaunchMode', () => {
|
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
// Every command that would END a server decides with this predicate:
|
|
35
|
+
// `restart`/`update` may replace it, and `stop` stops it by default. Guessing
|
|
36
|
+
// wrong here kills a `bun run dev` the user owns — that launcher exits with its
|
|
37
|
+
// child — or leaves a daemon serving the build that was just replaced.
|
|
38
|
+
describe('isCliManaged', () => {
|
|
39
|
+
test('owns the instances the CLI started', () => {
|
|
40
|
+
expect(isCliManaged('daemon')).toBe(true);
|
|
41
|
+
expect(isCliManaged('foreground')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('leaves every instance OMPChamber did not start alone', () => {
|
|
45
|
+
// `bun run dev`, `bun run start`, a manual run, a systemd/pm2 unit.
|
|
46
|
+
expect(isCliManaged('direct')).toBe(false);
|
|
47
|
+
// A probed or unreadable record never authorises a kill either.
|
|
48
|
+
expect(isCliManaged('unknown')).toBe(false);
|
|
49
|
+
expect(isCliManaged(undefined)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|