@akanjs/devkit 3.0.0-alpha.2 → 3.0.0-alpha.4

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/ssrScanner.ts DELETED
@@ -1,409 +0,0 @@
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
- }