├── .gitattributes ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── context.d.ts ├── context.js ├── examples ├── with-react-query │ ├── README.md │ ├── next.config.js │ ├── package.json │ └── pages │ │ ├── _app.js │ │ ├── api │ │ └── movies.js │ │ └── index.js ├── with-swr │ ├── README.md │ ├── next.config.js │ ├── package.json │ └── pages │ │ ├── api │ │ └── movies.js │ │ └── index.js └── with-typescript │ ├── README.md │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── pages │ ├── api │ │ └── host.ts │ └── index.tsx │ └── tsconfig.json ├── index.d.ts ├── index.js ├── jest.config.js ├── json-rpc-chrome-viewer.jpeg ├── lib ├── astUtils.ts ├── babelTransformContext.ts ├── babelTransformRpc.ts ├── browser.ts ├── context-internal.ts ├── context.ts ├── index.ts ├── jsonRpc.ts └── server.ts ├── package.json ├── test ├── __fixtures__ │ ├── basePath │ │ ├── jsconfig.json │ │ ├── next.config.js │ │ └── pages │ │ │ ├── api │ │ │ └── rpc-route.js │ │ │ └── index.js │ ├── basic-app │ │ ├── jsconfig.json │ │ ├── next.config.js │ │ └── pages │ │ │ ├── api │ │ │ ├── disallowed-syntax.js │ │ │ ├── non-rpc-route.js │ │ │ ├── rpc-route.js │ │ │ ├── rpc-syntax.js │ │ │ ├── wrapMethod1.js │ │ │ ├── wrapMethod2.js │ │ │ └── wrapMethod3.js │ │ │ ├── index.js │ │ │ ├── syntax.js │ │ │ ├── throws-non-error.js │ │ │ ├── throws.js │ │ │ ├── wrapped1.js │ │ │ ├── wrapped2.js │ │ │ └── wrapped3.js │ ├── context │ │ ├── jsconfig.json │ │ ├── middleware.js │ │ ├── next.config.js │ │ └── pages │ │ │ ├── _app.js │ │ │ ├── api │ │ │ ├── classicApi.js │ │ │ └── withContext.js │ │ │ ├── callRpc.js │ │ │ ├── cookies.js │ │ │ ├── getInitialProps1.js │ │ │ ├── getInitialProps2.js │ │ │ ├── getInitialProps3.js │ │ │ ├── getInitialProps4.js │ │ │ ├── getServerSideProps.js │ │ │ ├── getServerSideProps2.js │ │ │ ├── getServerSideProps3.js │ │ │ ├── getServerSideProps4.js │ │ │ └── index.js │ ├── typescript-context │ │ ├── next-env.d.ts │ │ ├── next.config.js │ │ ├── pages │ │ │ ├── api │ │ │ │ └── withContext.ts │ │ │ └── getServerSideProps.tsx │ │ └── tsconfig.json │ └── typescript │ │ ├── next-env.d.ts │ │ ├── next.config.js │ │ ├── pages │ │ ├── api │ │ │ ├── host.ts │ │ │ └── wrapped.ts │ │ └── index.tsx │ │ └── tsconfig.json ├── basePath.spec.ts ├── context.spec.ts ├── rpc.spec.ts ├── typescript.spec.ts ├── typescriptContext.spec.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [14.x, 16.x, 18.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: yarn install --frozen-lockfile 27 | - run: yarn build 28 | - run: yarn test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | examples/**/yarn.lock 107 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | dist 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | singleQuote: true, 4 | }; 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v3.7.0 - 2023-01-21 4 | 5 | - Remove `findPagesDir()` method from public API. It shouldn't have been there in the first place. 6 | - Adhere underlying messages to the [JSON-RPC](https://www.jsonrpc.org/specification) protocol. 7 | 8 | ## v3.6.0 - 2023-01-21 9 | 10 | _Accidental republish of 3.5.3._ 11 | 12 | ## v3.5.3 - 2023-01-07 13 | 14 | - Fix context when `getServerSideProps` is a function expression. 15 | 16 | ## v3.5.1 - 2022-05-28 17 | 18 | _Beginning of this changelog._ 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jan Potoms 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-rpc 2 | 3 | `next-rpc` makes exported functions from API routes accessible in the browser. Just import your API function and call it anywhere you want. 4 | 5 | ## Example 6 | 7 | Define your rpc route as follows: 8 | 9 | ```js 10 | // /pages/api/countries.js 11 | export const config = { rpc: true }; // enable rpc on this API route 12 | 13 | // export a function that needs to be called from the server and the browser 14 | export async function getName(code) { 15 | return db.query(`SELECT name FROM country WHERE code = ?`, code); 16 | } 17 | ``` 18 | 19 | Now in your components you can just import `getName` and call it anywhere you want: 20 | 21 | ```jsx 22 | // /pages/index.js 23 | import { getName } from './api/countries'; 24 | 25 | export default function MyPage({ initialData }) { 26 | const [countryName, setCountryName] = React.useState(initialData); 27 | 28 | return ( 29 | 32 | ); 33 | } 34 | ``` 35 | 36 | ## Installation 37 | 38 | Install the `next-rpc` module 39 | 40 | ``` 41 | npm install -S next-rpc 42 | ``` 43 | 44 | configure Next.js to use the module 45 | 46 | ```tsx 47 | // ./next.config.js 48 | const withRpc = require('next-rpc')(); 49 | module.exports = withRpc({ 50 | // your next.js config goes here 51 | }); 52 | ``` 53 | 54 | ## Why this library is needed 55 | 56 | Next.js 9.3 introduced [`getServerSideProps` and `getStaticProps`](https://nextjs.org/docs/basic-features/data-fetching). New ways of calling serverside code and transfer the data to the browser. a pattern emerged for sharing API routes serverside and browserside. In short the idea is to abstract the logic into an exported function for serverside code and expose the function to the browser through an API handler. 57 | 58 | ```js 59 | // /pages/api/myApi.js 60 | export async function getName(code) { 61 | return db.query(`SELECT name FROM country WHERE code = ?`, code); 62 | } 63 | 64 | export default async (req, res) => { 65 | res.send(await getName(req.query.code)); 66 | }; 67 | ``` 68 | 69 | This pattern is great as it avoids hitting the network when used serverside. Unfortunately, to use it client side it still involves a lot of ceremony. i.e. a http request handler needs to be set up, `fetch` needs to be used in the browser, the input and output needs to be correctly encoded and decoded. Error handling needs to be set up to deal with network related errors. If you use typescript you need to find a way to propagate the types from API to fetch result. etc... 70 | 71 | Wouldn't it be nice if all of that was automatically handled and all you'd need to do is import `getName` on the browserside, just like you do serverside? That's where `next-rpc` comes in. With a `next-rpc` enabled API route, all its exported functions automatically become available to the browser as well. 72 | 73 | > **Note:** `next-rpc` is not meant as a full replacement for Next.js API routes. Some use cases are still better solved with classic API routes. For instance when you want to rely on the existing browser caching mechanisms. 74 | 75 | ## Rules and limitations 76 | 77 | 1. Rpc routes are **only allowed to export async functions**. They also need to be statically analyzable as such. Therefore only the following is allowed, either: 78 | 79 | ```js 80 | export async function fn1() {} 81 | 82 | export const fn2 = async () => {}; 83 | ``` 84 | 85 | 2. All inputs and outputs must be simple **JSON serializable values**. 86 | 3. a **default export is not allowed**. `next-rpc` will generate one. 87 | 4. **You must enable rpc routes** through the `config` export. It must be an exported object that has the `rpc: true` property. 88 | 89 | ## typescript 90 | 91 | > Try [the example](https://github.com/Janpot/next-rpc/tree/master/examples/with-typescript) on [codesandbox](https://codesandbox.io/s/github/Janpot/next-rpc/tree/master/examples/with-typescript) 92 | 93 | `next-rpc` works really nicely with typescript. There is no serialization layer so functions just retain their type sigantures both on server and client. 94 | 95 | ## swr 96 | 97 | > Try [the example](https://github.com/Janpot/next-rpc/tree/master/examples/with-swr) on [codesandbox](https://codesandbox.io/s/github/Janpot/next-rpc/tree/master/examples/with-swr) 98 | 99 | `next-rpc` can work seamlessly with [`swr`](https://swr.vercel.app/). 100 | 101 | ```ts 102 | // ./pages/api/projects.js 103 | export const config = { rpc: true }; 104 | 105 | export async function getMovies(genre) { 106 | return db.query(`...`); 107 | } 108 | 109 | // ./pages/index.jsx 110 | import useSwr from 'swr'; 111 | import { getMovies } from './api/movies'; 112 | import MoviesList from '../components/MoviesList'; 113 | 114 | const callFn = (method, ...params) => method(...params); 115 | 116 | export default function Comedies() { 117 | const { data, error } = useSwr([getMovies, 'comedy'], callFn); 118 | if (error) return
failed to load
; 119 | if (!data) return
loading...
; 120 | return ; 121 | } 122 | ``` 123 | 124 | ## react-query 125 | 126 | > Try [the example](https://github.com/Janpot/next-rpc/tree/master/examples/with-react-query) on [codesandbox](https://codesandbox.io/s/github/Janpot/next-rpc/tree/master/examples/with-react-query) 127 | 128 | `next-rpc` can also work with [`react-query`](https://react-query.tanstack.com/). 129 | 130 | ```ts 131 | // ./pages/api/projects.js 132 | export const config = { rpc: true }; 133 | 134 | export async function getMovies(genre) { 135 | return db.query(`...`); 136 | } 137 | 138 | // ./pages/index.jsx 139 | import { QueryClient, QueryClientProvider, useQuery } from 'react-query'; 140 | import { getMovies } from './api/movies'; 141 | import MoviesList from '../components/MoviesList'; 142 | 143 | function App() { 144 | const queryClient = React.useMemo(() => new QueryClient(), []); 145 | return ( 146 | 147 | 148 | 149 | ); 150 | } 151 | 152 | export default function Movies({ genre = 'comedy' }) { 153 | const { isLoading, error, data } = useQuery(['getMovies', genre], () => 154 | getMovies(genre) 155 | ); 156 | if (error) return
failed to load
; 157 | if (isLoading) return
loading...
; 158 | return ; 159 | } 160 | ``` 161 | 162 | ## Middleware 163 | 164 | `next-rpc` allows for defining middleware functions that automatically wrap all your API methods. this could be useful for logging purposes. To define such middleware you can supply a `wrapMethod` option to the `config` export. This function receives the method it's wrapping along with some metadata and is expected to return a function with the exact same signature. Example: 165 | 166 | ```tsx 167 | import { NextRpcConfig, WrapMethod } from 'next-rpc'; 168 | 169 | const wrapMethod: WrapMethod = (method, meta) => { 170 | return async (...args) => { 171 | console.log(`calling "${meta.name}" on "${meta.pathname}" with ${args}`); 172 | const result = await method(...args); 173 | console.log(`result: ${result}`); 174 | return result; 175 | }; 176 | }; 177 | 178 | export const config: NextRpcConfig = { 179 | rpc: true, 180 | wrapMethod, 181 | }; 182 | ``` 183 | 184 | ## Debugging `next-rpc` 185 | 186 | Since version 3.7.0, `next-rpc` uses the [JSON-RPC](https://www.jsonrpc.org/specification) format for its messages. This makes it possible to use tools like [JSON RPC Chrome Viewer](https://chrome.google.com/webstore/detail/json-rpc-chrome-viewer/bfkookcjhlalpmeedppachhdkhmflbah) to introspect the rpc frames that are being transferred by `next-rpc`. 187 | 188 | ![JSON RPC Chrome Viewer screenshot](./json-rpc-chrome-viewer.jpeg) 189 | 190 | Disclaimer: I have no affiliation with this extension, use at your own discretion. 191 | 192 | ## Next.js request context 193 | 194 | > **⚠️ warning:** 195 | > 196 | > request context is not supported in the Next.js [edge runtime](https://nextjs.org/docs/api-reference/edge-runtime) and won't be for the foreseeable future. 197 | 198 | This library completely hides the network layer. This makes it elegant to use, but also imposes limitations. To efficiently be able to implement things like cookie authentication, access to the underlying requests is required. To enable that, this library introduces `next-rpc/context`. An example: 199 | 200 | ```js 201 | // ./pages/api/myRpc.js 202 | import { getContext } from 'next-rpc/context'; 203 | 204 | const config = { rpc: true }; 205 | 206 | export async function currentUser() { 207 | const { req } = getContext(); 208 | return getUserFromRequest(req); 209 | } 210 | ``` 211 | 212 | The `req` variable in the previous example will contain the `IncomingMessage` that lead to the call of `currentUser()`. That means it will receive `req` from either: 213 | 214 | - `NextPageContext`: if it traces back to `getServerSideProps` or `getInitialProps`. 215 | - `NextApiHandler`: if it traces back to a call in another API handler, or if it was called from the browser. 216 | 217 | `next-rpc` intercepts all instances of `getInitialProps`, `getServerSideProps` and api handlers and injects its context provider in there. From there on, every function invocation that descends from that point will be able to access the context through `getContext`. Since this feature relies on experimental APIs, it needs to be explicitly enabled by configuring the `experimentalContext` flag in `next.config.js`: 218 | 219 | ```js 220 | // ./next.config.js 221 | const withRpc = require('next-rpc')({ 222 | experimentalContext: true, 223 | }); 224 | module.exports = withRpc(); 225 | ``` 226 | 227 | ## How it works 228 | 229 | `next-rpc` compiles api routes. If it finds `rpc` enabled it will rewrite the module. In serverside bundles, it will generate an API handler that encapsulates all exported functions. For browserside bundles, it will replace each exported function with a function that uses `fetch` to call this API handler. 230 | 231 | It's important to note that `next-rpc` intends to be fully backwards compatible. If you don't specify the `rpc` option, the API route will behave as it does by default in Next.js. 232 | 233 | ## Roadmap 234 | 235 | - **Improve dev experience:** warn when using unserializable input/output 236 | - **Custom contexts:** it should be possible to build on the context feature to provide a custom context. e.g. `UserContext`. 237 | 238 | ## Contributing 239 | 240 | Bug fixing PRs are welcome, provided they are of high quality and accompagnied by tests. Don't open PRs for feature requests prior to my approval. 241 | -------------------------------------------------------------------------------- /context.d.ts: -------------------------------------------------------------------------------- 1 | import * as context from './dist/context'; 2 | export = context; 3 | -------------------------------------------------------------------------------- /context.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/context'); 2 | -------------------------------------------------------------------------------- /examples/with-react-query/README.md: -------------------------------------------------------------------------------- 1 | # Example with `react-query` 2 | 3 | Shows how to use `next-rpc` together with [`react-query`](https://react-query.tanstack.com/). 4 | 5 | ## How to use 6 | 7 | [Try it on CodeSandbox](https://codesandbox.io/s/github/Janpot/next-rpc/tree/master/examples/with-react-query) 8 | -------------------------------------------------------------------------------- /examples/with-react-query/next.config.js: -------------------------------------------------------------------------------- 1 | const withRpc = require('next-rpc')(); 2 | module.exports = withRpc(); 3 | -------------------------------------------------------------------------------- /examples/with-react-query/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-react-query", 3 | "version": "0.1.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "next": "latest", 12 | "next-rpc": "latest", 13 | "react": "latest", 14 | "react-dom": "latest", 15 | "react-query": "latest" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/with-react-query/pages/_app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Hydrate, QueryClient, QueryClientProvider } from 'react-query'; 3 | import { ReactQueryDevtools } from 'react-query/devtools'; 4 | 5 | export default function MyApp({ Component, pageProps }) { 6 | const queryClient = React.useMemo(() => new QueryClient(), []); 7 | 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /examples/with-react-query/pages/api/movies.js: -------------------------------------------------------------------------------- 1 | export const config = { rpc: true }; 2 | 3 | const LATENCY = 500; 4 | 5 | const delay = async (ms) => new Promise((r) => setTimeout(r, ms)); 6 | 7 | const flat = (arrays) => [].concat(...arrays); 8 | 9 | const movies = { 10 | action: [ 11 | { title: 'Die Hard', year: 1988 }, 12 | { title: 'Terminator', year: 1984 }, 13 | { title: 'Raiders of the Loast Ark', year: 1981 }, 14 | ], 15 | thriller: [ 16 | { title: 'The Silence of the Lambs', year: 1991 }, 17 | { title: 'The Sixth Sense', year: 1999 }, 18 | { title: 'American Psycho', year: 2000 }, 19 | ], 20 | animation: [ 21 | { title: 'Toy Story', year: 1995 }, 22 | { title: 'WALL-E', year: 2008 }, 23 | { title: 'Frozen', year: 2013 }, 24 | ], 25 | }; 26 | 27 | export async function getGenres() { 28 | await delay(LATENCY); 29 | return Object.keys(movies); 30 | } 31 | 32 | export async function getMovies(genre) { 33 | await delay(LATENCY); 34 | return genre ? movies[genre] : flat(Object.values(movies)); 35 | } 36 | -------------------------------------------------------------------------------- /examples/with-react-query/pages/index.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useQuery } from 'react-query'; 3 | import { getGenres, getMovies } from './api/movies'; 4 | 5 | export default function Movies() { 6 | const [genre, setGenre] = React.useState(''); 7 | 8 | const genres = useQuery('getGenres', getGenres); 9 | const movies = useQuery(['getMovies', genre], () => getMovies(genre)); 10 | 11 | return ( 12 |
13 |
14 | 34 | {movies.error 35 | ? 'error loading movies' 36 | : movies.data 37 | ? null 38 | : 'loading movies...'} 39 |
40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {movies.data 49 | ? movies.data.map((movie) => ( 50 | 51 | 52 | 53 | 54 | )) 55 | : null} 56 | 57 |
TitleYear
{movie.title}{movie.year}
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /examples/with-swr/README.md: -------------------------------------------------------------------------------- 1 | # Example with `swr` 2 | 3 | Shows how to use `next-rpc` together with [`swr`](https://swr.vercel.app/). 4 | 5 | ## How to use 6 | 7 | [Try it on CodeSandbox](https://codesandbox.io/s/github/Janpot/next-rpc/tree/master/examples/with-swr) 8 | -------------------------------------------------------------------------------- /examples/with-swr/next.config.js: -------------------------------------------------------------------------------- 1 | const withRpc = require('next-rpc')(); 2 | module.exports = withRpc(); 3 | -------------------------------------------------------------------------------- /examples/with-swr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-swr", 3 | "version": "0.1.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "next": "latest", 12 | "next-rpc": "latest", 13 | "react": "latest", 14 | "react-dom": "latest", 15 | "swr": "latest" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/with-swr/pages/api/movies.js: -------------------------------------------------------------------------------- 1 | export const config = { rpc: true }; 2 | 3 | const LATENCY = 500; 4 | 5 | const delay = async (ms) => new Promise((r) => setTimeout(r, ms)); 6 | 7 | const flat = (arrays) => [].concat(...arrays); 8 | 9 | const movies = { 10 | action: [ 11 | { title: 'Die Hard', year: 1988 }, 12 | { title: 'Terminator', year: 1984 }, 13 | { title: 'Raiders of the Loast Ark', year: 1981 }, 14 | ], 15 | thriller: [ 16 | { title: 'The Silence of the Lambs', year: 1991 }, 17 | { title: 'The Sixth Sense', year: 1999 }, 18 | { title: 'American Psycho', year: 2000 }, 19 | ], 20 | animation: [ 21 | { title: 'Toy Story', year: 1995 }, 22 | { title: 'WALL-E', year: 2008 }, 23 | { title: 'Frozen', year: 2013 }, 24 | ], 25 | }; 26 | 27 | export async function getGenres() { 28 | await delay(LATENCY); 29 | return Object.keys(movies); 30 | } 31 | 32 | export async function getMovies(genre) { 33 | await delay(LATENCY); 34 | return genre ? movies[genre] : flat(Object.values(movies)); 35 | } 36 | -------------------------------------------------------------------------------- /examples/with-swr/pages/index.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import useSwr from 'swr'; 3 | import { getGenres, getMovies } from './api/movies'; 4 | 5 | const callFn = (method, ...params) => method(...params); 6 | 7 | export default function Movies() { 8 | const [genre, setGenre] = React.useState(''); 9 | const genres = useSwr([getGenres], callFn); 10 | const movies = useSwr([getMovies, genre], callFn); 11 | return ( 12 |
13 |
14 | 34 | {movies.error 35 | ? 'error loading movies' 36 | : movies.data 37 | ? null 38 | : 'loading movies...'} 39 |
40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {movies.data 49 | ? movies.data.map((movie) => ( 50 | 51 | 52 | 53 | 54 | )) 55 | : null} 56 | 57 |
TitleYear
{movie.title}{movie.year}
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /examples/with-typescript/README.md: -------------------------------------------------------------------------------- 1 | # Typescript example 2 | 3 | Shows how `next-rpc` works nicely with typescript. 4 | 5 | ## How to use 6 | 7 | [Try it on CodeSandbox](https://codesandbox.io/s/github/Janpot/next-rpc/tree/master/examples/with-typescript) 8 | -------------------------------------------------------------------------------- /examples/with-typescript/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /examples/with-typescript/next.config.js: -------------------------------------------------------------------------------- 1 | const withRpc = require('next-rpc')(); 2 | module.exports = withRpc(); 3 | -------------------------------------------------------------------------------- /examples/with-typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-typescript", 3 | "version": "0.1.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "next": "latest", 12 | "next-rpc": "latest", 13 | "react": "latest", 14 | "react-dom": "latest" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "latest", 18 | "@types/react": "latest", 19 | "typescript": "latest" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/with-typescript/pages/api/host.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | 3 | export const config = { rpc: true }; 4 | 5 | export type HostInfo = { 6 | now: string; 7 | hostname: string; 8 | }; 9 | 10 | export async function getInfo(): Promise { 11 | return { 12 | now: new Date().toLocaleTimeString(), 13 | hostname: os.hostname(), 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /examples/with-typescript/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { GetServerSideProps } from 'next'; 3 | import { getInfo, HostInfo } from './api/host'; 4 | 5 | export const getServerSideProps: GetServerSideProps = async () => { 6 | return { 7 | // We can call the functions server-side 8 | props: await getInfo(), 9 | }; 10 | }; 11 | 12 | export default function Home(props: HostInfo) { 13 | const [info, setInfo] = React.useState(props); 14 | 15 | React.useEffect(() => { 16 | // And we can call the functions client-side 17 | const interval = setInterval(() => getInfo().then(setInfo), 2000); 18 | return () => clearInterval(interval); 19 | }, []); 20 | 21 | return ( 22 |
23 | It's now {info.now} on host {info.hostname} 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /examples/with-typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 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 | }, 17 | "exclude": ["node_modules"], 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 19 | } 20 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import index from './dist/index'; 2 | export * from './dist/index'; 3 | export = index; 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/index').default; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.(t|j)sx?$': '@swc/jest', 4 | }, 5 | transformIgnorePatterns: [ 6 | '/node_modules/(?:puppeteer|web-streams-polyfill)/.+\\.js$', 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /json-rpc-chrome-viewer.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janpot/next-rpc/b64c70b022edecb8faec5d8c3bb69eab9a5d4c13/json-rpc-chrome-viewer.jpeg -------------------------------------------------------------------------------- /lib/astUtils.ts: -------------------------------------------------------------------------------- 1 | import { Node, types } from '@babel/core'; 2 | 3 | const PURE_ANNOTATION = '#__PURE__'; 4 | 5 | export function annotateAsPure(t: typeof types, node: N): N { 6 | t.addComment(node, 'leading', PURE_ANNOTATION); 7 | return node; 8 | } 9 | 10 | export type Literal = 11 | | number 12 | | string 13 | | boolean 14 | | { [key: string]: Literal } 15 | | Literal[]; 16 | 17 | export function literalToAst( 18 | t: typeof types, 19 | literal: Literal 20 | ): babel.types.Expression { 21 | if (typeof literal === 'number') { 22 | return t.numericLiteral(literal); 23 | } else if (typeof literal === 'boolean') { 24 | return t.booleanLiteral(literal); 25 | } else if (typeof literal === 'string') { 26 | return t.stringLiteral(literal); 27 | } else if (Array.isArray(literal)) { 28 | return t.arrayExpression(literal.map((item) => literalToAst(t, item))); 29 | } else if (typeof literal === 'object') { 30 | return t.objectExpression( 31 | Object.entries(literal).map(([key, value]) => { 32 | return t.objectProperty(t.identifier(key), literalToAst(t, value)); 33 | }) 34 | ); 35 | } 36 | throw new Error(`Unsupported literal type "${typeof literal}"`); 37 | } 38 | -------------------------------------------------------------------------------- /lib/babelTransformContext.ts: -------------------------------------------------------------------------------- 1 | import { annotateAsPure } from './astUtils'; 2 | import * as babel from '@babel/core'; 3 | 4 | type Babel = typeof babel; 5 | 6 | const IMPORT_PATH = 'next-rpc/dist/context-internal'; 7 | 8 | export interface PluginOptions { 9 | apiDir: string; 10 | isServer: boolean; 11 | } 12 | 13 | function visitApiHandler( 14 | { types: t }: Babel, 15 | path: babel.NodePath 16 | ) { 17 | let defaultExportPath: 18 | | babel.NodePath 19 | | undefined; 20 | 21 | path.traverse({ 22 | ExportDefaultDeclaration(path) { 23 | defaultExportPath = path; 24 | }, 25 | }); 26 | 27 | if (defaultExportPath) { 28 | const { declaration } = defaultExportPath.node; 29 | if (t.isTSDeclareFunction(declaration)) { 30 | return; 31 | } 32 | 33 | const wrapApiHandlerIdentifier = 34 | path.scope.generateUidIdentifier('wrapApiHandler'); 35 | 36 | path.node.body.unshift( 37 | t.importDeclaration( 38 | [ 39 | t.importSpecifier( 40 | wrapApiHandlerIdentifier, 41 | t.identifier('wrapApiHandler') 42 | ), 43 | ], 44 | t.stringLiteral(IMPORT_PATH) 45 | ) 46 | ); 47 | 48 | const exportAsExpression = t.isDeclaration(declaration) 49 | ? t.toExpression(declaration) 50 | : declaration; 51 | 52 | defaultExportPath.replaceWith( 53 | t.exportDefaultDeclaration( 54 | annotateAsPure( 55 | t, 56 | t.callExpression(wrapApiHandlerIdentifier, [exportAsExpression]) 57 | ) 58 | ) 59 | ); 60 | } 61 | } 62 | 63 | function visitPage( 64 | { types: t }: Babel, 65 | path: babel.NodePath 66 | ) { 67 | const wrapGetServerSidePropsIdentifier = path.scope.generateUidIdentifier( 68 | 'wrapGetServerSideProps' 69 | ); 70 | const wrapPageIdentifier = path.scope.generateUidIdentifier('wrapPage'); 71 | 72 | path.node.body.unshift( 73 | t.importDeclaration( 74 | [ 75 | t.importSpecifier( 76 | wrapGetServerSidePropsIdentifier, 77 | t.identifier('wrapGetServerSideProps') 78 | ), 79 | t.importSpecifier(wrapPageIdentifier, t.identifier('wrapPage')), 80 | ], 81 | t.stringLiteral(IMPORT_PATH) 82 | ) 83 | ); 84 | 85 | path.traverse({ 86 | ExportNamedDeclaration(path) { 87 | const declarationPath = path.get('declaration'); 88 | if ( 89 | declarationPath.isFunctionDeclaration() && 90 | declarationPath.get('id').isIdentifier({ name: 'getServerSideProps' }) 91 | ) { 92 | const declaration = declarationPath.node; 93 | const exportAsExpression = t.isDeclaration(declaration) 94 | ? t.toExpression(declaration) 95 | : declaration; 96 | 97 | path.node.declaration = t.variableDeclaration('const', [ 98 | t.variableDeclarator( 99 | declaration.id as babel.types.Identifier, 100 | annotateAsPure( 101 | t, 102 | t.callExpression(wrapGetServerSidePropsIdentifier, [ 103 | exportAsExpression, 104 | ]) 105 | ) 106 | ), 107 | ]); 108 | } else if (declarationPath.isVariableDeclaration()) { 109 | const declarations = declarationPath.get('declarations'); 110 | 111 | for (const variableDeclaratorPath of declarations) { 112 | if ( 113 | variableDeclaratorPath 114 | .get('id') 115 | .isIdentifier({ name: 'getServerSideProps' }) 116 | ) { 117 | const initPath = variableDeclaratorPath.get('init'); 118 | if (initPath.node) { 119 | initPath.replaceWith( 120 | t.callExpression(wrapGetServerSidePropsIdentifier, [ 121 | initPath.node, 122 | ]) 123 | ); 124 | } 125 | } 126 | } 127 | } 128 | }, 129 | ExportDefaultDeclaration(defaultExportPath) { 130 | const { declaration } = defaultExportPath.node; 131 | if (t.isTSDeclareFunction(declaration)) { 132 | return; 133 | } 134 | 135 | if (t.isDeclaration(declaration)) { 136 | if (!declaration.id) { 137 | return; 138 | } 139 | 140 | defaultExportPath.insertAfter( 141 | t.expressionStatement( 142 | t.assignmentExpression( 143 | '=', 144 | declaration.id, 145 | annotateAsPure( 146 | t, 147 | t.callExpression(wrapPageIdentifier, [declaration.id]) 148 | ) 149 | ) 150 | ) 151 | ); 152 | } else { 153 | defaultExportPath.replaceWith( 154 | t.exportDefaultDeclaration( 155 | annotateAsPure( 156 | t, 157 | t.callExpression(wrapPageIdentifier, [declaration]) 158 | ) 159 | ) 160 | ); 161 | defaultExportPath.skip(); 162 | } 163 | }, 164 | }); 165 | } 166 | 167 | export default function ( 168 | babel: Babel, 169 | options: PluginOptions 170 | ): babel.PluginObj { 171 | const { apiDir, isServer } = options; 172 | 173 | return { 174 | visitor: { 175 | Program(path) { 176 | if (!isServer) { 177 | return; 178 | } 179 | 180 | const { filename } = this.file.opts; 181 | const isApiRoute = filename && filename.startsWith(apiDir); 182 | const isMiddleware = 183 | filename && /(\/|\\)_middleware\.\w+$/.test(filename); 184 | 185 | if (isApiRoute) { 186 | visitApiHandler(babel, path); 187 | } else if (!isMiddleware) { 188 | visitPage(babel, path); 189 | } 190 | }, 191 | }, 192 | }; 193 | } 194 | -------------------------------------------------------------------------------- /lib/babelTransformRpc.ts: -------------------------------------------------------------------------------- 1 | import { annotateAsPure, literalToAst } from './astUtils'; 2 | import * as babel from '@babel/core'; 3 | import { WrapMethodMeta } from './server'; 4 | 5 | type Babel = typeof babel; 6 | type BabelTypes = typeof babel.types; 7 | 8 | const IMPORT_PATH_SERVER = 'next-rpc/dist/server'; 9 | const IMPORT_PATH_BROWSER = 'next-rpc/dist/browser'; 10 | 11 | function buildRpcApiHandler( 12 | t: BabelTypes, 13 | createRpcHandlerIdentifier: babel.types.Identifier, 14 | rpcMethodNames: string[] 15 | ): babel.types.Expression { 16 | return annotateAsPure( 17 | t, 18 | t.callExpression(createRpcHandlerIdentifier, [ 19 | t.arrayExpression( 20 | rpcMethodNames.map((name) => 21 | t.arrayExpression([t.stringLiteral(name), t.identifier(name)]) 22 | ) 23 | ), 24 | ]) 25 | ); 26 | } 27 | 28 | function isAllowedTsExportDeclaration( 29 | declaration: babel.NodePath 30 | ): boolean { 31 | return ( 32 | declaration.isTSTypeAliasDeclaration() || 33 | declaration.isTSInterfaceDeclaration() 34 | ); 35 | } 36 | 37 | function getConfigObjectExpression( 38 | variable: babel.NodePath 39 | ): babel.NodePath | null { 40 | const identifier = variable.get('id'); 41 | const init = variable.get('init'); 42 | if ( 43 | identifier.isIdentifier() && 44 | identifier.node.name === 'config' && 45 | init.isObjectExpression() 46 | ) { 47 | return init; 48 | } else { 49 | return null; 50 | } 51 | } 52 | 53 | function getConfigObject( 54 | program: babel.NodePath 55 | ): babel.NodePath | null { 56 | for (const statement of program.get('body')) { 57 | if (statement.isExportNamedDeclaration()) { 58 | const declaration = statement.get('declaration'); 59 | if ( 60 | declaration.isVariableDeclaration() && 61 | declaration.node.kind === 'const' 62 | ) { 63 | for (const variable of declaration.get('declarations')) { 64 | const configObject = getConfigObjectExpression(variable); 65 | if (configObject) { 66 | return configObject; 67 | } 68 | } 69 | } 70 | } 71 | } 72 | return null; 73 | } 74 | 75 | function isRpc( 76 | configObject: babel.NodePath 77 | ): boolean { 78 | for (const property of configObject.get('properties')) { 79 | if (!property.isObjectProperty()) { 80 | continue; 81 | } 82 | const key = property.get('key'); 83 | const value = property.get('value'); 84 | if ( 85 | property.isObjectProperty() && 86 | key.isIdentifier({ name: 'rpc' }) && 87 | value.isBooleanLiteral({ value: true }) 88 | ) { 89 | return true; 90 | } 91 | } 92 | return false; 93 | } 94 | 95 | export interface PluginOptions { 96 | isServer: boolean; 97 | pagesDir: string; 98 | dev: boolean; 99 | apiDir: string; 100 | basePath: string; 101 | } 102 | 103 | export default function ( 104 | { types: t }: Babel, 105 | { apiDir, pagesDir, isServer, basePath }: PluginOptions 106 | ): babel.PluginObj { 107 | return { 108 | visitor: { 109 | Program(program) { 110 | const { filename } = this.file.opts; 111 | 112 | if (!filename) { 113 | return; 114 | } 115 | 116 | const isApiRoute = filename && filename.startsWith(apiDir); 117 | 118 | if (!isApiRoute) { 119 | return; 120 | } 121 | 122 | const configObject = getConfigObject(program); 123 | 124 | if (!configObject || !isRpc(configObject)) { 125 | return; 126 | } 127 | 128 | const rpcRelativePath = filename 129 | .slice(pagesDir.length) 130 | .replace(/\.[j|t]sx?$/, '') 131 | .replace(/\/index$/, ''); 132 | 133 | const rpcPath = 134 | basePath === '/' ? rpcRelativePath : `${basePath}/${rpcRelativePath}`; 135 | 136 | const rpcMethodNames: string[] = []; 137 | 138 | const createRpcMethodIdentifier = 139 | program.scope.generateUidIdentifier('createRpcMethod'); 140 | 141 | const createRpcMethod = ( 142 | rpcMethod: 143 | | babel.types.ArrowFunctionExpression 144 | | babel.types.FunctionExpression, 145 | meta: WrapMethodMeta 146 | ) => { 147 | return t.callExpression(createRpcMethodIdentifier, [ 148 | rpcMethod, 149 | literalToAst(t, meta), 150 | t.memberExpression( 151 | t.identifier('config'), 152 | t.identifier('wrapMethod') 153 | ), 154 | ]); 155 | }; 156 | 157 | for (const statement of program.get('body')) { 158 | if (statement.isExportNamedDeclaration()) { 159 | const declaration = statement.get('declaration'); 160 | if (isAllowedTsExportDeclaration(declaration)) { 161 | // ignore 162 | } else if (declaration.isFunctionDeclaration()) { 163 | if (!declaration.node.async) { 164 | throw declaration.buildCodeFrameError( 165 | 'rpc exports must be async functions' 166 | ); 167 | } 168 | const identifier = declaration.get('id'); 169 | const methodName = identifier.node?.name; 170 | if (methodName) { 171 | rpcMethodNames.push(methodName); 172 | if (isServer) { 173 | // replace with wrapped 174 | statement.replaceWith( 175 | t.exportNamedDeclaration( 176 | t.variableDeclaration('const', [ 177 | t.variableDeclarator( 178 | t.identifier(methodName), 179 | createRpcMethod(t.toExpression(declaration.node), { 180 | name: methodName, 181 | pathname: rpcPath, 182 | }) 183 | ), 184 | ]) 185 | ) 186 | ); 187 | } 188 | } 189 | } else if ( 190 | declaration.isVariableDeclaration() && 191 | declaration.node.kind === 'const' 192 | ) { 193 | for (const variable of declaration.get('declarations')) { 194 | const init = variable.get('init'); 195 | if (getConfigObjectExpression(variable)) { 196 | // ignore, this is the only allowed non-function export 197 | } else if ( 198 | init.isFunctionExpression() || 199 | init.isArrowFunctionExpression() 200 | ) { 201 | if (!init.node.async) { 202 | throw init.buildCodeFrameError( 203 | 'rpc exports must be async functions' 204 | ); 205 | } 206 | const { id } = variable.node; 207 | if (t.isIdentifier(id)) { 208 | const methodName = id.name; 209 | rpcMethodNames.push(methodName); 210 | if (isServer) { 211 | init.replaceWith( 212 | createRpcMethod(init.node, { 213 | name: methodName, 214 | pathname: rpcPath, 215 | }) 216 | ); 217 | } 218 | } 219 | } else { 220 | throw variable.buildCodeFrameError( 221 | 'rpc exports must be static functions' 222 | ); 223 | } 224 | } 225 | } else { 226 | for (const specifier of statement.get('specifiers')) { 227 | throw specifier.buildCodeFrameError( 228 | 'rpc exports must be static functions' 229 | ); 230 | } 231 | } 232 | } else if (statement.isExportDefaultDeclaration()) { 233 | throw statement.buildCodeFrameError( 234 | 'default exports are not allowed in rpc routes' 235 | ); 236 | } 237 | } 238 | 239 | if (isServer) { 240 | const createRpcHandlerIdentifier = 241 | program.scope.generateUidIdentifier('createRpcHandler'); 242 | 243 | let apiHandlerExpression = buildRpcApiHandler( 244 | t, 245 | createRpcHandlerIdentifier, 246 | rpcMethodNames 247 | ); 248 | 249 | program.unshiftContainer('body', [ 250 | t.importDeclaration( 251 | [ 252 | t.importSpecifier( 253 | createRpcMethodIdentifier, 254 | t.identifier('createRpcMethod') 255 | ), 256 | t.importSpecifier( 257 | createRpcHandlerIdentifier, 258 | t.identifier('createRpcHandler') 259 | ), 260 | ], 261 | t.stringLiteral(IMPORT_PATH_SERVER) 262 | ), 263 | ]); 264 | 265 | program.pushContainer('body', [ 266 | t.exportDefaultDeclaration(apiHandlerExpression), 267 | ]); 268 | } else { 269 | const createRpcFetcherIdentifier = 270 | program.scope.generateUidIdentifier('createRpcFetcher'); 271 | 272 | // Clear the whole body 273 | for (const statement of program.get('body')) { 274 | statement.remove(); 275 | } 276 | 277 | program.pushContainer('body', [ 278 | t.importDeclaration( 279 | [ 280 | t.importSpecifier( 281 | createRpcFetcherIdentifier, 282 | t.identifier('createRpcFetcher') 283 | ), 284 | ], 285 | t.stringLiteral(IMPORT_PATH_BROWSER) 286 | ), 287 | ...rpcMethodNames.map((name) => 288 | t.exportNamedDeclaration( 289 | t.variableDeclaration('const', [ 290 | t.variableDeclarator( 291 | t.identifier(name), 292 | annotateAsPure( 293 | t, 294 | t.callExpression(createRpcFetcherIdentifier, [ 295 | t.stringLiteral(rpcPath), 296 | t.stringLiteral(name), 297 | ]) 298 | ) 299 | ), 300 | ]) 301 | ) 302 | ), 303 | ]); 304 | } 305 | }, 306 | }, 307 | }; 308 | } 309 | -------------------------------------------------------------------------------- /lib/browser.ts: -------------------------------------------------------------------------------- 1 | import { JsonRpcRequest } from './jsonRpc'; 2 | 3 | function escapeRegExp(string: string): string { 4 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 5 | } 6 | 7 | /** 8 | * Workaround for https://github.com/facebook/create-react-app/issues/4760 9 | * See https://github.com/zeit/next.js/blob/b88f20c90bf4659b8ad5cb2a27956005eac2c7e8/packages/next/client/dev/error-overlay/hot-dev-client.js#L105 10 | */ 11 | function rewriteStacktrace(error: Error): Error { 12 | const toReplaceRegex = new RegExp( 13 | escapeRegExp(process.env.__NEXT_DIST_DIR as string), 14 | 'g' 15 | ); 16 | error.stack = 17 | error.stack && error.stack.replace(toReplaceRegex, '/_next/development'); 18 | return error; 19 | } 20 | 21 | type NextRpcCall = (...params: any[]) => any; 22 | 23 | let nextId = 1; 24 | 25 | function createRpcFetcher(url: string, method: string): NextRpcCall { 26 | return function rpcFetch() { 27 | return fetch(url, { 28 | method: 'POST', 29 | body: JSON.stringify({ 30 | jsonrpc: '2.0', 31 | id: nextId++, 32 | method, 33 | params: Array.prototype.slice.call(arguments), 34 | } satisfies JsonRpcRequest), 35 | headers: { 36 | 'content-type': 'application/json', 37 | }, 38 | }) 39 | .then(function (res) { 40 | if (!res.ok) { 41 | throw new Error('Unexpected HTTP status ' + res.status); 42 | } 43 | return res.json(); 44 | }) 45 | .then(function (json) { 46 | if (json.error) { 47 | let err = Object.assign( 48 | new Error(json.error.message), 49 | json.error.data 50 | ); 51 | if (process.env.NODE_ENV !== 'production') { 52 | err = rewriteStacktrace(err); 53 | } 54 | throw err; 55 | } 56 | return json.result; 57 | }); 58 | }; 59 | } 60 | 61 | Object.defineProperty(exports, '__esModule', { 62 | value: true, 63 | }); 64 | 65 | exports.createRpcFetcher = createRpcFetcher; 66 | -------------------------------------------------------------------------------- /lib/context-internal.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'async_hooks'; 2 | import type { 3 | NextApiHandler, 4 | GetServerSideProps, 5 | NextPageContext, 6 | NextPage, 7 | } from 'next'; 8 | import type { IncomingMessage, ServerResponse } from 'http'; 9 | 10 | interface NextRpcContext { 11 | req?: IncomingMessage; 12 | res?: ServerResponse; 13 | } 14 | 15 | const DEFAULT_CONTEXT = {}; 16 | 17 | const asyncLocalStorage = new AsyncLocalStorage(); 18 | 19 | export function getContext(): NextRpcContext { 20 | return asyncLocalStorage.getStore() || DEFAULT_CONTEXT; 21 | } 22 | 23 | export function wrapApiHandler(handler: NextApiHandler): NextApiHandler { 24 | return (req, res) => { 25 | const context = { req, res }; 26 | return asyncLocalStorage.run(context, () => handler(req, res)); 27 | }; 28 | } 29 | 30 | export function wrapGetServerSideProps( 31 | getServerSideProps: GetServerSideProps 32 | ): GetServerSideProps { 33 | return (context) => 34 | asyncLocalStorage.run(context, () => getServerSideProps(context)); 35 | } 36 | 37 | export type GetInitialProps = ( 38 | context: NextPageContext 39 | ) => IP | Promise; 40 | 41 | export function wrapGetInitialProps( 42 | getInitialProps: GetInitialProps 43 | ): GetInitialProps { 44 | return (context) => 45 | asyncLocalStorage.run(context, () => getInitialProps(context)); 46 | } 47 | 48 | export function wrapPage(Page: NextPage): NextPage { 49 | if (typeof Page.getInitialProps === 'function') { 50 | Page.getInitialProps = wrapGetInitialProps(Page.getInitialProps); 51 | } 52 | return new Proxy(Page, { 53 | set(target, property, value) { 54 | if (property === 'getInitialProps' && typeof value === 'function') { 55 | return Reflect.set(target, property, wrapGetInitialProps(value)); 56 | } 57 | return Reflect.set(target, property, value); 58 | }, 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /lib/context.ts: -------------------------------------------------------------------------------- 1 | export { getContext } from './context-internal'; 2 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import * as webpack from 'webpack'; 4 | import { NextConfig } from 'next'; 5 | import { PluginOptions as RpcPluginOptions } from './babelTransformRpc'; 6 | import { PluginOptions as ContextPluginOptions } from './babelTransformContext'; 7 | import { WrapMethod } from './server'; 8 | 9 | export interface NextRpcConfig { 10 | rpc: true; 11 | wrapMethod?: WrapMethod; 12 | } 13 | 14 | export interface WithRpcConfig { 15 | experimentalContext?: boolean; 16 | } 17 | 18 | export { WrapMethod }; 19 | 20 | export default function init(withRpcConfig: WithRpcConfig = {}) { 21 | return (nextConfig: NextConfig = {}): NextConfig => { 22 | return { 23 | ...nextConfig, 24 | 25 | webpack(config: webpack.Configuration, options) { 26 | const { experimentalContext = false } = withRpcConfig; 27 | const { isServer, dev, dir } = options; 28 | const pagesDir = findPagesDir(dir); 29 | const apiDir = path.resolve(pagesDir, './api'); 30 | 31 | const rpcPluginOptions: RpcPluginOptions = { 32 | isServer, 33 | pagesDir, 34 | dev, 35 | apiDir, 36 | basePath: nextConfig.basePath || '/', 37 | }; 38 | 39 | const contextPluginOptions: ContextPluginOptions = { apiDir, isServer }; 40 | 41 | config.module = config.module || {}; 42 | config.module.rules = config.module.rules || []; 43 | config.module.rules.push({ 44 | test: /\.(tsx|ts|js|mjs|jsx)$/, 45 | include: [pagesDir], 46 | use: [ 47 | options.defaultLoaders.babel, 48 | { 49 | loader: 'babel-loader', 50 | options: { 51 | sourceMaps: dev, 52 | plugins: [ 53 | [ 54 | require.resolve('../dist/babelTransformRpc'), 55 | rpcPluginOptions, 56 | ], 57 | ...(experimentalContext 58 | ? [ 59 | [ 60 | require.resolve('../dist/babelTransformContext'), 61 | contextPluginOptions, 62 | ], 63 | ] 64 | : []), 65 | require.resolve('@babel/plugin-syntax-jsx'), 66 | [ 67 | require.resolve('@babel/plugin-syntax-typescript'), 68 | { isTSX: true }, 69 | ], 70 | ], 71 | }, 72 | }, 73 | ], 74 | }); 75 | 76 | if (typeof nextConfig.webpack === 'function') { 77 | return nextConfig.webpack(config, options); 78 | } else { 79 | return config; 80 | } 81 | }, 82 | }; 83 | }; 84 | } 85 | 86 | // taken from https://github.com/vercel/next.js/blob/v12.1.5/packages/next/lib/find-pages-dir.ts 87 | function findPagesDir(dir: string): string { 88 | // prioritize ./pages over ./src/pages 89 | let curDir = path.join(dir, 'pages'); 90 | if (fs.existsSync(curDir)) return curDir; 91 | 92 | curDir = path.join(dir, 'src/pages'); 93 | if (fs.existsSync(curDir)) return curDir; 94 | 95 | // Check one level up the tree to see if the pages directory might be there 96 | if (fs.existsSync(path.join(dir, '..', 'pages'))) { 97 | throw new Error( 98 | 'No `pages` directory found. Did you mean to run `next` in the parent (`../`) directory?' 99 | ); 100 | } 101 | 102 | throw new Error( 103 | "Couldn't find a `pages` directory. Please create one under the project root" 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /lib/jsonRpc.ts: -------------------------------------------------------------------------------- 1 | // https://www.jsonrpc.org/specification 2 | 3 | export type JsonRpcRequestId = string | number | null; 4 | 5 | export type JsonValue = 6 | | string 7 | | number 8 | | boolean 9 | | null 10 | | JsonValue[] 11 | | { [key: string]: JsonValue }; 12 | 13 | export interface JsonRpcRequest { 14 | jsonrpc: '2.0'; 15 | method: string; 16 | params: JsonValue[]; 17 | id: JsonRpcRequestId; 18 | } 19 | 20 | export interface JsonRpcError { 21 | code: number; 22 | message: string; 23 | data?: JsonValue; 24 | } 25 | 26 | export interface JsonRpcSuccessResponse { 27 | jsonrpc: '2.0'; 28 | result: JsonValue; 29 | error?: undefined; 30 | id: JsonRpcRequestId; 31 | } 32 | 33 | export interface JsonRpcErrorResponse { 34 | jsonrpc: '2.0'; 35 | result?: undefined; 36 | error: JsonRpcError; 37 | id: JsonRpcRequestId; 38 | } 39 | 40 | export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; 41 | -------------------------------------------------------------------------------- /lib/server.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from 'next'; 2 | import { JsonRpcResponse } from './jsonRpc'; 3 | 4 | export type Method

= (...params: P) => Promise; 5 | export type WrapMethodMeta = { 6 | name: string; 7 | pathname: string; 8 | }; 9 | 10 | export interface WrapMethod { 11 |

( 12 | method: Method, 13 | meta: WrapMethodMeta 14 | ): Method; 15 | } 16 | 17 | export function createRpcMethod

( 18 | method: Method, 19 | meta: WrapMethodMeta, 20 | customWrapRpcMethod: unknown 21 | ): Method { 22 | let wrapped = method; 23 | if (typeof customWrapRpcMethod === 'function') { 24 | wrapped = customWrapRpcMethod(method, meta); 25 | if (typeof wrapped !== 'function') { 26 | throw new Error( 27 | `wrapMethod didn't return a function, got "${typeof wrapped}"` 28 | ); 29 | } 30 | } else if ( 31 | customWrapRpcMethod !== undefined && 32 | customWrapRpcMethod !== null 33 | ) { 34 | throw new Error( 35 | `Invalid wrapMethod type, expected "function", got "${typeof customWrapRpcMethod}"` 36 | ); 37 | } 38 | return async (...args) => wrapped(...args); 39 | } 40 | 41 | export function createRpcHandler( 42 | methodsInit: [string, (...params: any[]) => Promise][] 43 | ): NextApiHandler { 44 | const methods = new Map(methodsInit); 45 | return async (req, res) => { 46 | if (req.method !== 'POST') { 47 | res.status(405); 48 | res.json({ 49 | jsonrpc: '2.0', 50 | id: null, 51 | error: { 52 | code: -32001, 53 | message: 'Server error', 54 | data: { 55 | cause: `HTTP method "${req.method}" is not allowed`, 56 | }, 57 | }, 58 | } satisfies JsonRpcResponse); 59 | return; 60 | } 61 | 62 | const { id, method, params } = req.body; 63 | const requestedFn = methods.get(method); 64 | 65 | if (typeof requestedFn !== 'function') { 66 | res.status(400); 67 | res.json({ 68 | jsonrpc: '2.0', 69 | id, 70 | error: { 71 | code: -32601, 72 | message: 'Method not found', 73 | data: { 74 | cause: `Method "${method}" is not a function`, 75 | }, 76 | }, 77 | } satisfies JsonRpcResponse); 78 | return; 79 | } 80 | 81 | try { 82 | const result = await requestedFn(...params); 83 | return res.json({ 84 | jsonrpc: '2.0', 85 | id, 86 | result, 87 | } satisfies JsonRpcResponse); 88 | } catch (error) { 89 | const { 90 | name = 'NextRpcError', 91 | message = `Invalid value thrown in "${method}", must be instance of Error`, 92 | stack = undefined, 93 | } = error instanceof Error ? error : {}; 94 | return res.json({ 95 | jsonrpc: '2.0', 96 | id, 97 | error: { 98 | code: 1, 99 | message, 100 | data: { 101 | name, 102 | ...(process.env.NODE_ENV === 'production' ? {} : { stack }), 103 | }, 104 | }, 105 | } satisfies JsonRpcResponse); 106 | } 107 | }; 108 | } 109 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-rpc", 3 | "version": "3.7.0", 4 | "description": "Call serverside code from the browser in Next.js applications", 5 | "main": "index.js", 6 | "repository": "https://github.com/Janpot/next-rpc", 7 | "author": "Jan Potoms", 8 | "license": "MIT", 9 | "files": [ 10 | "dist/*", 11 | "index.js", 12 | "index.d.ts", 13 | "context.js", 14 | "context.d.ts" 15 | ], 16 | "devDependencies": { 17 | "@swc/core": "^1.2.111", 18 | "@swc/jest": "^0.2.5", 19 | "@types/babel__core": "^7.1.14", 20 | "@types/jest": "^29.2.5", 21 | "@types/node": "^18.11.18", 22 | "@types/puppeteer": "^7.0.4", 23 | "@types/react": "^18.0.26", 24 | "@types/react-dom": "^18.0.10", 25 | "@types/webpack": "^5.28.0", 26 | "execa": "^6.0.0", 27 | "get-port": "^6.0.0", 28 | "jest": "^29.3.1", 29 | "jest-circus": "^29.3.1", 30 | "next": "^13.1.1", 31 | "next.js": "^1.0.3", 32 | "node-fetch": "^3.1.0", 33 | "prettier": "^2.2.1", 34 | "puppeteer": "19.4.1", 35 | "react": "^18.2.0", 36 | "react-dom": "^18.2.0", 37 | "strip-ansi": "^7.0.1", 38 | "typescript": "^4.5.2" 39 | }, 40 | "scripts": { 41 | "prettier": "prettier --check \"**/*.{js,ts,jsx,tsx,json,yml,md}\"", 42 | "lint": "$npm_execpath run prettier", 43 | "jest": "jest --runInBand", 44 | "test": "$npm_execpath run lint && $npm_execpath run jest", 45 | "fix": "$npm_execpath run prettier -- --write", 46 | "next": "next", 47 | "prepublishOnly": "$npm_execpath run build && $npm_execpath run test", 48 | "build": "tsc", 49 | "dev": "tsc --watch" 50 | }, 51 | "peerDependencies": { 52 | "next": ">=10" 53 | }, 54 | "dependencies": { 55 | "@babel/core": "^7.16.0", 56 | "@babel/plugin-syntax-jsx": "^7.16.0", 57 | "@babel/plugin-syntax-typescript": "^7.16.0", 58 | "babel-loader": "^9.1.0", 59 | "regenerator-runtime": "^0.13.9" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/__fixtures__/basePath/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/__fixtures__/basePath/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const withRpc = require('../../..')(); 3 | module.exports = withRpc({ 4 | basePath: '/hello/world', 5 | webpack(config) { 6 | config.resolve.alias['next-rpc'] = path.resolve(__dirname, '../../..'); 7 | return config; 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /test/__fixtures__/basePath/pages/api/rpc-route.js: -------------------------------------------------------------------------------- 1 | export const config = { 2 | rpc: true, 3 | }; 4 | 5 | export async function echo(...params) { 6 | return params; 7 | } 8 | 9 | export async function throws(message, code) { 10 | throw Object.assign(new Error(message), { code }); 11 | } 12 | 13 | export async function throwsNonError(thing) { 14 | throw thing; 15 | } 16 | -------------------------------------------------------------------------------- /test/__fixtures__/basePath/pages/index.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { echo } from './api/rpc-route'; 3 | 4 | export async function getServerSideProps() { 5 | return { 6 | props: { 7 | data: await echo('foo', 'bar'), 8 | }, 9 | }; 10 | } 11 | 12 | export default function Index(props) { 13 | const [data, setData] = React.useState(); 14 | React.useEffect(() => { 15 | echo('baz', 'quux').then(setData); 16 | }, []); 17 | return ( 18 |

19 |
{props.data.join(' ')}
20 | {data ?
{data.join(' ')}
: null} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const withRpc = require('../../..')(); 3 | module.exports = withRpc({ 4 | webpack(config) { 5 | config.resolve.alias['next-rpc'] = path.resolve(__dirname, '../../..'); 6 | return config; 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/api/disallowed-syntax.js: -------------------------------------------------------------------------------- 1 | export const config = { 2 | rpc: true, 3 | }; 4 | 5 | /* TEST "exporting a non-function" 6 | export const constant = 1; 7 | /**/ 8 | 9 | /* TEST "exporting a non-async function" 10 | export function notAsync() { 11 | return 1; 12 | } 13 | /**/ 14 | 15 | /* TEST "exporting a non-async arrow function" 16 | export const notAsyncArrow = () => 1; 17 | /**/ 18 | 19 | /* TEST "exporting a non-async function expression" 20 | export const notAsyncFunctionExpr = function () { 21 | return 1; 22 | } 23 | /**/ 24 | 25 | /* TEST "exporting a non-static function" 26 | async function myFunction() { 27 | return 1; 28 | } 29 | export { myFunction }; 30 | /**/ 31 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/api/non-rpc-route.js: -------------------------------------------------------------------------------- 1 | // anything should still be allowed to be exported 2 | export const someValue = 1; 3 | 4 | // default exports should still be allowed 5 | export default (req, res) => { 6 | res.send('hello'); 7 | }; 8 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/api/rpc-route.js: -------------------------------------------------------------------------------- 1 | export const config = { 2 | rpc: true, 3 | }; 4 | 5 | export async function echo(...params) { 6 | return params; 7 | } 8 | 9 | export async function throws(message, code) { 10 | throw Object.assign(new Error(message), { code }); 11 | } 12 | 13 | export async function throwsNonError(thing) { 14 | throw thing; 15 | } 16 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/api/rpc-syntax.js: -------------------------------------------------------------------------------- 1 | // all following ways of declaring an rpc function should be allowed 2 | 3 | export const config = { 4 | rpc: true, 5 | }; 6 | 7 | export async function f1() { 8 | return 1; 9 | } 10 | 11 | export const f2 = async function () { 12 | return 2; 13 | }; 14 | 15 | export const f3 = async function f3() { 16 | return 3; 17 | }; 18 | 19 | export const f4 = async () => 4; 20 | 21 | export const f5 = async () => 5, 22 | f6 = async () => 6; 23 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/api/wrapMethod1.js: -------------------------------------------------------------------------------- 1 | function wrapMethod(method) { 2 | return async (...args) => `wrapped result "${await method(...args)}"`; 3 | } 4 | 5 | export const config = { 6 | rpc: true, 7 | wrapMethod, 8 | }; 9 | 10 | export async function echo(...params) { 11 | return `original called with ${params}`; 12 | } 13 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/api/wrapMethod2.js: -------------------------------------------------------------------------------- 1 | export const config = { 2 | rpc: true, 3 | wrapMethod(method) { 4 | return async (...args) => `wrapped result "${await method(...args)}"`; 5 | }, 6 | }; 7 | 8 | export async function echo(...params) { 9 | return `original called with ${params}`; 10 | } 11 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/api/wrapMethod3.js: -------------------------------------------------------------------------------- 1 | export const config = { 2 | rpc: true, 3 | wrapMethod: (method) => { 4 | return async (...args) => `wrapped result "${await method(...args)}"`; 5 | }, 6 | }; 7 | 8 | export async function echo(...params) { 9 | return `original called with ${params}`; 10 | } 11 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/index.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { echo } from './api/rpc-route'; 3 | 4 | export async function getServerSideProps() { 5 | return { 6 | props: { 7 | data: await echo('foo', 'bar'), 8 | }, 9 | }; 10 | } 11 | 12 | export default function Index(props) { 13 | const [data, setData] = React.useState(); 14 | React.useEffect(() => { 15 | echo('baz', 'quux').then(setData); 16 | }, []); 17 | return ( 18 |
19 |
{props.data.join(' ')}
20 | {data ?
{data.join(' ')}
: null} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/syntax.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { f1, f2, f3, f4, f5, f6 } from './api/rpc-syntax'; 3 | 4 | export default function Index(props) { 5 | const [results, setResults] = React.useState(); 6 | React.useEffect( 7 | () => Promise.all([f1(), f2(), f3(), f4(), f5(), f6()]).then(setResults), 8 | [] 9 | ); 10 | return results ?
{results.join(' ')}
: null; 11 | } 12 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/throws-non-error.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { throwsNonError } from './api/rpc-route'; 3 | 4 | export default function Index(props) { 5 | const [error, setError] = React.useState(); 6 | React.useEffect(() => { 7 | throwsNonError('a string').catch(setError); 8 | }, []); 9 | return error ? ( 10 |
11 | {error.message} : {error.code || 'NO_CODE'} 12 |
13 | ) : null; 14 | } 15 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/throws.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { throws } from './api/rpc-route'; 3 | 4 | export default function Index(props) { 5 | const [error, setError] = React.useState(); 6 | React.useEffect(() => { 7 | throws('the message', 'THE_CODE').catch(setError); 8 | }, []); 9 | return error ? ( 10 |
11 | {error.message} : {error.code || 'NO_CODE'} 12 |
13 | ) : null; 14 | } 15 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/wrapped1.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { echo } from './api/wrapMethod1'; 3 | 4 | export async function getServerSideProps() { 5 | return { 6 | props: { 7 | data: await echo('foo', 'bar'), 8 | }, 9 | }; 10 | } 11 | 12 | export default function Index(props) { 13 | const [data, setData] = React.useState(); 14 | React.useEffect(() => { 15 | echo('baz', 'quux').then(setData); 16 | }, []); 17 | return ( 18 |
19 |
{props.data}
20 | {data ?
{data}
: null} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/wrapped2.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { echo } from './api/wrapMethod2'; 3 | 4 | export async function getServerSideProps() { 5 | return { 6 | props: { 7 | data: await echo('bar', 'foo'), 8 | }, 9 | }; 10 | } 11 | 12 | export default function Index(props) { 13 | const [data, setData] = React.useState(); 14 | React.useEffect(() => { 15 | echo('quux', 'baz').then(setData); 16 | }, []); 17 | return ( 18 |
19 |
{props.data}
20 | {data ?
{data}
: null} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /test/__fixtures__/basic-app/pages/wrapped3.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { echo } from './api/wrapMethod3'; 3 | 4 | export async function getServerSideProps() { 5 | return { 6 | props: { 7 | data: await echo('quux', 'foo'), 8 | }, 9 | }; 10 | } 11 | 12 | export default function Index(props) { 13 | const [data, setData] = React.useState(); 14 | React.useEffect(() => { 15 | echo('bar', 'baz').then(setData); 16 | }, []); 17 | return ( 18 |
19 |
{props.data}
20 | {data ?
{data}
: null} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /test/__fixtures__/context/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/__fixtures__/context/middleware.js: -------------------------------------------------------------------------------- 1 | export function middleware(req) {} 2 | -------------------------------------------------------------------------------- /test/__fixtures__/context/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const withRpc = require('../../..')({ 3 | experimentalContext: true, 4 | }); 5 | module.exports = withRpc({ 6 | webpack(config) { 7 | config.resolve.alias['next-rpc'] = path.resolve(__dirname, '../../..'); 8 | return config; 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/_app.js: -------------------------------------------------------------------------------- 1 | import App from 'next/app'; 2 | import { hasContext } from './api/withContext'; 3 | 4 | function MyApp({ Component, pageProps }) { 5 | return ( 6 | <> 7 | {pageProps.appHasContext ?
: null} 8 | 9 | 10 | ); 11 | } 12 | 13 | MyApp.getInitialProps = async (appContext) => { 14 | const { pageProps, ...appProps } = await App.getInitialProps(appContext); 15 | const appHasContext = await hasContext(appContext); 16 | return { ...appProps, pageProps: { ...pageProps, appHasContext } }; 17 | }; 18 | 19 | export default MyApp; 20 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/api/classicApi.js: -------------------------------------------------------------------------------- 1 | import { hasContext } from './withContext'; 2 | import { getContext } from 'next-rpc/context'; 3 | 4 | export default async function (req, res) { 5 | const ctx = getContext(); 6 | res.json({ 7 | apiHasContext: ctx.req === req && ctx.res === res, 8 | rpcHasContext: await hasContext({ req, res }), 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/api/withContext.js: -------------------------------------------------------------------------------- 1 | import { getContext } from 'next-rpc/context'; 2 | 3 | export const config = { 4 | rpc: true, 5 | }; 6 | 7 | export async function hasContext(expected) { 8 | const got = getContext(); 9 | if (expected) { 10 | return got && expected.req === got.req && expected.res === got.res; 11 | } else { 12 | return !!(got && got.req && got.res); 13 | } 14 | } 15 | 16 | export async function getUrl() { 17 | const { req } = getContext(); 18 | return req.url; 19 | } 20 | 21 | export async function setSomeCookie(name, value) { 22 | const { req, res } = getContext(); 23 | res.setHeader('set-cookie', `${name}=${value}; path=/`); 24 | } 25 | 26 | export async function getSomeCookie(name) { 27 | const { req } = getContext(); 28 | const cookieHeader = req.headers.cookie || ''; 29 | const cookies = new Map( 30 | cookieHeader.split(';').map((c) => c.trim().split('=')) 31 | ); 32 | return cookies.get(name); 33 | } 34 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/callRpc.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { hasContext } from './api/withContext'; 3 | 4 | export default function Page() { 5 | const [result, setResult] = React.useState(false); 6 | React.useEffect(() => { 7 | hasContext().then(setResult); 8 | }, []); 9 | return result ?
: null; 10 | } 11 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/cookies.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { getSomeCookie, setSomeCookie } from './api/withContext'; 3 | 4 | export default function Page({ hasContext }) { 5 | const [done, setDone] = React.useState(false); 6 | const [initial, setInitial] = React.useState(undefined); 7 | const [final, setFinal] = React.useState(undefined); 8 | React.useEffect(() => { 9 | (async () => { 10 | setInitial(await getSomeCookie('cookieName')); 11 | await setSomeCookie('cookieName', 'some cookie value'); 12 | setFinal(await getSomeCookie('cookieName')); 13 | setDone(true); 14 | })(); 15 | }, []); 16 | return ( 17 |
18 | {initial} 19 | {final ? {final} : null} 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/getInitialProps1.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { hasContext } from './api/withContext'; 3 | 4 | export default function Page({ hasContext }) { 5 | return hasContext ?
: null; 6 | } 7 | 8 | Page.getInitialProps = async (ctx) => { 9 | return { 10 | hasContext: await hasContext(ctx), 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/getInitialProps2.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { hasContext } from './api/withContext'; 3 | 4 | Page.getInitialProps = async (ctx) => { 5 | return { 6 | hasContext: await hasContext(ctx), 7 | }; 8 | }; 9 | 10 | export default function Page({ hasContext }) { 11 | return hasContext ?
: null; 12 | } 13 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/getInitialProps3.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { hasContext } from './api/withContext'; 3 | import { Component } from 'react'; 4 | 5 | export default class Page extends Component { 6 | static async getInitialProps(ctx) { 7 | return { 8 | hasContext: await hasContext(ctx), 9 | }; 10 | } 11 | 12 | render() { 13 | return this.props.hasContext ?
: null; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/getInitialProps4.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { hasContext } from './api/withContext'; 3 | 4 | function Page({ hasContext }) { 5 | return hasContext ?
: null; 6 | } 7 | 8 | Page.getInitialProps = async (ctx) => { 9 | return { 10 | hasContext: await hasContext(ctx), 11 | }; 12 | }; 13 | 14 | export default Page; 15 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/getServerSideProps.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { hasContext } from './api/withContext'; 3 | 4 | export async function getServerSideProps(ctx) { 5 | return { 6 | props: { 7 | hasContext: await hasContext(ctx), 8 | }, 9 | }; 10 | } 11 | 12 | export default function Page({ hasContext }) { 13 | return hasContext ?
: null; 14 | } 15 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/getServerSideProps2.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { hasContext } from './api/withContext'; 3 | 4 | export const getServerSideProps = async function getServerSideProps(ctx) { 5 | return { 6 | props: { 7 | hasContext: await hasContext(ctx), 8 | }, 9 | }; 10 | }; 11 | 12 | export default function Page({ hasContext }) { 13 | return hasContext ?
: null; 14 | } 15 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/getServerSideProps3.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { hasContext } from './api/withContext'; 3 | 4 | export const getServerSideProps = async (ctx) => { 5 | return { 6 | props: { 7 | hasContext: await hasContext(ctx), 8 | }, 9 | }; 10 | }; 11 | 12 | export default function Page({ hasContext }) { 13 | return hasContext ?
: null; 14 | } 15 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/getServerSideProps4.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { hasContext } from './api/withContext'; 3 | 4 | export const getServerSideProps = async function (ctx) { 5 | return { 6 | props: { 7 | hasContext: await hasContext(ctx), 8 | }, 9 | }; 10 | }; 11 | 12 | export default function Page({ hasContext }) { 13 | return hasContext ?
: null; 14 | } 15 | -------------------------------------------------------------------------------- /test/__fixtures__/context/pages/index.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function Page() { 4 | return
hello
; 5 | } 6 | -------------------------------------------------------------------------------- /test/__fixtures__/typescript-context/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /test/__fixtures__/typescript-context/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const withRpc = require('../../..')({ 3 | experimentalContext: true, 4 | }); 5 | module.exports = withRpc({ 6 | webpack(config) { 7 | config.resolve.alias['next-rpc'] = path.resolve(__dirname, '../../..'); 8 | return config; 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /test/__fixtures__/typescript-context/pages/api/withContext.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, ServerResponse } from 'http'; 2 | import { getContext } from 'next-rpc/context'; 3 | 4 | export const config = { 5 | rpc: true, 6 | }; 7 | 8 | interface Expected { 9 | req?: IncomingMessage; 10 | res?: ServerResponse; 11 | } 12 | 13 | export async function hasContext(expected?: Expected) { 14 | const got = getContext(); 15 | if (expected) { 16 | return got && expected.req === got.req && expected.res === got.res; 17 | } else { 18 | return !!(got && got.req && got.res); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/__fixtures__/typescript-context/pages/getServerSideProps.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { GetServerSideProps } from 'next'; 3 | import { hasContext } from './api/withContext'; 4 | 5 | interface HomeProps { 6 | hasContext: boolean; 7 | } 8 | 9 | export const getServerSideProps: GetServerSideProps = async ( 10 | ctx 11 | ) => { 12 | return { 13 | props: { 14 | hasContext: await hasContext(ctx), 15 | }, 16 | }; 17 | }; 18 | 19 | export default function Page({ hasContext }: HomeProps) { 20 | console.log(hasContext); 21 | return hasContext ?
: null; 22 | } 23 | -------------------------------------------------------------------------------- /test/__fixtures__/typescript-context/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "baseUrl": ".", 5 | "paths": { 6 | "next-rpc": ["../../.."], 7 | "next-rpc/*": ["../../../*"] 8 | }, 9 | "target": "es5", 10 | "lib": ["dom", "dom.iterable", "esnext"], 11 | "allowJs": true, 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "esModuleInterop": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "preserve", 21 | "incremental": true 22 | }, 23 | "exclude": ["node_modules"], 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 25 | } 26 | -------------------------------------------------------------------------------- /test/__fixtures__/typescript/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /test/__fixtures__/typescript/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const withRpc = require('../../..')(); 3 | module.exports = withRpc({ 4 | webpack(config) { 5 | config.resolve.alias['next-rpc'] = path.resolve(__dirname, '../../..'); 6 | return config; 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /test/__fixtures__/typescript/pages/api/host.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | 3 | export const config = { rpc: true }; 4 | 5 | // should be legal to export types 6 | export type HostInfo = { 7 | now: string; 8 | hostname: string; 9 | }; 10 | 11 | // should be legal to export interfaces 12 | export interface SomeInterface { 13 | x: number; 14 | } 15 | 16 | export async function getInfo(): Promise { 17 | return { 18 | now: new Date().toLocaleTimeString(), 19 | hostname: os.hostname(), 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /test/__fixtures__/typescript/pages/api/wrapped.ts: -------------------------------------------------------------------------------- 1 | import { NextRpcConfig, WrapMethod } from '../../../../..'; 2 | 3 | const wrapMethod: WrapMethod = (method, meta) => { 4 | return async (...args) => { 5 | console.log(`calling "${meta.name}" on "${meta.pathname}" with ${args}`); 6 | const result = await method(...args); 7 | console.log(`result: ${result}`); 8 | return result; 9 | }; 10 | }; 11 | 12 | export const config: NextRpcConfig = { 13 | rpc: true, 14 | wrapMethod, 15 | }; 16 | -------------------------------------------------------------------------------- /test/__fixtures__/typescript/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { GetServerSideProps } from 'next'; 3 | import { getInfo, HostInfo } from './api/host'; 4 | 5 | export const getServerSideProps: GetServerSideProps = async () => { 6 | return { 7 | // We can call the functions server-side 8 | props: await getInfo(), 9 | }; 10 | }; 11 | 12 | export default function Home(props: HostInfo) { 13 | const [info, setInfo] = React.useState(props); 14 | 15 | React.useEffect(() => { 16 | // And we can call the functions client-side 17 | const interval = setInterval(() => getInfo().then(setInfo), 2000); 18 | return () => clearInterval(interval); 19 | }, []); 20 | 21 | return ( 22 |
23 | It's now {info.now} on host {info.hostname} 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /test/__fixtures__/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "exclude": ["node_modules"], 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 20 | } 21 | -------------------------------------------------------------------------------- /test/basePath.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import puppeteer, { Browser } from 'puppeteer'; 3 | import { buildNext, startNext, cleanup, RunningNextApp } from './utils'; 4 | 5 | const FIXTURE_PATH = path.resolve(__dirname, './__fixtures__/basePath'); 6 | 7 | afterAll(() => cleanup(FIXTURE_PATH)); 8 | 9 | describe('basic-app', () => { 10 | let browser: Browser; 11 | let app: RunningNextApp; 12 | 13 | beforeAll(async () => { 14 | await Promise.all([ 15 | buildNext(FIXTURE_PATH), 16 | puppeteer.launch().then((b) => (browser = b)), 17 | ]); 18 | app = await startNext(FIXTURE_PATH); 19 | }, 30000); 20 | 21 | afterAll(async () => { 22 | await Promise.all([browser && browser.close(), app && app.kill()]); 23 | }); 24 | 25 | test('should call the rpc method on a basePath', async () => { 26 | const page = await browser.newPage(); 27 | try { 28 | await page.goto(new URL('/hello/world', app.url).toString()); 29 | const ssrData = await page.$eval('#ssr', (el) => el.textContent); 30 | expect(ssrData).toBe('foo bar'); 31 | await page.waitForSelector('#browser'); 32 | const browserData = await page.$eval('#browser', (el) => el.textContent); 33 | expect(browserData).toBe('baz quux'); 34 | } finally { 35 | await page.close(); 36 | } 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/context.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import puppeteer, { Browser } from 'puppeteer'; 3 | import { buildNext, startNext, cleanup, RunningNextApp } from './utils'; 4 | import fetch from 'node-fetch'; 5 | 6 | const FIXTURE_PATH = path.resolve(__dirname, './__fixtures__/context'); 7 | 8 | afterAll(() => cleanup(FIXTURE_PATH)); 9 | 10 | describe('context', () => { 11 | let browser: Browser; 12 | let app: RunningNextApp; 13 | 14 | beforeAll(async () => { 15 | await Promise.all([ 16 | buildNext(FIXTURE_PATH), 17 | puppeteer.launch().then((b) => (browser = b)), 18 | ]); 19 | app = await startNext(FIXTURE_PATH); 20 | }, 30000); 21 | 22 | afterAll(async () => { 23 | await Promise.all([browser && browser.close(), app && app.kill()]); 24 | }); 25 | 26 | test('should provide context when called through getServerSidProps, function declaration', async () => { 27 | const page = await browser.newPage(); 28 | try { 29 | await page.goto(new URL('/getServerSideProps', app.url).toString()); 30 | expect(await page.$('#has-context')).not.toBeNull(); 31 | } finally { 32 | await page.close(); 33 | } 34 | }); 35 | 36 | test('should provide context when called through getServerSidProps, named function expression', async () => { 37 | const page = await browser.newPage(); 38 | try { 39 | await page.goto(new URL('/getServerSideProps2', app.url).toString()); 40 | expect(await page.$('#has-context')).not.toBeNull(); 41 | } finally { 42 | await page.close(); 43 | } 44 | }); 45 | 46 | test('should provide context when called through getServerSidProps, arrow function', async () => { 47 | const page = await browser.newPage(); 48 | try { 49 | await page.goto(new URL('/getServerSideProps3', app.url).toString()); 50 | expect(await page.$('#has-context')).not.toBeNull(); 51 | } finally { 52 | await page.close(); 53 | } 54 | }); 55 | 56 | test('should provide context when called through getServerSidProps, anonymous function expression', async () => { 57 | const page = await browser.newPage(); 58 | try { 59 | await page.goto(new URL('/getServerSideProps4', app.url).toString()); 60 | expect(await page.$('#has-context')).not.toBeNull(); 61 | } finally { 62 | await page.close(); 63 | } 64 | }); 65 | 66 | test('should provide context through getInitialProps after page', async () => { 67 | const page = await browser.newPage(); 68 | try { 69 | await page.goto(new URL('/getInitialProps1', app.url).toString()); 70 | expect(await page.$('#has-context')).not.toBeNull(); 71 | } finally { 72 | await page.close(); 73 | } 74 | }); 75 | 76 | test('should provide context through getInitialProps before page', async () => { 77 | const page = await browser.newPage(); 78 | try { 79 | await page.goto(new URL('/getInitialProps2', app.url).toString()); 80 | expect(await page.$('#has-context')).not.toBeNull(); 81 | } finally { 82 | await page.close(); 83 | } 84 | }); 85 | 86 | test('should provide context through getInitialProps as static class property', async () => { 87 | const page = await browser.newPage(); 88 | try { 89 | await page.goto(new URL('/getInitialProps3', app.url).toString()); 90 | expect(await page.$('#has-context')).not.toBeNull(); 91 | } finally { 92 | await page.close(); 93 | } 94 | }); 95 | 96 | test('should provide context through getInitialProps on Page with default export', async () => { 97 | const page = await browser.newPage(); 98 | try { 99 | await page.goto(new URL('/getInitialProps4', app.url).toString()); 100 | expect(await page.$('#has-context')).not.toBeNull(); 101 | } finally { 102 | await page.close(); 103 | } 104 | }); 105 | 106 | test('should provide context in _app', async () => { 107 | const page = await browser.newPage(); 108 | try { 109 | await page.goto(new URL('/', app.url).toString()); 110 | expect(await page.$('#app-has-context')).not.toBeNull(); 111 | } finally { 112 | await page.close(); 113 | } 114 | }); 115 | 116 | test('should have context in api routes', async () => { 117 | const response = await fetch( 118 | new URL('/api/classicApi', app.url).toString() 119 | ); 120 | expect(response).toHaveProperty('ok', true); 121 | const result = await response.json(); 122 | expect(result).toHaveProperty('apiHasContext', true); 123 | expect(result).toHaveProperty('rpcHasContext', true); 124 | }); 125 | 126 | test('should have context in rpc routes', async () => { 127 | const page = await browser.newPage(); 128 | try { 129 | await page.goto(new URL('/callRpc', app.url).toString()); 130 | await page.waitForSelector('#has-context'); 131 | expect(await page.$('#has-context')).not.toBeNull(); 132 | } finally { 133 | await page.close(); 134 | } 135 | }); 136 | 137 | test('should be able to set cookies', async () => { 138 | const page = await browser.newPage(); 139 | try { 140 | const initialPageCookies = await page.cookies(); 141 | expect(initialPageCookies).not.toContainEqual( 142 | expect.objectContaining({ name: 'cookieName' }) 143 | ); 144 | 145 | await page.goto(new URL('/cookies', app.url).toString()); 146 | await page.waitForSelector('#final-value'); 147 | const initialValue = await page.$eval( 148 | '#initial-value', 149 | (e) => e.textContent 150 | ); 151 | const finalValue = await page.$eval('#final-value', (e) => e.textContent); 152 | expect(initialValue).toBe(''); 153 | expect(finalValue).toBe('some cookie value'); 154 | 155 | const finalPageCookies = await page.cookies(); 156 | expect(finalPageCookies).toContainEqual( 157 | expect.objectContaining({ 158 | name: 'cookieName', 159 | value: 'some cookie value', 160 | }) 161 | ); 162 | } finally { 163 | await page.close(); 164 | } 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /test/rpc.spec.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import puppeteer from 'puppeteer'; 3 | import * as fs from 'fs/promises'; 4 | import { buildNext, startNext, cleanup, RunningNextApp } from './utils'; 5 | import fetch from 'node-fetch'; 6 | import { Browser } from 'puppeteer'; 7 | 8 | const FIXTURE_PATH = path.resolve(__dirname, './__fixtures__/basic-app'); 9 | 10 | async function withEnabledTest( 11 | filepath: string, 12 | test: string, 13 | assertions: () => Promise 14 | ): Promise { 15 | const content = await fs.readFile(filepath, { encoding: 'utf-8' }); 16 | const newContent = content.replace(`/* TEST "${test}"`, ''); 17 | try { 18 | await fs.writeFile(filepath, newContent, { encoding: 'utf-8' }); 19 | await assertions(); 20 | } finally { 21 | await fs.writeFile(filepath, content, { encoding: 'utf-8' }); 22 | } 23 | } 24 | 25 | beforeAll(() => cleanup(FIXTURE_PATH)); 26 | 27 | describe('rpc', () => { 28 | let browser: Browser; 29 | let app: RunningNextApp; 30 | 31 | beforeAll(async () => { 32 | await Promise.all([ 33 | buildNext(FIXTURE_PATH), 34 | puppeteer.launch().then((b) => (browser = b)), 35 | ]); 36 | app = await startNext(FIXTURE_PATH); 37 | }, 30000); 38 | 39 | afterAll(async () => { 40 | await Promise.all([browser && browser.close(), app && app.kill()]); 41 | }); 42 | 43 | test('should call the rpc method everywhere', async () => { 44 | const page = await browser.newPage(); 45 | try { 46 | await page.goto(new URL('/', app.url).toString()); 47 | const ssrData = await page.$eval('#ssr', (el) => el.textContent); 48 | expect(ssrData).toBe('foo bar'); 49 | await page.waitForSelector('#browser'); 50 | const browserData = await page.$eval('#browser', (el) => el.textContent); 51 | expect(browserData).toBe('baz quux'); 52 | } finally { 53 | await page.close(); 54 | } 55 | }); 56 | 57 | test('should reject on errors', async () => { 58 | const page = await browser.newPage(); 59 | try { 60 | await page.goto(new URL('/throws', app.url).toString()); 61 | await page.waitForSelector('#error'); 62 | const error = await page.$eval('#error', (el) => el.textContent); 63 | // avoid leaking error internals, don't forward the code 64 | expect(error).toBe('the message : NO_CODE'); 65 | } finally { 66 | await page.close(); 67 | } 68 | }); 69 | 70 | test('should handle when non-errors are thrown', async () => { 71 | const page = await browser.newPage(); 72 | try { 73 | await page.goto(new URL('/throws-non-error', app.url).toString()); 74 | await page.waitForSelector('#error'); 75 | const error = await page.$eval('#error', (el) => el.textContent); 76 | // avoid leaking error internals, don't forward the code 77 | expect(error).toBe( 78 | 'Invalid value thrown in "throwsNonError", must be instance of Error : NO_CODE' 79 | ); 80 | } finally { 81 | await page.close(); 82 | } 83 | }); 84 | 85 | test('should pass all allowed syntaxes', async () => { 86 | const page = await browser.newPage(); 87 | try { 88 | await page.goto(new URL('/syntax', app.url).toString()); 89 | await page.waitForSelector('#results'); 90 | const browserData = await page.$eval('#results', (el) => el.textContent); 91 | expect(browserData).toBe('1 2 3 4 5 6'); 92 | } finally { 93 | await page.close(); 94 | } 95 | }); 96 | 97 | test("shouldn't leak prototype methods", async () => { 98 | const response = await fetch( 99 | new URL('/api/rpc-route', app.url).toString(), 100 | { 101 | method: 'POST', 102 | headers: { 103 | 'content-type': 'application/json', 104 | }, 105 | body: JSON.stringify({ method: 'toString', params: [] }), 106 | } 107 | ); 108 | expect(response).toHaveProperty('status', 400); 109 | const responseBody = await response.json(); 110 | expect(responseBody).toHaveProperty(['error', 'code'], -32601); 111 | expect(responseBody).toHaveProperty( 112 | ['error', 'message'], 113 | expect.stringMatching('Method not found') 114 | ); 115 | expect(responseBody).toHaveProperty( 116 | ['error', 'data', 'cause'], 117 | expect.stringMatching('Method "toString" is not a function') 118 | ); 119 | }); 120 | 121 | test("shouldn't accept non-POST requests", async () => { 122 | const response = await fetch( 123 | new URL('/api/rpc-route', app.url).toString(), 124 | { 125 | method: 'GET', 126 | headers: { 127 | 'content-type': 'application/json', 128 | }, 129 | } 130 | ); 131 | expect(response).toHaveProperty('status', 405); 132 | const responseBody = await response.json(); 133 | expect(responseBody).toHaveProperty( 134 | ['error', 'message'], 135 | expect.stringMatching('Server error') 136 | ); 137 | expect(responseBody).toHaveProperty( 138 | ['error', 'data', 'cause'], 139 | expect.stringMatching('method "GET" is not allowed') 140 | ); 141 | }); 142 | 143 | test('should wrap methods 1', async () => { 144 | const page = await browser.newPage(); 145 | try { 146 | await page.goto(new URL('/wrapped1', app.url).toString()); 147 | const ssrData = await page.$eval('#ssr', (el) => el.textContent); 148 | expect(ssrData).toBe('wrapped result "original called with foo,bar"'); 149 | await page.waitForSelector('#browser'); 150 | const browserData = await page.$eval('#browser', (el) => el.textContent); 151 | expect(browserData).toBe( 152 | 'wrapped result "original called with baz,quux"' 153 | ); 154 | } finally { 155 | await page.close(); 156 | } 157 | }); 158 | 159 | test('should wrap methods 2', async () => { 160 | const page = await browser.newPage(); 161 | try { 162 | await page.goto(new URL('/wrapped2', app.url).toString()); 163 | const ssrData = await page.$eval('#ssr', (el) => el.textContent); 164 | expect(ssrData).toBe('wrapped result "original called with bar,foo"'); 165 | await page.waitForSelector('#browser'); 166 | const browserData = await page.$eval('#browser', (el) => el.textContent); 167 | expect(browserData).toBe( 168 | 'wrapped result "original called with quux,baz"' 169 | ); 170 | } finally { 171 | await page.close(); 172 | } 173 | }); 174 | 175 | test('should wrap methods 3', async () => { 176 | const page = await browser.newPage(); 177 | try { 178 | await page.goto(new URL('/wrapped3', app.url).toString()); 179 | const ssrData = await page.$eval('#ssr', (el) => el.textContent); 180 | expect(ssrData).toBe('wrapped result "original called with quux,foo"'); 181 | await page.waitForSelector('#browser'); 182 | const browserData = await page.$eval('#browser', (el) => el.textContent); 183 | expect(browserData).toBe('wrapped result "original called with bar,baz"'); 184 | } finally { 185 | await page.close(); 186 | } 187 | }); 188 | }); 189 | 190 | describe('build', () => { 191 | test('should fail on non function export', async () => { 192 | await withEnabledTest( 193 | path.resolve(FIXTURE_PATH, './pages/api/disallowed-syntax.js'), 194 | 'exporting a non-function', 195 | async () => { 196 | const build = buildNext(FIXTURE_PATH); 197 | await expect(build).rejects.toHaveProperty( 198 | 'message', 199 | expect.stringMatching('rpc exports must be static functions') 200 | ); 201 | } 202 | ); 203 | }, 30000); 204 | 205 | test('should fail on non-async function export', async () => { 206 | await withEnabledTest( 207 | path.resolve(FIXTURE_PATH, './pages/api/disallowed-syntax.js'), 208 | 'exporting a non-async function', 209 | async () => { 210 | const build = buildNext(FIXTURE_PATH); 211 | await expect(build).rejects.toHaveProperty( 212 | 'message', 213 | expect.stringMatching('rpc exports must be async functions') 214 | ); 215 | } 216 | ); 217 | }, 30000); 218 | 219 | test('should fail on non-async arrow function export', async () => { 220 | await withEnabledTest( 221 | path.resolve(FIXTURE_PATH, './pages/api/disallowed-syntax.js'), 222 | 'exporting a non-async arrow function', 223 | async () => { 224 | const build = buildNext(FIXTURE_PATH); 225 | await expect(build).rejects.toHaveProperty( 226 | 'message', 227 | expect.stringMatching('rpc exports must be async functions') 228 | ); 229 | } 230 | ); 231 | }, 30000); 232 | 233 | test('should fail on non-async function expression export', async () => { 234 | await withEnabledTest( 235 | path.resolve(FIXTURE_PATH, './pages/api/disallowed-syntax.js'), 236 | 'exporting a non-async function expression', 237 | async () => { 238 | const build = buildNext(FIXTURE_PATH); 239 | await expect(build).rejects.toHaveProperty( 240 | 'message', 241 | expect.stringMatching('rpc exports must be async functions') 242 | ); 243 | } 244 | ); 245 | }, 30000); 246 | 247 | test('should fail on non-static function export', async () => { 248 | await withEnabledTest( 249 | path.resolve(FIXTURE_PATH, './pages/api/disallowed-syntax.js'), 250 | 'exporting a non-static function', 251 | async () => { 252 | const build = buildNext(FIXTURE_PATH); 253 | await expect(build).rejects.toHaveProperty( 254 | 'message', 255 | expect.stringMatching('rpc exports must be static functions') 256 | ); 257 | } 258 | ); 259 | }, 30000); 260 | }); 261 | -------------------------------------------------------------------------------- /test/typescript.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { buildNext, cleanup } from './utils'; 3 | 4 | const FIXTURE_PATH = path.resolve(__dirname, './__fixtures__/typescript'); 5 | 6 | afterAll(() => cleanup(FIXTURE_PATH)); 7 | 8 | describe('typescript', () => { 9 | test('should build without errors', async () => { 10 | await buildNext(FIXTURE_PATH); 11 | }, 30000); 12 | }); 13 | -------------------------------------------------------------------------------- /test/typescriptContext.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import puppeteer, { Browser } from 'puppeteer'; 3 | import { buildNext, startNext, cleanup, RunningNextApp } from './utils'; 4 | 5 | const FIXTURE_PATH = path.resolve( 6 | __dirname, 7 | './__fixtures__/typescript-context' 8 | ); 9 | 10 | afterAll(() => cleanup(FIXTURE_PATH)); 11 | 12 | describe('typescript-context', () => { 13 | let browser: Browser; 14 | let app: RunningNextApp; 15 | 16 | beforeAll(async () => { 17 | await Promise.all([ 18 | buildNext(FIXTURE_PATH), 19 | puppeteer.launch().then((b) => (browser = b)), 20 | ]); 21 | app = await startNext(FIXTURE_PATH); 22 | }, 30000); 23 | 24 | afterAll(async () => { 25 | await Promise.all([browser && browser.close(), app && app.kill()]); 26 | }); 27 | 28 | test('should provide context when called through getServerSidProps, function declaration', async () => { 29 | const page = await browser.newPage(); 30 | try { 31 | await page.goto(new URL('/getServerSideProps', app.url).toString()); 32 | expect(await page.$('#has-context')).not.toBeNull(); 33 | } finally { 34 | await page.close(); 35 | } 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { execa } from 'execa'; 2 | import * as fs from 'fs/promises'; 3 | import { Writable, Readable } from 'stream'; 4 | import * as path from 'path'; 5 | import getPort from 'get-port'; 6 | import { ChildProcess } from 'child_process'; 7 | import stripAnsi from 'strip-ansi'; 8 | 9 | const VERBOSE = true; 10 | const FORCE_COLOR = true; 11 | 12 | async function appReady(stdout: Readable): Promise { 13 | return new Promise((resolve) => { 14 | stdout.pipe( 15 | new Writable({ 16 | write(chunk, encoding, callback) { 17 | if (/ready - started server on/i.test(stripAnsi(String(chunk)))) { 18 | resolve(); 19 | } 20 | callback(); 21 | }, 22 | }) 23 | ); 24 | }); 25 | } 26 | 27 | function redirectOutput(cp: ChildProcess) { 28 | if (VERBOSE) { 29 | process.stdin.pipe(cp.stdin); 30 | cp.stdout.pipe(process.stdout); 31 | cp.stderr.pipe(process.stderr); 32 | } 33 | } 34 | 35 | export async function buildNext(appPath: string) { 36 | const cp = execa('next', ['build'], { 37 | preferLocal: true, 38 | cwd: appPath, 39 | env: { 40 | FORCE_COLOR: VERBOSE ? '1' : '0', 41 | }, 42 | }); 43 | redirectOutput(cp); 44 | await cp; 45 | } 46 | 47 | export interface RunningNextApp { 48 | url: string; 49 | kill: (...args: any[]) => boolean; 50 | } 51 | 52 | export async function startNext( 53 | appPath: string, 54 | port?: number 55 | ): Promise { 56 | port = port || (await getPort()); 57 | const app = execa('next', ['start', '-p', String(port)], { 58 | preferLocal: true, 59 | cwd: appPath, 60 | env: { 61 | FORCE_COLOR: FORCE_COLOR ? '1' : '0', 62 | }, 63 | }); 64 | redirectOutput(app); 65 | await appReady(app.stdout); 66 | return { 67 | url: `http://127.0.0.1:${port}/`, 68 | kill: app.kill.bind(app), 69 | }; 70 | } 71 | 72 | export async function cleanup(appPath: string): Promise { 73 | try { 74 | await fs.rm(path.resolve(appPath, './.next'), { recursive: true }); 75 | } catch (err) { 76 | if (err.code === 'ENOENT') { 77 | return; 78 | } 79 | throw err; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "checkJs": true, 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "allowJs": true, 10 | "strict": true, 11 | "declaration": true, 12 | "noUnusedLocals": true, 13 | "rootDir": "lib", 14 | "outDir": "dist" 15 | }, 16 | "exclude": ["node_modules", "test/__fixtures__"], 17 | "include": ["lib/**/*"] 18 | } 19 | --------------------------------------------------------------------------------