@krizaka/config 0.9.1 → 0.10.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/README.md CHANGED
@@ -54,6 +54,7 @@ the package.
54
54
  | `@krizaka/config/dependency-cruiser` | The shared dependency-cruiser config, generated from the covered-domains map: one `krizaka-first-<domain>` rule per domain (dates, i18n, query, icons, ui), `no-circular`, `not-to-unresolvable`, `not-to-dev-dep`. `.withOptions({ exemptFrom })` for a monorepo whose own packages are the facades. |
55
55
  | `@krizaka/config/covered-domains` · `/covered-domains.json` | The covered-domains map (one data file) and its helpers: the message, the patterns, the Markdown table of « Use Krizaka first ». |
56
56
  | `krizaka-known-violations` (bin) · `@krizaka/config/known-violations` | The known violations of dependency-cruiser, which only shrink, see below. |
57
+ | `krizaka-server-client-boundary` (bin) · `@krizaka/config/server-client-boundary` | Fails when a server-rendered module imports a function from a `"use client"` `@krizaka/*` module (React #441), see below. |
57
58
  | `@krizaka/config/tsconfig/*.json` | `base` (strict, ES2022, bundler resolution), `react-library` (+ DOM, `react-jsx`), `next` (+ the Next plugin, `noEmit`, `incremental`), `react-native` (`jsx: react-native`, `customConditions`; with Expo: `"extends": ["expo/tsconfig.base", "@krizaka/config/tsconfig/react-native.json"]`). |
58
59
  | `@krizaka/config/prettier` · `.editorconfig` | The formatting. |
59
60
  | `krizaka-ratchet` (bin) | The debt counter, see below. |
@@ -170,6 +171,21 @@ stays only for repositories that have not moved yet.
170
171
  `noLocaleTernary({ files, routing })`: `routing` lists the files allowed to compare locales (default: `proxy.ts`,
171
172
  `middleware.ts`, `i18n.ts`, `seo.ts`, `app/[locale]/layout.tsx`, `I18nProvider.tsx`).
172
173
 
174
+ ### Server/client boundary (`krizaka-server-client-boundary`)
175
+
176
+ A Next.js server module that calls a function exported by a `"use client"` module (`readFilterParams()` of
177
+ `@krizaka/ui/filter-bar`) crashes the page with React error #441. Unit tests ignore the directive and `next build` does not
178
+ see it. Every Next.js App Router app runs the guard in its lint:
179
+
180
+ ```sh
181
+ krizaka-server-client-boundary # from the app's folder: reads app/ or src/app/ and the "@/*" alias of tsconfig.json
182
+ krizaka-server-client-boundary --root apps/web --app app --scope @krizaka
183
+ ```
184
+
185
+ It walks the pages, layouts and routes, then every local module they import, and stops at a `"use client"` file. A lowercase
186
+ value import (a function, a hook, a constant) from a client module of the scope is an offence; components and `import type`
187
+ are fine. Render a component, or move the function to a plain module. `checkBoundary()` is also exported for a test.
188
+
173
189
  ### Environment variable names (`krizaka/env-name`)
174
190
 
175
191
  Environment variables are named by bounded context (krizaka/.github#46): `<CONTEXT>_<THING>` with CONTEXT one of
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // krizaka-server-client-boundary: no server-rendered module calls a function exported by a "use client" Krizaka module (React #441).
3
+ import { checkBoundary, DEFAULT_SCOPE } from "../ratchet/server-client-boundary.js";
4
+
5
+ const USAGE = `Usage: krizaka-server-client-boundary [--root <app folder>] [--app <app router folder>] [--alias <folder of "@/">] [--scope ${DEFAULT_SCOPE}]
6
+
7
+ Walks what the server renders (pages, layouts, routes and the local modules they import, down to a "use client" file) and fails
8
+ when one imports a function (a lowercase export) from a "use client" module of the scope: the server would call a client
9
+ reference and the page would crash (React error #441). Render a component, or move the function to a plain module.`;
10
+
11
+ const options = {};
12
+ const args = process.argv.slice(2);
13
+ for (let i = 0; i < args.length; i++) {
14
+ const arg = args[i];
15
+ if (arg === "--root") options.root = args[++i];
16
+ else if (arg === "--app") options.appDir = args[++i];
17
+ else if (arg === "--alias") options.alias = args[++i];
18
+ else if (arg === "--scope") options.scope = args[++i];
19
+ else {
20
+ console.error(USAGE);
21
+ process.exit(2);
22
+ }
23
+ }
24
+ try {
25
+ const { seen, offences } = checkBoundary(options);
26
+ if (offences.length === 0) {
27
+ console.log(`krizaka-server-client-boundary: ${seen.size} server modules checked, no client function called from the server.`);
28
+ } else {
29
+ console.error(`krizaka-server-client-boundary: ${offences.length} server module(s) import a function from a "use client" module (React #441):`);
30
+ for (const line of offences) console.error(` ${line}`);
31
+ process.exit(1);
32
+ }
33
+ } catch (error) {
34
+ console.error(error instanceof Error ? error.message : String(error));
35
+ process.exit(2);
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/config",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "The shared configuration of every Krizaka JavaScript repository: ESLint flat configs (base, Next.js, the four UI rules, the stack rules, no locale ternary), the « Krizaka first » covered-domains map and the dependency-cruiser preset generated from it (refused imports, cycles, unresolvable imports, dev dependencies), tsconfig bases, Prettier, EditorConfig, and the krizaka-ratchet and krizaka-known-violations debt counters.",
5
5
  "keywords": [
6
6
  "krizaka",
@@ -36,6 +36,7 @@
36
36
  "./covered-domains": "./covered-domains.cjs",
37
37
  "./covered-domains.json": "./covered-domains.json",
38
38
  "./known-violations": "./ratchet/known-violations.js",
39
+ "./server-client-boundary": "./ratchet/server-client-boundary.js",
39
40
  "./patterns": "./patterns.js",
40
41
  "./ratchet": "./ratchet/ratchet.js",
41
42
  "./prettier": "./prettier.config.js",
@@ -48,7 +49,8 @@
48
49
  },
49
50
  "bin": {
50
51
  "krizaka-ratchet": "./bin/krizaka-ratchet.mjs",
51
- "krizaka-known-violations": "./bin/krizaka-known-violations.mjs"
52
+ "krizaka-known-violations": "./bin/krizaka-known-violations.mjs",
53
+ "krizaka-server-client-boundary": "./bin/krizaka-server-client-boundary.mjs"
52
54
  },
53
55
  "files": [
54
56
  "bin",
@@ -0,0 +1,187 @@
1
+ // The server/client boundary of a Next.js app (App Router): a module the server renders must not call a function that a
2
+ // `"use client"` module of a Krizaka package exports. Next replaces such an export with a client reference, and calling it on the
3
+ // server crashes the page with React error #441 ("Attempted to call readFilterParams() from the server"). Unit tests never see
4
+ // it (vitest ignores the directive) and `next build` does not either, so this walks what the server renders: the pages, layouts
5
+ // and routes, then every local module they import, stopping at a `"use client"` file.
6
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
7
+ import { dirname, join, relative, resolve } from "node:path";
8
+
9
+ export const DEFAULT_SCOPE = "@krizaka";
10
+ const ENTRY = /(?:^|[\\/])(?:page|layout|route|error|global-error|loading|not-found|default|template)\.(?:ts|tsx|js|jsx|mjs)$/;
11
+ // `import … from "x"` / `export … from "x"`: the clause is everything up to the first quote (no nested quantifier: linear).
12
+ const STATEMENT = /(?:^|\n)[ \t]*(import|export)\b([^"';]*)["']([^"']+)["']/g;
13
+ const EXTENSIONS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs"];
14
+
15
+ /** @param {string} dir @returns {string[]} */
16
+ const walk = (dir) =>
17
+ readdirSync(dir).flatMap((name) => {
18
+ const path = join(dir, name);
19
+ if (name === "node_modules" || name === ".next") return [];
20
+ return statSync(path).isDirectory() ? walk(path) : [path];
21
+ });
22
+
23
+ /** @param {string} path */
24
+ /** Does the module start with the `"use client"` directive (comments and blank lines may come first)? Scanned, not matched: linear. @param {string} source */
25
+ export const isClientSource = (source) => {
26
+ let i = 0;
27
+ for (;;) {
28
+ while (i < source.length && /\s/.test(source[i])) i++;
29
+ if (source.startsWith("/*", i)) {
30
+ const end = source.indexOf("*/", i + 2);
31
+ if (end < 0) return false;
32
+ i = end + 2;
33
+ } else if (source.startsWith("//", i)) {
34
+ const end = source.indexOf("\n", i);
35
+ if (end < 0) return false;
36
+ i = end + 1;
37
+ } else break;
38
+ }
39
+ const quote = source[i];
40
+ return (quote === '"' || quote === "'") && source.startsWith("use client", i + 1) && source[i + 11] === quote;
41
+ };
42
+
43
+ /** @param {string} path */
44
+ const isFile = (path) => existsSync(path) && statSync(path).isFile();
45
+
46
+ /** The `"@/*"` alias of tsconfig.json (read as text: comments and trailing commas are fine); `@/` means the root when there is none. */
47
+ /** @param {string} root */
48
+ export const readAlias = (root) => {
49
+ try {
50
+ const text = readFileSync(join(root, "tsconfig.json"), "utf8");
51
+ const key = text.indexOf('"@/*"');
52
+ const open = key < 0 ? -1 : text.indexOf('"', text.indexOf("[", key) + 1);
53
+ const target = open < 0 ? "" : text.slice(open + 1, text.indexOf('"', open + 1));
54
+ if (target) return target.replace(/\/?\*$/, "") || ".";
55
+ } catch {
56
+ // no tsconfig, or one we cannot read: the default stands
57
+ }
58
+ return ".";
59
+ };
60
+
61
+ /** The first folder of `candidates` that exists under root. */
62
+ /** @param {string} root */
63
+ const findApp = (root) => ["app", "src/app"].map((d) => join(root, d)).find((d) => existsSync(d));
64
+
65
+ /** @param {string} root @param {string} aliasDir @param {string} from @param {string} spec @returns {string | null} */
66
+ const resolveLocal = (root, aliasDir, from, spec) => {
67
+ let base = null;
68
+ if (spec.startsWith("@/")) base = join(root, aliasDir, spec.slice(2));
69
+ else if (spec.startsWith(".")) base = resolve(dirname(from), spec);
70
+ if (!base) return null;
71
+ for (const ext of EXTENSIONS) if (isFile(base + ext)) return base + ext;
72
+ for (const index of ["index.ts", "index.tsx", "index.js"]) if (isFile(join(base, index))) return join(base, index);
73
+ return null;
74
+ };
75
+
76
+ /** @param {string} from @param {string} scope @param {string} name @returns {string | null} */
77
+ const findPackageDir = (from, scope, name) => {
78
+ for (let dir = from; ; dir = dirname(dir)) {
79
+ const candidate = join(dir, "node_modules", scope, name);
80
+ if (existsSync(candidate)) return candidate;
81
+ if (dirname(dir) === dir) return null;
82
+ }
83
+ };
84
+
85
+ /** @param {unknown} target @returns {string | null} */
86
+ const pick = (target) => {
87
+ if (typeof target === "string") return target;
88
+ if (!target || typeof target !== "object") return null;
89
+ for (const key of ["import", "module", "default", "node", "require"]) {
90
+ const found = pick(/** @type {Record<string, unknown>} */ (target)[key]);
91
+ if (found) return found;
92
+ }
93
+ return null;
94
+ };
95
+
96
+ /** Is the module `spec` of a package a `"use client"` module? Reads the first line of the built entry its `exports` names. */
97
+ /** @param {string} root @param {string} spec */
98
+ export const packageIsClient = (root, spec) => {
99
+ const [scope, name, ...rest] = spec.split("/");
100
+ const dir = findPackageDir(root, scope, name);
101
+ if (!dir) return false;
102
+ let file;
103
+ try {
104
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
105
+ const key = rest.length ? `./${rest.join("/")}` : ".";
106
+ file = pick(manifest.exports?.[key]) ?? (rest.length ? null : manifest.module ?? manifest.main);
107
+ } catch {
108
+ return false;
109
+ }
110
+ if (!file) return false;
111
+ const entry = join(dir, file);
112
+ return isFile(entry) && isClientSource(readFileSync(entry, "utf8"));
113
+ };
114
+
115
+ /**
116
+ * The lowercase (function-like) names a statement's clause brings in as values; a type-only import is erased and harmless.
117
+ * `clause` is the text between `import`/`export` and the quote of the module, e.g. ` { a, type B } from `.
118
+ * @param {string} clause
119
+ * @returns {string[]}
120
+ */
121
+ export const functionNames = (clause) => {
122
+ const words = clause.trim().split(/\s+/);
123
+ if (words.at(-1) === "from") words.pop();
124
+ const text = words.join(" ");
125
+ if (text.startsWith("type ") || text.startsWith("type{")) return [];
126
+ const open = text.indexOf("{");
127
+ const close = open < 0 ? -1 : text.indexOf("}", open);
128
+ const names = open < 0 ? [] : bracedNames(text.slice(open + 1, close < 0 ? text.length : close));
129
+ const head = defaultImportName(open < 0 ? text : `${text.slice(0, open)} ${close < 0 ? "" : text.slice(close + 1)}`);
130
+ if (head) names.push(head);
131
+ return names;
132
+ };
133
+
134
+ /** The function-like names inside the braces of an import clause (`type X` entries are erased). @param {string} braces @returns {string[]} */
135
+ const bracedNames = (braces) =>
136
+ braces
137
+ .split(",")
138
+ .map((part) => part.trim().split(" "))
139
+ .filter((tokens) => tokens[0] && !(tokens[0] === "type" && tokens.length > 1) && /^[a-z_$]/.test(tokens[0]))
140
+ .map((tokens) => tokens[0]);
141
+
142
+ /** The default import of what is left outside the braces, when it is function-like: `import readIt from "@krizaka/x"`. @param {string} rest @returns {string | null} */
143
+ const defaultImportName = (rest) => {
144
+ const head = rest.replaceAll(",", " ").trim().split(" ")[0] ?? "";
145
+ return head && !head.startsWith("*") && /^[a-z_$]/.test(head) ? head : null;
146
+ };
147
+
148
+ /**
149
+ * Walk what the server renders. `appDirs` defaults to `app` or `src/app`.
150
+ * Returns the visited files and the offences (`file imports a, b from @krizaka/x ("use client")`).
151
+ */
152
+ /** @param {{ root?: string, appDir?: string, alias?: string, scope?: string }} [options] @returns {{ seen: Set<string>, offences: string[] }} */
153
+ export const checkBoundary = ({ root = process.cwd(), appDir, alias, scope = DEFAULT_SCOPE } = {}) => {
154
+ const app = appDir ? resolve(root, appDir) : findApp(root);
155
+ if (!app) throw new Error(`No app folder under ${root} (looked for app and src/app); pass --app.`);
156
+ const aliasDir = alias ?? readAlias(root);
157
+ /** @type {Set<string>} */
158
+ const seen = new Set();
159
+ /** @type {string[]} */
160
+ const offences = [];
161
+ const clientCache = new Map();
162
+ /** @param {string} spec */
163
+ const isClient = (spec) => {
164
+ if (!clientCache.has(spec)) clientCache.set(spec, packageIsClient(root, spec));
165
+ return clientCache.get(spec);
166
+ };
167
+ /** @param {string} file */
168
+ const visit = (file) => {
169
+ if (seen.has(file)) return;
170
+ seen.add(file);
171
+ const source = readFileSync(file, "utf8");
172
+ if (isClientSource(source)) return;
173
+ for (const match of source.matchAll(STATEMENT)) {
174
+ const [, , clause, spec] = match;
175
+ if (!clause.trimEnd().endsWith("from")) continue; // `import "x"`, or a string in some other statement
176
+ if (spec.startsWith(`${scope}/`)) {
177
+ const names = functionNames(clause);
178
+ if (names.length && isClient(spec)) offences.push(`${relative(root, file)} imports ${names.join(", ")} from ${spec} ("use client")`);
179
+ continue;
180
+ }
181
+ const local = resolveLocal(root, aliasDir, file, spec);
182
+ if (local && /\.(?:ts|tsx|js|jsx|mjs)$/.test(local)) visit(local);
183
+ }
184
+ };
185
+ for (const entry of walk(app).filter((f) => ENTRY.test(f) && !/\.(?:test|spec)\./.test(f))) visit(entry);
186
+ return { seen, offences };
187
+ };