@akanjs/devkit 2.4.2-rc.3 → 3.0.0-alpha.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/agentsIndex.test.ts +84 -0
- package/agentsIndex.ts +146 -0
- package/akanContext.ts +122 -0
- package/executors.ts +26 -0
- package/frontendBuild/frontendBuild.test.ts +3 -1
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -0
- package/frontendBuild/styleContract.ts +29 -0
- package/frontendBuild/styleGuard.test.ts +165 -0
- package/frontendBuild/styleGuard.ts +322 -0
- package/frontendBuild/themeValidator.test.ts +70 -0
- package/frontendBuild/themeValidator.ts +150 -0
- package/index.ts +1 -0
- package/integration/devResourceProbe.ts +7 -2
- package/integration/ssrMemoryProbe.ts +542 -0
- package/lint/no-arbitrary-color.grit +19 -0
- package/lint/no-daisyui-legacy-class.grit +20 -0
- package/lint/no-inline-color.grit +19 -0
- package/lint/no-interpolated-arbitrary-class.grit +24 -0
- package/lint/no-raw-palette-class.grit +25 -0
- package/package.json +2 -3
- package/qualityScanner.test.ts +140 -1
- package/qualityScanner.ts +41 -2
- package/recipeScanner.test.ts +183 -0
- package/recipeScanner.ts +233 -0
- package/scanInfo.ts +3 -0
- package/spinner.test.ts +81 -0
- package/spinner.ts +22 -2
- package/ssrScanner.ts +409 -0
- package/transforms/externalizeFrameworkPlugin.ts +2 -2
package/scanInfo.ts
CHANGED
|
@@ -44,6 +44,9 @@ type DatabaseFileType = (typeof databaseFileTypes)[number];
|
|
|
44
44
|
type ModuleKind = "database" | "service" | "scalar";
|
|
45
45
|
|
|
46
46
|
const appRootAllowedFiles = new Set([
|
|
47
|
+
// 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
|
|
48
|
+
"AGENTS.md",
|
|
49
|
+
"CLAUDE.md",
|
|
47
50
|
"akan.app.json",
|
|
48
51
|
"akan.config.ts",
|
|
49
52
|
"capacitor.config.ts",
|
package/spinner.test.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Spinner } from "./spinner";
|
|
3
|
+
|
|
4
|
+
interface TtyStub {
|
|
5
|
+
restore: () => void;
|
|
6
|
+
rawModeCalls: boolean[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ora only reaches for stdin when both streams look interactive, so the stub has to fake a tty on
|
|
11
|
+
* stderr (which decides `isEnabled`) as well as on stdin.
|
|
12
|
+
*/
|
|
13
|
+
const stubTty = (): TtyStub => {
|
|
14
|
+
const rawModeCalls: boolean[] = [];
|
|
15
|
+
const stdin = process.stdin as NodeJS.ReadStream & { setRawMode?: (mode: boolean) => unknown };
|
|
16
|
+
const previous = {
|
|
17
|
+
stdinIsTTY: stdin.isTTY,
|
|
18
|
+
stderrIsTTY: process.stderr.isTTY,
|
|
19
|
+
stderrColumns: process.stderr.columns,
|
|
20
|
+
setRawMode: stdin.setRawMode,
|
|
21
|
+
isPaused: stdin.isPaused,
|
|
22
|
+
};
|
|
23
|
+
stdin.isTTY = true;
|
|
24
|
+
process.stderr.isTTY = true;
|
|
25
|
+
process.stderr.columns = 120;
|
|
26
|
+
stdin.setRawMode = (mode: boolean) => {
|
|
27
|
+
rawModeCalls.push(mode);
|
|
28
|
+
return stdin;
|
|
29
|
+
};
|
|
30
|
+
stdin.isPaused = () => true;
|
|
31
|
+
return {
|
|
32
|
+
rawModeCalls,
|
|
33
|
+
restore: () => {
|
|
34
|
+
stdin.isTTY = previous.stdinIsTTY;
|
|
35
|
+
process.stderr.isTTY = previous.stderrIsTTY;
|
|
36
|
+
process.stderr.columns = previous.stderrColumns;
|
|
37
|
+
stdin.setRawMode = previous.setRawMode;
|
|
38
|
+
stdin.isPaused = previous.isPaused;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe("Spinner", () => {
|
|
44
|
+
// A raw terminal at spawn time is what a Bun child snapshots and writes back when it exits, which is
|
|
45
|
+
// how `akan start` used to leave a terminal that no longer turns Ctrl+C into SIGINT.
|
|
46
|
+
test("never puts the terminal into raw mode while it spins", () => {
|
|
47
|
+
const tty = stubTty();
|
|
48
|
+
try {
|
|
49
|
+
const spinner = new Spinner("Preparing backend...").start();
|
|
50
|
+
expect(tty.rawModeCalls).toEqual([]);
|
|
51
|
+
spinner.succeed("prepared");
|
|
52
|
+
expect(tty.rawModeCalls).toEqual([]);
|
|
53
|
+
} finally {
|
|
54
|
+
tty.restore();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("keeps ora's stdin discarder disabled", () => {
|
|
59
|
+
expect(Spinner.oraOptions.discardStdin).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// An unsized pty reports `isTTY: true` with `columns: 0`, which turns ora's clear loop into an
|
|
63
|
+
// infinite one — the process then writes cursor moves until it is SIGKILLed.
|
|
64
|
+
test("refuses to animate against a tty that reports no width", () => {
|
|
65
|
+
expect(Spinner.canAnimate({ isTTY: true, columns: 0 } as NodeJS.WriteStream)).toBe(false);
|
|
66
|
+
expect(Spinner.canAnimate({ isTTY: true, columns: 120 } as NodeJS.WriteStream)).toBe(true);
|
|
67
|
+
expect(Spinner.canAnimate({ isTTY: false, columns: 0 } as NodeJS.WriteStream)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("falls back to plain lines when the terminal has no width", () => {
|
|
71
|
+
const previous = { isTTY: process.stderr.isTTY, columns: process.stderr.columns };
|
|
72
|
+
process.stderr.isTTY = true;
|
|
73
|
+
process.stderr.columns = 0;
|
|
74
|
+
try {
|
|
75
|
+
expect(new Spinner("Preparing backend...").enableSpin).toBe(false);
|
|
76
|
+
} finally {
|
|
77
|
+
process.stderr.isTTY = previous.isTTY;
|
|
78
|
+
process.stderr.columns = previous.columns;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
package/spinner.ts
CHANGED
|
@@ -2,6 +2,26 @@ import ora, { type Ora } from "ora";
|
|
|
2
2
|
|
|
3
3
|
export class Spinner {
|
|
4
4
|
static padding = 12;
|
|
5
|
+
/**
|
|
6
|
+
* XXX: `discardStdin` must stay off. It makes ora put the terminal into raw mode for as long as the
|
|
7
|
+
* spinner runs, and a Bun child spawned in that window snapshots the raw termios and writes it back
|
|
8
|
+
* when it exits — long after the spinner restored the terminal. `akan start` spawns the builder and
|
|
9
|
+
* the backend under the "Preparing backend..." spinner, so the first builder recycle (a config or
|
|
10
|
+
* runtime-metadata change) SIGTERMs that child and silently turns the developer's terminal raw:
|
|
11
|
+
* `isig` goes off, Ctrl+C stops producing SIGINT at all, and the dev server looks unkillable.
|
|
12
|
+
*/
|
|
13
|
+
static oraOptions = { discardStdin: false } as const;
|
|
14
|
+
/**
|
|
15
|
+
* ora sizes its clear loop as `ceil(lineWidth / stream.columns)`, so a tty that reports **0** columns
|
|
16
|
+
* makes it `Infinity` and `clear()` never returns. An unsized pty does exactly that (`isTTY: true`,
|
|
17
|
+
* `columns: 0`) — CI runners, `expect`/`script` harnesses, some detached panes. Measured: 750MB of
|
|
18
|
+
* cursor moves and 8.7GB RSS inside a minute, in a loop that no longer reaches the point where SIGINT
|
|
19
|
+
* or SIGTERM could be handled, so only SIGKILL ends it. A terminal with no width has nothing to
|
|
20
|
+
* animate anyway; fall back to plain lines.
|
|
21
|
+
*/
|
|
22
|
+
static canAnimate(stream: NodeJS.WriteStream = process.stderr): boolean {
|
|
23
|
+
return !stream.isTTY || stream.columns > 0;
|
|
24
|
+
}
|
|
5
25
|
spinner: Ora;
|
|
6
26
|
stopWatch: NodeJS.Timeout | null = null;
|
|
7
27
|
startAt: Date = new Date();
|
|
@@ -12,10 +32,10 @@ export class Spinner {
|
|
|
12
32
|
Spinner.padding = Math.max(Spinner.padding, prefix.length);
|
|
13
33
|
this.prefix = prefix;
|
|
14
34
|
this.message = message;
|
|
15
|
-
this.spinner = ora(message);
|
|
35
|
+
this.spinner = ora({ ...Spinner.oraOptions, text: message });
|
|
16
36
|
this.spinner.prefixText = prefix.padStart(Spinner.padding, " ");
|
|
17
37
|
this.spinner.indent = indent;
|
|
18
|
-
this.enableSpin = enableSpin;
|
|
38
|
+
this.enableSpin = enableSpin && Spinner.canAnimate();
|
|
19
39
|
}
|
|
20
40
|
start() {
|
|
21
41
|
this.startAt = new Date();
|
package/ssrScanner.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
|
|
4
|
+
/** Server/client render split for one app or lib, measured in JSX elements rather than files. */
|
|
5
|
+
export interface SsrBalanceEntry {
|
|
6
|
+
scope: string;
|
|
7
|
+
serverMass: number;
|
|
8
|
+
clientMass: number;
|
|
9
|
+
serverShare: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SsrScanResult {
|
|
13
|
+
warnings: QualityWarning[];
|
|
14
|
+
balance: SsrBalanceEntry[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ComponentInfo {
|
|
18
|
+
name: string;
|
|
19
|
+
line: number;
|
|
20
|
+
mass: number;
|
|
21
|
+
touches: string[];
|
|
22
|
+
vendorTags: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A component with no client-only touch at all never needed the client bundle, so even a small subtree is
|
|
26
|
+
// worth moving. A mostly-static component keeps its interaction and hands the static part to the server, so
|
|
27
|
+
// it only pays off once the static subtree is large enough to matter.
|
|
28
|
+
const STATIC_COMPONENT_MIN_MASS = 4;
|
|
29
|
+
const MIXED_COMPONENT_MIN_MASS = 10;
|
|
30
|
+
const MIXED_COMPONENT_MAX_TOUCHES = 2;
|
|
31
|
+
const MODULE_SERVER_VIEW_MIN_CLIENT_MASS = 12;
|
|
32
|
+
|
|
33
|
+
export class SsrScanner {
|
|
34
|
+
// `usePage` and `getSelf` read request-scoped server context and are legal in server components, so they
|
|
35
|
+
// must not count as evidence that a file needs "use client".
|
|
36
|
+
static #serverSafeCalls = new Set(["usePage", "getSelf", "useServer"]);
|
|
37
|
+
static #clientGlobals = new Set([
|
|
38
|
+
"window",
|
|
39
|
+
"document",
|
|
40
|
+
"navigator",
|
|
41
|
+
"localStorage",
|
|
42
|
+
"sessionStorage",
|
|
43
|
+
"location",
|
|
44
|
+
"history",
|
|
45
|
+
"screen",
|
|
46
|
+
"matchMedia",
|
|
47
|
+
"IntersectionObserver",
|
|
48
|
+
"ResizeObserver",
|
|
49
|
+
"MutationObserver",
|
|
50
|
+
"requestAnimationFrame",
|
|
51
|
+
"WebSocket",
|
|
52
|
+
]);
|
|
53
|
+
// Runtime singletons that only exist in the client bundle; importing either is what forces the directive.
|
|
54
|
+
static #clientRuntimeImports = new Set(["st", "fetch"]);
|
|
55
|
+
|
|
56
|
+
scan(sourceFiles: SourceFileInfo[]): SsrScanResult {
|
|
57
|
+
const componentFiles = sourceFiles.filter((sourceFile) => this.#isBalancedFile(sourceFile.file));
|
|
58
|
+
return {
|
|
59
|
+
warnings: [
|
|
60
|
+
...componentFiles.flatMap((sourceFile) => this.#scanFile(sourceFile)),
|
|
61
|
+
...this.#scanModules(componentFiles),
|
|
62
|
+
],
|
|
63
|
+
balance: this.#measureBalance(componentFiles),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#scanFile(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
68
|
+
if (!this.#hasUseClient(sourceFile.sourceFile)) return [];
|
|
69
|
+
const vendorNames = this.#getVendorNames(sourceFile.sourceFile);
|
|
70
|
+
const hasVendorImport = this.#hasVendorImport(sourceFile.sourceFile);
|
|
71
|
+
const importsClientRuntime = this.#importsClientRuntime(sourceFile.sourceFile);
|
|
72
|
+
const components = this.#getComponents(sourceFile, vendorNames);
|
|
73
|
+
const warnings: QualityWarning[] = [];
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
!hasVendorImport &&
|
|
77
|
+
!importsClientRuntime &&
|
|
78
|
+
this.#getTouches(sourceFile.sourceFile, sourceFile.sourceFile).length === 0 &&
|
|
79
|
+
!this.#isConventionClientFile(sourceFile.file)
|
|
80
|
+
) {
|
|
81
|
+
warnings.push({
|
|
82
|
+
rule: "akan.ssr.unnecessary-use-client",
|
|
83
|
+
scope: "ssr",
|
|
84
|
+
severity: "warning",
|
|
85
|
+
file: sourceFile.file,
|
|
86
|
+
line: 1,
|
|
87
|
+
message: `"use client" is declared but the file uses no client-only capability (hook, event handler, store, or browser API).`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const component of components) {
|
|
92
|
+
if (component.vendorTags) continue;
|
|
93
|
+
if (component.touches.length === 0 && component.mass >= STATIC_COMPONENT_MIN_MASS) {
|
|
94
|
+
warnings.push({
|
|
95
|
+
rule: "akan.ssr.client-static-component",
|
|
96
|
+
scope: "ssr",
|
|
97
|
+
severity: "warning",
|
|
98
|
+
file: sourceFile.file,
|
|
99
|
+
line: component.line,
|
|
100
|
+
message: `Client component "${component.name}" renders ${component.mass} JSX elements with no client-only capability. It is server-renderable markup sitting in the client bundle.`,
|
|
101
|
+
});
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
component.touches.length >= 1 &&
|
|
106
|
+
component.touches.length <= MIXED_COMPONENT_MAX_TOUCHES &&
|
|
107
|
+
component.mass >= MIXED_COMPONENT_MIN_MASS
|
|
108
|
+
) {
|
|
109
|
+
warnings.push({
|
|
110
|
+
rule: "akan.ssr.client-static-markup",
|
|
111
|
+
scope: "ssr",
|
|
112
|
+
severity: "warning",
|
|
113
|
+
file: sourceFile.file,
|
|
114
|
+
line: component.line,
|
|
115
|
+
message: `Client component "${component.name}" renders ${component.mass} JSX elements around only ${component.touches.length} client-only touch (${[...new Set(component.touches)].join(", ")}). Most of this subtree does not need the client bundle.`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
warnings.push(...this.#getMountLoadWarnings(sourceFile));
|
|
121
|
+
warnings.push(...this.#getTemplateStateWarnings(sourceFile));
|
|
122
|
+
return warnings;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A database module whose rendering happens entirely in Template/Zone/Util has no server-rendered surface at
|
|
126
|
+
// all, so every consumer pays for hydration even when it only needs to display the model.
|
|
127
|
+
#scanModules(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
128
|
+
const modules = new Map<string, { clientMass: number; serverFiles: number; line: string }>();
|
|
129
|
+
for (const sourceFile of sourceFiles) {
|
|
130
|
+
const moduleDir = this.#getModuleDir(sourceFile.file);
|
|
131
|
+
if (!moduleDir) continue;
|
|
132
|
+
const entry = modules.get(moduleDir) ?? { clientMass: 0, serverFiles: 0, line: sourceFile.file };
|
|
133
|
+
if (this.#hasUseClient(sourceFile.sourceFile)) entry.clientMass += this.#getMass(sourceFile.sourceFile);
|
|
134
|
+
else if (/\.(Unit|View)\.tsx$/.test(sourceFile.file)) entry.serverFiles += 1;
|
|
135
|
+
modules.set(moduleDir, entry);
|
|
136
|
+
}
|
|
137
|
+
return [...modules]
|
|
138
|
+
.filter(([, entry]) => entry.serverFiles === 0 && entry.clientMass >= MODULE_SERVER_VIEW_MIN_CLIENT_MASS)
|
|
139
|
+
.map(([moduleDir, entry]) => ({
|
|
140
|
+
rule: "akan.ssr.module-missing-server-view",
|
|
141
|
+
scope: "ssr" as const,
|
|
142
|
+
severity: "warning" as const,
|
|
143
|
+
file: entry.line,
|
|
144
|
+
message: `Module "${moduleDir}" renders ${entry.clientMass} JSX elements from client files only; it declares no Unit or View server component.`,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A load fired from a mount-only effect is data the route already could have fetched: the client renders an
|
|
149
|
+
// empty shell, hydrates, then fetches. A reactive effect (non-empty deps) responds to client state instead
|
|
150
|
+
// and has no server-side equivalent, so only the empty-dependency form is a finding.
|
|
151
|
+
#getMountLoadWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
152
|
+
const warnings: QualityWarning[] = [];
|
|
153
|
+
const visit = (node: ts.Node) => {
|
|
154
|
+
if (this.#isMountEffect(sourceFile.sourceFile, node)) {
|
|
155
|
+
for (const load of this.#getLoadCalls(sourceFile.sourceFile, node)) {
|
|
156
|
+
warnings.push({
|
|
157
|
+
rule: "akan.ssr.client-mount-load",
|
|
158
|
+
scope: "ssr",
|
|
159
|
+
severity: "warning",
|
|
160
|
+
file: sourceFile.file,
|
|
161
|
+
line: this.#getLine(sourceFile.sourceFile, load.node),
|
|
162
|
+
message: `Mount-only effect loads server data with ${load.callee}(). The route can fetch this before the first byte instead.`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
ts.forEachChild(node, visit);
|
|
167
|
+
};
|
|
168
|
+
ts.forEachChild(sourceFile.sourceFile, visit);
|
|
169
|
+
return warnings;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#isMountEffect(sourceFile: ts.SourceFile, node: ts.Node) {
|
|
173
|
+
if (!ts.isCallExpression(node)) return false;
|
|
174
|
+
const callee = node.expression.getText(sourceFile);
|
|
175
|
+
if (callee !== "useEffect" && callee !== "useLayoutEffect") return false;
|
|
176
|
+
const deps = node.arguments[1];
|
|
177
|
+
return !!deps && ts.isArrayLiteralExpression(deps) && deps.elements.length === 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#getLoadCalls(sourceFile: ts.SourceFile, node: ts.Node) {
|
|
181
|
+
const calls: Array<{ callee: string; node: ts.Node }> = [];
|
|
182
|
+
const visit = (child: ts.Node) => {
|
|
183
|
+
if (ts.isCallExpression(child)) {
|
|
184
|
+
const callee = child.expression.getText(sourceFile);
|
|
185
|
+
if (/^fetch\.[a-z]/.test(callee) || /^st\.do\.(init|get|view|load|list|count|insight)[A-Z]/.test(callee))
|
|
186
|
+
calls.push({ callee, node: child });
|
|
187
|
+
}
|
|
188
|
+
ts.forEachChild(child, visit);
|
|
189
|
+
};
|
|
190
|
+
ts.forEachChild(node, visit);
|
|
191
|
+
return calls;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#getTemplateStateWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
195
|
+
if (!sourceFile.file.endsWith(".Template.tsx")) return [];
|
|
196
|
+
const warnings: QualityWarning[] = [];
|
|
197
|
+
const visit = (node: ts.Node) => {
|
|
198
|
+
if (ts.isCallExpression(node) && node.expression.getText(sourceFile.sourceFile) === "useState") {
|
|
199
|
+
warnings.push({
|
|
200
|
+
rule: "akan.ssr.template-client-state",
|
|
201
|
+
scope: "ssr",
|
|
202
|
+
severity: "warning",
|
|
203
|
+
file: sourceFile.file,
|
|
204
|
+
line: this.#getLine(sourceFile.sourceFile, node),
|
|
205
|
+
message: "Template holds form state in useState. Templates are store-driven and carry no local state.",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
ts.forEachChild(node, visit);
|
|
209
|
+
};
|
|
210
|
+
ts.forEachChild(sourceFile.sourceFile, visit);
|
|
211
|
+
return warnings;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#measureBalance(sourceFiles: SourceFileInfo[]): SsrBalanceEntry[] {
|
|
215
|
+
const scopes = new Map<string, { serverMass: number; clientMass: number }>();
|
|
216
|
+
for (const sourceFile of sourceFiles) {
|
|
217
|
+
const segments = sourceFile.file.split("/");
|
|
218
|
+
const scope = `${segments[0]}/${segments[1]}`;
|
|
219
|
+
const entry = scopes.get(scope) ?? { serverMass: 0, clientMass: 0 };
|
|
220
|
+
const mass = this.#getMass(sourceFile.sourceFile);
|
|
221
|
+
if (this.#hasUseClient(sourceFile.sourceFile)) entry.clientMass += mass;
|
|
222
|
+
else entry.serverMass += mass;
|
|
223
|
+
scopes.set(scope, entry);
|
|
224
|
+
}
|
|
225
|
+
const entries = [...scopes]
|
|
226
|
+
.map(([scope, mass]) => ({ scope, ...mass, serverShare: getShare(mass.serverMass, mass.clientMass) }))
|
|
227
|
+
.sort((a, b) => a.scope.localeCompare(b.scope));
|
|
228
|
+
if (entries.length < 2) return entries;
|
|
229
|
+
const serverMass = entries.reduce((sum, entry) => sum + entry.serverMass, 0);
|
|
230
|
+
const clientMass = entries.reduce((sum, entry) => sum + entry.clientMass, 0);
|
|
231
|
+
return [...entries, { scope: "workspace", serverMass, clientMass, serverShare: getShare(serverMass, clientMass) }];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#getComponents(sourceFile: SourceFileInfo, vendorNames: Set<string>): ComponentInfo[] {
|
|
235
|
+
const components: ComponentInfo[] = [];
|
|
236
|
+
for (const statement of sourceFile.sourceFile.statements) {
|
|
237
|
+
for (const { name, node } of this.#getComponentNodes(statement)) {
|
|
238
|
+
if (!/^[A-Z]/.test(name)) continue;
|
|
239
|
+
components.push({
|
|
240
|
+
name,
|
|
241
|
+
line: this.#getLine(sourceFile.sourceFile, node),
|
|
242
|
+
mass: this.#getMass(node),
|
|
243
|
+
touches: this.#getTouches(sourceFile.sourceFile, node),
|
|
244
|
+
vendorTags: [...this.#getTagNames(sourceFile.sourceFile, node)].some((tag) => vendorNames.has(tag)),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return components;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#getComponentNodes(statement: ts.Statement): Array<{ name: string; node: ts.Node }> {
|
|
252
|
+
if (ts.isFunctionDeclaration(statement) && statement.body)
|
|
253
|
+
return [{ name: statement.name?.text ?? "default", node: statement.body }];
|
|
254
|
+
if (!ts.isVariableStatement(statement)) return [];
|
|
255
|
+
return statement.declarationList.declarations.flatMap((declaration) => {
|
|
256
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) return [];
|
|
257
|
+
if (!ts.isArrowFunction(declaration.initializer) && !ts.isFunctionExpression(declaration.initializer)) return [];
|
|
258
|
+
return [{ name: declaration.name.text, node: declaration.initializer }];
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#getTouches(sourceFile: ts.SourceFile, node: ts.Node): string[] {
|
|
263
|
+
const touches: string[] = [];
|
|
264
|
+
const visit = (child: ts.Node) => {
|
|
265
|
+
if (ts.isCallExpression(child)) {
|
|
266
|
+
const callee = child.expression.getText(sourceFile);
|
|
267
|
+
const bareName = callee.split(".").pop() ?? callee;
|
|
268
|
+
if (callee === "createContext" || callee === "lazy") touches.push(callee);
|
|
269
|
+
else if (/^use[A-Z]/.test(bareName) && !SsrScanner.#serverSafeCalls.has(bareName)) touches.push(bareName);
|
|
270
|
+
}
|
|
271
|
+
if (ts.isJsxAttribute(child) && /^on[A-Z]/.test(child.name.getText(sourceFile)))
|
|
272
|
+
touches.push(child.name.getText(sourceFile));
|
|
273
|
+
if (ts.isPropertyAccessExpression(child)) {
|
|
274
|
+
const root = getAccessRoot(child);
|
|
275
|
+
if (root === "st") touches.push("st");
|
|
276
|
+
else if (SsrScanner.#clientGlobals.has(root)) touches.push(root);
|
|
277
|
+
}
|
|
278
|
+
ts.forEachChild(child, visit);
|
|
279
|
+
};
|
|
280
|
+
ts.forEachChild(node, visit);
|
|
281
|
+
return touches;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#getTagNames(sourceFile: ts.SourceFile, node: ts.Node): Set<string> {
|
|
285
|
+
const tags = new Set<string>();
|
|
286
|
+
const visit = (child: ts.Node) => {
|
|
287
|
+
if (ts.isJsxOpeningElement(child) || ts.isJsxSelfClosingElement(child))
|
|
288
|
+
tags.add(child.tagName.getText(sourceFile).split(".")[0]);
|
|
289
|
+
ts.forEachChild(child, visit);
|
|
290
|
+
};
|
|
291
|
+
ts.forEachChild(node, visit);
|
|
292
|
+
return tags;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#getMass(node: ts.Node) {
|
|
296
|
+
let mass = 0;
|
|
297
|
+
const visit = (child: ts.Node) => {
|
|
298
|
+
if (ts.isJsxOpeningElement(child) || ts.isJsxSelfClosingElement(child)) mass += 1;
|
|
299
|
+
ts.forEachChild(child, visit);
|
|
300
|
+
};
|
|
301
|
+
ts.forEachChild(node, visit);
|
|
302
|
+
return mass;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#hasUseClient(sourceFile: ts.SourceFile) {
|
|
306
|
+
const first = sourceFile.statements[0];
|
|
307
|
+
if (!first || !ts.isExpressionStatement(first) || !ts.isStringLiteral(first.expression)) return false;
|
|
308
|
+
return first.expression.text === "use client";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// A bare specifier is a third-party package: it may be client-only, which is a legitimate reason for the
|
|
312
|
+
// directive that no amount of AST reading can rule out.
|
|
313
|
+
#hasVendorImport(sourceFile: ts.SourceFile) {
|
|
314
|
+
return sourceFile.statements.some(
|
|
315
|
+
(statement) => ts.isImportDeclaration(statement) && isVendorSpecifier(getSpecifier(statement)),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#getVendorNames(sourceFile: ts.SourceFile) {
|
|
320
|
+
const names = new Set<string>();
|
|
321
|
+
for (const statement of sourceFile.statements) {
|
|
322
|
+
if (!ts.isImportDeclaration(statement) || !isVendorSpecifier(getSpecifier(statement))) continue;
|
|
323
|
+
const clause = statement.importClause;
|
|
324
|
+
if (clause?.name) names.add(clause.name.text);
|
|
325
|
+
if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings))
|
|
326
|
+
names.add(clause.namedBindings.name.text);
|
|
327
|
+
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
|
|
328
|
+
for (const element of clause.namedBindings.elements) names.add(element.name.text);
|
|
329
|
+
}
|
|
330
|
+
return names;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#importsClientRuntime(sourceFile: ts.SourceFile) {
|
|
334
|
+
for (const statement of sourceFile.statements) {
|
|
335
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
336
|
+
const bindings = statement.importClause?.namedBindings;
|
|
337
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
338
|
+
if (bindings.elements.some((element) => SsrScanner.#clientRuntimeImports.has(element.name.text))) return true;
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Zone/Template/Util carry the directive mechanically by file role, and `index_.tsx` is the declared
|
|
344
|
+
// "use client" + lazy() boundary. In neither case is the directive a stray — for module UI it means markup
|
|
345
|
+
// belongs in a Unit or View instead, which the component rules already cover.
|
|
346
|
+
#isConventionClientFile(file: string) {
|
|
347
|
+
if (file.endsWith("/index_.tsx")) return true;
|
|
348
|
+
return /\.(Zone|Template|Util)\.tsx$/.test(file) && this.#getModuleDir(file) !== null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#isBalancedFile(file: string) {
|
|
352
|
+
if (!file.endsWith(".tsx") || file.endsWith(".test.tsx") || file.endsWith(".spec.tsx")) return false;
|
|
353
|
+
const segments = file.split("/");
|
|
354
|
+
if (segments[0] !== "apps" && segments[0] !== "libs") return false;
|
|
355
|
+
return segments[2] === "ui" || segments[2] === "lib";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#getModuleDir(file: string) {
|
|
359
|
+
const segments = file.split("/");
|
|
360
|
+
const libIndex = segments.indexOf("lib");
|
|
361
|
+
if (libIndex < 1 || segments.length <= libIndex + 2) return null;
|
|
362
|
+
const moduleName = segments[libIndex + 1];
|
|
363
|
+
if (moduleName.startsWith("_")) return null;
|
|
364
|
+
return segments.slice(0, libIndex + 2).join("/");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#getLine(sourceFile: ts.SourceFile, node: ts.Node) {
|
|
368
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Share of component rendering an app or lib should keep on the server before the split needs a reason. */
|
|
373
|
+
export const SSR_SERVER_SHARE_TARGET = 0.5;
|
|
374
|
+
|
|
375
|
+
export function formatSsrBalance(balance: SsrBalanceEntry[]) {
|
|
376
|
+
if (balance.length === 0) return ["No component files found."];
|
|
377
|
+
return balance.map((entry) => {
|
|
378
|
+
const total = entry.serverMass + entry.clientMass;
|
|
379
|
+
const share = `${Math.round(entry.serverShare * 100)}% server`;
|
|
380
|
+
const counts = `${entry.serverMass} of ${total} JSX elements, ${entry.clientMass} client`;
|
|
381
|
+
const flag =
|
|
382
|
+
entry.serverShare < SSR_SERVER_SHARE_TARGET
|
|
383
|
+
? ` <- below the ${Math.round(SSR_SERVER_SHARE_TARGET * 100)}% target`
|
|
384
|
+
: "";
|
|
385
|
+
return ` ${entry.scope}: ${share} (${counts})${flag}`;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function getShare(serverMass: number, clientMass: number) {
|
|
390
|
+
const total = serverMass + clientMass;
|
|
391
|
+
return total === 0 ? 1 : serverMass / total;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function getSpecifier(statement: ts.ImportDeclaration) {
|
|
395
|
+
return ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : "";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function isVendorSpecifier(specifier: string) {
|
|
399
|
+
if (specifier === "" || specifier.startsWith(".") || specifier.startsWith("/")) return false;
|
|
400
|
+
if (specifier === "react" || specifier === "react-dom" || specifier.startsWith("react/")) return false;
|
|
401
|
+
if (specifier.startsWith("node:")) return false;
|
|
402
|
+
return !/^(akanjs|@akanjs|@libs|@apps|@contract)(\/|$)/.test(specifier);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getAccessRoot(node: ts.PropertyAccessExpression) {
|
|
406
|
+
let current: ts.Expression = node;
|
|
407
|
+
while (ts.isPropertyAccessExpression(current)) current = current.expression;
|
|
408
|
+
return ts.isIdentifier(current) ? current.text : "";
|
|
409
|
+
}
|
|
@@ -11,7 +11,7 @@ import type { App } from "../commandDecorators";
|
|
|
11
11
|
* - `packages: "external"` externalizes every bare specifier, including
|
|
12
12
|
* `@apps/*` / `@libs/*` — workspace packages that must stay bundled
|
|
13
13
|
* so the `"use client"` plugin can rewrite their exports — and ordinary
|
|
14
|
-
* npm dependencies like `dayjs` / `
|
|
14
|
+
* npm dependencies like `dayjs` / `tailwind-merge`, which the production runtime
|
|
15
15
|
* package.json does not install for the SSR pages artifact.
|
|
16
16
|
* - Top-level `external: [...]` applies to macro-time module resolution
|
|
17
17
|
* too; any `with { type: "macro" }` import chain that transitively
|
|
@@ -169,7 +169,7 @@ export async function createExternalizeFrameworkPlugin(options: ExternalizeFrame
|
|
|
169
169
|
// that case directly.
|
|
170
170
|
return undefined;
|
|
171
171
|
}
|
|
172
|
-
// Everything else (ordinary npm dependencies like dayjs /
|
|
172
|
+
// Everything else (ordinary npm dependencies like dayjs / tailwind-merge /
|
|
173
173
|
// immer) is bundled into the pages artifact.
|
|
174
174
|
return undefined;
|
|
175
175
|
});
|