├── .changeset
└── config.json
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .prettierrc
├── .vscode
└── settings.json
├── README.md
├── elacca
├── CHANGELOG.md
├── README.md
├── package.json
├── src
│ ├── babelDebugOutputs.ts
│ ├── babelTransformPages.ts
│ ├── index.ts
│ ├── plugin.test.ts
│ ├── removeFunctionDependencies.ts
│ ├── turbopackLoader.ts
│ └── utils.ts
└── tsconfig.json
├── example-app
├── .gitignore
├── CHANGELOG.md
├── elacca-outputs
│ ├── client
│ │ └── src
│ │ │ └── pages
│ │ │ ├── _app.tsx
│ │ │ └── index.tsx
│ └── server
│ │ └── src
│ │ └── pages
│ │ ├── _app.tsx
│ │ └── index.tsx
├── next.config.js
├── package.json
├── postcss.config.js
├── public
│ ├── next.svg
│ └── vercel.svg
├── src
│ ├── pages
│ │ ├── _app.tsx
│ │ ├── index.tsx
│ │ └── styles.css
│ └── utils.ts
├── tailwind.config.js
└── tsconfig.json
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tsconfig.base.json
└── vitest.config.js
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@1.4.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "linked": [],
6 | "access": "public",
7 | "baseBranch": "main",
8 | "updateInternalDependencies": "patch",
9 | "ignore": []
10 | }
11 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 |
6 | jobs:
7 | ci:
8 | timeout-minutes: 30
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v3
12 | with:
13 | fetch-depth: 0
14 | - uses: actions/setup-node@v3
15 | with:
16 | node-version: 18
17 | registry-url: https://registry.npmjs.org/
18 | - uses: pnpm/action-setup@v2
19 | with:
20 | version: 8
21 | run_install: false
22 | - name: Install pnpm dependencies (with cache)
23 | uses: covbot/pnpm-install-with-cache@v1
24 | # scripts
25 | - run: pnpm build
26 | - run: pnpm test
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | esm
4 | .DS_Store
5 | *.tsbuildinfo
6 | .ultra.cache.json
7 | coverage
8 | elacca/elacca-outputs
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "always",
3 | "jsxSingleQuote": true,
4 | "tabWidth": 4,
5 | "semi": false,
6 | "singleQuote": true,
7 | "trailingComma": "all"
8 | }
9 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
elacca
6 |
Improve your Next.js app cold start time and server load
7 |
8 |
9 |
10 |
11 | Reduce your Next.js app server code size by disabling SSR for specific pages.
12 |
13 | > Only works with pages, not app directory
14 |
15 | ## Why
16 |
17 | - Improve cold start times in serverless environments
18 | - Improve rendering times, since the server no longer needs to render the page to html
19 | - Improve memory usage on the server (your pages no longer load React components code in memory)
20 | - Makes `edge` Vercel deploy possible if your current bundle size is more than 2Mb compressed
21 | - When SSR is not very useful, for example when making dashboards, where SEO is not important
22 |
23 | ## Install
24 |
25 | ```
26 | npm i -D elacca
27 | ```
28 |
29 | ## Usage
30 |
31 | Full application example in the [example-app](./example-app) folder.
32 |
33 | ```js
34 | // next.config.js
35 | const { withElacca } = require('elacca')
36 |
37 | /** @type {import('next').NextConfig} */
38 | const config = {}
39 |
40 | const elacca = withElacca({})
41 |
42 | const nextConfig = elacca(config) // notice the double invocation
43 |
44 | module.exports = nextConfig
45 | ```
46 |
47 | When using the `pages` directory, you can add a directive to disable SSR for a specific page:
48 |
49 | ```js
50 | // pages/index.js
51 | 'skip ssr'
52 |
53 | export default function Home() {
54 | return hello world
55 | }
56 | ```
57 |
58 | ## How It Works
59 |
60 | To have an intuitive understanding of how this works, you can check out how this plugin transforms pages in the [example-app/elacca-outputs](./example-app/elacca-outputs) folder.
61 |
62 | - When a page has a "skip ssr" directive, this plugin will transform the page code
63 | - On the server the page renders a component that returns `null`
64 | - On the client the page renders null until the component mounts, removing the need to hydrate the page
65 | - This is implemented as a babel plugin that only runs on pages files, so your build should remain fast (all other files are not parsed by babel, usually the code inside the pages folder is not much)
66 |
67 | ## Why The Name
68 |
69 | From the [Dune wiki](https://dune.fandom.com/wiki/Elacca_drug):
70 |
71 | > The Elacca drug is a narcotic that was formed by the burning of Elacca Wood of the planet Ecaz. Its main characteristic when administered was that it would eliminate the user's will for self-preservation
72 |
--------------------------------------------------------------------------------
/elacca/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # elacca
2 |
3 | ## 0.5.4
4 |
5 | ### Patch Changes
6 |
7 | - Add missing dep
8 |
9 | ## 0.5.3
10 |
11 | ### Patch Changes
12 |
13 | - Update babel packages
14 |
15 | ## 0.5.2
16 |
17 | ### Patch Changes
18 |
19 | - Rename identity function to prevent clashes
20 |
21 | ## 0.5.1
22 |
23 | ### Patch Changes
24 |
25 | - Added missing dep dedent
26 |
27 | ## 0.5.0
28 |
29 | ### Minor Changes
30 |
31 | - Use useSyncExternalStore to track if isClient or isServer
32 |
33 | ## 0.4.1
34 |
35 | ### Patch Changes
36 |
37 | - Support multiple loaders, chain plugins
38 |
39 | ## 0.4.0
40 |
41 | ### Minor Changes
42 |
43 | - Added support for --turbo, requires latest next canary
44 |
45 | ## 0.3.4
46 |
47 | ### Patch Changes
48 |
49 | - Fix page mutations
50 |
51 | ## 0.3.3
52 |
53 | ### Patch Changes
54 |
55 | - Fix runtime error on log
56 |
57 | ## 0.3.2
58 |
59 | ### Patch Changes
60 |
61 | - Fix assignments to page, `Page.isLayout = true` now works
62 |
63 | ## 0.3.1
64 |
65 | ### Patch Changes
66 |
67 | - Don't run debug plugin if not necessary
68 |
69 | ## 0.3.0
70 |
71 | ### Minor Changes
72 |
73 | - Don't process all files, fix bug uing both webpack include and exclude
74 |
75 | ## 0.2.1
76 |
77 | ### Patch Changes
78 |
79 | - Stop dead code elimination after 50 iterations
80 |
81 | ## 0.2.0
82 |
83 | ### Minor Changes
84 |
85 | - Added elacca-outputs folder in debug mode, removed a log, resolve babel-loader from plugin node_modules
86 |
87 | ## 0.1.1
88 |
89 | ### Patch Changes
90 |
91 | - Faster plugin, ignore api dir
92 |
93 | ## 0.1.0
94 |
95 | ### Minor Changes
96 |
97 | - Remove dead code in server pass
98 |
99 | ## 0.0.2
100 |
101 | ### Patch Changes
102 |
103 | - remove unused functions when in server
104 | - annotate functions as pure
105 |
106 | ## 0.0.1
107 |
108 | ### Patch Changes
109 |
110 | - Initial publish
111 |
--------------------------------------------------------------------------------
/elacca/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
elacca
6 |
Improve your Next.js app cold start time and server load
7 |
8 |
9 |
10 |
11 | Reduce your Next.js app server code size by disabling SSR for specific pages.
12 |
13 | > Only works with pages, not app directory
14 |
15 | ## Why
16 |
17 | - Improve cold start times in serverless environments
18 | - Improve rendering times, since the server no longer needs to render the page to html
19 | - Improve memory usage on the server (your pages no longer load React components code in memory)
20 | - Makes `edge` Vercel deploy possible if your current bundle size is more than 2Mb compressed
21 | - When SSR is not very useful, for example when making dashboards, where SEO is not important
22 |
23 | ## Install
24 |
25 | ```
26 | npm i -D elacca
27 | ```
28 |
29 | ## Usage
30 |
31 | Full application example in the [example-app](./example-app) folder.
32 |
33 | ```js
34 | // next.config.js
35 | const { withElacca } = require('elacca')
36 |
37 | /** @type {import('next').NextConfig} */
38 | const config = {}
39 |
40 | const elacca = withElacca({})
41 |
42 | const nextConfig = elacca(config) // notice the double invocation
43 |
44 | module.exports = nextConfig
45 | ```
46 |
47 | When using the `pages` directory, you can add a directive to disable SSR for a specific page:
48 |
49 | ```js
50 | // pages/index.js
51 | 'skip ssr'
52 |
53 | export default function Home() {
54 | return hello world
55 | }
56 | ```
57 |
58 | ## How It Works
59 |
60 | To have an intuitive understanding of how this works, you can check out how this plugin transforms pages in the [example-app/elacca-outputs](./example-app/elacca-outputs) folder.
61 |
62 | - When a page has a "skip ssr" directive, this plugin will transform the page code
63 | - On the server the page renders a component that returns `null`
64 | - On the client the page renders null until the component mounts, removing the need to hydrate the page
65 | - This is implemented as a babel plugin that only runs on pages files, so your build should remain fast (all other files are not parsed by babel, usually the code inside the pages folder is not much)
66 |
67 | ## Why The Name
68 |
69 | From the [Dune wiki](https://dune.fandom.com/wiki/Elacca_drug):
70 |
71 | > The Elacca drug is a narcotic that was formed by the burning of Elacca Wood of the planet Ecaz. Its main characteristic when administered was that it would eliminate the user's will for self-preservation
72 |
--------------------------------------------------------------------------------
/elacca/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "elacca",
3 | "version": "0.5.4",
4 | "description": "",
5 | "main": "dist/index.js",
6 | "types": "dist/index.d.ts",
7 | "repository": "https://github.com/remorses/elacca",
8 | "scripts": {
9 | "build": "cp ../README.md ./README.md && rm -rf dist && tsc",
10 | "test": "DEBUG_ELACCA=1 pnpm vitest -u",
11 | "prepublishOnly": "npm run build",
12 | "watch": "tsc -w"
13 | },
14 | "files": [
15 | "dist",
16 | "src"
17 | ],
18 | "keywords": [],
19 | "author": "Tommaso De Rossi, morse ",
20 | "license": "MIT",
21 | "peerDependencies": {
22 | "next": ">=10"
23 | },
24 | "devDependencies": {
25 | "@babel/generator": "^7.24.1",
26 | "@babel/plugin-syntax-jsx": "^7.24.1",
27 | "@babel/preset-react": "^7.24.1",
28 | "@babel/types": "^7.24.0",
29 | "@prettier/sync": "^0.3.0",
30 | "@types/babel__core": "^7.20.5",
31 | "@types/webpack": "^5.28.5",
32 | "next": "14.2.0-canary.26",
33 | "webpack": "^5.88.2"
34 | },
35 | "dependencies": {
36 | "@babel/core": "^7.24.3",
37 | "@babel/helper-annotate-as-pure": "^7.22.5",
38 | "@babel/plugin-syntax-jsx": "^7.22.5",
39 | "@babel/helper-module-imports": "^7.24.3",
40 | "@babel/parser": "^7.24.1",
41 | "@babel/plugin-syntax-typescript": "^7.24.1",
42 | "@babel/plugin-transform-react-pure-annotations": "^7.24.1",
43 | "@babel/plugin-transform-typescript": "^7.24.1",
44 | "babel-loader": "^9.1.3",
45 | "dedent": "^1.5.1"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/elacca/src/babelDebugOutputs.ts:
--------------------------------------------------------------------------------
1 | import * as babel from '@babel/core'
2 | import generate from '@babel/generator'
3 | import * as types from '@babel/types'
4 | import fs from 'fs'
5 | import { default as nodePath, default as path } from 'path'
6 | import { shouldBeSkipped } from './babelTransformPages'
7 | import { getFileName, logger } from './utils'
8 |
9 | type Babel = { types: typeof types }
10 |
11 | let deletedDir = false
12 |
13 | export default function debugOutputsPlugin(
14 | { types: t }: Babel,
15 | { apiDir, pagesDir, isServer, basePath }: any,
16 | ): babel.PluginObj | undefined {
17 | const cwd = process.cwd()
18 |
19 | if (!deletedDir) {
20 | deletedDir = true
21 |
22 | fs.mkdirSync('./elacca-outputs', { recursive: true })
23 | }
24 | return {
25 | visitor: {
26 | Program: {
27 | exit(program, state) {
28 | const filePath =
29 | getFileName(state) ??
30 | nodePath.join('pages', 'Default.js')
31 |
32 | if (!process.env.DEBUG_ELACCA) {
33 | return
34 | }
35 | if (shouldBeSkipped({filePath, program, pagesDir})) {
36 | logger.log('skipping because not a page', filePath)
37 | return
38 | }
39 |
40 | // stringify the AST and print it
41 | const output = generate(
42 | program.node,
43 | {
44 | /* options */
45 | },
46 | this.file.code,
47 | )
48 | let p = path.resolve(
49 | './elacca-outputs',
50 | isServer ? 'server/' : 'client/',
51 | path.relative(cwd, path.resolve(filePath)),
52 | )
53 | logger.log(`plugin output:`, p)
54 | fs.mkdirSync(path.dirname(p), { recursive: true })
55 | fs.writeFileSync(p, output.code)
56 | },
57 | },
58 | },
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/elacca/src/babelTransformPages.ts:
--------------------------------------------------------------------------------
1 | import { addNamed as addNamedImport } from '@babel/helper-module-imports'
2 | import fs from 'fs'
3 |
4 | import type { NodePath, PluginPass } from '@babel/core'
5 |
6 | import * as babel from '@babel/core'
7 | import { parse } from '@babel/parser'
8 | import * as types from '@babel/types'
9 | import { isExportDefaultDeclaration } from '@babel/types'
10 | import dedent from 'dedent'
11 | import { default as nodePath, default as path } from 'path'
12 | import { removeFunctionDependencies } from './removeFunctionDependencies'
13 | import { elaccaDirective, getFileName, logger } from './utils'
14 |
15 | type Babel = { types: typeof types }
16 |
17 | const { name } = require('../package.json')
18 |
19 | function getConfigObjectExpression(
20 | variable: babel.NodePath,
21 | ): babel.NodePath | null {
22 | const identifier = variable.get('id')
23 | const init = variable.get('init')
24 | if (
25 | identifier.isIdentifier() &&
26 | identifier.node.name === 'config' &&
27 | init.isObjectExpression()
28 | ) {
29 | return init
30 | } else {
31 | return null
32 | }
33 | }
34 |
35 | export function getConfigObject(
36 | program: babel.NodePath,
37 | ): babel.NodePath | null {
38 | for (const statement of program.get('body')) {
39 | if (statement.isExportNamedDeclaration()) {
40 | const declaration = statement.get('declaration')
41 | if (
42 | declaration.isVariableDeclaration() &&
43 | declaration.node.kind === 'const'
44 | ) {
45 | for (const variable of declaration.get('declarations')) {
46 | const configObject = getConfigObjectExpression(variable)
47 | if (configObject) {
48 | return configObject
49 | }
50 | }
51 | }
52 | }
53 | }
54 | return null
55 | }
56 |
57 | // https://github.com/blitz-js/babel-plugin-superjson-next/blob/main/src/index.ts#L121C22-L121C22
58 | function removeDefaultExport({
59 | program,
60 | isServer = false,
61 | }: {
62 | program
63 | isServer?: boolean
64 | }) {
65 | const body = program.get('body')
66 |
67 | const defaultDecl = body.find((path) => isExportDefaultDeclaration(path))
68 | if (!defaultDecl) {
69 | logger.log('no default export, skipping')
70 | return
71 | }
72 | const { node } = defaultDecl
73 |
74 | let defaultExportName = ''
75 |
76 | if (types.isIdentifier(node.declaration)) {
77 | defaultExportName = node.declaration.name
78 | defaultDecl.remove()
79 |
80 | if (isServer) {
81 | let nodeToRemove = body.find((path) => {
82 | if (types.isFunctionDeclaration(path.node)) {
83 | return path.node.id?.name === defaultExportName
84 | } else if (
85 | types.isVariableDeclaration(path.node) &&
86 | path.node?.declarations?.length === 1
87 | ) {
88 | const decl = path.node.declarations[0]
89 | if (types.isIdentifier(decl.id)) {
90 | return decl.id.name === defaultExportName
91 | }
92 | } else if (types.isClassDeclaration(path.node)) {
93 | return path.node.id?.name === defaultExportName
94 | } else {
95 | logger.log(`ignored ${path?.node?.type}`)
96 | }
97 | })
98 | if (nodeToRemove) {
99 | logger.log(`removing func decl ${defaultExportName}`)
100 | nodeToRemove.remove()
101 | }
102 | }
103 | } else if (
104 | types.isFunctionDeclaration(node.declaration) &&
105 | node.declaration.id
106 | ) {
107 | defaultExportName = node.declaration.id.name
108 | if (isServer) {
109 | defaultDecl.remove()
110 | } else {
111 | defaultDecl.replaceInline(node.declaration)
112 | }
113 | } else {
114 | logger.log(`ignored ${node?.declaration?.type}`)
115 | }
116 |
117 | logger.log(`transformed default export`, defaultExportName)
118 | return defaultExportName
119 | }
120 |
121 | /**
122 | * transforms `export { default } from ".."` import & export line
123 | */
124 | function transformImportExportDefault(paths: NodePath[]) {
125 | for (const path of paths) {
126 | if (types.isExportNamedDeclaration(path) as any) {
127 | for (const specifier of path.node.specifiers) {
128 | logger.log(specifier.exported.name)
129 | if (specifier.exported.name === 'default') {
130 | path.insertAfter(
131 | types.exportDefaultDeclaration(
132 | types.identifier(specifier.local.name),
133 | ) as any,
134 | )
135 |
136 | path.node.specifiers.splice(
137 | path.node.specifiers.indexOf(specifier),
138 | 1,
139 | )
140 |
141 | if (path.node.specifiers.length === 0) {
142 | path.remove()
143 | }
144 | }
145 | }
146 | }
147 | }
148 | }
149 |
150 | export interface PluginOptions {
151 | isServer: boolean
152 |
153 | pagesDir: string
154 | }
155 |
156 | export default function (
157 | { types: t }: Babel,
158 | { pagesDir, isServer }: PluginOptions,
159 | ): babel.PluginObj {
160 | return {
161 | visitor: {
162 | // Directive(path) {
163 | // const { node } = path
164 |
165 | // if (node.value.value === elaccaDirective) {
166 | // path.remove()
167 | // }
168 | // },
169 | Program(program, state) {
170 | const filePath =
171 | getFileName(state) ?? nodePath.join('pages', 'Default.js')
172 | logger.log('transforming', filePath)
173 |
174 | if (shouldBeSkipped({ filePath, program, pagesDir })) {
175 | logger.log('skipping because not a page', filePath)
176 | return
177 | }
178 |
179 | transformImportExportDefault(program.get('body'))
180 |
181 | const pageComponentName = removeDefaultExport({
182 | program,
183 | isServer,
184 | })
185 | if (!pageComponentName) {
186 | logger.log('no page component name found, skipping')
187 | return
188 | }
189 |
190 | if (isServer) {
191 | removeFunctionDependencies({
192 | name: pageComponentName,
193 | path: program,
194 | state,
195 | })
196 | }
197 | let defaultExportName = isServer
198 | ? pageComponentName
199 | : 'DefaultExportRenamedByElacca'
200 |
201 | // add a `export default renamedPage` at the end
202 | if (isServer) {
203 | program.node.body?.push(
204 | parse(dedent`
205 | function ${defaultExportName}() {
206 | return null
207 | }
208 | `).program.body[0] as any,
209 | )
210 | } else {
211 | // add import React from react
212 | const reactImport = addNamedImport(
213 | program,
214 | 'default',
215 | 'react',
216 | {},
217 | )
218 | program.node.body?.push(
219 | ...parse(
220 | dedent`
221 | const __identityFunction = () => {}
222 | function ${defaultExportName}(props) {
223 | const isClient = ${reactImport.name}.useSyncExternalStore(__identityFunction, () => true, () => false)
224 | return isClient ? ${reactImport.name}.createElement(${pageComponentName}, props) : null
225 | }
226 | `,
227 | ).program.body,
228 | )
229 | program.node.body?.push(
230 | parse(
231 | dedent`Object.assign(${defaultExportName}, ${pageComponentName})`,
232 | ).program.body[0] as any,
233 | )
234 | }
235 |
236 | program.node.body?.push(
237 | types.exportDefaultDeclaration(
238 | types.identifier(defaultExportName),
239 | ),
240 | )
241 | },
242 | },
243 | }
244 | }
245 |
246 | const filesToSkip = ([] as string[]).concat(
247 | ...['_document', '_error'].map((name) => [
248 | name + '.js',
249 | name + '.jsx',
250 | name + '.ts',
251 | name + '.tsx',
252 | ]),
253 | )
254 |
255 | export function shouldBeSkipped({ pagesDir, filePath, program = null as any }) {
256 | if (!filePath.includes('pages' + path.sep)) {
257 | return true
258 | }
259 | if (filePath.includes('pages' + path.sep + 'api' + path.sep)) {
260 | return true
261 | }
262 | if (filesToSkip.some((fileToSkip) => filePath.includes(fileToSkip))) {
263 | return true
264 | }
265 | // if outside of pagesDir, skip
266 | const abs = path.resolve(filePath)
267 | if (pagesDir && !abs.startsWith(pagesDir)) {
268 | console.log('skipping', abs, 'because outside of pagesDir', pagesDir)
269 | return true
270 | }
271 | if (!program) {
272 | return false
273 | }
274 | const dir = program.node.directives?.find(
275 | (x) => x.value?.value === elaccaDirective,
276 | )
277 | if (!dir) {
278 | return true
279 | }
280 | return false
281 | }
282 |
283 | // taken from https://github.com/vercel/next.js/blob/v12.1.5/packages/next/lib/find-pages-dir.ts
284 | export function findPagesDir(dir: string): string {
285 | logger.log('finding pages dir')
286 | // prioritize ./pages over ./src/pages
287 | let curDir = path.join(dir, 'pages')
288 | if (fs.existsSync(curDir)) return curDir
289 |
290 | curDir = path.join(dir, 'src/pages')
291 | if (fs.existsSync(curDir)) return curDir
292 |
293 | // Check one level up the tree to see if the pages directory might be there
294 | if (fs.existsSync(path.join(dir, '..', 'pages'))) {
295 | throw new Error(
296 | 'No `pages` directory found. Did you mean to run `next` in the parent (`../`) directory?',
297 | )
298 | }
299 |
300 | throw new Error(
301 | "Couldn't find a `pages` directory. Please create one under the project root",
302 | )
303 | }
304 |
--------------------------------------------------------------------------------
/elacca/src/index.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path'
2 | import * as fs from 'fs'
3 | import type * as webpack from 'webpack'
4 | import { NextConfig } from 'next'
5 | import {
6 | PluginOptions as ElaccaPluginOptions,
7 | findPagesDir,
8 | } from './babelTransformPages'
9 |
10 | export type PluginOptions = {}
11 |
12 | export function plugins(opts: { isServer?: boolean; pagesDir?: string }) {
13 | return [
14 | [require.resolve('../dist/babelTransformPages'), opts],
15 | require.resolve('@babel/plugin-syntax-jsx'),
16 | [
17 | require.resolve('@babel/plugin-transform-typescript'),
18 | { isTSX: true },
19 | ],
20 |
21 | process.env.DEBUG_ELACCA && [
22 | require.resolve('../dist/babelDebugOutputs'),
23 | opts,
24 | ],
25 | ].filter(Boolean)
26 | }
27 |
28 | export function withElacca(config: PluginOptions = {}) {
29 | return (nextConfig: NextConfig = {}): NextConfig => {
30 | applyTurbopackOptions(nextConfig)
31 | // return nextConfig
32 |
33 | if (process.env.DEBUG_ELACCA) {
34 | try {
35 | fs.rmdirSync('./elacca-outputs', { recursive: true })
36 | } catch {}
37 | }
38 | return {
39 | ...nextConfig,
40 |
41 | webpack(config: webpack.Configuration, options) {
42 | const { isServer, dev, dir } = options
43 | const pagesDir = findPagesDir(dir)
44 | const apiDir = path.resolve(pagesDir, './api')
45 |
46 | const opts: ElaccaPluginOptions = {
47 | isServer,
48 | pagesDir,
49 |
50 | // apiDir,
51 | // basePath: nextConfig.basePath || '/',
52 | }
53 |
54 | config.module = config.module || {}
55 | config.module.rules = config.module.rules || []
56 | config.module.rules.push({
57 | test: /\.(tsx|ts|js|mjs|jsx)$/,
58 | resource: {
59 | and: [pagesDir],
60 | not: [apiDir],
61 | },
62 | use: [
63 | options.defaultLoaders.babel,
64 | {
65 | loader: require.resolve('babel-loader'),
66 | options: {
67 | sourceMaps: dev,
68 | plugins: plugins(opts),
69 | },
70 | },
71 | ],
72 | })
73 |
74 | if (typeof nextConfig.webpack === 'function') {
75 | return nextConfig.webpack(config, options)
76 | } else {
77 | return config
78 | }
79 | },
80 | }
81 | }
82 | }
83 |
84 | function applyTurbopackOptions(nextConfig: NextConfig): void {
85 | nextConfig.experimental ??= {}
86 | nextConfig.experimental.turbo ??= {}
87 | nextConfig.experimental.turbo.rules ??= {}
88 |
89 | const rules = nextConfig.experimental.turbo.rules
90 |
91 | const pagesDir = findPagesDir(process.cwd())
92 | const options = { pagesDir }
93 | const glob = '{./src/pages,./pages/}/**/*.{ts,tsx,js,jsx}'
94 | rules[glob] ??= {}
95 | const globbed: any = rules[glob]
96 | globbed.browser ??= {}
97 | globbed.browser.loaders ??= []
98 | globbed.browser.as = '*.tsx'
99 | globbed.browser.loaders.push({
100 | loader: require.resolve('../dist/turbopackLoader'),
101 | options: { ...options, isServer: false },
102 | })
103 | globbed.default ??= {}
104 | globbed.default.loaders ??= []
105 | globbed.default.as = '*.tsx'
106 | globbed.default.loaders.push({
107 | loader: require.resolve('../dist/turbopackLoader'),
108 | options: { ...options, isServer: true },
109 | })
110 | }
111 |
--------------------------------------------------------------------------------
/elacca/src/plugin.test.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/umijs/umi/blob/3.x/packages/babel-plugin-no-anonymous-default-export/src/index.test.ts
2 | import { transform } from '@babel/core'
3 | import { format } from 'prettier'
4 | import synchronizedPrettier from '@prettier/sync'
5 |
6 | import { test, expect } from 'vitest'
7 | import dedent from 'dedent'
8 | import { plugins } from '.'
9 |
10 | function runPlugin(
11 | code: string,
12 | opts?: { cwd: string; plugins?: any[]; filename: string },
13 | ) {
14 | const client = transform(dedent`${code}`, {
15 | babelrc: false,
16 | sourceType: 'module',
17 | plugins: plugins({ isServer: false }),
18 | ...opts,
19 | })?.code
20 | const server = transform(dedent`${code}`, {
21 | babelrc: false,
22 | sourceType: 'module',
23 | plugins: plugins({ isServer: true,}),
24 | ...opts,
25 | })?.code
26 |
27 | return [server, client].map((x) =>
28 | synchronizedPrettier.format(x || '', { parser: 'acorn' }),
29 | )
30 | }
31 |
32 | test('normal arrow function, export default later', () => {
33 | const opts = {
34 | cwd: '/a/b/c',
35 | filename: '/pages/index.tsx',
36 | }
37 | expect(
38 | runPlugin(
39 | `
40 | "skip ssr"
41 | const SrcPagesId = () => {
42 | return Hello
;
43 | };
44 | export default SrcPagesId;
45 | `,
46 | opts,
47 | ),
48 | ).toMatchInlineSnapshot(`
49 | [
50 | "\\"skip ssr\\";
51 |
52 | function SrcPagesId() {
53 | return null;
54 | }
55 | export default SrcPagesId;
56 | ",
57 | "\\"skip ssr\\";
58 |
59 | import _default from \\"react\\";
60 | const SrcPagesId = () => {
61 | return Hello
;
62 | };
63 | const __identityFunction = () => {};
64 | function DefaultExportRenamedByElacca(props) {
65 | const isClient = _default.useSyncExternalStore(
66 | __identityFunction,
67 | () => true,
68 | () => false,
69 | );
70 | return isClient ? _default.createElement(SrcPagesId, props) : null;
71 | }
72 | Object.assign(DefaultExportRenamedByElacca, SrcPagesId);
73 | export default DefaultExportRenamedByElacca;
74 | ",
75 | ]
76 | `)
77 | })
78 |
79 | test('normal arrow function, already imports react', () => {
80 | const opts = {
81 | cwd: '/a/b/c',
82 | filename: '/pages/index.tsx',
83 | }
84 | expect(
85 | runPlugin(
86 | `
87 | "skip ssr"
88 | import React from 'react'
89 | const SrcPagesId = () => {
90 | return Hello
;
91 | };
92 | export default SrcPagesId;
93 | `,
94 | opts,
95 | ),
96 | ).toMatchInlineSnapshot(`
97 | [
98 | "\\"skip ssr\\";
99 |
100 | function SrcPagesId() {
101 | return null;
102 | }
103 | export default SrcPagesId;
104 | ",
105 | "\\"skip ssr\\";
106 |
107 | import React, { default as _default } from \\"react\\";
108 | const SrcPagesId = () => {
109 | return Hello
;
110 | };
111 | const __identityFunction = () => {};
112 | function DefaultExportRenamedByElacca(props) {
113 | const isClient = _default.useSyncExternalStore(
114 | __identityFunction,
115 | () => true,
116 | () => false,
117 | );
118 | return isClient ? _default.createElement(SrcPagesId, props) : null;
119 | }
120 | Object.assign(DefaultExportRenamedByElacca, SrcPagesId);
121 | export default DefaultExportRenamedByElacca;
122 | ",
123 | ]
124 | `)
125 | })
126 | test('function declaration, export later', () => {
127 | const opts = {
128 | cwd: '/a/b/c',
129 | filename: '/pages/index.tsx',
130 | }
131 | expect(
132 | runPlugin(
133 | `
134 | "skip ssr"
135 | function SrcPagesId() {
136 | return Hello
;
137 | };
138 | export default SrcPagesId;
139 | `,
140 | opts,
141 | ),
142 | ).toMatchInlineSnapshot(`
143 | [
144 | "\\"skip ssr\\";
145 |
146 | function SrcPagesId() {
147 | return null;
148 | }
149 | export default SrcPagesId;
150 | ",
151 | "\\"skip ssr\\";
152 |
153 | import _default from \\"react\\";
154 | function SrcPagesId() {
155 | return Hello
;
156 | }
157 | const __identityFunction = () => {};
158 | function DefaultExportRenamedByElacca(props) {
159 | const isClient = _default.useSyncExternalStore(
160 | __identityFunction,
161 | () => true,
162 | () => false,
163 | );
164 | return isClient ? _default.createElement(SrcPagesId, props) : null;
165 | }
166 | Object.assign(DefaultExportRenamedByElacca, SrcPagesId);
167 | export default DefaultExportRenamedByElacca;
168 | ",
169 | ]
170 | `)
171 | })
172 | test('export default function declaration', () => {
173 | const opts = {
174 | cwd: '/a/b/c',
175 | filename: '/pages/index.tsx',
176 | }
177 | expect(
178 | runPlugin(
179 | `
180 | "skip ssr"
181 | export default function SrcPagesId() {
182 | return Hello
;
183 | };
184 | `,
185 | opts,
186 | ),
187 | ).toMatchInlineSnapshot(`
188 | [
189 | "\\"skip ssr\\";
190 |
191 | function SrcPagesId() {
192 | return null;
193 | }
194 | export default SrcPagesId;
195 | ",
196 | "\\"skip ssr\\";
197 |
198 | import _default from \\"react\\";
199 | function SrcPagesId() {
200 | return Hello
;
201 | }
202 | const __identityFunction = () => {};
203 | function DefaultExportRenamedByElacca(props) {
204 | const isClient = _default.useSyncExternalStore(
205 | __identityFunction,
206 | () => true,
207 | () => false,
208 | );
209 | return isClient ? _default.createElement(SrcPagesId, props) : null;
210 | }
211 | Object.assign(DefaultExportRenamedByElacca, SrcPagesId);
212 | export default DefaultExportRenamedByElacca;
213 | ",
214 | ]
215 | `)
216 | })
217 | test('export named default', () => {
218 | const opts = {
219 | cwd: '/a/b/c',
220 | filename: '/pages/index.tsx',
221 | }
222 | expect(
223 | runPlugin(
224 | `
225 | "skip ssr"
226 | function SrcPagesId() {
227 | return Hello
;
228 | };
229 | export { SrcPagesId as default };
230 | `,
231 | opts,
232 | ),
233 | ).toMatchInlineSnapshot(`
234 | [
235 | "\\"skip ssr\\";
236 |
237 | function SrcPagesId() {
238 | return null;
239 | }
240 | export default SrcPagesId;
241 | ",
242 | "\\"skip ssr\\";
243 |
244 | import _default from \\"react\\";
245 | function SrcPagesId() {
246 | return Hello
;
247 | }
248 | const __identityFunction = () => {};
249 | function DefaultExportRenamedByElacca(props) {
250 | const isClient = _default.useSyncExternalStore(
251 | __identityFunction,
252 | () => true,
253 | () => false,
254 | );
255 | return isClient ? _default.createElement(SrcPagesId, props) : null;
256 | }
257 | Object.assign(DefaultExportRenamedByElacca, SrcPagesId);
258 | export default DefaultExportRenamedByElacca;
259 | ",
260 | ]
261 | `)
262 | })
263 | test('export named class', () => {
264 | const opts = {
265 | cwd: '/a/b/c',
266 | filename: '/pages/index.tsx',
267 | }
268 | // TODO export named class is ignored for now
269 | expect(
270 | runPlugin(
271 | `
272 | "skip ssr"
273 | export class Page extends React.Component {
274 | }
275 | `,
276 | opts,
277 | ),
278 | ).toMatchInlineSnapshot(`
279 | [
280 | "\\"skip ssr\\";
281 |
282 | export class Page extends React.Component {}
283 | ",
284 | "\\"skip ssr\\";
285 |
286 | export class Page extends React.Component {}
287 | ",
288 | ]
289 | `)
290 | })
291 | test('export class after', () => {
292 | const opts = {
293 | cwd: '/a/b/c',
294 | filename: '/pages/index.tsx',
295 | }
296 | expect(
297 | runPlugin(
298 | `
299 | "skip ssr"
300 | class Page extends React.Component {
301 | }
302 | export default Page
303 | `,
304 | opts,
305 | ),
306 | ).toMatchInlineSnapshot(`
307 | [
308 | "\\"skip ssr\\";
309 |
310 | function Page() {
311 | return null;
312 | }
313 | export default Page;
314 | ",
315 | "\\"skip ssr\\";
316 |
317 | import _default from \\"react\\";
318 | class Page extends React.Component {}
319 | const __identityFunction = () => {};
320 | function DefaultExportRenamedByElacca(props) {
321 | const isClient = _default.useSyncExternalStore(
322 | __identityFunction,
323 | () => true,
324 | () => false,
325 | );
326 | return isClient ? _default.createElement(Page, props) : null;
327 | }
328 | Object.assign(DefaultExportRenamedByElacca, Page);
329 | export default DefaultExportRenamedByElacca;
330 | ",
331 | ]
332 | `)
333 | })
334 |
335 | test('remove dead code 1', () => {
336 | const opts = {
337 | cwd: '/a/b/c',
338 | filename: '/pages/index.tsx',
339 | }
340 | expect(
341 | runPlugin(
342 | `
343 | "skip ssr"
344 | import dead from 'dead'
345 | function unused() {
346 | dead()
347 | console.log('unused')
348 | }
349 |
350 | function Page() {
351 | return unused()
352 | }
353 | export default Page
354 | `,
355 | opts,
356 | ),
357 | ).toMatchInlineSnapshot(`
358 | [
359 | "\\"skip ssr\\";
360 |
361 | function Page() {
362 | return null;
363 | }
364 | export default Page;
365 | ",
366 | "\\"skip ssr\\";
367 |
368 | import _default from \\"react\\";
369 | import dead from \\"dead\\";
370 | function unused() {
371 | dead();
372 | console.log(\\"unused\\");
373 | }
374 | function Page() {
375 | return unused();
376 | }
377 | const __identityFunction = () => {};
378 | function DefaultExportRenamedByElacca(props) {
379 | const isClient = _default.useSyncExternalStore(
380 | __identityFunction,
381 | () => true,
382 | () => false,
383 | );
384 | return isClient ? _default.createElement(Page, props) : null;
385 | }
386 | Object.assign(DefaultExportRenamedByElacca, Page);
387 | export default DefaultExportRenamedByElacca;
388 | ",
389 | ]
390 | `)
391 | })
392 |
393 | test('remove dead code 2', () => {
394 | const opts = {
395 | cwd: '/a/b/c',
396 | filename: '/pages/index.tsx',
397 | }
398 | expect(
399 | runPlugin(
400 | `
401 | "skip ssr"
402 | import Dead from 'dead'
403 | function unused() {
404 |
405 | console.log( )
406 | }
407 |
408 | function Page() {
409 | unused()
410 | return
411 | }
412 | export default Page
413 |
414 | function Providers() {
415 | return
416 | }
417 | `,
418 | opts,
419 | ),
420 | ).toMatchInlineSnapshot(`
421 | [
422 | "\\"skip ssr\\";
423 |
424 | function Page() {
425 | return null;
426 | }
427 | export default Page;
428 | ",
429 | "\\"skip ssr\\";
430 |
431 | import _default from \\"react\\";
432 | import Dead from \\"dead\\";
433 | function unused() {
434 | console.log( );
435 | }
436 | function Page() {
437 | unused();
438 | return ;
439 | }
440 | function Providers() {
441 | return ;
442 | }
443 | const __identityFunction = () => {};
444 | function DefaultExportRenamedByElacca(props) {
445 | const isClient = _default.useSyncExternalStore(
446 | __identityFunction,
447 | () => true,
448 | () => false,
449 | );
450 | return isClient ? _default.createElement(Page, props) : null;
451 | }
452 | Object.assign(DefaultExportRenamedByElacca, Page);
453 | export default DefaultExportRenamedByElacca;
454 | ",
455 | ]
456 | `)
457 | })
458 | test('page component references and mutations work', () => {
459 | const opts = {
460 | cwd: '/a/b/c',
461 | filename: '/pages/index.tsx',
462 | }
463 | expect(
464 | runPlugin(
465 | `
466 | "skip ssr"
467 | function Page() {
468 | unused()
469 | return
470 | }
471 | Page.layout = 'xx'
472 | export default Page
473 |
474 | `,
475 | opts,
476 | ),
477 | ).toMatchInlineSnapshot(`
478 | [
479 | "\\"skip ssr\\";
480 |
481 | Page.layout = \\"xx\\";
482 | function Page() {
483 | return null;
484 | }
485 | export default Page;
486 | ",
487 | "\\"skip ssr\\";
488 |
489 | import _default from \\"react\\";
490 | function Page() {
491 | unused();
492 | return ;
493 | }
494 | Page.layout = \\"xx\\";
495 | const __identityFunction = () => {};
496 | function DefaultExportRenamedByElacca(props) {
497 | const isClient = _default.useSyncExternalStore(
498 | __identityFunction,
499 | () => true,
500 | () => false,
501 | );
502 | return isClient ? _default.createElement(Page, props) : null;
503 | }
504 | Object.assign(DefaultExportRenamedByElacca, Page);
505 | export default DefaultExportRenamedByElacca;
506 | ",
507 | ]
508 | `)
509 | })
510 |
--------------------------------------------------------------------------------
/elacca/src/removeFunctionDependencies.ts:
--------------------------------------------------------------------------------
1 | import {
2 | NodePath,
3 | PluginObj,
4 | types as BabelTypes,
5 | } from 'next/dist/compiled/babel/core'
6 | import * as t from '@babel/types'
7 | import { logger } from './utils'
8 |
9 | type PluginState = {
10 | refs: Set>
11 | done: boolean
12 | }
13 |
14 | // taken from here: https://github.com/vercel/next.js/blob/adc413b3fb09be41b30ff7a86481e8f42cbb5447/packages/next/src/build/babel/plugins/next-ssg-transform.ts
15 | export function removeFunctionDependencies({
16 | name,
17 | path,
18 | state,
19 | }): PluginObj {
20 | function getIdentifier(
21 | path:
22 | | NodePath
23 | | NodePath
24 | | NodePath,
25 | ): NodePath | null {
26 | const parentPath = path.parentPath
27 | if (parentPath.type === 'VariableDeclarator') {
28 | const pp = parentPath as NodePath
29 | const name = pp.get('id')
30 | return name.node.type === 'Identifier'
31 | ? (name as NodePath)
32 | : null
33 | }
34 |
35 | if (parentPath.type === 'AssignmentExpression') {
36 | const pp = parentPath as NodePath
37 | const name = pp.get('left')
38 | return name.node.type === 'Identifier'
39 | ? (name as NodePath)
40 | : null
41 | }
42 |
43 | if (path.node.type === 'ArrowFunctionExpression') {
44 | return null
45 | }
46 |
47 | return path.node.id && path.node.id.type === 'Identifier'
48 | ? (path.get('id') as NodePath)
49 | : null
50 | }
51 |
52 | function isIdentifierReferenced(
53 | ident: NodePath,
54 | ): boolean {
55 | const b = ident.scope.getBinding(ident.node.name)
56 | if (b?.referenced) {
57 | // Functions can reference themselves, so we need to check if there's a
58 | // binding outside the function scope or not.
59 | if (b.path.type === 'FunctionDeclaration') {
60 | return !b.constantViolations
61 | .concat(b.referencePaths)
62 | // Check that every reference is contained within the function:
63 | .every((ref) => ref.findParent((p) => p === b.path))
64 | }
65 |
66 | return true
67 | }
68 | return false
69 | }
70 |
71 | function markFunction(
72 | path:
73 | | NodePath
74 | | NodePath
75 | | NodePath,
76 | state: PluginState,
77 | ): void {
78 | const ident = getIdentifier(path)
79 | if (ident?.node && isIdentifierReferenced(ident)) {
80 | state.refs.add(ident)
81 | }
82 | }
83 |
84 | function markImport(
85 | path:
86 | | NodePath
87 | | NodePath
88 | | NodePath,
89 | state: PluginState,
90 | ): void {
91 | const local = path.get('local') as NodePath
92 | if (isIdentifierReferenced(local)) {
93 | state.refs.add(local)
94 | }
95 | }
96 | state.refs = new Set>()
97 |
98 | state.done = false
99 | const isDataIdentifier = (
100 | thisName: string,
101 | state: PluginState,
102 | ): boolean => {
103 | logger.log('isDataIdentifier', thisName)
104 | // return true if it is the default export
105 | return thisName === name
106 | }
107 | path.traverse(
108 | {
109 | VariableDeclarator(variablePath, variableState) {
110 | if (variablePath.node.id.type === 'Identifier') {
111 | const local = variablePath.get(
112 | 'id',
113 | ) as NodePath
114 | if (isIdentifierReferenced(local)) {
115 | variableState.refs.add(local)
116 | }
117 | } else if (variablePath.node.id.type === 'ObjectPattern') {
118 | const pattern = variablePath.get(
119 | 'id',
120 | ) as NodePath
121 |
122 | const properties = pattern.get('properties')
123 | properties.forEach((p) => {
124 | const local = p.get(
125 | p.node.type === 'ObjectProperty'
126 | ? 'value'
127 | : p.node.type === 'RestElement'
128 | ? 'argument'
129 | : (function () {
130 | throw new Error('invariant')
131 | })(),
132 | ) as NodePath
133 | if (isIdentifierReferenced(local)) {
134 | variableState.refs.add(local)
135 | }
136 | })
137 | } else if (variablePath.node.id.type === 'ArrayPattern') {
138 | const pattern = variablePath.get(
139 | 'id',
140 | ) as NodePath
141 |
142 | const elements = pattern.get('elements')
143 | elements.forEach((e) => {
144 | let local: NodePath
145 | if (e.node?.type === 'Identifier') {
146 | local = e as NodePath
147 | } else if (e.node?.type === 'RestElement') {
148 | local = e.get(
149 | 'argument',
150 | ) as NodePath
151 | } else {
152 | return
153 | }
154 |
155 | if (isIdentifierReferenced(local)) {
156 | variableState.refs.add(local)
157 | }
158 | })
159 | }
160 | },
161 | FunctionDeclaration: markFunction,
162 | FunctionExpression: markFunction,
163 | ArrowFunctionExpression: markFunction,
164 | ImportSpecifier: markImport,
165 | ImportDefaultSpecifier: markImport,
166 | ImportNamespaceSpecifier: markImport,
167 | ExportNamedDeclaration(exportNamedPath, exportNamedState) {
168 | const specifiers = exportNamedPath.get('specifiers')
169 | if (specifiers.length) {
170 | specifiers.forEach((s) => {
171 | if (
172 | isDataIdentifier(
173 | t.isIdentifier(s.node.exported)
174 | ? s.node.exported.name
175 | : s.node.exported.value,
176 | exportNamedState,
177 | )
178 | ) {
179 | s.remove()
180 | }
181 | })
182 |
183 | if (exportNamedPath.node.specifiers.length < 1) {
184 | exportNamedPath.remove()
185 | }
186 | return
187 | }
188 |
189 | const decl = exportNamedPath.get('declaration') as NodePath<
190 | | BabelTypes.FunctionDeclaration
191 | | BabelTypes.VariableDeclaration
192 | >
193 | if (decl == null || decl.node == null) {
194 | return
195 | }
196 |
197 | switch (decl.node.type) {
198 | case 'FunctionDeclaration': {
199 | const name = decl.node.id!.name
200 | if (isDataIdentifier(name, exportNamedState)) {
201 | exportNamedPath.remove()
202 | }
203 | break
204 | }
205 | case 'VariableDeclaration': {
206 | const inner = decl.get(
207 | 'declarations',
208 | ) as NodePath[]
209 | inner.forEach((d) => {
210 | if (d.node.id.type !== 'Identifier') {
211 | return
212 | }
213 | const name = d.node.id.name
214 | if (isDataIdentifier(name, exportNamedState)) {
215 | d.remove()
216 | }
217 | })
218 | break
219 | }
220 | default: {
221 | break
222 | }
223 | }
224 | },
225 | },
226 | state,
227 | )
228 |
229 | const refs = state.refs
230 | let count: number
231 |
232 | function sweepFunction(
233 | sweepPath:
234 | | NodePath
235 | | NodePath
236 | | NodePath,
237 | ): void {
238 | const ident = getIdentifier(sweepPath)
239 | if (ident?.node && refs.has(ident) && !isIdentifierReferenced(ident)) {
240 | ++count
241 |
242 | if (
243 | t.isAssignmentExpression(sweepPath.parentPath) ||
244 | t.isVariableDeclarator(sweepPath.parentPath)
245 | ) {
246 | sweepPath.parentPath.remove()
247 | } else {
248 | sweepPath.remove()
249 | }
250 | }
251 | }
252 |
253 | function sweepImport(
254 | sweepPath:
255 | | NodePath
256 | | NodePath
257 | | NodePath,
258 | ): void {
259 | const local = sweepPath.get('local') as NodePath
260 | if (refs.has(local) && !isIdentifierReferenced(local)) {
261 | ++count
262 | sweepPath.remove()
263 | if (
264 | (sweepPath.parent as BabelTypes.ImportDeclaration).specifiers
265 | .length === 0
266 | ) {
267 | sweepPath.parentPath.remove()
268 | }
269 | }
270 | }
271 | let iterations = 0
272 |
273 | let maxIterations = 20
274 |
275 | do {
276 | ;(path.scope as any).crawl()
277 | iterations += 1
278 | count = 0
279 |
280 | path.traverse({
281 | // eslint-disable-next-line no-loop-func
282 | VariableDeclarator(variablePath) {
283 | if (variablePath.node.id.type === 'Identifier') {
284 | const local = variablePath.get(
285 | 'id',
286 | ) as NodePath
287 | if (refs.has(local) && !isIdentifierReferenced(local)) {
288 | ++count
289 | variablePath.remove()
290 | }
291 | } else if (variablePath.node.id.type === 'ObjectPattern') {
292 | const pattern = variablePath.get(
293 | 'id',
294 | ) as NodePath
295 |
296 | const beforeCount = count
297 | const properties = pattern.get('properties')
298 | properties.forEach((p) => {
299 | const local = p.get(
300 | p.node.type === 'ObjectProperty'
301 | ? 'value'
302 | : p.node.type === 'RestElement'
303 | ? 'argument'
304 | : (function () {
305 | throw new Error('invariant')
306 | })(),
307 | ) as NodePath
308 |
309 | if (refs.has(local) && !isIdentifierReferenced(local)) {
310 | ++count
311 | p.remove()
312 | }
313 | })
314 |
315 | if (
316 | beforeCount !== count &&
317 | pattern.get('properties').length < 1
318 | ) {
319 | variablePath.remove()
320 | }
321 | } else if (variablePath.node.id.type === 'ArrayPattern') {
322 | const pattern = variablePath.get(
323 | 'id',
324 | ) as NodePath
325 |
326 | const beforeCount = count
327 | const elements = pattern.get('elements')
328 | elements.forEach((e) => {
329 | let local: NodePath
330 | if (e.node?.type === 'Identifier') {
331 | local = e as NodePath
332 | } else if (e.node?.type === 'RestElement') {
333 | local = e.get(
334 | 'argument',
335 | ) as NodePath
336 | } else {
337 | return
338 | }
339 |
340 | if (refs.has(local) && !isIdentifierReferenced(local)) {
341 | ++count
342 | e.remove()
343 | }
344 | })
345 |
346 | if (
347 | beforeCount !== count &&
348 | pattern.get('elements').length < 1
349 | ) {
350 | variablePath.remove()
351 | }
352 | }
353 | },
354 | FunctionDeclaration: sweepFunction,
355 | FunctionExpression: sweepFunction,
356 | ArrowFunctionExpression: sweepFunction,
357 | ImportSpecifier: sweepImport,
358 | ImportDefaultSpecifier: sweepImport,
359 | ImportNamespaceSpecifier: sweepImport,
360 | })
361 | } while (count && iterations < maxIterations)
362 | }
363 |
--------------------------------------------------------------------------------
/elacca/src/turbopackLoader.ts:
--------------------------------------------------------------------------------
1 | import { transform } from '@babel/core'
2 | import type webpack from 'webpack'
3 | import { plugins } from '.'
4 | import { shouldBeSkipped } from './babelTransformPages'
5 | import { logger } from './utils'
6 |
7 | export default async function (
8 | this: LoaderThis,
9 | source: string,
10 | map: any,
11 | ) {
12 | if (typeof map === 'string') {
13 | map = JSON.parse(map)
14 | }
15 | // eslint-disable-next-line no-console
16 | // console.log(JSON.stringify(this, null, 2))
17 | const callback = this.async()
18 |
19 | try {
20 | const options = this.getOptions()
21 | const { isServer, pagesDir } = options
22 |
23 | // console.log('isServer', isServer)
24 | if (shouldBeSkipped({ filePath: this.resourcePath || '', pagesDir })) {
25 | callback(null, source, map)
26 | return
27 | }
28 |
29 | const res = transform(source || '', {
30 | babelrc: false,
31 | sourceType: 'module',
32 | plugins: plugins({ isServer, pagesDir }) as any,
33 | filename: this.resourcePath,
34 |
35 | // cwd: process.cwd(),
36 | inputSourceMap: map,
37 | sourceMaps: true,
38 |
39 | // cwd: this.context,
40 | })
41 |
42 | callback(null, res?.code || '', JSON.stringify(res?.map) || undefined)
43 | } catch (e: any) {
44 | logger.error(e)
45 | callback(e)
46 | }
47 | }
48 |
49 | export type LoaderThis = {
50 | /**
51 | * Path to the file being loaded
52 | *
53 | * https://webpack.js.org/api/loaders/#thisresourcepath
54 | */
55 | resourcePath: string
56 |
57 | /**
58 | * Function to add outside file used by loader to `watch` process
59 | *
60 | * https://webpack.js.org/api/loaders/#thisadddependency
61 | */
62 | addDependency: (filepath: string) => void
63 |
64 | /**
65 | * Marks a loader result as cacheable.
66 | *
67 | * https://webpack.js.org/api/loaders/#thiscacheable
68 | */
69 | cacheable: (flag: boolean) => void
70 |
71 | /**
72 | * Marks a loader as asynchronous
73 | *
74 | * https://webpack.js.org/api/loaders/#thisasync
75 | */
76 | async: webpack.LoaderContext['async']
77 |
78 | /**
79 | * Return errors, code, and sourcemaps from an asynchronous loader
80 | *
81 | * https://webpack.js.org/api/loaders/#thiscallback
82 | */
83 | callback: webpack.LoaderContext['callback']
84 | /**
85 | * Loader options in Webpack 5
86 | *
87 | * https://webpack.js.org/api/loaders/#thisgetoptionsschema
88 | */
89 | getOptions: () => Options
90 | }
91 |
--------------------------------------------------------------------------------
/elacca/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { PluginPass } from "@babel/core"
2 |
3 | const enabled = !!process.env.DEBUG_ELACCA
4 | export const logger = {
5 | log(...args) {
6 | enabled && console.log('[elacca]:', ...args)
7 | },
8 | error(...args) {
9 | enabled && console.log('[elacca]:', ...args)
10 | },
11 | }
12 |
13 | export const elaccaDirective = 'skip ssr'
14 |
15 |
16 |
17 |
18 | export function getFileName(state: PluginPass) {
19 | const { filename, cwd } = state
20 |
21 | if (!filename) {
22 | return undefined
23 | }
24 |
25 | if (cwd && filename.startsWith(cwd)) {
26 | return filename.slice(cwd.length + 1)
27 | }
28 |
29 | return filename
30 | }
--------------------------------------------------------------------------------
/elacca/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "rootDir": "src",
5 | "outDir": "dist"
6 | },
7 | "exclude": ["node_modules", "dist"],
8 | "include": ["src"]
9 | }
10 |
--------------------------------------------------------------------------------
/example-app/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
--------------------------------------------------------------------------------
/example-app/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # nextjs-app
2 |
3 | ## 0.1.18
4 |
5 | ### Patch Changes
6 |
7 | - Updated dependencies
8 | - elacca@0.5.4
9 |
10 | ## 0.1.17
11 |
12 | ### Patch Changes
13 |
14 | - Updated dependencies
15 | - elacca@0.5.3
16 |
17 | ## 0.1.16
18 |
19 | ### Patch Changes
20 |
21 | - Updated dependencies
22 | - elacca@0.5.2
23 |
24 | ## 0.1.15
25 |
26 | ### Patch Changes
27 |
28 | - Updated dependencies
29 | - elacca@0.5.1
30 |
31 | ## 0.1.14
32 |
33 | ### Patch Changes
34 |
35 | - Updated dependencies
36 | - elacca@0.5.0
37 |
38 | ## 0.1.13
39 |
40 | ### Patch Changes
41 |
42 | - Updated dependencies
43 | - elacca@0.4.1
44 |
45 | ## 0.1.12
46 |
47 | ### Patch Changes
48 |
49 | - Updated dependencies
50 | - elacca@0.4.0
51 |
52 | ## 0.1.11
53 |
54 | ### Patch Changes
55 |
56 | - Updated dependencies
57 | - elacca@0.3.4
58 |
59 | ## 0.1.10
60 |
61 | ### Patch Changes
62 |
63 | - Updated dependencies
64 | - elacca@0.3.3
65 |
66 | ## 0.1.9
67 |
68 | ### Patch Changes
69 |
70 | - Updated dependencies
71 | - elacca@0.3.2
72 |
73 | ## 0.1.8
74 |
75 | ### Patch Changes
76 |
77 | - Updated dependencies
78 | - elacca@0.3.1
79 |
80 | ## 0.1.7
81 |
82 | ### Patch Changes
83 |
84 | - Updated dependencies
85 | - elacca@0.3.0
86 |
87 | ## 0.1.6
88 |
89 | ### Patch Changes
90 |
91 | - Updated dependencies
92 | - elacca@0.2.1
93 |
94 | ## 0.1.5
95 |
96 | ### Patch Changes
97 |
98 | - Updated dependencies
99 | - elacca@0.2.0
100 |
101 | ## 0.1.4
102 |
103 | ### Patch Changes
104 |
105 | - Updated dependencies
106 | - elacca@0.1.1
107 |
108 | ## 0.1.3
109 |
110 | ### Patch Changes
111 |
112 | - Updated dependencies
113 | - elacca@0.1.0
114 |
115 | ## 0.1.2
116 |
117 | ### Patch Changes
118 |
119 | - Updated dependencies
120 | - Updated dependencies
121 | - elacca@0.0.2
122 |
123 | ## 0.1.1
124 |
125 | ### Patch Changes
126 |
127 | - Updated dependencies
128 | - elacca@0.0.1
129 |
--------------------------------------------------------------------------------
/example-app/elacca-outputs/client/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | 'skip ssr';
2 |
3 | import _default from "react";
4 | import './styles.css';
5 | import { someUtil } from '@/utils';
6 | function MyApp({
7 | Component,
8 | pageProps
9 | }) {
10 | someUtil();
11 |
12 | // some
13 | return ;
14 | }
15 | const __identityFunction = () => {};
16 | function DefaultExportRenamedByElacca(props) {
17 | const isClient = _default.useSyncExternalStore(__identityFunction, () => true, () => false);
18 | return isClient ? _default.createElement(MyApp, props) : null;
19 | }
20 | Object.assign(DefaultExportRenamedByElacca, MyApp);
21 | export default DefaultExportRenamedByElacca;
--------------------------------------------------------------------------------
/example-app/elacca-outputs/client/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | 'skip ssr';
2 |
3 | import _default from "react";
4 | import { Checkbox, FormControl, FormLabel, HStack, PinInput, PinInputField, Select, Slider, SliderFilledTrack, SliderThumb, SliderTrack, Switch, Textarea } from '@chakra-ui/react';
5 | import { ChakraProvider } from '@chakra-ui/react';
6 | import React from 'react';
7 | import { createContext } from 'react';
8 | function HeavyComponent({
9 | hello
10 | }) {
11 | return
12 |
13 | hello
14 |
15 |
16 | Option 1
17 | Option 2
18 | Option 3
19 |
20 |
21 |
22 | Checkbox
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | Enable email alerts?
42 |
43 |
44 |
45 |
Heavy Component {hello}
46 |
47 | ;
48 | }
49 | export function getServerSideProps() {
50 | return {
51 | props: {
52 | hello: 'world'
53 | }
54 | };
55 | }
56 | const context = createContext({});
57 | const __identityFunction = () => {};
58 | function DefaultExportRenamedByElacca(props) {
59 | const isClient = _default.useSyncExternalStore(__identityFunction, () => true, () => false);
60 | return isClient ? _default.createElement(HeavyComponent, props) : null;
61 | }
62 | Object.assign(DefaultExportRenamedByElacca, HeavyComponent);
63 | export default DefaultExportRenamedByElacca;
--------------------------------------------------------------------------------
/example-app/elacca-outputs/server/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | 'skip ssr';
2 |
3 | import './styles.css';
4 | function MyApp() {
5 | return null;
6 | }
7 | export default MyApp;
--------------------------------------------------------------------------------
/example-app/elacca-outputs/server/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | 'skip ssr';
2 |
3 | import { createContext } from 'react';
4 | export function getServerSideProps() {
5 | return {
6 | props: {
7 | hello: 'world'
8 | }
9 | };
10 | }
11 | const context = createContext({});
12 | function HeavyComponent() {
13 | return null;
14 | }
15 | export default HeavyComponent;
--------------------------------------------------------------------------------
/example-app/next.config.js:
--------------------------------------------------------------------------------
1 | const { withElacca } = require('elacca')
2 |
3 | const withBundleAnalyzer = require('@next/bundle-analyzer')({
4 | enabled: !!process.env.ANAL,
5 | })
6 |
7 | /** @type {import('next').NextConfig} */
8 | const config = {
9 | reactStrictMode: false,
10 | typescript: {
11 | ignoreBuildErrors: true,
12 | },
13 | output: 'standalone',
14 | outputFileTracing: true,
15 | eslint: {
16 | ignoreDuringBuilds: true,
17 | },
18 | cleanDistDir: true,
19 | experimental: {
20 | externalDir: true,
21 | // serverMinification: false,
22 | outputFileTracingExcludes: {
23 | '*': [
24 | '@vercel', //
25 | 'react-dom-experimental',
26 | 'babel-packages',
27 | 'babel',
28 | 'node-fetch',
29 | ].map((x) => './**/next/compiled/' + x),
30 | },
31 | },
32 | }
33 | const nextConfig = withBundleAnalyzer(withElacca()(config))
34 |
35 | module.exports = nextConfig
36 |
--------------------------------------------------------------------------------
/example-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-app",
3 | "version": "0.1.18",
4 | "private": true,
5 | "scripts": {
6 | "dev": "rm -rf .next && DEBUG_ELACCA=1 next dev --turbo -p 3434",
7 | "build": "rm -rf .next && DEBUG_ELACCA=1 next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@chakra-ui/react": "^2.8.0",
13 | "@motionone/dom": "^10.16.2",
14 | "@next/bundle-analyzer": "^13.4.16",
15 | "@types/node": "20.2.5",
16 | "@types/react": "18.2.9",
17 | "@types/react-dom": "18.2.4",
18 | "autoprefixer": "10.4.14",
19 | "critters": "^0.0.20",
20 | "elacca": "workspace:*",
21 | "next": "14.2.0-canary.26",
22 | "popmotion": "^11.0.5",
23 | "postcss": "8.4.24",
24 | "react": "18.2.0",
25 | "react-dom": "18.2.0",
26 | "react-server-dom-webpack": "^0.0.1",
27 | "tailwindcss": "3.3.2",
28 | "typescript": "^5.4.2"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/example-app/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/example-app/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example-app/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example-app/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | 'skip ssr'
2 | import { ChakraProvider } from '@chakra-ui/react'
3 | import './styles.css'
4 | import type { AppProps } from 'next/app'
5 | import { someUtil } from '@/utils'
6 |
7 | export default function MyApp({ Component, pageProps }: AppProps) {
8 | someUtil()
9 |
10 | // some
11 | return
12 | }
13 |
--------------------------------------------------------------------------------
/example-app/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | 'skip ssr'
2 | import {
3 | Checkbox,
4 | FormControl,
5 | FormLabel,
6 | HStack,
7 | PinInput,
8 | PinInputField,
9 | Select,
10 | Slider,
11 | SliderFilledTrack,
12 | SliderThumb,
13 | SliderTrack,
14 | Switch,
15 | Textarea,
16 | } from '@chakra-ui/react'
17 | import { ChakraProvider } from '@chakra-ui/react'
18 | import React from 'react'
19 | import { createContext } from 'react'
20 |
21 | export default function HeavyComponent({ hello }) {
22 | return (
23 |
24 |
25 | hello
26 |
27 |
28 | Option 1
29 | Option 2
30 | Option 3
31 |
32 |
33 |
34 | Checkbox
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | Enable email alerts?
54 |
55 |
56 |
57 |
Heavy Component {hello}
58 |
59 |
60 | )
61 | }
62 |
63 | export function getServerSideProps() {
64 | return {
65 | props: {
66 | hello: 'world',
67 | },
68 | }
69 | }
70 |
71 |
72 |
73 | const context = createContext({})
74 |
--------------------------------------------------------------------------------
/example-app/src/pages/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | max-width: 600px;
3 | margin: auto;
4 | }
5 |
--------------------------------------------------------------------------------
/example-app/src/utils.ts:
--------------------------------------------------------------------------------
1 |
2 | export function someUtil() {
3 | return 'some util'
4 | }
--------------------------------------------------------------------------------
/example-app/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
5 | './src/components/**/*.{js,ts,jsx,tsx,mdx}',
6 | './src/app/**/*.{js,ts,jsx,tsx,mdx}',
7 | ],
8 | theme: {
9 | extend: {
10 | backgroundImage: {
11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
12 | 'gradient-conic':
13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
14 | },
15 | },
16 | },
17 | plugins: [],
18 | }
19 |
--------------------------------------------------------------------------------
/example-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": false,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | },
25 | "strictNullChecks": true
26 | },
27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28 | "exclude": ["node_modules", "elacca-outputs"]
29 | }
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "root",
3 | "private": true,
4 | "scripts": {
5 | "test": "NODE_ENV=test vitest",
6 | "watch": "pnpm -r watch",
7 | "build": "pnpm -r build",
8 | "release": "pnpm build && changeset"
9 | },
10 | "devDependencies": {
11 | "@changesets/cli": "^2.26.2",
12 | "prettier": "^3.0.2",
13 | "tsx": "^3.12.7",
14 | "typescript": "^5.4.2",
15 | "vite": "^4.4.9",
16 | "vitest": "^0.34.1"
17 | },
18 | "repository": "https://github.com/remorses/",
19 | "author": "remorses ",
20 | "license": ""
21 | }
22 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - new-package-template
3 | - elacca
4 | - example-app
5 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "module": "commonjs",
5 | "allowJs": true,
6 | "moduleResolution": "Node",
7 | "lib": [
8 | "es2022",
9 | "es2017",
10 | "es7",
11 | "es6"
12 | // "dom"
13 | ],
14 | "declaration": true,
15 | "declarationMap": true,
16 | "strict": true,
17 | "downlevelIteration": true,
18 | "esModuleInterop": true,
19 | "noImplicitAny": false,
20 | "sourceMap": true,
21 | "jsx": "react",
22 | "skipLibCheck": true
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/vitest.config.js:
--------------------------------------------------------------------------------
1 | // vite.config.ts
2 | import { defineConfig } from 'vite'
3 |
4 | export default defineConfig({
5 | esbuild: {
6 | jsx: 'transform',
7 | },
8 | forceRerunTriggers: ['elacca/src/**/*.ts',],
9 | test: {
10 | exclude: ['**/dist/**', '**/esm/**', '**/node_modules/**', '**/e2e/**'],
11 | },
12 | })
13 |
--------------------------------------------------------------------------------