├── .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 | 20 |
21 |
22 | Checkbox 23 |
24 |