auto-server-split 0.0.0-stage → 1.0.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/CHANGELOG.md +116 -0
- package/LICENSE +21 -0
- package/README.md +279 -2
- package/dist/bundleSize-KPGJRR3B.js +8 -0
- package/dist/bundleSize-KPGJRR3B.js.map +1 -0
- package/dist/chunk-6OAWHATP.js +4847 -0
- package/dist/chunk-6OAWHATP.js.map +1 -0
- package/dist/chunk-YCSBOPV3.js +134 -0
- package/dist/chunk-YCSBOPV3.js.map +1 -0
- package/dist/chunk-YQKO5FJM.js +155 -0
- package/dist/chunk-YQKO5FJM.js.map +1 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/eslint/index.d.ts +17 -0
- package/dist/eslint/index.js +176 -0
- package/dist/eslint/index.js.map +1 -0
- package/dist/index.d.ts +108 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/interactive-JF66CJGV.js +66 -0
- package/dist/interactive-JF66CJGV.js.map +1 -0
- package/package.json +112 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The project follows [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## 1.0.0 — interactive mode, docs, benchmarks
|
|
6
|
+
|
|
7
|
+
- `--interactive`: `[y]es / [n]o / [r]ename islands / [d]iff / [q]uit`; renames re-run the engine
|
|
8
|
+
and its self-check.
|
|
9
|
+
- `--version` prints the package version.
|
|
10
|
+
- `--write` refuses untracked and git-ignored targets as well as modified ones.
|
|
11
|
+
- Effects that sync island state move with that island; shared-context hooks (`usePathname`…) are
|
|
12
|
+
allowed in per-item list islands; `const X = () => <jsx/>` components are no longer mistaken for
|
|
13
|
+
module constants in multi-component files; blank lines between moved statements are kept.
|
|
14
|
+
- Real-path resolution of the project root (symlinked checkouts, macOS temp dirs).
|
|
15
|
+
- README, `docs/api.md`, `docs/configuration.md`, `docs/eslint.md`, `docs/benchmarks.md`.
|
|
16
|
+
- `scripts/benchmark.mjs` (three real App Router apps) and `scripts/perf.mjs` (500-file dry run in
|
|
17
|
+
~7 s).
|
|
18
|
+
- CLI and interactive-mode tests; fixture 32 (`islandsDir`).
|
|
19
|
+
- Release prep: `prepublishOnly` runs lint, typecheck and tests (which rebuild `dist/`); Next.js
|
|
20
|
+
hook names moved into the framework adapter so `analysis/` stays framework-neutral; packaging
|
|
21
|
+
verified by installing the packed tarball into a fresh Next.js 16 app (CLI, API, types, ESLint
|
|
22
|
+
plugin, `.ts` config) and running the CLI on Node 20.
|
|
23
|
+
- `--interactive` can't be combined with `--dry-run` (it used to write the accepted splits anyway).
|
|
24
|
+
- New skip code `measure-failed`: when esbuild can't bundle a file, its saving is unknown, so it no
|
|
25
|
+
longer passes `minBytesSaved` silently. With `minBytesSaved: 0` the file is split and the result
|
|
26
|
+
has `measureError` ("saving not measured" in the CLI output and summary).
|
|
27
|
+
- Size measurement no longer fails on CSS Modules (`import styles from "./Card.module.css"`):
|
|
28
|
+
esbuild's built-in `local-css` loader for `.module.css` took precedence over the empty `.css`
|
|
29
|
+
loader, so every such file was skipped as `measure-failed` under the default `minBytesSaved`.
|
|
30
|
+
`.module.css`, `.module.scss`, `.module.sass` and `.module.less` imports are now measured as empty.
|
|
31
|
+
- The ESLint rule now applies the CLI's config and thresholds: it loads every config format
|
|
32
|
+
(`.ts`, `.mts`, `.mjs`, `.js`, `.json`), its `minStaticRatio` can raise the config's but not
|
|
33
|
+
lower it, and it measures the saving and applies `minBytesSaved` (new option `measure`). It only
|
|
34
|
+
reports files the CLI would split.
|
|
35
|
+
- Untyped linting builds the program the way TypeScript does: a file the tsconfig's `include`
|
|
36
|
+
doesn't match but that an included file imports (for example a `.jsx` component with `allowJs`)
|
|
37
|
+
is analysed, as the CLI already did.
|
|
38
|
+
- Files saved with Windows line endings (CRLF) keep them: inserted lines and the island file use
|
|
39
|
+
the original's line ending. They used to come out LF, and an interactive rename turned the whole
|
|
40
|
+
rewritten file LF (ts-morph's `replaceWithText` rewrites line breaks; the self-check now sets
|
|
41
|
+
file text byte for byte).
|
|
42
|
+
- Repository housekeeping: author and license holder set to Himesh Gunathilake; fixtures moved to
|
|
43
|
+
`test/fixtures/`; `CONTRIBUTING.md`, `.editorconfig`, `.nvmrc`; `format`/`format:check` scripts
|
|
44
|
+
(checked in CI); `noUnusedLocals`/`noUnusedParameters`; unused exports and dead helpers removed.
|
|
45
|
+
|
|
46
|
+
## 0.5.0 — ESLint plugin
|
|
47
|
+
|
|
48
|
+
- `auto-server-split/eslint`: flat-config plugin with `prefer-server-split` (report only, on the
|
|
49
|
+
`"use client"` directive) and `configs.recommended` (`warn`, `minStaticRatio: 0.5`).
|
|
50
|
+
- Typed linting uses the full project for the program's tsconfig; without it, a lazy project loads
|
|
51
|
+
only the linted file, its imports and its importers (module graph built syntactically). Both are
|
|
52
|
+
cached per tsconfig for the lint process. The rule runs the self-check before reporting.
|
|
53
|
+
- Engine refactor: synchronous `analyzeFile` / `checkAnalyzed`, `createRunContext`.
|
|
54
|
+
- RuleTester tests with and without typed linting.
|
|
55
|
+
|
|
56
|
+
## 0.4.0 — multiple components, Mongoose, JSX
|
|
57
|
+
|
|
58
|
+
- Multiple components per file: unchanged server components, wholesale-moved interactive helpers,
|
|
59
|
+
server components copied into islands that render them.
|
|
60
|
+
- `mongoose.autoSerialize`: Mongoose documents and ObjectId-bearing values crossing the boundary
|
|
61
|
+
are reduced to the fields the island reads (`String()` for ObjectIds, Dates kept).
|
|
62
|
+
- `.jsx` input (untyped-boundary rules), CSS Modules on both sides, `src/` path aliases.
|
|
63
|
+
- Second e2e app (`shop`); both apps pass the before/after DOM equality checks.
|
|
64
|
+
- Fixtures 28–31c.
|
|
65
|
+
|
|
66
|
+
## 0.3.0 — donut slots, e2e
|
|
67
|
+
|
|
68
|
+
- Donut/slot pattern: static, non-trivial subtrees inside an island stay on the server and are
|
|
69
|
+
passed in as `children` (or `slot1`, `slot2`, ...).
|
|
70
|
+
- `e2e/run.mjs` + `e2e/apps/blog`: `next build` before and after `--write`, identical server HTML,
|
|
71
|
+
identical DOM after hydration and after every Playwright interaction, per-route JS sizes.
|
|
72
|
+
Blog app: 16 components split, 5 skipped as expected, `/` ships 18.5 KB gzip less JavaScript.
|
|
73
|
+
- Fixture 27 (accordion donut), donut unit tests.
|
|
74
|
+
|
|
75
|
+
## 0.2.0 — grouping, loops, packages, size
|
|
76
|
+
|
|
77
|
+
- Union-find grouping of anchors that share client state or handlers; LCA island roots; merging of
|
|
78
|
+
nested islands; escape check.
|
|
79
|
+
- `.map` loop islands (loop variable becomes a prop, `key` moves to the usage). Islands that need
|
|
80
|
+
component-level hooks are hoisted above the `.map` so state stays shared.
|
|
81
|
+
- Custom hooks (imported or declared in the file), browser globals, client-only packages
|
|
82
|
+
(framer-motion etc.), `next/navigation` hooks.
|
|
83
|
+
- Server Actions: imported actions move with the island; action props pass through when every
|
|
84
|
+
importer passes a Server Action.
|
|
85
|
+
- Module-level helpers and constants used by islands are copied into the island file and removed
|
|
86
|
+
from the server file when no longer used there.
|
|
87
|
+
- Unit tests: LCA, grouping/union-find, taint propagation, loop rule, import pruning.
|
|
88
|
+
- Fixtures 08–26.
|
|
89
|
+
|
|
90
|
+
## 0.1.0 — first split
|
|
91
|
+
|
|
92
|
+
- Module graph over the whole tsconfig program plus JS/MDX files outside it (static imports,
|
|
93
|
+
re-exports, `import()`, `require`), with client reachability from `"use client"` roots.
|
|
94
|
+
- Importer check (direct, through barrels, dynamic, value usage).
|
|
95
|
+
- Client-only marking: hooks, event/ref/function props, browser globals, client-only packages,
|
|
96
|
+
render nondeterminism, with fixpoint propagation over the component body.
|
|
97
|
+
- Leaf islands (plus union-find grouping, LCA, escape check and `.map` handling, unit-tested in 0.2).
|
|
98
|
+
- Free variables become props; serializability checked with the type checker.
|
|
99
|
+
- Rewrite with magic-string (untouched bytes preserved), island file generation, import
|
|
100
|
+
move/copy/prune, optional Prettier pass.
|
|
101
|
+
- Self-check before every report/write: parse, no new type errors, no client code left, serializable
|
|
102
|
+
props, no server-only imports in the island.
|
|
103
|
+
- esbuild size measurement (isolated + conservative gzip), `minBytesSaved`, `minStaticRatio`.
|
|
104
|
+
- CLI: `--dry-run` (default), `--write` (git-dirty guard, `--allow-dirty`), `--check` (exit 2), `--json`,
|
|
105
|
+
`--config`, `--root`, `--verbose`, `--no-measure`.
|
|
106
|
+
- Config file loading and validation (`auto-split.config.{ts,mts,mjs,js,json}`).
|
|
107
|
+
- Fixtures: PostCard (exact spec output), server props, two islands, and skips for client importer,
|
|
108
|
+
context provider, function prop, conditional hook.
|
|
109
|
+
|
|
110
|
+
## 0.0.0 — scaffold
|
|
111
|
+
|
|
112
|
+
- pnpm + tsup (ESM only) + strict TypeScript + Vitest + ESLint flat config + GitHub Actions CI.
|
|
113
|
+
- Fixture runner (`test/fixtures.test.ts`) with `UPDATE_FIXTURES=1` regeneration.
|
|
114
|
+
- `SkipCode` framework (`src/analysis/skip.ts`).
|
|
115
|
+
- `docs/prior-art.md`: no existing tool performs this split automatically.
|
|
116
|
+
- npm name `auto-server-split` checked: available (404 on 2026-10-19).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Himesh Gunathilake
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,280 @@
|
|
|
1
|
-
#
|
|
1
|
+
# auto-server-split
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Split partly-interactive `"use client"` components into a Server Component plus small generated client islands, so the browser downloads less JavaScript, with no manual refactoring.**
|
|
4
|
+
|
|
5
|
+
For the Next.js App Router and React Server Components. It ships as a CLI codemod, a programmatic API and an ESLint plugin, all built on the same analysis engine. When the tool isn't sure a split is safe, it skips the file and tells you why.
|
|
6
|
+
|
|
7
|
+
## Before / after
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
// PostCard.tsx (before)
|
|
11
|
+
"use client";
|
|
12
|
+
import { format } from "date-fns";
|
|
13
|
+
import { marked } from "marked";
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
|
|
16
|
+
type Post = { title: string; date: string; body: string };
|
|
17
|
+
|
|
18
|
+
export default function PostCard({ post }: { post: Post }) {
|
|
19
|
+
const [liked, setLiked] = useState(false);
|
|
20
|
+
return (
|
|
21
|
+
<article>
|
|
22
|
+
<h2>{post.title}</h2>
|
|
23
|
+
<time>{format(new Date(post.date), "PPP")}</time>
|
|
24
|
+
<div dangerouslySetInnerHTML={{ __html: marked(post.body) as string }} />
|
|
25
|
+
<button onClick={() => setLiked(!liked)}>{liked ? "♥" : "♡"}</button>
|
|
26
|
+
</article>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
// PostCard.tsx (after): a Server Component; date-fns and marked no longer ship to the browser
|
|
33
|
+
import { format } from "date-fns";
|
|
34
|
+
import { marked } from "marked";
|
|
35
|
+
import { PostCardButton } from "./PostCard.client";
|
|
36
|
+
|
|
37
|
+
type Post = { title: string; date: string; body: string };
|
|
38
|
+
|
|
39
|
+
export default function PostCard({ post }: { post: Post }) {
|
|
40
|
+
return (
|
|
41
|
+
<article>
|
|
42
|
+
<h2>{post.title}</h2>
|
|
43
|
+
<time>{format(new Date(post.date), "PPP")}</time>
|
|
44
|
+
<div dangerouslySetInnerHTML={{ __html: marked(post.body) as string }} />
|
|
45
|
+
<PostCardButton />
|
|
46
|
+
</article>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
// PostCard.client.tsx (generated)
|
|
53
|
+
"use client";
|
|
54
|
+
import { useState } from "react";
|
|
55
|
+
|
|
56
|
+
export function PostCardButton() {
|
|
57
|
+
const [liked, setLiked] = useState(false);
|
|
58
|
+
return <button onClick={() => setLiked(!liked)}>{liked ? "♥" : "♡"}</button>;
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
In the e2e app that renders this component, the route ships **18.5 KB gzip less JavaScript**. The page's HTML is identical before and after, and the DOM stays identical after every click.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
Node.js 20 or later. The analysis uses the TypeScript compiler that ships with the tool (via ts-morph), so it doesn't depend on your project's TypeScript version (tested with TypeScript 5 and 6 projects). Your project's own Prettier is used for formatting when it has a Prettier config.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install --save-dev auto-server-split # or pnpm add -D / yarn add -D
|
|
70
|
+
npx auto-server-split --help
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Recommended workflow
|
|
74
|
+
|
|
75
|
+
1. **Start from a clean git state.** `--write` refuses to touch files with uncommitted changes, so commit or stash first. Every change the tool makes then shows up as one reviewable diff.
|
|
76
|
+
2. **Preview.** Start with one folder rather than the whole app:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npx auto-server-split components
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Nothing is written. The output looks like this (abridged, colours removed):
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
✔ split components/ArticleFooter.tsx · 1 island · 83% static · ~83 B gzip saved (estimated)
|
|
86
|
+
ArticleFooterButton <button>
|
|
87
|
+
===================================================================
|
|
88
|
+
--- a/components/ArticleFooter.tsx
|
|
89
|
+
+++ b/components/ArticleFooter.tsx
|
|
90
|
+
@@ -1,4 +1,4 @@
|
|
91
|
+
-"use client";
|
|
92
|
+
+import { ArticleFooterButton } from "./ArticleFooter.client";
|
|
93
|
+
…
|
|
94
|
+
– skipped components/Badge.tsx [imported-by-client] imported by components/ClientPanel.tsx (client)
|
|
95
|
+
– skipped components/Banner.tsx:6:5 [state-spans-component] client state is used across the whole returned tree (<div>, <small>)
|
|
96
|
+
|
|
97
|
+
Summary
|
|
98
|
+
files scanned: 27 (21 with "use client")
|
|
99
|
+
files split: 16 (dry run, nothing written)
|
|
100
|
+
files skipped: 5
|
|
101
|
+
1 imported-by-client imported by a client module
|
|
102
|
+
1 state-spans-component client state spans the whole component
|
|
103
|
+
…
|
|
104
|
+
estimated saving: 727 B gzip of client JavaScript (1 split not measured)
|
|
105
|
+
|
|
106
|
+
Run again with --write to apply.
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
3. **Apply.** Use `--write` to apply every split, or `--interactive` to accept, reject or rename them one at a time.
|
|
110
|
+
4. **Review before you commit.**
|
|
111
|
+
- Read the diff: the rewritten `X.tsx` files and the new `X.client.tsx` files.
|
|
112
|
+
- Run your build, type checker and tests.
|
|
113
|
+
- Click through the affected pages, especially the interactive parts.
|
|
114
|
+
- Look for timezone- or locale-dependent output (dates, numbers, `toLocaleString`). That code now runs on the server, which may use a different timezone or locale from your users' browsers.
|
|
115
|
+
5. **Run it again.** Splitting a file can make another file splittable: a component that was only imported by a client component becomes splittable once that importer turns into a Server Component. Repeat until the summary shows no new splits.
|
|
116
|
+
6. **Look at the skips.** Some point to a small refactor that makes the file splittable (for example `exports-non-components` or `context-provider`). Others, such as `fully-interactive`, mean the component belongs on the client. [docs/skip-reasons.md](docs/skip-reasons.md) has a fix for each.
|
|
117
|
+
7. **Keep it from regressing** with the [ESLint rule](#eslint) in the editor and [`--check` in CI](#ci).
|
|
118
|
+
|
|
119
|
+
**Island names** are the component name followed by the island's root element in PascalCase: `PostCard` + `<button>` gives `PostCardButton`, and `<motion.div>` gives `PostCardMotionDiv`. When that name is already taken (by another island or a name in the file), a number is added (`ToolbarButton2`). Rename them with `[r]ename` in `--interactive` mode, or edit the generated file afterwards: it's ordinary source code, and the tool never writes into an existing `X.client.tsx`.
|
|
120
|
+
|
|
121
|
+
**To undo a split** before committing, restore the original file and delete the generated one:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
git restore components/PostCard.tsx && rm components/PostCard.client.tsx
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## CLI
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npx auto-server-split <paths...> [--dry-run | --write | --check] [--interactive] [--json]
|
|
131
|
+
[--config <file>] [--root <dir>] [--allow-dirty] [--verbose] [--no-measure]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
| Flag | What it does |
|
|
135
|
+
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
136
|
+
| _(default)_ `--dry-run` | Prints a coloured unified diff per file, the islands and the estimated KB saved. Skipped files show their reason. Nothing is written. |
|
|
137
|
+
| `--write` | Applies the changes. Refuses if any target file has uncommitted changes, is untracked or is git-ignored (or the project isn't a git repository), unless you pass `--allow-dirty`. |
|
|
138
|
+
| `--check` | For CI: exits `2` if any split is available. Changes nothing. |
|
|
139
|
+
| `--allow-dirty` | Lets `--write` and `--interactive` write files that have uncommitted changes, are untracked or git-ignored, or are outside a git repository. |
|
|
140
|
+
| `--interactive` | Walks through each proposed split: `[y]es / [n]o / [r]ename islands / [d]iff / [q]uit`, then writes the accepted ones (same git checks as `--write`). Renames go back through the engine and its self-check. Needs a terminal; can't be combined with `--dry-run`, `--json` or `--check`. |
|
|
141
|
+
| `--json` | Machine-readable report with the same shape as the API result. |
|
|
142
|
+
| `--config <file>` | Config file (default: `auto-split.config.{ts,mts,mjs,js,json}` in the project root). |
|
|
143
|
+
| `--root <dir>` | Project root, where `tsconfig.json` lives (default: the current directory). |
|
|
144
|
+
| `--no-measure` | Skips the esbuild size measurement (faster; `minBytesSaved` isn't applied, and no saving is reported). |
|
|
145
|
+
| `--verbose` | Prints stack traces for internal errors. |
|
|
146
|
+
| `--help`, `--version` | Usage and version. |
|
|
147
|
+
|
|
148
|
+
Without paths, the config's `include` globs are used (`app/**`, `components/**`, `src/**`).
|
|
149
|
+
Exit codes: `0` ok, `1` internal error or bad input, `2` splits available (with `--check`).
|
|
150
|
+
The summary lists files scanned, files split, files skipped grouped by reason, and the total estimated saving
|
|
151
|
+
(see the sample output under [Recommended workflow](#recommended-workflow)).
|
|
152
|
+
|
|
153
|
+
### CI
|
|
154
|
+
|
|
155
|
+
`--check` exits `2` when a split is available, which fails the step:
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
# .github/workflows/ci.yml (excerpt)
|
|
159
|
+
- run: npx auto-server-split --check
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
To report splits without failing the build, add `continue-on-error: true` to the step. The output lists each available split and its estimated saving either way.
|
|
163
|
+
|
|
164
|
+
## API
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { split } from "auto-server-split";
|
|
168
|
+
|
|
169
|
+
const result = await split({
|
|
170
|
+
root: process.cwd(), // project root (where tsconfig.json lives)
|
|
171
|
+
paths: ["app", "components"],
|
|
172
|
+
write: false,
|
|
173
|
+
config: {}, // overrides the config file
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
for (const file of result.files) {
|
|
177
|
+
console.log(file.file, file.status, file.skipped?.code, file.bytesSaved?.conservative);
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
See [docs/api.md](docs/api.md) for the full result type.
|
|
182
|
+
|
|
183
|
+
## ESLint
|
|
184
|
+
|
|
185
|
+
```js
|
|
186
|
+
// eslint.config.js
|
|
187
|
+
import tseslint from "typescript-eslint";
|
|
188
|
+
import autoSplit from "auto-server-split/eslint";
|
|
189
|
+
|
|
190
|
+
export default [
|
|
191
|
+
...tseslint.configs.recommended, // or any config that parses TypeScript/JSX
|
|
192
|
+
autoSplit.configs.recommended,
|
|
193
|
+
];
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The rule `auto-server-split/prefer-server-split` reports on the `"use client"` directive:
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
PostCard is 80% static with 1 interactive island (<button>). Run: npx auto-server-split app/blog/PostCard.tsx --write
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
It applies the same config file, self-check and size thresholds as the CLI, so it only reports files the CLI would split. It has no autofix, because a split creates a new file and ESLint fixes can't do that. The CLI is the fixer. See [docs/eslint.md](docs/eslint.md).
|
|
203
|
+
|
|
204
|
+
## Configuration
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
// auto-split.config.ts
|
|
208
|
+
import { defineConfig } from "auto-server-split";
|
|
209
|
+
|
|
210
|
+
export default defineConfig({
|
|
211
|
+
include: ["app/**", "components/**", "src/**"],
|
|
212
|
+
exclude: ["**/*.test.*", "**/*.stories.*"],
|
|
213
|
+
clientOnlyPackages: ["my-ui-lib/interactive"], // added to the built-in list
|
|
214
|
+
serverSafeHooks: [], // e.g. ["useTranslations"]
|
|
215
|
+
clientFileSuffix: ".client", // or islandsDir: "_islands"
|
|
216
|
+
minBytesSaved: 2048, // gzip bytes, conservative estimate
|
|
217
|
+
minStaticRatio: 0.3,
|
|
218
|
+
prettier: "auto", // format touched files with the project's Prettier config
|
|
219
|
+
mongoose: { autoSerialize: false },
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Unknown keys are an error. See [docs/configuration.md](docs/configuration.md).
|
|
224
|
+
|
|
225
|
+
## Skip reasons
|
|
226
|
+
|
|
227
|
+
Every skip has a stable code, a one-sentence message and, where possible, a source location.
|
|
228
|
+
[docs/skip-reasons.md](docs/skip-reasons.md) has an example and a fix for each.
|
|
229
|
+
|
|
230
|
+
| Code | When |
|
|
231
|
+
| ---------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
232
|
+
| `imported-by-client` | A client module imports the file (directly or through a barrel) |
|
|
233
|
+
| `exports-non-components` | The file exports hooks, utilities or values |
|
|
234
|
+
| `state-spans-component` | Client state is used across the whole returned element |
|
|
235
|
+
| `context-provider` | The file creates or provides a Context |
|
|
236
|
+
| `css-in-js-runtime` | styled-components, Emotion, styled-jsx… |
|
|
237
|
+
| `non-serializable-prop` | A value crossing the boundary isn't serializable |
|
|
238
|
+
| `untyped-boundary` | `.js`/`.jsx` file and the prop types can't be proven |
|
|
239
|
+
| `spread-props` | A spread that can't be proven serializable |
|
|
240
|
+
| `conditional-hooks-or-returns` | Conditional hooks, or early returns that depend on client state |
|
|
241
|
+
| `class-component` | The file contains a class component |
|
|
242
|
+
| `dynamic-import` / `render-prop` / `hoc` | `next/dynamic`/`lazy`/`import()`, render props, `memo`/`forwardRef` |
|
|
243
|
+
| `server-env-difference` | Reads a non-`NEXT_PUBLIC_` env var |
|
|
244
|
+
| `no-islands` / `fully-interactive` | No client code at all (the directive may be unnecessary), or nothing would stay on the server |
|
|
245
|
+
| `below-threshold` | Below `minBytesSaved` or `minStaticRatio` |
|
|
246
|
+
| `measure-failed` | esbuild couldn't bundle the file, so `minBytesSaved` can't be checked |
|
|
247
|
+
| `output-exists` / `would-create-route` | File-system conflicts |
|
|
248
|
+
| `unsupported-pattern` | Valid code this version doesn't handle (the message says which) |
|
|
249
|
+
| `self-check-failed` | The generated code failed the self-check |
|
|
250
|
+
| `parse-error` / `internal-error` | Bugs: reported per file, never crash the run |
|
|
251
|
+
|
|
252
|
+
## How it decides
|
|
253
|
+
|
|
254
|
+
1. **Module graph.** Every file the tsconfig covers (plus JS/MDX files it doesn't), with static imports, re-exports, `import()` and `next/dynamic`. Anything reachable from a `"use client"` module is client code.
|
|
255
|
+
2. **Candidates.** A `"use client"` file is a candidate only if every importer is a server module that renders its components as `<X />`, it exports only components and types, and nothing at module level behaves differently on the server.
|
|
256
|
+
3. **Client-only marking.** Hooks, event/ref/function props, browser globals, client-only packages (framer-motion, Radix, react-hook-form…), render nondeterminism (`Date.now()`, `Math.random()`), and anything that can't be proven server-safe (unknown React packages, local modules that use hooks) are client-only. Taint propagates through the component body to a fixpoint.
|
|
257
|
+
4. **Islands.** Elements that use client-only code are anchors. Anchors that share state merge (union-find), the island root is their lowest common ancestor, and an escape check grows the island until no client value is read outside it. In `.map()` lists, the mapped element becomes a per-item island, unless that would duplicate component-level state. Effects join the island whose state they use. Static, non-trivial subtrees inside an island stay on the server as `children` ("donut"). In a file with several components, a non-exported helper component that is entirely interactive moves into the island file whole.
|
|
258
|
+
5. **Boundary.** Free variables become props and are proven serializable with the type checker (React's rules: primitives, plain objects, arrays, `Date`, `Map`, `Set`, typed arrays, Promises, JSX, Server Actions). Non-serializable helpers are copied into the island instead.
|
|
259
|
+
6. **Rewrite.** magic-string range edits keep every untouched byte of the original. Imports move, are copied or are pruned, the island file is generated, and Prettier runs only if the project has a config. Inserted lines and the island file use the original file's line ending (CRLF or LF, taken from its first line); a file that already mixes the two keeps its existing lines as they are.
|
|
260
|
+
7. **Self-check.** Before anything is reported or written, the output is typechecked in memory, re-analysed (no client code may remain in the server file), and every prop is re-checked. The typecheck allows no _new_ diagnostics: errors the file already had are tolerated, and diagnostics are compared by code and message. `.js`/`.jsx` files only get semantic checks when `checkJs` is on (never with a `jsconfig.json`), so for them this step is mostly a parse check.
|
|
261
|
+
8. **Measure.** The original file and the island file are bundled with esbuild (react/next external) and the gzip sizes compared. The conservative number leaves out dependencies other client modules still ship. If esbuild can't bundle a file, the saving is unknown: the file is skipped (`measure-failed`) unless `minBytesSaved` is 0, in which case it's split and labelled "saving not measured".
|
|
262
|
+
|
|
263
|
+
## Limitations
|
|
264
|
+
|
|
265
|
+
- **It's conservative.** On three well-known open-source apps, most `"use client"` files are skipped, because they are already genuinely interactive ([docs/benchmarks.md](docs/benchmarks.md)). The tool pays off in codebases that put `"use client"` high in the tree.
|
|
266
|
+
- **Server vs browser rendering differences** that the tool can't see stay a risk: for example, timezone- or locale-dependent formatting moves from the browser to the server (`format(new Date(x), "PPP")` in the example above). Nondeterministic calls it _can_ see (`Date.now()`, `Math.random()`, `new Date()`) are kept on the client.
|
|
267
|
+
- **Safety of local modules is decided per module, not per export**, so one client-only helper in a utility module keeps every caller of that module on the client.
|
|
268
|
+
- Third-party React packages are client-only unless they have a `"use client"` entry or are on the small built-in server-safe list. Unknown packages stay in islands.
|
|
269
|
+
- Not handled yet (they skip): class components, `memo`/`forwardRef`, render props, `next/dynamic`, Context providers, runtime CSS-in-JS, statements other than declarations/hook calls/early returns in a component body, and existing `X.client.tsx` files (it never merges).
|
|
270
|
+
- The ESLint rule caches the module graph and config per lint process, and can't load a config file that uses top-level await (rules are synchronous). It reads the config file the CLI finds by default, not one passed with `--config`.
|
|
271
|
+
- "Same rendered output" is enforced by the analysis rules, not re-checked on every run. The e2e suite checks it on two sample apps (identical server HTML, identical DOM after every interaction).
|
|
272
|
+
- Next.js only for now; framework specifics live in `src/frameworks/next.ts`.
|
|
273
|
+
|
|
274
|
+
## Contributing
|
|
275
|
+
|
|
276
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the project layout, tests and release checklist.
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
[MIT](LICENSE) © Himesh Gunathilake
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|