├── .babelrc ├── .eslintrc ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .releaserc.json ├── LICENSE ├── README.md ├── config.d.ts ├── config.js ├── examples └── example-app │ ├── README.md │ ├── next-env.d.ts │ ├── next.config.js │ ├── pages │ ├── _app.tsx │ ├── api │ │ └── hello.ts │ └── index.tsx │ ├── public │ ├── favicon.ico │ └── vercel.svg │ ├── styles │ ├── Home.module.css │ └── globals.css │ ├── tsconfig.json │ └── utils │ └── example.preval.ts ├── jest.config.js ├── loader.js ├── package-lock.json ├── package.json ├── renovate.json ├── scripts ├── build ├── build-example-app └── download-from-next-server ├── setup-jest.js ├── src ├── __example-files__ │ ├── deps.preval.ts │ ├── function-that-throws.ts │ ├── invalid-json.preval.ts │ ├── no-default-export.preval.ts │ ├── simple.preval.ts │ ├── test-module.ts │ ├── throws-indirect.preval.ts │ ├── throws.preval.ts │ ├── tsconfig-paths.preval.ts │ └── uses-fetch.preval.ts ├── create-next-plugin-preval.ts ├── index.d.ts ├── index.js ├── is-serializable.test.ts ├── is-serializable.ts ├── loader-utils.d.ts ├── loader.test.ts └── loader.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "targets": "node 12 and not IE 11" }], 4 | "@babel/preset-typescript" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - alpha 6 | - main 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: 18 20 | - name: Install dependencies 21 | run: npm ci 22 | - name: Run tests 23 | run: npm t -- --coverage 24 | - name: Upload coverage to Codecov 25 | uses: codecov/codecov-action@v2 26 | - name: Release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | run: npx semantic-release 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | - pull_request 4 | 5 | jobs: 6 | release: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2 12 | with: 13 | fetch-depth: 0 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: 18 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Lint 24 | run: npm run lint 25 | 26 | - name: Run tests 27 | run: npm t -- --coverage 28 | 29 | - name: Build example app 30 | run: npm run build-example-app 31 | 32 | - name: Upload coverage to Codecov 33 | uses: codecov/codecov-action@v2 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .DS_Store 5 | .env 6 | /examples/example-app/.next 7 | /examples/example-app/package.json 8 | /examples/example-app/package-lock.json 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | coverage 3 | node_modules 4 | scripts 5 | src 6 | .babelrc 7 | .env 8 | .gitignore 9 | .prettierrc 10 | .releaserc.json 11 | jest.config.js 12 | package-lock.json 13 | tsconfig.json 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main", { "name": "alpha", "prerelease": true }], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | "@semantic-release/npm", 7 | "@semantic-release/github" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Rico Kahler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-plugin-preval · [![codecov](https://codecov.io/gh/ricokahler/next-plugin-preval/branch/main/graph/badge.svg?token=ZMYB4EW4SH)](https://codecov.io/gh/ricokahler/next-plugin-preval) [![github status checks](https://badgen.net/github/checks/ricokahler/next-plugin-preval/main)](https://github.com/ricokahler/next-plugin-preval/actions) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 2 | 3 | > Pre-evaluate async functions (for data fetches) at build time and import them like JSON 4 | 5 | ```js 6 | // data.preval.js (or data.preval.ts) 7 | 8 | // step 1: create a data.preval.js (or data.preval.ts) file 9 | import preval from 'next-plugin-preval'; 10 | 11 | // step 2: write an async function that fetches your data 12 | async function getData() { 13 | const { title, body } = await /* your data fetching function */; 14 | return { title, body }; 15 | } 16 | 17 | // step 3: export default and wrap with `preval()` 18 | export default preval(getData()); 19 | ``` 20 | 21 | ```js 22 | // Component.js (or Component.ts) 23 | 24 | // step 4: import the preval 25 | import data from './data.preval'; 26 | 27 | // step 5: use the data. (it's there synchronously from the build step!) 28 | const { title, body } = data; 29 | 30 | function Component() { 31 | return ( 32 | <> 33 |

{title}

34 |

{body}

35 | 36 | ); 37 | } 38 | 39 | export default Component; 40 | ``` 41 | 42 | ## Why? 43 | 44 | The primary mechanism Next.js provides for static data is `getStaticProps` — which is a great feature and is the right tool for many use cases. However, there are other use cases for static data that are not covered by `getStaticProps`. 45 | 46 | - **Site-wide data**: if you have static data that's required across many different pages, `getStaticProps` is a somewhat awkward mechanism because for each new page, you'll have to re-fetch that same static data. For example, if you use `getStaticProps` to fetch content for your header, that data will be re-fetched on every page change. 47 | - **Static data for API routes**: It can be useful to pre-evaluate data fetches in API routes to speed up response times and offload work from your database. `getStaticProps` does not work for API routes while `next-plugin-preval` does. 48 | - **De-duped and code split data**: Since `next-plugin-preval` behaves like importing JSON, you can leverage the optimizations bundlers have for importing standard static assets. This includes standard code-splitting and de-duping. 49 | - **Zero runtime**: Preval files don't get sent to the browser, only their outputted JSON. 50 | 51 | See the [recipes](#recipes) for concrete examples. 52 | 53 | ## Installation 54 | 55 | ### Install 56 | 57 | ``` 58 | yarn add next-plugin-preval 59 | ``` 60 | 61 | or 62 | 63 | ``` 64 | npm i next-plugin-preval 65 | ``` 66 | 67 | ### Add to next.config.js 68 | 69 | ```js 70 | // next.config.js 71 | const createNextPluginPreval = require('next-plugin-preval/config'); 72 | const withNextPluginPreval = createNextPluginPreval(); 73 | 74 | module.exports = withNextPluginPreval(/* optionally add a next.js config */); 75 | ``` 76 | 77 | ## Usage 78 | 79 | Create a file with the extension `.preval.ts` or `.preval.js` then export a promise wrapped in `preval()`. 80 | 81 | ```js 82 | // my-data.preval.js 83 | import preval from 'next-plugin-preval'; 84 | 85 | async function getData() { 86 | return { hello: 'world'; } 87 | } 88 | 89 | export default preval(getData()); 90 | ``` 91 | 92 | Then import that file anywhere. The result of the promise is returned. 93 | 94 | ```js 95 | // component.js (or any file) 96 | import myData from './my-data.preval'; // 👈 this is effectively like importing JSON 97 | 98 | function Component() { 99 | return ( 100 |
101 |
{JSON.stringify(myData, null, 2)}
102 |
103 | ); 104 | } 105 | 106 | export default Component; 107 | ``` 108 | 109 | When you import a `.preval` file, it's like you're importing JSON. `next-plugin-preval` will run your function during the build and inline a JSON blob as a module. 110 | 111 | ## ⚠️ Important notes 112 | 113 | This works via a webpack loader that takes your code, compiles it, and runs it inside of Node.js. 114 | 115 | - Since this is an optimization at the bundler level, it will not update with Next.js [preview mode](https://nextjs.org/docs/advanced-features/preview-mode), during dynamic SSR, or even [ISR](https://nextjs.org/docs/basic-features/data-fetching#incremental-static-regeneration). Once this data is generated during the initial build, it can't change. It's like importing JSON. See [this pattern](#supporting-preview-mode) for a work around. 116 | - Because this plugin runs code directly in Node.js, code is not executed in the typical Next.js server context. This means certain injections Next.js does at the bundler level will not be available. We try our best to mock this context via [`require('next')`](https://github.com/ricokahler/next-plugin-preval/issues/12). For most data queries this should be sufficient, however please [open an issue](https://github.com/ricokahler/next-plugin-preval/issues/new) if something seems off. 117 | 118 | ## Recipes 119 | 120 | ### Site-wide data: Shared header 121 | 122 | ```js 123 | // header-data.preval.js 124 | import preval from 'next-plugin-preval'; 125 | 126 | async function getHeaderData() { 127 | const headerData = await /* your data fetching function */; 128 | 129 | return headerData; 130 | } 131 | 132 | export default preval(getHeaderData()); 133 | ``` 134 | 135 | ```js 136 | // header.js 137 | import headerData from './header-data.preval'; 138 | const { title } = headerData; 139 | 140 | function Header() { 141 | return
{title}
; 142 | } 143 | 144 | export default Header; 145 | ``` 146 | 147 | ### Static data for API routes: Pre-evaluated listings 148 | 149 | ```js 150 | // products.preval.js 151 | import preval from 'next-plugin-preval'; 152 | 153 | async function getProducts() { 154 | const products = await /* your data fetching function */; 155 | 156 | // create a hash-map for O(1) lookups 157 | return products.reduce((productsById, product) => { 158 | productsById[product.id] = product; 159 | return productsById; 160 | }, {}); 161 | } 162 | 163 | export default preval(getProducts()); 164 | ``` 165 | 166 | ```js 167 | // /pages/api/products/[id].js 168 | import productsById from '../products.preval.js'; 169 | 170 | const handler = (req, res) => { 171 | const { id } = req.params; 172 | 173 | const product = productsById[id]; 174 | 175 | if (!product) { 176 | res.status(404).end(); 177 | return; 178 | } 179 | 180 | res.json(product); 181 | }; 182 | 183 | export default handler; 184 | ``` 185 | 186 | ### Code-split static data: Loading non-critical data 187 | 188 | ```js 189 | // states.preval.js 190 | import preval from 'next-plugin-preval'; 191 | 192 | async function getAvailableStates() { 193 | const states = await /* your data fetching function */; 194 | return states; 195 | } 196 | 197 | export default preval(getAvailableStates()); 198 | ``` 199 | 200 | ```js 201 | // state-picker.js 202 | import { useState, useEffect } from 'react'; 203 | 204 | function StatePicker({ value, onChange }) { 205 | const [states, setStates] = useState([]); 206 | 207 | useEffect(() => { 208 | // ES6 dynamic import 209 | import('./states.preval').then((response) => setStates(response.default)); 210 | }, []); 211 | 212 | if (!states.length) { 213 | return
Loading…
; 214 | } 215 | 216 | return ( 217 | 224 | ); 225 | } 226 | ``` 227 | 228 | ### Supporting preview mode 229 | 230 | As stated in the [notes](#%EF%B8%8F-important-notes), the result of next-plugin-preval won't change after it leaves the build. However, you can still make preview mode work if you extract your data fetching function and conditionally call it based on preview mode (via [`context.preview`](https://nextjs.org/docs/advanced-features/preview-mode#step-2-update-getstaticprops). If preview mode is not active, you can default to the preval file. 231 | 232 | ```js 233 | // get-data.js 234 | 235 | // 1. extract a data fetching function 236 | async function getData() { 237 | const data = await /* your data fetching function */; 238 | return data 239 | } 240 | ``` 241 | 242 | ```js 243 | // data.preval.js 244 | import preval from 'next-plugin-preval'; 245 | import getData from './getData'; 246 | 247 | // 2. use that data fetching function in the preval 248 | export default preval(getData()); 249 | ``` 250 | 251 | ```js 252 | // /pages/some-page.js 253 | import data from './data.preval'; 254 | import getData from './get-data'; 255 | 256 | export async function getStaticProps(context) { 257 | // 3. conditionally call the data fetching function defaulting to the prevalled version 258 | const data = context.preview ? await getData() : data; 259 | 260 | return { props: { data } }; 261 | } 262 | ``` 263 | 264 | ## Related Projects 265 | 266 | - [`next-data-hooks`](https://github.com/ricokahler/next-data-hooks) — creates a pattern to use `getStaticProps` as React hooks. Great for the site-wide data case when preview mode or ISR is needed. 267 | -------------------------------------------------------------------------------- /config.d.ts: -------------------------------------------------------------------------------- 1 | import createNextPluginPreval from './dist/create-next-plugin-preval'; 2 | 3 | declare const _: typeof createNextPluginPreval; 4 | export = _; 5 | export as namespace _; 6 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/create-next-plugin-preval').default; 2 | -------------------------------------------------------------------------------- /examples/example-app/README.md: -------------------------------------------------------------------------------- 1 | The following is an example app used primarily for testing next-plugin-preval in CI 2 | -------------------------------------------------------------------------------- /examples/example-app/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /examples/example-app/next.config.js: -------------------------------------------------------------------------------- 1 | const createNextPluginPreval = require('next-plugin-preval/config'); 2 | const withNextPluginPreval = createNextPluginPreval(); 3 | 4 | module.exports = withNextPluginPreval({ 5 | eslint: { 6 | ignoreDuringBuilds: true, 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /examples/example-app/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app'; 2 | import '../styles/globals.css'; 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return ; 6 | } 7 | 8 | export default MyApp; 9 | -------------------------------------------------------------------------------- /examples/example-app/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from 'next'; 2 | import exampleData from '@/utils/example.preval'; 3 | 4 | const handler: NextApiHandler = (_req, res) => { 5 | res.status(200).json(exampleData); 6 | }; 7 | 8 | export default handler; 9 | -------------------------------------------------------------------------------- /examples/example-app/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import Image from 'next/image'; 3 | import exampleData from '@/utils/example.preval'; 4 | import styles from '../styles/Home.module.css'; 5 | 6 | export default function Home() { 7 | return ( 8 |
9 | 10 | Create Next App 11 |
{JSON.stringify(exampleData, null, 2)}
12 | 13 | 14 | 15 | 16 |
17 |

18 | Welcome to Next.js! 19 |

20 | 21 |

22 | Get started by editing{' '} 23 | pages/index.js 24 |

25 | 26 | 55 |
56 | 57 | 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /examples/example-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ricokahler/next-plugin-preval/25101a11ffc7d47ee5745e23c52ba3247321c6c0/examples/example-app/public/favicon.ico -------------------------------------------------------------------------------- /examples/example-app/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /examples/example-app/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | padding: 0 0.5rem; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | height: 100vh; 9 | } 10 | 11 | .main { 12 | padding: 5rem 0; 13 | flex: 1; 14 | display: flex; 15 | flex-direction: column; 16 | justify-content: center; 17 | align-items: center; 18 | } 19 | 20 | .footer { 21 | width: 100%; 22 | height: 100px; 23 | border-top: 1px solid #eaeaea; 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | } 28 | 29 | .footer a { 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | flex-grow: 1; 34 | } 35 | 36 | .title a { 37 | color: #0070f3; 38 | text-decoration: none; 39 | } 40 | 41 | .title a:hover, 42 | .title a:focus, 43 | .title a:active { 44 | text-decoration: underline; 45 | } 46 | 47 | .title { 48 | margin: 0; 49 | line-height: 1.15; 50 | font-size: 4rem; 51 | } 52 | 53 | .title, 54 | .description { 55 | text-align: center; 56 | } 57 | 58 | .description { 59 | line-height: 1.5; 60 | font-size: 1.5rem; 61 | } 62 | 63 | .code { 64 | background: #fafafa; 65 | border-radius: 5px; 66 | padding: 0.75rem; 67 | font-size: 1.1rem; 68 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 69 | Bitstream Vera Sans Mono, Courier New, monospace; 70 | } 71 | 72 | .grid { 73 | display: flex; 74 | align-items: center; 75 | justify-content: center; 76 | flex-wrap: wrap; 77 | max-width: 800px; 78 | margin-top: 3rem; 79 | } 80 | 81 | .card { 82 | margin: 1rem; 83 | padding: 1.5rem; 84 | text-align: left; 85 | color: inherit; 86 | text-decoration: none; 87 | border: 1px solid #eaeaea; 88 | border-radius: 10px; 89 | transition: color 0.15s ease, border-color 0.15s ease; 90 | width: 45%; 91 | } 92 | 93 | .card:hover, 94 | .card:focus, 95 | .card:active { 96 | color: #0070f3; 97 | border-color: #0070f3; 98 | } 99 | 100 | .card h2 { 101 | margin: 0 0 1rem 0; 102 | font-size: 1.5rem; 103 | } 104 | 105 | .card p { 106 | margin: 0; 107 | font-size: 1.25rem; 108 | line-height: 1.5; 109 | } 110 | 111 | .logo { 112 | height: 1em; 113 | margin-left: 0.5rem; 114 | } 115 | 116 | @media (max-width: 600px) { 117 | .grid { 118 | width: 100%; 119 | flex-direction: column; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /examples/example-app/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /examples/example-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 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 | "baseUrl": ".", 17 | "paths": { 18 | "@/*": ["*"] 19 | } 20 | }, 21 | 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /examples/example-app/utils/example.preval.ts: -------------------------------------------------------------------------------- 1 | import preval from 'next-plugin-preval'; 2 | 3 | async function getData() { 4 | await new Promise((resolve) => setTimeout(resolve, 100)); 5 | return { data: 'hello_world' }; 6 | } 7 | 8 | export default preval(getData()); 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | coverageDirectory: 'coverage', 4 | testPathIgnorePatterns: ['node_modules', '/dist'], 5 | setupFilesAfterEnv: ['./setup-jest.js'], 6 | }; 7 | -------------------------------------------------------------------------------- /loader.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/loader'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-plugin-preval", 3 | "version": "0.0.0", 4 | "description": "", 5 | "keywords": [ 6 | "next", 7 | "next.js", 8 | "preval" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/ricokahler/next-plugin-preval.git" 13 | }, 14 | "license": "MIT", 15 | "author": { 16 | "name": "Rico Kahler", 17 | "email": "ricokahler@me.com", 18 | "url": "https://github.com/ricokahler" 19 | }, 20 | "main": "./dist/index.js", 21 | "types": "./dist/index.d.ts", 22 | "scripts": { 23 | "test": "jest", 24 | "typecheck": "tsc --noEmit --emitDeclarationOnly false", 25 | "semantic-release": "semantic-release", 26 | "build": "./scripts/build", 27 | "lint": "eslint src", 28 | "prepare": "npm run build", 29 | "build-example-app": "./scripts/build-example-app" 30 | }, 31 | "dependencies": { 32 | "@babel/core": "^7.12.10", 33 | "@babel/preset-env": "^7.12.11", 34 | "@babel/register": "^7.13.16", 35 | "babel-plugin-module-resolver": "^4.1.0", 36 | "loader-utils": "^2.0.0", 37 | "regenerator-runtime": "^0.14.0", 38 | "require-from-string": "^2.0.2", 39 | "tsconfig-paths": "^3.9.0", 40 | "webpack": "^5.56.0" 41 | }, 42 | "devDependencies": { 43 | "@babel/cli": "7.26.4", 44 | "@babel/preset-typescript": "7.26.0", 45 | "@types/babel__core": "7.20.5", 46 | "@types/jest": "26.0.24", 47 | "@types/require-from-string": "1.2.3", 48 | "@typescript-eslint/eslint-plugin": "4.33.0", 49 | "@typescript-eslint/parser": "4.33.0", 50 | "@babel/eslint-parser": "7.26.8", 51 | "eslint": "7.32.0", 52 | "eslint-config-react-app": "6.0.0", 53 | "eslint-plugin-flowtype": "5.10.0", 54 | "eslint-plugin-import": "2.31.0", 55 | "eslint-plugin-jsx-a11y": "6.10.2", 56 | "eslint-plugin-react": "7.37.4", 57 | "eslint-plugin-react-hooks": "4.6.2", 58 | "jest": "27.5.1", 59 | "next": "15.0.3", 60 | "prettier": "2.8.8", 61 | "react": "18.3.1", 62 | "react-dom": "18.3.1", 63 | "semantic-release": "17.4.7", 64 | "typescript": "4.9.5" 65 | }, 66 | "peerDependencies": { 67 | "next": "^9 || ^10 || ^11 || ^12.0.0 || ^13 || ^14 || ^15.0.0" 68 | } 69 | } -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", "schedule:monthly"], 3 | "groupName": "all", 4 | "rebaseWhen": "behind-base-branch", 5 | "automerge": true 6 | } 7 | -------------------------------------------------------------------------------- /scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # exit on any error 4 | set -e 5 | 6 | echo Cleaning… 7 | rm -rf dist 8 | 9 | echo Compiling JS… 10 | npx babel \ 11 | --ignore "src/**/*.test.ts","src/**/*.d.ts","**/*/__example-files__/**/*"\ 12 | --source-maps true \ 13 | --extensions .ts,.js \ 14 | --out-dir dist \ 15 | ./src 16 | 17 | echo Compling types… 18 | npx tsc 19 | 20 | echo Copying files… 21 | cp ./src/index.d.ts ./dist 22 | -------------------------------------------------------------------------------- /scripts/build-example-app: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # exit on any error 4 | set -e 5 | 6 | echo Building… 7 | npm run build 8 | 9 | echo Cleaning example app… 10 | cd ./examples/example-app 11 | rm -rf node_modules package.json package-lock.json 12 | 13 | echo Installing deps… 14 | npm init -y 15 | npm i next@latest typescript@latest react@latest react-dom@latest @types/react@latest ../../ 16 | 17 | echo Building example app… 18 | npx next build 19 | 20 | echo Testing that the preval worked… 21 | ../../scripts/download-from-next-server . / | grep hello_world 22 | ../../scripts/download-from-next-server . /api/hello | grep hello_world 23 | -------------------------------------------------------------------------------- /scripts/download-from-next-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const http = require('http'); 3 | const next = require('next'); 4 | const fetch = require('node-fetch'); 5 | const path = require('path'); 6 | 7 | const [exampleAppPath, requestPath] = process.argv.slice(2); 8 | 9 | const app = next({ 10 | dev: false, 11 | dir: path.resolve(process.cwd(), exampleAppPath), 12 | }); 13 | const handler = app.getRequestHandler(); 14 | 15 | async function main() { 16 | await app.prepare(); 17 | 18 | /** @type {http.Server} */ 19 | const server = await new Promise((resolve, reject) => { 20 | const server = http.createServer(handler).listen(0, (err) => { 21 | if (err) reject(err); 22 | else resolve(server); 23 | }); 24 | }); 25 | 26 | const address = server.address(); 27 | 28 | if (typeof address !== 'object' || !address) { 29 | throw new Error('Could not get port'); 30 | } 31 | 32 | const port = address.port; 33 | 34 | const response = await fetch(`http://localhost:${port}${requestPath}`); 35 | const text = await response.text(); 36 | 37 | console.log(text); 38 | 39 | await new Promise((resolve, reject) => 40 | server.close((err) => { 41 | if (err) reject(err); 42 | else resolve(); 43 | }) 44 | ); 45 | } 46 | 47 | main().catch((e) => { 48 | console.error(e); 49 | process.exit(1); 50 | }); 51 | -------------------------------------------------------------------------------- /setup-jest.js: -------------------------------------------------------------------------------- 1 | jest.setTimeout(15 * 1000); 2 | -------------------------------------------------------------------------------- /src/__example-files__/deps.preval.ts: -------------------------------------------------------------------------------- 1 | import dependentModule from './test-module'; 2 | import preval from 'next-plugin-preval'; 3 | 4 | async function getData() { 5 | await new Promise((resolve) => setTimeout(resolve, 0)); 6 | 7 | const world: string = 'world'; 8 | const result = await dependentModule(); 9 | 10 | return { [result]: world }; 11 | } 12 | 13 | export default preval(getData()); 14 | -------------------------------------------------------------------------------- /src/__example-files__/function-that-throws.ts: -------------------------------------------------------------------------------- 1 | function functionThatThrows() { 2 | throw new Error('TAKE IT EASY'); 3 | } 4 | 5 | export default functionThatThrows; 6 | -------------------------------------------------------------------------------- /src/__example-files__/invalid-json.preval.ts: -------------------------------------------------------------------------------- 1 | import preval from 'next-plugin-preval'; 2 | 3 | const getPrevalData = async () => { 4 | await new Promise((resolve) => setTimeout(resolve, 0)); 5 | 6 | return { data: { hello: undefined } }; 7 | }; 8 | 9 | export default preval(getPrevalData()); 10 | -------------------------------------------------------------------------------- /src/__example-files__/no-default-export.preval.ts: -------------------------------------------------------------------------------- 1 | export const getPrevalData = async () => { 2 | await new Promise((resolve) => setTimeout(resolve, 0)); 3 | 4 | return { data: { hello: undefined } }; 5 | }; 6 | -------------------------------------------------------------------------------- /src/__example-files__/simple.preval.ts: -------------------------------------------------------------------------------- 1 | import preval from 'next-plugin-preval'; 2 | 3 | export const getPrevalData = async () => { 4 | await new Promise((resolve) => setTimeout(resolve, 0)); 5 | 6 | return { hello: 'world' }; 7 | }; 8 | 9 | export default preval(getPrevalData()); 10 | -------------------------------------------------------------------------------- /src/__example-files__/test-module.ts: -------------------------------------------------------------------------------- 1 | async function testDependencyModule(): Promise { 2 | await new Promise((resolve) => setTimeout(resolve, 0)); 3 | 4 | return 'hello'; 5 | } 6 | 7 | export default testDependencyModule; 8 | -------------------------------------------------------------------------------- /src/__example-files__/throws-indirect.preval.ts: -------------------------------------------------------------------------------- 1 | import preval from 'next-plugin-preval'; 2 | import functionThatThrows from './function-that-throws'; 3 | 4 | async function throwsIndirect() { 5 | await new Promise((resolve) => setTimeout(resolve, 100)); 6 | functionThatThrows(); 7 | 8 | return { data: 'never_should_return' }; 9 | } 10 | 11 | export default preval(throwsIndirect()); 12 | -------------------------------------------------------------------------------- /src/__example-files__/throws.preval.ts: -------------------------------------------------------------------------------- 1 | import preval from 'next-plugin-preval'; 2 | 3 | async function getData() { 4 | throw new Error('WHOA THERE!'); 5 | } 6 | 7 | export default preval(getData()); 8 | -------------------------------------------------------------------------------- /src/__example-files__/tsconfig-paths.preval.ts: -------------------------------------------------------------------------------- 1 | import dependentModule from '@/test-module'; 2 | import preval from 'next-plugin-preval'; 3 | 4 | const getPrevalData = async () => { 5 | await new Promise((resolve) => setTimeout(resolve, 0)); 6 | 7 | const world: string = 'world'; 8 | const result = await dependentModule(); 9 | 10 | return { [result]: world }; 11 | }; 12 | 13 | export default preval(getPrevalData()); 14 | -------------------------------------------------------------------------------- /src/__example-files__/uses-fetch.preval.ts: -------------------------------------------------------------------------------- 1 | import preval from 'next-plugin-preval'; 2 | 3 | async function getDataViaFetch() { 4 | const response = await fetch('https://example.com'); 5 | const html = await response.text(); 6 | 7 | return { html }; 8 | } 9 | 10 | export default preval(getDataViaFetch()); 11 | -------------------------------------------------------------------------------- /src/create-next-plugin-preval.ts: -------------------------------------------------------------------------------- 1 | interface NextPluginPrevalOptions { 2 | // just a placeholder for now 3 | } 4 | 5 | interface WebpackConfig { 6 | module?: { 7 | rules: any[]; 8 | }; 9 | [key: string]: any; 10 | } 11 | 12 | interface WebpackOptions { 13 | buildId: string; 14 | dev: boolean; 15 | isServer: boolean; 16 | defaultLoaders: object; 17 | babel: object; 18 | } 19 | 20 | interface NextConfigValue { 21 | webpack?: (config: WebpackConfig, options: WebpackOptions) => WebpackConfig; 22 | [key: string]: any; 23 | } 24 | 25 | type NextConfig = NextConfigValue | ((...args: any[]) => NextConfigValue); 26 | 27 | function createNextPluginPreval(_options?: NextPluginPrevalOptions) { 28 | function withNextPluginPreval(_nextConfig?: NextConfig) { 29 | const normalizedNextConfig = 30 | typeof _nextConfig === 'function' ? _nextConfig : () => _nextConfig || {}; 31 | 32 | return (...args: any[]): NextConfigValue => { 33 | const nextConfig = normalizedNextConfig(...args); 34 | 35 | return { 36 | ...nextConfig, 37 | webpack: (config: WebpackConfig, options: WebpackOptions) => { 38 | const webpackConfig = nextConfig.webpack?.(config, options) || config; 39 | const rules = webpackConfig.module?.rules; 40 | 41 | if (!rules) { 42 | throw new Error( 43 | 'Next Plugin Preval could not find webpack rules. Please file an issue.' 44 | ); 45 | } 46 | 47 | rules.push({ 48 | test: /\.preval\.(t|j)sx?$/, 49 | loader: require.resolve('./loader'), 50 | }); 51 | 52 | return webpackConfig; 53 | }, 54 | }; 55 | }; 56 | } 57 | 58 | return withNextPluginPreval; 59 | } 60 | 61 | export default createNextPluginPreval; 62 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | type Unwrap = T extends Promise ? U : T; 2 | declare function preval(value: T): Unwrap; 3 | 4 | export default preval; 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function (x) { 2 | return x; 3 | }; 4 | -------------------------------------------------------------------------------- /src/is-serializable.test.ts: -------------------------------------------------------------------------------- 1 | import isSerializable from './is-serializable'; 2 | 3 | describe('isSerializable', () => { 4 | it('throws if it finds a circular reference', () => { 5 | const ref = {}; 6 | 7 | Object.defineProperty(ref, 'self', { 8 | enumerable: true, 9 | get: () => ref, 10 | }); 11 | 12 | expect(() => isSerializable('filename.ts', ref)) 13 | .toThrowErrorMatchingInlineSnapshot(` 14 | "Error serializing \`.self\` returned from \`preval\` in \\"filename.ts\\". 15 | Reason: Circular references cannot be expressed in JSON (references: \`(self)\`)." 16 | `); 17 | }); 18 | 19 | it('throws if it finds undefined', () => { 20 | const data = { foo: undefined }; 21 | 22 | expect(() => isSerializable('filename.ts', data)) 23 | .toThrowErrorMatchingInlineSnapshot(` 24 | "Error serializing \`.foo\` returned from \`preval\` in \\"filename.ts\\". 25 | Reason: \`undefined\` cannot be serialized as JSON. Please use \`null\` or omit this value." 26 | `); 27 | }); 28 | 29 | it('throws serializing complex identifiers', () => { 30 | const data = { 31 | arr: [{ 'foo!': undefined }], 32 | }; 33 | 34 | expect(() => isSerializable('filename.ts', data)) 35 | .toThrowErrorMatchingInlineSnapshot(` 36 | "Error serializing \`.arr[0][\\"foo!\\"]\` returned from \`preval\` in \\"filename.ts\\". 37 | Reason: \`undefined\` cannot be serialized as JSON. Please use \`null\` or omit this value." 38 | `); 39 | }); 40 | 41 | it('works with nested objects', () => { 42 | const data = { 43 | arr: [{ foo: { bar: [{ test: 'hey' }] } }], 44 | }; 45 | 46 | expect(isSerializable('filename.ts', data)).toBe(true); 47 | }); 48 | 49 | it('throws if it finds an unsupported types', () => { 50 | const data = { foo: Symbol() }; 51 | 52 | expect(() => isSerializable('filename.ts', data)) 53 | .toThrowErrorMatchingInlineSnapshot(` 54 | "Error serializing \`.foo\` returned from \`preval\` in \\"filename.ts\\". 55 | Reason: \`symbol\` cannot be serialized as JSON. Please only return JSON serializable data types." 56 | `); 57 | }); 58 | 59 | test.todo('invariant: Unknown error encountered in Object.'); 60 | test.todo('invariant: Unknown error encountered in Array.'); 61 | }); 62 | -------------------------------------------------------------------------------- /src/is-serializable.ts: -------------------------------------------------------------------------------- 1 | // this copied and pasted from is from next.js 2 | // https://github.com/vercel/next.js/blob/235b4cd0a879c947a7b6906c75f1a1b0ba53ce62/packages/next/lib/is-serializable-props.ts 3 | import { SerializableError } from 'next/dist/lib/is-serializable-props'; 4 | 5 | const regexpPlainIdentifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/; 6 | const method = 'preval'; 7 | 8 | function isPlainObject(obj: any): boolean { 9 | return Object.prototype.toString.call(obj) === '[object Object]'; 10 | } 11 | 12 | function isSerializable(filename: string, input: any): true { 13 | function visit(visited: Map, value: any, path: string) { 14 | if (visited.has(value)) { 15 | throw new SerializableError( 16 | filename, 17 | method, 18 | path, 19 | `Circular references cannot be expressed in JSON (references: \`${ 20 | visited.get(value) || '(self)' 21 | }\`).` 22 | ); 23 | } 24 | 25 | visited.set(value, path); 26 | } 27 | 28 | function isSerializable( 29 | refs: Map, 30 | value: any, 31 | path: string 32 | ): true { 33 | const type = typeof value; 34 | if ( 35 | // `null` can be serialized, but not `undefined`. 36 | value === null || 37 | // n.b. `bigint`, `function`, `symbol`, and `undefined` cannot be 38 | // serialized. 39 | // 40 | // `object` is special-cased below, as it may represent `null`, an Array, 41 | // a plain object, a class, et al. 42 | type === 'boolean' || 43 | type === 'number' || 44 | type === 'string' 45 | ) { 46 | return true; 47 | } 48 | 49 | if (type === 'undefined') { 50 | throw new SerializableError( 51 | filename, 52 | method, 53 | path, 54 | '`undefined` cannot be serialized as JSON. Please use `null` or omit this value.' 55 | ); 56 | } 57 | 58 | if (isPlainObject(value)) { 59 | visit(refs, value, path); 60 | 61 | if ( 62 | Object.entries(value).every(([key, nestedValue]) => { 63 | const nextPath = regexpPlainIdentifier.test(key) 64 | ? `${path}.${key}` 65 | : `${path}[${JSON.stringify(key)}]`; 66 | 67 | const newRefs = new Map(refs); 68 | return ( 69 | isSerializable(newRefs, key, nextPath) && 70 | isSerializable(newRefs, nestedValue, nextPath) 71 | ); 72 | }) 73 | ) { 74 | return true; 75 | } 76 | 77 | throw new SerializableError( 78 | filename, 79 | method, 80 | path, 81 | `invariant: Unknown error encountered in Object.` 82 | ); 83 | } 84 | 85 | if (Array.isArray(value)) { 86 | visit(refs, value, path); 87 | 88 | if ( 89 | value.every((nestedValue, index) => { 90 | const newRefs = new Map(refs); 91 | return isSerializable(newRefs, nestedValue, `${path}[${index}]`); 92 | }) 93 | ) { 94 | return true; 95 | } 96 | 97 | throw new SerializableError( 98 | filename, 99 | method, 100 | path, 101 | `invariant: Unknown error encountered in Array.` 102 | ); 103 | } 104 | 105 | // None of these can be expressed as JSON: 106 | // const type: "bigint" | "symbol" | "object" | "function" 107 | throw new SerializableError( 108 | filename, 109 | method, 110 | path, 111 | '`' + 112 | type + 113 | '`' + 114 | (type === 'object' 115 | ? ` ("${Object.prototype.toString.call(value)}")` 116 | : '') + 117 | ' cannot be serialized as JSON. Please only return JSON serializable data types.' 118 | ); 119 | } 120 | 121 | return isSerializable(new Map(), input, ''); 122 | } 123 | 124 | export default isSerializable; 125 | -------------------------------------------------------------------------------- /src/loader-utils.d.ts: -------------------------------------------------------------------------------- 1 | // the @types/loader-utils package is out-of-date, 2 | // we should check the version later 3 | declare module 'loader-utils' { 4 | import { LoaderContext } from 'webpack'; 5 | 6 | export function getOptions( 7 | loaderContext: LoaderContext 8 | ): OptionsType; 9 | } 10 | -------------------------------------------------------------------------------- /src/loader.test.ts: -------------------------------------------------------------------------------- 1 | import loader, { _prevalLoader } from './loader'; 2 | 3 | // silences the console.error 4 | declare namespace global { 5 | const console: Console & { error: jest.Mock }; 6 | } 7 | 8 | beforeEach(() => { 9 | jest.spyOn(console, 'error'); 10 | global.console.error.mockImplementation(() => {}); 11 | }); 12 | 13 | afterEach(() => { 14 | global.console.error.mockRestore(); 15 | }); 16 | 17 | describe('_prevalLoader', () => { 18 | it('takes in code as a string and pre-evaluates it into JSON', async () => { 19 | const result = await _prevalLoader( 20 | '', 21 | require.resolve('./__example-files__/simple.preval.ts'), 22 | {} 23 | ); 24 | 25 | expect(result).toBe( 26 | 'module.exports = JSON.parse("{\\"hello\\":\\"world\\"}")' 27 | ); 28 | }); 29 | 30 | it('compiles itself and dependencies according to the available babel config', async () => { 31 | const result = await _prevalLoader( 32 | '', 33 | require.resolve('./__example-files__/deps.preval.ts'), 34 | {} 35 | ); 36 | 37 | expect(result).toBe( 38 | 'module.exports = JSON.parse("{\\"hello\\":\\"world\\"}")' 39 | ); 40 | }); 41 | 42 | it('works with tsconfig paths', async () => { 43 | const result = await _prevalLoader( 44 | '', 45 | require.resolve('./__example-files__/tsconfig-paths.preval.ts'), 46 | {} 47 | ); 48 | 49 | expect(result).toBe( 50 | 'module.exports = JSON.parse("{\\"hello\\":\\"world\\"}")' 51 | ); 52 | }); 53 | 54 | it('throws if an invalid JSON object is returned', async () => { 55 | let caught = false; 56 | try { 57 | await _prevalLoader( 58 | '', 59 | require.resolve('./__example-files__/invalid-json.preval.ts'), 60 | {} 61 | ); 62 | } catch (e) { 63 | caught = true; 64 | expect(e.message.replace(/"[^"]+"/g, '"FILENAME"')).toBe( 65 | 'Error serializing `.data.hello` returned from `preval` in "FILENAME".\nReason: `undefined` cannot be serialized as JSON. Please use `null` or omit this value.' 66 | ); 67 | } 68 | 69 | expect(caught).toBe(true); 70 | }); 71 | 72 | it('throws if no default export was found', async () => { 73 | let caught = false; 74 | try { 75 | await _prevalLoader( 76 | '', 77 | require.resolve('./__example-files__/no-default-export.preval.ts'), 78 | {} 79 | ); 80 | } catch (e) { 81 | caught = true; 82 | expect(e.message.replace(/"[^"]+"/g, '"FILENAME"')).toBe( 83 | 'Failed to pre-evaluate "FILENAME". Error: No default export. Did you forget to `export default`? See above for full stack trace.' 84 | ); 85 | } 86 | 87 | expect(caught).toBe(true); 88 | }); 89 | 90 | it('correctly propagates the stack trace', async () => { 91 | let caught = false; 92 | try { 93 | await _prevalLoader( 94 | '', 95 | require.resolve('./__example-files__/throws-indirect.preval.ts'), 96 | {} 97 | ); 98 | } catch (e) { 99 | caught = true; 100 | expect(e.message.replace(/"[^"]+"/g, '"FILENAME"')).toBe( 101 | 'Failed to pre-evaluate "FILENAME". Error: TAKE IT EASY See above for full stack trace.' 102 | ); 103 | expect(global.console.error).toHaveBeenCalledTimes(1); 104 | 105 | // prove that the stack trace is there 106 | const stackTraceErrorMessage = global.console.error.mock.calls[0][1]; 107 | expect(stackTraceErrorMessage.includes('functionThatThrows')).toBe(true); 108 | expect(stackTraceErrorMessage.includes('function-that-throws')).toBe( 109 | true 110 | ); 111 | } 112 | 113 | expect(caught).toBe(true); 114 | }); 115 | 116 | it("polyfills fetch (and others) via require('next')", async () => { 117 | const result = await _prevalLoader( 118 | '', 119 | require.resolve('./__example-files__/uses-fetch.preval.ts'), 120 | {} 121 | ); 122 | 123 | expect(result.includes('Example Domain')).toBe(true); 124 | }); 125 | }); 126 | 127 | describe('loader', () => { 128 | it('throws if async is not available', () => { 129 | let caught = false; 130 | try { 131 | loader.call({ async: () => null, cacheable: () => {} }); 132 | } catch (e) { 133 | caught = true; 134 | expect(e.message).toBe( 135 | 'Async was not supported by webpack. Please open an issue in next-plugin-preval.' 136 | ); 137 | } 138 | 139 | expect(caught).toBe(true); 140 | }); 141 | 142 | it('calls the callback with the result if successful', (done) => { 143 | const callback = (err, result) => { 144 | expect(err).toBe(null); 145 | expect(result).toBe( 146 | 'module.exports = JSON.parse("{\\"hello\\":\\"world\\"}")' 147 | ); 148 | done(); 149 | }; 150 | 151 | loader.call( 152 | { 153 | async: () => callback, 154 | cacheable: () => {}, 155 | resourcePath: require.resolve('./__example-files__/simple.preval.ts'), 156 | }, 157 | '' 158 | ); 159 | }); 160 | 161 | it('calls the callback an error if unsuccessful', (done) => { 162 | const callback = (err) => { 163 | expect(err.message.replace(/"[^"]+"/g, '"FILENAME"')).toBe( 164 | 'Failed to pre-evaluate "FILENAME". Error: WHOA THERE! See above for full stack trace.' 165 | ); 166 | expect(global.console.error).toHaveBeenCalledTimes(1); 167 | expect(global.console.error.mock.calls[0][0]).toBe( 168 | '[next-plugin-preval]' 169 | ); 170 | done(); 171 | }; 172 | 173 | loader.call( 174 | { 175 | async: () => callback, 176 | resource: 'test-resource', 177 | cacheable: () => {}, 178 | resourcePath: require.resolve('./__example-files__/throws.preval.ts'), 179 | }, 180 | '' 181 | ); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /src/loader.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import type webpack from 'webpack'; 3 | import requireFromString from 'require-from-string'; 4 | // @ts-expect-error 5 | import { resolvePath as defaultResolvePath } from 'babel-plugin-module-resolver'; 6 | import { getOptions } from 'loader-utils'; 7 | import { createMatchPath, loadConfig } from 'tsconfig-paths'; 8 | import isSerializable from './is-serializable'; 9 | // @ts-expect-error 10 | import register, { revert } from '@babel/register'; 11 | 12 | class PrevalError extends Error {} 13 | 14 | interface PrevalLoaderOptions { 15 | extensions?: string[]; 16 | } 17 | 18 | const defaultExtensions = ['.js', '.jsx', '.ts', '.tsx']; 19 | 20 | const isRecord = (something: unknown): something is Record => 21 | typeof something === 'object' && !!something && !Array.isArray(something); 22 | 23 | const readJson = (filename: string) => { 24 | try { 25 | return require(filename); 26 | } catch { 27 | return undefined; 28 | } 29 | }; 30 | 31 | const fileExists = (filename: string) => { 32 | try { 33 | return fs.existsSync(filename); 34 | } catch { 35 | return false; 36 | } 37 | }; 38 | 39 | export async function _prevalLoader( 40 | _: string, 41 | resource: string, 42 | options: PrevalLoaderOptions 43 | ) { 44 | const { extensions = defaultExtensions } = options; 45 | 46 | const configLoaderResult = loadConfig(); 47 | 48 | const configLoaderSuccessResult = 49 | configLoaderResult.resultType === 'failed' ? null : configLoaderResult; 50 | 51 | const matchPath = 52 | configLoaderSuccessResult && 53 | createMatchPath( 54 | configLoaderSuccessResult.absoluteBaseUrl, 55 | configLoaderSuccessResult.paths 56 | ); 57 | 58 | const moduleResolver = 59 | configLoaderSuccessResult && 60 | ([ 61 | 'module-resolver', 62 | { 63 | extensions, 64 | resolvePath: (sourcePath: string, currentFile: string, opts: any) => { 65 | if (matchPath) { 66 | try { 67 | return matchPath(sourcePath, readJson, fileExists, extensions); 68 | } catch { 69 | return defaultResolvePath(sourcePath, currentFile, opts); 70 | } 71 | } 72 | 73 | return defaultResolvePath(sourcePath, currentFile, opts); 74 | }, 75 | }, 76 | ] as const); 77 | 78 | register({ 79 | // this is used by `next/babel` preset to conditionally remove loaders. 80 | // without it, it causes the dreaded `e.charCodeAt is not a function` error. 81 | // see: 82 | // - https://github.com/ricokahler/next-plugin-preval/issues/66 83 | // - https://github.com/vercel/next.js/blob/37d11008250b3b87dfa4625cd228ac173d4d3563/packages/next/build/babel/preset.ts#L65 84 | caller: { isServer: true }, 85 | presets: ['next/babel', ['@babel/preset-env', { targets: 'node 12' }]], 86 | plugins: [ 87 | // conditionally add 88 | ...(moduleResolver ? [moduleResolver] : []), 89 | ], 90 | rootMode: 'upward-optional', 91 | // TODO: this line may cause performance issues, it makes babel compile 92 | // things `node_modules` however this is currently required for setups that 93 | // include the use of sym-linked deps as part of workspaces (both yarn and 94 | // npm) 95 | ignore: [], 96 | // disables the warning "Babel has de-optimized the styling of..." 97 | compact: true, 98 | extensions, 99 | }); 100 | 101 | const data = await (async () => { 102 | try { 103 | const mod = requireFromString( 104 | `require('next');\nmodule.exports = require(${JSON.stringify( 105 | resource 106 | )})`, 107 | `${resource}.preval.js` 108 | ); 109 | 110 | if (!mod.default) { 111 | throw new PrevalError( 112 | 'No default export. Did you forget to `export default`?' 113 | ); 114 | } 115 | 116 | return await mod.default; 117 | } catch (e) { 118 | if (isRecord(e) && 'stack' in e) { 119 | // TODO: use the webpack logger. i tried this and it didn't output anything. 120 | console.error('[next-plugin-preval]', e.stack); 121 | } 122 | 123 | throw new PrevalError( 124 | `Failed to pre-evaluate "${resource}". ${e} See above for full stack trace.` 125 | ); 126 | } finally { 127 | revert(); 128 | } 129 | })(); 130 | 131 | isSerializable(resource, data); 132 | 133 | // NOTE we wrap in JSON.parse because that's faster for JS engines to parse 134 | // over javascript. see here https://v8.dev/blog/cost-of-javascript-2019#json 135 | // 136 | // We wrap in JSON.stringify twice. Once for a JSON string and once again for 137 | // a JSON string that can be embeddable in javascript. 138 | return `module.exports = JSON.parse(${JSON.stringify(JSON.stringify(data))})`; 139 | } 140 | 141 | const loader = function ( 142 | this: webpack.LoaderContext, 143 | content: string 144 | ) { 145 | const callback = this.async(); 146 | 147 | this.cacheable(false); 148 | 149 | if (!callback) { 150 | throw new PrevalError( 151 | 'Async was not supported by webpack. Please open an issue in next-plugin-preval.' 152 | ); 153 | } 154 | 155 | _prevalLoader(content.toString(), this.resourcePath, getOptions(this)) 156 | .then((result) => { 157 | callback(null, result); 158 | }) 159 | .catch((e) => { 160 | callback(e); 161 | }); 162 | }; 163 | 164 | export default loader; 165 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "baseUrl": ".", 11 | "declaration": true, 12 | "emitDeclarationOnly": true, 13 | "outDir": "./dist", 14 | "paths": { 15 | "@/test-module": ["./src/__example-files__/test-module"], 16 | "next-plugin-preval": ["./src"] 17 | } 18 | }, 19 | "exclude": ["**/*.test.ts", "**/*/__example-files__/**/*"], 20 | "include": ["src/**/*"] 21 | } 22 | --------------------------------------------------------------------------------