@ogygia/cli 0.11.5 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai/SKILL.md +3 -3
- package/dist/mcp.js +44 -22
- package/dist/messages.js +75 -0
- package/dist/perf/cli.js +136 -19
- package/dist/perf/measure.js +11 -2
- package/package.json +12 -5
package/ai/SKILL.md
CHANGED
|
@@ -147,12 +147,12 @@ Each island is its own hydration root. Props cross by **value** (devalue), never
|
|
|
147
147
|
## Timing and nesting
|
|
148
148
|
|
|
149
149
|
- `interaction` on an island: zero JS until first pointer/key/focus **inside** the region. The waking click is captured and replayed; typing survives; hover prefetches the chunk. Caveat: the replayed event is not a trusted gesture — `window.open`/clipboard/fullscreen will be blocked; detect with `hydratedBy() === 'interaction'` and render e.g. a real `<a target="_blank">`.
|
|
150
|
-
- `interaction` on a server island = an **on-demand hole**: zero bytes until the visitor shows intent inside it — the first hover (`pointerover`, which bubbles through the boxless `display:contents` wrapper from a child), focus, touch, or key — then ONE fetch, **morphed** in so what the visitor already opened in the static fallback stays open (no click capture/replay — the fallback handles the gesture natively). Hover is the trigger, so the HTML is usually there before the visitor reaches a submenu. To have it there BEFORE the first hover, add `prefetch: 'idle'` (or `load` / `visible` / a media query — deferred-only, never `interaction`): the HTML is warmed into the frame store at that schedule and the hover swaps from it with no request. The shape for a
|
|
150
|
+
- `interaction` on a server island = an **on-demand hole**: zero bytes until the visitor shows intent inside it — the first hover (`pointerover`, which bubbles through the boxless `display:contents` wrapper from a child), focus, touch, or key — then ONE fetch, **morphed** in so what the visitor already opened in the static fallback stays open (no click capture/replay — the fallback handles the gesture natively). Hover is the trigger, so the HTML is usually there before the visitor reaches a submenu. To have it there BEFORE the first hover, add `prefetch: 'idle'` (or `load` / `visible` / a media query — deferred-only, never `interaction`): the HTML is warmed into the frame store at that schedule and the hover swaps from it with no request. The shape for a nav menu: fallback = the menu's L1/L2 (today's SSR HTML, byte-identical), hole = the same menu with L3/L4 rendered on the server — no L4 in the initial HTML, no menu JS, no feed download in the browser.
|
|
151
151
|
- Nesting — one rule, **closest marked parent decides**: island-in-island shares the parent's JS (one tree, child's schedule ignored, dev warns); lake-in-island stays static; island-in-lake wakes on its own; server-island-in-island renders inline (`deferred` ignored); an island inside a server island's `ogygiaFallback` is a full region that wakes on its own (the fallback belongs to the page, and a `keepFallback()`-kept fallback stays interactive).
|
|
152
152
|
- **An island hydrates against its own server markup.** The element keeps the HTML it connected with; if another script edited the island while it slept (a design-system runtime stripping whitespace, an A/B tool, a translator) and Svelte's walk mismatches on wake, the runtime puts the server markup back and hydrates that (`data-og-healed`, dev warning) instead of re-rendering client-side; only a second failure falls back to Svelte's recovery (`data-og-recovered`). Foreign scripts should still leave `<ogygia-region>` subtrees alone — whatever they added is gone after a heal.
|
|
153
153
|
- **A lake on a csr=true (Kit-hydrated) page is adopted, not hydrated.** Kit's pass keeps the lake's SSR element as opaque DOM (its component ships no client JS — not even in Kit's bundle), and everything authored INSIDE the lake stays in ogygia's world: islands and server islands there emit real regions and wake on the runtime. This is how a site header (static markup + `render: 'deferred'` personal holes + a `wake: 'interaction'` search) stays zero-JS-for-anonymous on csr=true pages too: mark the header import `with { wake: 'none' }` in the layout. Caveat: a lake first mounted during a Kit client-side navigation has no server HTML and renders empty (dev warns) — keep chrome lakes in a layout that persists, or serve the route csr=false. A hole rendered OUTSIDE a lake on a csr=true page is Kit-hydrated around; if Kit gives up hydrating the document (`Failed to hydrate`: a component threw) and mounts it fresh, the hole is rendered again with no address — the runtime restores it from the document's holes record (by identity, `data-og-hole`), so it still fetches; a hole rendered only in the browser has no record and keeps its fallback.
|
|
154
154
|
- Dedupe: same component + same strategy/options = ONE wrapper module and ONE client chunk, however many instances (each instance still gets its own region + props payload).
|
|
155
|
-
- A hole's timing is `fetch`, never `wake`: `with { render: 'deferred', fetch: 'visible' }`.
|
|
155
|
+
- A hole's timing is `fetch`, never `wake`: `with { render: 'deferred', fetch: 'visible' }`. A `wake` on a `render: 'deferred'` import is deprecated: it still times the hole, with a build warning naming the `fetch` to write (both at once fails); a `fetch` without `render: 'deferred'` fails too.
|
|
156
156
|
|
|
157
157
|
## csr=false lifecycle rules
|
|
158
158
|
|
|
@@ -317,7 +317,7 @@ CLI (`@ogygia/cli`; `npx ogygia …` runs it): `npx ogygia init` · `site init`
|
|
|
317
317
|
- **Programmatic routes** take the same `config` in table grammar: `page(C, { config: { postrender: false } })`, `layout(name, C, { config })`, `routes(table, { config })` — merged table → layouts → page. The table registers a matcher at construction; the handle asks it for any request Kit's file router didn't claim (`route.id` null under `sequence(postrender.handle(), ogygia.handle(), app.handle)`, or a catch-all `+server.ts`). Endpoint routes and unclaimed paths follow the vite-config default. The router's default `cache-control: no-store` document stamp yields to the verdict on eligible requests (stored → `public, s-maxage`; refused → `private, no-store`; an explicit page header always wins).
|
|
318
318
|
- Invalidation (any server code, e.g. a CMS webhook endpoint; reaches the store/edges `postrender.handle()` was given): `postrender.invalidate('/fr/fr/solar/')` (exact URL), `postrender.invalidateWhere({ prefix: '/fr/fr/' })` (subtree = locale/section nuke), `postrender.invalidate(fn, [args])` (declared sources).
|
|
319
319
|
- Declared sources (the reverse index): `export const loadDoc = import.meta.og.source(async (…) => …)` — top-level `export const` only (macro laws). Every page whose render calls it files a receipt; `invalidate(loadDoc, [args])` evicts exactly the consuming pages, at origin AND every edge. `{ key: (…args) => string }` canonicalizes exotic args.
|
|
320
|
-
- Stores: `
|
|
320
|
+
- Stores: `new MemoryStore()` (default) · `new ValkeyStore(client)` · `new UpstashStore({ url, token })` · `new CloudflareKvStore(binding)`. Edges: `new AkamaiEdge(cfg)` · `new CloudFrontEdge(cfg)` · `new CloudflareEdge(cfg)`. Fan-out is allSettled inside ogygia — one edge down never fails a publish or a request.
|
|
321
321
|
- The vary law is the cookie-less CDN key, formalized: DEFAULT-valued reads still store (that render IS the canonical), and once minted everyone gets the canonical copy. Truly per-visitor content belongs in a deferred hole; A/B'd pages auto-disqualify on any flag read.
|
|
322
322
|
- Eligible responses are stamped `cache-control: public, s-maxage=<ttl>` plus each edge's tags (Akamai/Cloudflare prefix tags), so CDNs follow proven per-page headers instead of blanket rules. csr=true pages store the same — Kit hydrates the stored copy.
|
|
323
323
|
- **Self-freshening header on a stored page** (SSR'd, no deferred fallback): mark the component `with { render: 'live', wake: 'load' }` INSIDE a small island host — its HTML bakes into the stored bytes, and on served-from-store documents (the handle stamps a doc marker) it revalidates on first mount: a fresh server render with the visitor's cookies, swapped in. Anonymous traffic mints the canonical (default-valued reads store). A live region at page top level OUTSIDE any island bakes once and never revalidates — always give it an island host (dev warns).
|
package/dist/mcp.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { line } from "./messages.js";
|
|
1
2
|
import { createInterface } from "node:readline";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
4
|
+
import { STALE, flights, scope } from "@ogygia/machine";
|
|
3
5
|
import { gzipSync } from "node:zlib";
|
|
4
6
|
import { readFileSync, readdirSync } from "node:fs";
|
|
5
7
|
import path from "node:path";
|
|
@@ -22,15 +24,18 @@ import { CLIENT_BINDING_STUB, collect_flag_sites, flags_manifest, islandVirtualI
|
|
|
22
24
|
* @packageDocumentation
|
|
23
25
|
*/
|
|
24
26
|
let profiler_reading;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
/** The reading's load, kept once it lands; a failed one (the plugin not installed yet) is not kept,
|
|
28
|
+
* so the next call loads it, with no restart of this long-lived server (#295). */
|
|
29
|
+
const reading_load = flights({
|
|
30
|
+
name: "profiler report reading",
|
|
31
|
+
run: async () => {
|
|
28
32
|
const from_app = createRequire(path.join(process.cwd(), "package.json"));
|
|
29
|
-
profiler_reading = await import(pathToFileURL(from_app.resolve("@ogygia/devtools/report")).href);
|
|
30
|
-
} catch {
|
|
31
|
-
profiler_reading = null;
|
|
33
|
+
return profiler_reading = await import(pathToFileURL(from_app.resolve("@ogygia/devtools/report")).href);
|
|
32
34
|
}
|
|
33
|
-
|
|
35
|
+
});
|
|
36
|
+
async function load_profiler_reading() {
|
|
37
|
+
const got = await reading_load.get().catch(() => null);
|
|
38
|
+
return got === STALE ? null : got;
|
|
34
39
|
}
|
|
35
40
|
const TRAILING_SLASH_RE = /\/$/;
|
|
36
41
|
const HTTP_URL_RE = /^https?:\/\//i;
|
|
@@ -243,7 +248,7 @@ const TOOLS = [
|
|
|
243
248
|
},
|
|
244
249
|
{
|
|
245
250
|
name: "ogygia_profile",
|
|
246
|
-
description: "Profile the SERVER-SIDE render of a route — runs the whole thing itself, nothing to download. ASK THE USER which URL to profile before calling (they prefer to name the host): a full URL like `http://localhost:5173/fr/fr` (local) or `https://app.com/fr/fr` (remote). Pass it as `url`. A bare path is accepted too, but then the tool just asks you for the URL (and lists any local server it can see). Records N renders and returns a digest: verdict (compute- vs io-bound), render p50, CPU findings, time-by-category, hottest functions + components (Svelte names each after its file), network, heap.
|
|
251
|
+
description: "Profile the SERVER-SIDE render of a route — runs the whole thing itself, nothing to download. ASK THE USER which URL to profile before calling (they prefer to name the host): a full URL like `http://localhost:5173/fr/fr` (local) or `https://app.com/fr/fr` (remote). Pass it as `url`. A bare path is accepted too, but then the tool just asks you for the URL (and lists any local server it can see). Records N renders and returns a digest: verdict (compute- vs io-bound), render p50, CPU findings, time-by-category, hottest functions + components (Svelte names each after its file), network, heap. Clears a stale session (auto /reset + retry); leaves a live recording alone and says the profiler is busy. Requires the profiler turned on: `devtools({ profiler: true })` from `@ogygia/devtools/vite` in the app’s vite.config (the tool tells you how if missing). Prod build = real numbers; dev is indicative (the digest says which). For a report you already downloaded, use ogygia_profile_open.",
|
|
247
252
|
inputSchema: {
|
|
248
253
|
type: "object",
|
|
249
254
|
properties: {
|
|
@@ -359,7 +364,7 @@ function tool_compile(args) {
|
|
|
359
364
|
try {
|
|
360
365
|
out = compile(source, filename, true, csr);
|
|
361
366
|
} catch (e) {
|
|
362
|
-
return fail(
|
|
367
|
+
return fail(line("cli_transform_failed", { error: e instanceof Error ? e.message : String(e) }));
|
|
363
368
|
}
|
|
364
369
|
const map = out.islands.length ? out.islands.map((i) => `- ${i.component} (as ${i.local}) — ${i.strategy}${i.id ? ` · id ${i.id.slice(0, 8)}` : ""}`).join("\n") : "(no marked components — the whole file is free server HTML)";
|
|
365
370
|
const structured = JSON.stringify({
|
|
@@ -380,7 +385,7 @@ function tool_islands(args) {
|
|
|
380
385
|
try {
|
|
381
386
|
out = compile(source, "App.svelte", true, false);
|
|
382
387
|
} catch (e) {
|
|
383
|
-
return fail(
|
|
388
|
+
return fail(line("cli_transform_failed", { error: e instanceof Error ? e.message : String(e) }));
|
|
384
389
|
}
|
|
385
390
|
const structured = out.islands.map((i) => ({
|
|
386
391
|
component: i.component,
|
|
@@ -415,7 +420,7 @@ function tool_explain(args) {
|
|
|
415
420
|
try {
|
|
416
421
|
out = compile(source, "App.svelte", true, false);
|
|
417
422
|
} catch (e) {
|
|
418
|
-
return fail(
|
|
423
|
+
return fail(line("cli_transform_failed", { error: e instanceof Error ? e.message : String(e) }));
|
|
419
424
|
}
|
|
420
425
|
if (!out.islands.length) return text("This component marks nothing, so it ships as pure server HTML — zero JavaScript, no islands.");
|
|
421
426
|
const lines = out.islands.map((i) => {
|
|
@@ -815,14 +820,15 @@ const LOCAL_PORTS = [
|
|
|
815
820
|
/** fetch with a hard deadline — a hung server can't wedge the tool. */
|
|
816
821
|
async function fetch_timeout(url, opts, ms) {
|
|
817
822
|
const ac = new AbortController();
|
|
818
|
-
const
|
|
823
|
+
const deadline = scope();
|
|
824
|
+
deadline.timeout(() => ac.abort(), ms);
|
|
819
825
|
try {
|
|
820
826
|
return await fetch(url, {
|
|
821
827
|
...opts,
|
|
822
828
|
signal: ac.signal
|
|
823
829
|
});
|
|
824
830
|
} finally {
|
|
825
|
-
|
|
831
|
+
deadline.close();
|
|
826
832
|
}
|
|
827
833
|
}
|
|
828
834
|
/** Probe localhost ports in parallel for a MOUNTED profiler; returns hits in priority order. */
|
|
@@ -842,6 +848,17 @@ async function find_local_profiler(base, key) {
|
|
|
842
848
|
return (await Promise.all(probes)).filter((o) => !!o);
|
|
843
849
|
}
|
|
844
850
|
const NOT_MOUNTED = (base, where) => `The profiler is not mounted at ${base} on ${where}. Install @ogygia/devtools and turn it on in vite.config.ts:\n\n import { devtools } from '@ogygia/devtools/vite';\n plugins: [ogygia(), devtools({ profiler: true /* or { secret, path, … } */ }), sveltekit()]\n\nogygia.handle() mounts it for you — no hooks wiring needed. Restart the server, then retry.`;
|
|
851
|
+
/** The holder a 409 names: null when it names none (an older profiler, or a body that is not its). */
|
|
852
|
+
async function busy_holder(res) {
|
|
853
|
+
try {
|
|
854
|
+
return (await res.json()).holder ?? null;
|
|
855
|
+
} catch {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
function busy_message(origin, holder) {
|
|
860
|
+
return `The profiler on ${origin} is busy: ${holder ? `a ${holder.kind === "window" ? "recording window" : holder.kind === "background" ? "background capture" : "page profile"} started ${Math.round(holder.age_ms / 1e3)}s ago` : "another recording"} holds it, and it may be someone else's, so ogygia_profile leaves it alone. Wait for it to end and retry (the dashboard's Reset takes it over, if it is yours and stuck).`;
|
|
861
|
+
}
|
|
845
862
|
async function tool_profile(args) {
|
|
846
863
|
const raw = String(args.url ?? "").trim();
|
|
847
864
|
if (!raw) return fail("`url` is required — a route path like `/fr/fr` (profiles your local running server), or a full URL like `https://app.com/fr/fr` (profiles that host).");
|
|
@@ -895,19 +912,23 @@ async function tool_profile(args) {
|
|
|
895
912
|
const aborted = e instanceof Error && e.name === "AbortError";
|
|
896
913
|
return fail(aborted ? `Recording ${route} on ${origin} took over 90s and was aborted. Try fewer \`runs\`, or if this host times out (serverless), download the .ogp from ${origin}${profiler_base} and open it with ogygia_profile_open.` : `Recording failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
897
914
|
}
|
|
898
|
-
if (res.status === 409)
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
915
|
+
if (res.status === 409) {
|
|
916
|
+
const holder = await busy_holder(res);
|
|
917
|
+
if (holder?.stale !== true) return fail(busy_message(origin, holder));
|
|
918
|
+
try {
|
|
919
|
+
await fetch_timeout(new URL(origin + profiler_base + "/reset"), {
|
|
920
|
+
redirect: "manual",
|
|
921
|
+
headers: key ? { "x-profiler-key": key } : {}
|
|
922
|
+
}, 5e3);
|
|
923
|
+
res = await record_once();
|
|
924
|
+
} catch {}
|
|
925
|
+
if (res.status === 409) return fail(busy_message(origin, await busy_holder(res)));
|
|
926
|
+
}
|
|
905
927
|
if ((res.headers.get("content-type") ?? "").includes("application/json")) {
|
|
906
928
|
await load_profiler_reading();
|
|
907
929
|
return text(render_profile(origin, await res.json()));
|
|
908
930
|
}
|
|
909
931
|
const body = (await res.text()).replace(HTML_TAG_G, " ").replace(WS_G, " ").trim().slice(0, 300);
|
|
910
|
-
if (res.status === 409) return fail(`A profile is already running on ${origin} and /reset didn’t clear it. It auto-heals after ~2 min — wait and retry.`);
|
|
911
932
|
if (res.status === 401 || res.status === 403) return fail(`The profiler on ${origin} is locked. Pass its secret as \`key\` (ask the user for it).`);
|
|
912
933
|
return fail(`profiler did not return JSON (HTTP ${res.status}). ${body || "(check the profiler key / route path)"}`);
|
|
913
934
|
}
|
|
@@ -1141,6 +1162,7 @@ async function tool_fragment(args) {
|
|
|
1141
1162
|
}
|
|
1142
1163
|
return text(`# ogygia fragment probe — ${origin}\n\n- routes endpoint (\`/og/fragment/page\`): ${posture}\n\n## Widgets (\`__catalog\`)\n${widgets_block}`);
|
|
1143
1164
|
}
|
|
1165
|
+
/** Run one tool, as the server does for a `tools/call`. @internal Exported for tests. */
|
|
1144
1166
|
async function dispatch_tool(name, args) {
|
|
1145
1167
|
switch (name) {
|
|
1146
1168
|
case "ogygia_compile": return tool_compile(args);
|
|
@@ -1242,4 +1264,4 @@ async function runMcp() {
|
|
|
1242
1264
|
}
|
|
1243
1265
|
}
|
|
1244
1266
|
}
|
|
1245
|
-
export { render_profile, runMcp };
|
|
1267
|
+
export { dispatch_tool, load_profiler_reading, render_profile, runMcp };
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { define_messages, prose } from "@ogygia/core/internal/messages";
|
|
2
|
+
// src/messages.ts
|
|
3
|
+
/**
|
|
4
|
+
* EVERY MESSAGE THE CLI PRINTS AS AN OGYGIA MESSAGE, in one place: its code, its wording, what to do
|
|
5
|
+
* about it (core's `@ogygia/core/internal/messages` says how the system works).
|
|
6
|
+
*
|
|
7
|
+
* fail(line('cli_transform_failed', { error }));
|
|
8
|
+
*
|
|
9
|
+
* The CLI runs in Node, never in a page, so the wording always prints, then the code's page. (The
|
|
10
|
+
* commands' own usage lines and `✗` failures are terminal output, not messages.)
|
|
11
|
+
*/
|
|
12
|
+
/** The errors: `throw new OgygiaError(code, args)`, or `line(code, args)` where an error is answered
|
|
13
|
+
* as text (an MCP tool's result). */
|
|
14
|
+
const CLI_ERRORS = {
|
|
15
|
+
cli_transform_failed: {
|
|
16
|
+
text: (a) => prose`
|
|
17
|
+
transform error:
|
|
18
|
+
|
|
19
|
+
${a.error}
|
|
20
|
+
`,
|
|
21
|
+
details: () => prose`
|
|
22
|
+
An MCP tool (\`ogygia_compile\`, \`ogygia_islands\`, \`ogygia_explain\`) ran ogygia's
|
|
23
|
+
transform on the component it was given, and the transform threw. \`error\` is the
|
|
24
|
+
transform's own message, which usually names the rule the component breaks and links its
|
|
25
|
+
page.
|
|
26
|
+
`
|
|
27
|
+
},
|
|
28
|
+
cli_server_port_busy: {
|
|
29
|
+
text: (a) => prose`
|
|
30
|
+
port ${a.port} (${a.url}) is still held by another process after ${a.ms} ms: the server
|
|
31
|
+
was not started.
|
|
32
|
+
`,
|
|
33
|
+
details: () => prose`
|
|
34
|
+
\`ogygia bisect\` starts each commit's server (\`--start\`) on the port of \`--url\`, and
|
|
35
|
+
waits for that port to be free first: a server still on it would answer the readiness
|
|
36
|
+
check, and bisect would measure that server instead of the commit's. Its own servers are
|
|
37
|
+
gone before the next one starts, so the process on the port is someone else's (a server
|
|
38
|
+
left running in another terminal, an earlier run): stop it, or point \`--url\` at a free
|
|
39
|
+
port.
|
|
40
|
+
`
|
|
41
|
+
},
|
|
42
|
+
cli_server_exited_early: {
|
|
43
|
+
text: (a) => prose`
|
|
44
|
+
the server (${a.cmd}) exited before it answered (${a.how}). Its last output:
|
|
45
|
+
|
|
46
|
+
${a.output || "(none)"}
|
|
47
|
+
`,
|
|
48
|
+
details: () => prose`
|
|
49
|
+
\`ogygia bisect\` started the commit's server with \`--start\` and it exited before
|
|
50
|
+
\`--url\` answered: a server that cannot take its port (EADDRINUSE), a build output that
|
|
51
|
+
is not there, or a crash on boot. Its output says which. The commit is skipped.
|
|
52
|
+
`
|
|
53
|
+
},
|
|
54
|
+
cli_server_not_ready: {
|
|
55
|
+
text: (a) => prose`
|
|
56
|
+
the server did not answer at ${a.url} within ${a.ms} ms, and was stopped. Its last output:
|
|
57
|
+
|
|
58
|
+
${a.output || "(none)"}
|
|
59
|
+
`,
|
|
60
|
+
details: () => prose`
|
|
61
|
+
\`ogygia bisect\` polls \`--url\` until the commit's server answers (any status under 500),
|
|
62
|
+
for up to \`--ready\` ms. A server that takes longer to boot needs a larger \`--ready\`; one
|
|
63
|
+
that listens on another port needs \`--url\` to name that port. The commit is skipped.
|
|
64
|
+
`
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
/** The warnings: `warn(code, args, ...rest)`. */
|
|
68
|
+
const CLI_WARNINGS = {};
|
|
69
|
+
const own = define_messages(CLI_ERRORS, CLI_WARNINGS);
|
|
70
|
+
/** `throw new OgygiaError(code, args)`; `warn(code, args, ...rest)` logs; `line(code, args)` is the
|
|
71
|
+
* line as a string. */
|
|
72
|
+
const OgygiaError = own.OgygiaError;
|
|
73
|
+
const warn = own.warn;
|
|
74
|
+
const line = own.line;
|
|
75
|
+
export { CLI_ERRORS, CLI_WARNINGS, OgygiaError, line, warn };
|
package/dist/perf/cli.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { OgygiaError } from "../messages.js";
|
|
1
2
|
import { measure } from "./measure.js";
|
|
2
3
|
import { verdict } from "./compare.js";
|
|
3
4
|
import { markdown, summary } from "./report.js";
|
|
4
5
|
import { bisect } from "./bisect.js";
|
|
6
|
+
import { machine } from "@ogygia/machine";
|
|
5
7
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
8
|
import path from "node:path";
|
|
7
9
|
import { execSync, spawn } from "node:child_process";
|
|
10
|
+
import { connect } from "node:net";
|
|
8
11
|
import { tmpdir } from "node:os";
|
|
12
|
+
import { setTimeout } from "node:timers/promises";
|
|
9
13
|
// src/perf/cli.ts
|
|
10
14
|
/**
|
|
11
15
|
* `ogygia perf` and `ogygia bisect` — the runners around the pure modules. Loaded by the CLI on
|
|
@@ -92,29 +96,145 @@ function sh(cmd, cwd, timeout_ms) {
|
|
|
92
96
|
}
|
|
93
97
|
});
|
|
94
98
|
}
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
const server_machine = machine()({
|
|
100
|
+
name: "bisect server",
|
|
101
|
+
initial: "spawning",
|
|
102
|
+
steps: {
|
|
103
|
+
spawning: [
|
|
104
|
+
"ready",
|
|
105
|
+
"stopping",
|
|
106
|
+
"exited_early"
|
|
107
|
+
],
|
|
108
|
+
ready: ["stopping", "exited_early"],
|
|
109
|
+
stopping: ["exited"],
|
|
110
|
+
exited_early: ["exited"],
|
|
111
|
+
exited: []
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
/** How long a stopped server has to exit on SIGTERM before its group gets SIGKILL. */
|
|
115
|
+
const STOP_GRACE_MS = 1e4;
|
|
116
|
+
/** `ms` later, or at once when `signal` aborts. Its timer holds the process open, as a wait the
|
|
117
|
+
* command itself awaits must (a scope's timer does not). */
|
|
118
|
+
const sleep = (ms, signal) => setTimeout(ms, void 0, { signal }).catch((error) => {
|
|
119
|
+
if (!signal?.aborted) throw error;
|
|
120
|
+
});
|
|
121
|
+
/** Something accepts a connection at `host:port` (an HTTP server or not: either would get in the
|
|
122
|
+
* way of the readiness check). */
|
|
123
|
+
function port_held(host, port) {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const socket = connect({
|
|
126
|
+
host,
|
|
127
|
+
port
|
|
128
|
+
});
|
|
129
|
+
socket.setTimeout(1e3, () => (socket.destroy(), resolve(false)));
|
|
130
|
+
socket.once("connect", () => (socket.destroy(), resolve(true)));
|
|
131
|
+
socket.once("error", () => resolve(false));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/** The process group still has a process in it. */
|
|
135
|
+
function group_alive(pid) {
|
|
136
|
+
try {
|
|
137
|
+
process.kill(-pid, 0);
|
|
138
|
+
return true;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return error.code === "EPERM";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Start a server command and wait until `url` answers (within `ready_ms`, the wait for a free
|
|
145
|
+
* port included). Returns its stop, which resolves once the server's whole process group has
|
|
146
|
+
* exited (SIGTERM, then SIGKILL after `grace_ms`). Throws when the port stays held, when the server
|
|
147
|
+
* exits before it answers (with its output), or when it never answers (stopped first).
|
|
148
|
+
*/
|
|
149
|
+
async function serve(cmd, cwd, url, ready_ms, grace_ms = STOP_GRACE_MS) {
|
|
150
|
+
const deadline = Date.now() + ready_ms;
|
|
151
|
+
const at = new URL(url);
|
|
152
|
+
const host = at.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
153
|
+
const port = Number(at.port) || (at.protocol === "https:" ? 443 : 80);
|
|
154
|
+
while (await port_held(host, port)) {
|
|
155
|
+
if (Date.now() >= deadline) throw new OgygiaError("cli_server_port_busy", {
|
|
156
|
+
url,
|
|
157
|
+
port,
|
|
158
|
+
ms: ready_ms
|
|
159
|
+
});
|
|
160
|
+
await sleep(200);
|
|
161
|
+
}
|
|
162
|
+
const m = server_machine.create();
|
|
163
|
+
let output = "";
|
|
164
|
+
const keep = (chunk) => {
|
|
165
|
+
output = (output + String(chunk)).slice(-65536);
|
|
166
|
+
};
|
|
167
|
+
const tail = () => output.split("\n").slice(-40).join("\n").trim();
|
|
97
168
|
const child = spawn(cmd, {
|
|
98
169
|
cwd,
|
|
99
170
|
shell: true,
|
|
100
|
-
stdio:
|
|
171
|
+
stdio: [
|
|
172
|
+
"ignore",
|
|
173
|
+
"pipe",
|
|
174
|
+
"pipe"
|
|
175
|
+
],
|
|
101
176
|
detached: true,
|
|
102
177
|
env: { ...process.env }
|
|
103
178
|
});
|
|
104
|
-
|
|
179
|
+
child.stdout?.on("data", keep);
|
|
180
|
+
child.stderr?.on("data", keep);
|
|
181
|
+
const closed = new Promise((r) => child.once("close", () => r()));
|
|
182
|
+
const pid = child.pid;
|
|
183
|
+
const signal_group = (sig) => {
|
|
105
184
|
try {
|
|
106
|
-
if (
|
|
185
|
+
if (pid !== void 0) process.kill(-pid, sig);
|
|
107
186
|
} catch {}
|
|
108
187
|
};
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
188
|
+
let exit_seen = false;
|
|
189
|
+
const on_exit = async (how) => {
|
|
190
|
+
if (exit_seen) return;
|
|
191
|
+
exit_seen = true;
|
|
192
|
+
if (m.move("spawning", "exited_early", { how }) || m.move("ready", "exited_early", { how })) signal_group("SIGKILL");
|
|
193
|
+
while (pid !== void 0 && group_alive(pid)) await sleep(25);
|
|
194
|
+
await Promise.race([closed, sleep(1e3)]);
|
|
195
|
+
if (!m.move("stopping", "exited")) m.move("exited_early", "exited");
|
|
196
|
+
};
|
|
197
|
+
child.once("exit", (code, sig) => void on_exit(sig ? `signal ${sig}` : `code ${code}`));
|
|
198
|
+
child.once("error", (error) => {
|
|
199
|
+
keep(error.message);
|
|
200
|
+
on_exit(error.message);
|
|
201
|
+
});
|
|
202
|
+
const stop = async () => {
|
|
203
|
+
if (m.move("spawning", "stopping") || m.move("ready", "stopping")) {
|
|
204
|
+
signal_group("SIGTERM");
|
|
205
|
+
m.scope.timeout(() => signal_group("SIGKILL"), grace_ms);
|
|
206
|
+
}
|
|
207
|
+
await m.wait("exited");
|
|
208
|
+
};
|
|
209
|
+
if (await m.scope.task(async (signal) => {
|
|
210
|
+
while (Date.now() < deadline && !signal.aborted) {
|
|
211
|
+
try {
|
|
212
|
+
const res = await fetch(url, {
|
|
213
|
+
redirect: "manual",
|
|
214
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(2e3)])
|
|
215
|
+
});
|
|
216
|
+
await res.body?.cancel();
|
|
217
|
+
if (res.status < 500) return true;
|
|
218
|
+
} catch {}
|
|
219
|
+
await sleep(200, signal);
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}) === true && m.move("spawning", "ready")) return stop;
|
|
223
|
+
const now = m.now;
|
|
224
|
+
if (now.kind === "exited_early") {
|
|
225
|
+
await m.wait("exited");
|
|
226
|
+
throw new OgygiaError("cli_server_exited_early", {
|
|
227
|
+
cmd,
|
|
228
|
+
how: now.how,
|
|
229
|
+
output: tail()
|
|
230
|
+
});
|
|
115
231
|
}
|
|
116
|
-
stop();
|
|
117
|
-
throw new
|
|
232
|
+
await stop();
|
|
233
|
+
throw new OgygiaError("cli_server_not_ready", {
|
|
234
|
+
url,
|
|
235
|
+
ms: ready_ms,
|
|
236
|
+
output: tail()
|
|
237
|
+
});
|
|
118
238
|
}
|
|
119
239
|
async function runBisect(argv) {
|
|
120
240
|
const good = arg(argv, "--good");
|
|
@@ -143,7 +263,6 @@ async function runBisect(argv) {
|
|
|
143
263
|
}
|
|
144
264
|
process.stdout.write(`ogygia bisect · ${commits.length} commits between ${good.slice(0, 7)} and ${bad.slice(0, 7)} · page ${page}\n`);
|
|
145
265
|
const worktree = mkdtempSync(path.join(tmpdir(), "ogygia-bisect-"));
|
|
146
|
-
const running = { stop: null };
|
|
147
266
|
const measure_sha = async (sha) => {
|
|
148
267
|
try {
|
|
149
268
|
const dir = path.join(worktree, sha.slice(0, 12));
|
|
@@ -154,7 +273,7 @@ async function runBisect(argv) {
|
|
|
154
273
|
process.stdout.write(` ${sha.slice(0, 7)}: build failed — skipped\n`);
|
|
155
274
|
return null;
|
|
156
275
|
}
|
|
157
|
-
|
|
276
|
+
const stop = await serve(start, dir, url, ready_ms);
|
|
158
277
|
try {
|
|
159
278
|
return (await measure({
|
|
160
279
|
url,
|
|
@@ -163,8 +282,7 @@ async function runBisect(argv) {
|
|
|
163
282
|
key
|
|
164
283
|
})).pages[0].render_p50;
|
|
165
284
|
} finally {
|
|
166
|
-
|
|
167
|
-
running.stop = null;
|
|
285
|
+
await stop();
|
|
168
286
|
}
|
|
169
287
|
} catch (e) {
|
|
170
288
|
process.stdout.write(` ${sha.slice(0, 7)}: ${e instanceof Error ? e.message : String(e)} — skipped\n`);
|
|
@@ -211,11 +329,10 @@ async function runBisect(argv) {
|
|
|
211
329
|
process.stdout.write(`\nfirst bad commit: ${result.culprit.slice(0, 7)} — ${subject}\n${result.last_good ? `last good: ${result.last_good.slice(0, 7)}\n` : ""}bar: ${result.bar.toFixed(1)} ms · ${result.steps.length} steps${result.skipped.length ? ` · skipped ${result.skipped.length}` : ""}\n`);
|
|
212
330
|
return 0;
|
|
213
331
|
} finally {
|
|
214
|
-
running.stop?.();
|
|
215
332
|
rmSync(worktree, {
|
|
216
333
|
recursive: true,
|
|
217
334
|
force: true
|
|
218
335
|
});
|
|
219
336
|
}
|
|
220
337
|
}
|
|
221
|
-
export { number_arg, runBisect, runPerf };
|
|
338
|
+
export { number_arg, runBisect, runPerf, serve };
|
package/dist/perf/measure.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
+
import { scope } from "@ogygia/machine";
|
|
1
2
|
// src/perf/measure.ts
|
|
3
|
+
/**
|
|
4
|
+
* MEASURE — a page list rendered under the profiler on a running server (a local preview, a
|
|
5
|
+
* deployed one), folded into a small snapshot a CI step can keep and diff. Nothing new is
|
|
6
|
+
* instrumented: this is the `/__profiler/page?format=json` report the UI reads, reduced to the
|
|
7
|
+
* numbers a budget can be set on. Pure apart from the fetch it is handed, so a test drives it with
|
|
8
|
+
* a fake and a runner with the real one.
|
|
9
|
+
*/
|
|
2
10
|
const r1 = (n) => Math.round(n * 10) / 10;
|
|
3
11
|
const median = (xs) => xs.length ? [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] : 0;
|
|
4
12
|
/** One report → one page's snapshot. */
|
|
@@ -62,7 +70,8 @@ async function measure(opts) {
|
|
|
62
70
|
if (!page.startsWith("/")) throw new Error(`page must be a path on the site: ${page}`);
|
|
63
71
|
const url = `${origin}${base}/page?p=${encodeURIComponent(page)}&runs=${runs}&format=json`;
|
|
64
72
|
const ctrl = new AbortController();
|
|
65
|
-
const
|
|
73
|
+
const deadline = scope();
|
|
74
|
+
deadline.timeout(() => ctrl.abort(), opts.timeout_ms ?? 12e4);
|
|
66
75
|
let res;
|
|
67
76
|
try {
|
|
68
77
|
res = await f(url, {
|
|
@@ -76,7 +85,7 @@ async function measure(opts) {
|
|
|
76
85
|
} catch (e) {
|
|
77
86
|
throw new Error(`${page}: the profiler did not answer (${e instanceof Error ? e.message : String(e)})`);
|
|
78
87
|
} finally {
|
|
79
|
-
|
|
88
|
+
deadline.close();
|
|
80
89
|
}
|
|
81
90
|
if (res.status === 409) throw new Error(`${page}: the profiler is busy on that server (another recording is running)`);
|
|
82
91
|
if (!res.ok) throw new Error(`${page}: the profiler answered ${res.status}${res.status === 401 || res.status === 302 || res.status === 303 ? " — is the key right (x-profiler-key)?" : ""}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygia/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "The ogygia command line: init and site scaffolds, the AI skill, the MCP server, perf budgets and bisect, federation keys and catalogs",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -27,15 +27,22 @@
|
|
|
27
27
|
"type": "module",
|
|
28
28
|
"exports": {
|
|
29
29
|
"./bin": "./dist/cli.mjs",
|
|
30
|
+
"./internal/messages": {
|
|
31
|
+
"types": "./src/messages.ts",
|
|
32
|
+
"default": "./dist/messages.js"
|
|
33
|
+
},
|
|
30
34
|
"./package.json": "./package.json"
|
|
31
35
|
},
|
|
32
36
|
"publishConfig": {
|
|
33
37
|
"access": "public"
|
|
34
38
|
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@ogygia/machine": "^0.13.0"
|
|
41
|
+
},
|
|
35
42
|
"devDependencies": {
|
|
36
|
-
"@ogygia/
|
|
37
|
-
"@ogygia/
|
|
38
|
-
"@ogygia/
|
|
43
|
+
"@ogygia/config": "0.0.0",
|
|
44
|
+
"@ogygia/core": "0.13.0",
|
|
45
|
+
"@ogygia/devtools": "0.13.0",
|
|
39
46
|
"@sveltejs/sv-utils": "^0.3.3",
|
|
40
47
|
"@types/node": "^26.1.2",
|
|
41
48
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
@@ -45,7 +52,7 @@
|
|
|
45
52
|
"vitest": "^4.1.12"
|
|
46
53
|
},
|
|
47
54
|
"peerDependencies": {
|
|
48
|
-
"@ogygia/core": "^0.
|
|
55
|
+
"@ogygia/core": "^0.13.0",
|
|
49
56
|
"playwright": "^1.62.0",
|
|
50
57
|
"svelte": "^5.56.8"
|
|
51
58
|
},
|