├── .commitlintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .prettierrc ├── .releaserc.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build.js ├── example ├── .gitignore ├── README.md ├── middleware.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ └── preview.ts │ ├── foo │ │ ├── bar.tsx │ │ └── baz.tsx │ └── top.tsx ├── public │ ├── favicon.ico │ └── vercel.svg ├── tsconfig.json ├── utils │ └── cookie-control.ts └── yarn.lock ├── package.json ├── readme ├── 00.png ├── 01.png └── 02.png ├── src ├── __tests__ │ ├── mddleware.spec.ts │ └── with-split.spec.ts ├── constants.ts ├── index.ts ├── make-runtime-config.ts ├── middleware.ts ├── random.ts ├── types.ts └── with-split.ts ├── tsconfig.json ├── vitest.config.ts └── yarn.lock /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - beta 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | Test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: 18.x 20 | registry-url: https://registry.npmjs.org 21 | - uses: actions/cache@v2 22 | with: 23 | path: "**/node_modules" 24 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 25 | - name: Install dependencies 💿 26 | run: yarn install --frozen-lockfile 27 | - name: Run Tests 🧪 28 | run: yarn test:coverage 29 | - name: Report coverage (client) 📏 30 | uses: codecov/codecov-action@v2 31 | with: 32 | token: ${{ secrets.CODECOV_TOKEN }} 33 | 34 | Publish: 35 | runs-on: ubuntu-latest 36 | needs: 37 | - Test 38 | if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta' }} 39 | steps: 40 | - uses: actions/checkout@v2 41 | - name: Use Node.js 42 | uses: actions/setup-node@v2 43 | with: 44 | node-version: 18.x 45 | registry-url: https://registry.npmjs.org 46 | - uses: actions/cache@v2 47 | with: 48 | path: "**/node_modules" 49 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 50 | - name: Install dependencies 💿 51 | run: yarn install --frozen-lockfile 52 | - name: Publish 🚀 53 | run: yarn run semantic-release 54 | env: 55 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # os 2 | .DS_Store 3 | 4 | # editor 5 | .idea 6 | .vscode 7 | 8 | # node 9 | node_modules 10 | npm-debug.log* 11 | 12 | # project 13 | coverage 14 | build 15 | esm -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "arrowParens": "always" 10 | } 11 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main", {"name": "beta", "prerelease": true}] 3 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributions 2 | 3 | :crystal_ball: Thanks for considering contributing to this project! :crystal_ball: 4 | 5 | These guidelines will help you send a pull request. 6 | 7 | If you're submitting an issue instead, please skip this document. 8 | 9 | If your pull request is related to a typo or the documentation being unclear, please click on the relevant page's `Edit` 10 | button (pencil icon) and directly suggest a correction instead. 11 | 12 | This project was made with your goodwill. The simplest way to give back is by starring and sharing it online. 13 | 14 | Everyone is welcome regardless of personal background. 15 | 16 | ## Development process 17 | 18 | First fork and clone the repository. 19 | 20 | Run: 21 | 22 | ```bash 23 | yarn 24 | ``` 25 | 26 | Make sure everything is correctly setup with: 27 | 28 | ```bash 29 | yarn test 30 | ``` 31 | 32 | ## How to write commit messages 33 | 34 | We use [Conventional Commit messages](https://www.conventionalcommits.org/) to automate version management. 35 | 36 | Most common commit message prefixes are: 37 | 38 | * `fix:` which represents bug fixes, and generate a patch release. 39 | * `feat:` which represents a new feature, and generate a minor release. 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 AijiUejima 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![codecov](https://codecov.io/gh/aiji42/next-with-split/branch/main/graph/badge.svg?token=P126VM3CI1)](https://codecov.io/gh/aiji42/next-with-split) 2 | [![npm version](https://badge.fury.io/js/next-with-split.svg)](https://badge.fury.io/js/next-with-split) 3 | 4 | ![How it works 01](https://github.com/aiji42/next-with-split/blob/main/readme/00.png?raw=true) 5 | 6 | # :ab: next-with-split 7 | 8 | **This is magic!:crystal_ball:** 9 | It enables branch-based split testing (A/B testing) on Vercel and other providers, just like the Netify's [Split Testing](https://docs.netlify.com/site-deploys/split-testing/). 10 | 11 | This plugin lets you divide traffic to your site between different deploys, straight from CDN network. It is not the traditional split testing on a per-component or per-page file basis. 12 | You deploy the main branch (original) and the branch derived from it (challenger) on Vercel and other providers, and use Next.js middleware and cookies to separate two or more environments. Since there is no need to duplicate code, it is easier to manage and prevents the bundle size from increasing. 13 | 14 | ## Example 15 | 16 | [A/B test example](https://next-with-split-aiji42.vercel.app/foo/bar) 17 | 18 | ## How it works 19 | 20 | ![How it works 01](https://github.com/aiji42/next-with-split/blob/main/readme/01.png?raw=true) 21 | 22 | ![How it works 02](https://github.com/aiji42/next-with-split/blob/main/readme/02.png?raw=true) 23 | 24 | ## Require 25 | 26 | - Using Next.js >=13 27 | 28 | If you are using Next.js v12 series, use next-with-split@5.1.0. 29 | 30 | ## Installation 31 | 32 | ``` 33 | npm install --save next-with-split 34 | ``` 35 | 36 | ## Usage 37 | 1\. Customize `next.config.js` and create `middleware.ts`. (in main branch) 38 | ```js 39 | // next.config.js 40 | const withSplit = require('next-with-split').withSplit({}) 41 | 42 | module.export = withSplit({ 43 | // write your next.js configuration values. 44 | }) 45 | ``` 46 | 47 | ```js 48 | // middleware.ts 49 | export { middleware } from 'next-with-split' 50 | ``` 51 | 52 | If you already have middleware code, please refer to the following. 53 | ```js 54 | // middleware.ts 55 | import { middleware as withSplit } from 'next-with-split' 56 | 57 | export const middleware = (req) => { 58 | const res = withSplit(req) 59 | // write your middleware code 60 | return res 61 | } 62 | ``` 63 | 64 | 2\. Derive a branch from the main branch as challenger. 65 | - **NOTE:** Challenger branch also needs to have `next.config.js` customized (No. 1). 66 | 67 | 3\. Deploy the challenger branch for preview and get the hostname. 68 | 69 | 4\. Modify next.config.js in the main branch. 70 | ```js 71 | // next.config.js 72 | const withSplit = require('next-with-split').withSplit({ 73 | splits: { 74 | example1: { // Identification of A/B tests (any) 75 | path: '/foo/*', // Paths to perform A/B testing. (regular expression) 76 | hosts: { 77 | // [branch name]: host name 78 | original: 'example.com', 79 | 'challenger-for-example1': 'challenger-for-example1.vercel.app', 80 | }, 81 | cookie: { // Optional (For Sticky's control) 82 | maxAge: 60 * 60 * 12 * 1000 // Number of valid milliseconds for sticky sessions. (default is 1 day) 83 | } 84 | }, 85 | // Multiple A/B tests can be run simultaneously. 86 | example2: { 87 | path: '/bar/*', 88 | hosts: { 89 | original: 'example.com', 90 | 'challenger-for-example2': 'challenger-for-example2.vercel.app', 91 | // It is possible to distribute access to two or more targets as in A/B/C testing. 92 | 'challenger2-for-example2': 'challenger2-for-example2.vercel.app', 93 | } 94 | } 95 | } 96 | }) 97 | 98 | module.export = withSplit({ 99 | // write your next.js configuration values. 100 | }) 101 | ``` 102 | - If you use a provider other than Vercel, please configure the following manual. 103 | **Note: This setting is also required for the Challenger deployments.** 104 | ```js 105 | // next.config.js 106 | const withSplit = require('next-with-split').withSplit({ 107 | splits: {...}, 108 | isOriginal: false, // Control it so that it is true on the original deployment (basically the main branch) and false on all other deployments., 109 | hostname: 'challenger.example.com', // Set the hostname in the Challenger deployment. If this is not set, you will not be able to access the assets and images. 110 | currentBranch: 'chllenger1', // Optional. Set the value if you use `process.env.NEXT_PUBLIC_IS_TARGET_SPLIT_TESTING`. 111 | }) 112 | 113 | module.export = withSplit({ 114 | // write your next.js configuration values. 115 | }) 116 | ``` 117 | 118 | 5\. Deploy the main branch. 119 | 120 | 6\. The network will be automatically split and the content will be served! 121 | It is also sticky, controlled by cookies. 122 | 123 | ## Features 124 | - If the deployment is subject to A/B testing, `process.env.NEXT_PUBLIC_IS_TARGET_SPLIT_TESTING` is set to 'true'. 125 | - CAUTION: Only if the key set in `hosts` matches the branch name. 126 | 127 | - When Next.js preview mode is turned on, access will automatically be allocated to the original deployment. 128 | - Set the `hosts` key to `original`, `master` or `main`. 129 | 130 | - You can control the behavior of `withSplit` by forcing it by passing an environment variable at server startup. 131 | Use it for verification in your development environment. 132 | - `SPLIT_ACTIVE=true yarn dev`: forced active. 133 | - `SPLIT_DISABLE=true yarn dev`: forced disable. 134 | 135 | - By default, access to deployments is allocated in equal proportions. If you want to add bias to the access sorting, set `wight`. 136 | ```js 137 | // next.config.js 138 | const withSplit = require('next-with-split').withSplit({ 139 | splits: { 140 | example1: { 141 | path: '/foo/*', 142 | hosts: { 143 | // original : challenger1 : challenger2 = 3(50%) : 2(33%) : 1(16%) 144 | original: { host: 'example.com', weight: 3 }, 145 | challenger1: { host: 'challenger1.vercel.app', weight: 2 }, 146 | challenger2: 'challenger2.vercel.app', // If `weight` is not specified, the value is 1. 147 | } 148 | } 149 | } 150 | }) 151 | ``` 152 | 153 | ## Impact on Performance 154 | 155 | This library uses middleware to allocate A/B tests. In general, inserting middleware into the route of the content adds some overhead. 156 | We actually deployed it to Vercel and measured the difference between the original and challenger, and the average difference was 70 to 90ms ([#137](https://github.com/aiji42/next-with-split/issues/137#issuecomment-993518576)). 157 | The challenger is rewritten to a different host according to the configuration in the middleware, which causes a round trip. 158 | The original also had an overhead of about 50ms compared to when no A/B testing was done (when no middleware was deployed). In other words, the challenger has a delay of up to 150ms compared to when it is not A/B tested. 159 | Once the user lands on a page, these delays are not a big problem since navigation between pages is resolved quickly by prefetch, but be careful when landing or processing a full page reload. (Google says that TTFB should be less than 200ms.) 160 | 161 | To avoid adding unnecessary latency... 162 | 1. Make sure that the middlewares do not get into routes that are not related to A/B testing. 163 | - Use [`confit.matcher`](https://nextjs.org/docs/advanced-features/middleware#matcher) to limit the scope of influence of the middleware to the target path of the A/B test. 164 | 2. The middleware for next-with-split is not needed in challengers, so remove the middlewares. (You do need to configure next.config.js, however.) 165 | 3. While stopping A/B tests, remove the middlewares. 166 | 167 | ## Contributing 168 | Please read [CONTRIBUTING.md](https://github.com/aiji42/next-with-split/blob/main/CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 169 | 170 | ## License 171 | This project is licensed under the MIT License - see the [LICENSE](https://github.com/aiji42/next-with-split/blob/main/LICENSE) file for details 172 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | const { build } = require('esbuild') 2 | const { dependencies, peerDependencies } = require('./package.json') 3 | 4 | const omitModulePlugin = (filter, modName) => ({ 5 | name: 'omit', 6 | setup(build) { 7 | build.onResolve({ filter }, (args) => ({ 8 | path: args.path, 9 | namespace: 'omitted' 10 | })) 11 | 12 | build.onLoad({ filter: /.*/, namespace: 'omitted' }, (args) => ({ 13 | contents: `export const ${modName} = () => { 14 | throw new Error('Not defined ${modName}.') 15 | }`, 16 | loader: 'js' 17 | })) 18 | } 19 | }) 20 | 21 | const shared = { 22 | entryPoints: ['./src/index.ts'], 23 | external: Object.keys({ ...dependencies, ...peerDependencies }), 24 | bundle: true, 25 | outdir: './build', 26 | target: 'esnext' 27 | } 28 | 29 | build({ 30 | ...shared, 31 | plugins: [omitModulePlugin(/middleware$/, 'middleware')], 32 | outExtension: { '.js': '.cjs' }, 33 | format: 'cjs' 34 | }) 35 | 36 | build({ 37 | ...shared, 38 | plugins: [omitModulePlugin(/with-split$/, 'withSplit')], 39 | outExtension: { '.js': '.mjs' }, 40 | format: 'esm' 41 | }) 42 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.tsx`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /example/middleware.ts: -------------------------------------------------------------------------------- 1 | export { middleware } from 'next-with-split' 2 | -------------------------------------------------------------------------------- /example/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 | -------------------------------------------------------------------------------- /example/next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-var-requires: 0 */ 2 | const withSplit = require('next-with-split').withSplit({ 3 | splits: { 4 | test1: { 5 | path: '/foo/*', 6 | hosts: { 7 | original: 'next-with-split.vercel.app', 8 | challenger1: 'next-with-split-git-challenger-aiji42.vercel.app' 9 | } 10 | } 11 | } 12 | }) 13 | 14 | module.exports = withSplit({ 15 | async redirects() { 16 | return [ 17 | { 18 | source: '/', 19 | destination: '/foo/bar', 20 | permanent: false 21 | } 22 | ] 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@geist-ui/react": "^2.2.5", 12 | "next": "12.2.2", 13 | "next-with-split": "5.1.0-beta.1", 14 | "react": "18.2.0", 15 | "react-dom": "18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^18.0.3", 19 | "@types/react": "18.0.15", 20 | "typescript": "4.7.4" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from 'next/app' 2 | import { GeistProvider, CssBaseline } from '@geist-ui/react' 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return ( 6 | 7 | 8 | 9 | 10 | ) 11 | } 12 | export default MyApp 13 | -------------------------------------------------------------------------------- /example/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { 2 | Html, 3 | Head, 4 | Main, 5 | NextScript, 6 | DocumentContext 7 | } from 'next/document' 8 | import { CssBaseline } from '@geist-ui/react' 9 | 10 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 11 | // @ts-ignore 12 | class MyDocument extends Document { 13 | static async getInitialProps(ctx: DocumentContext) { 14 | const initialProps = await Document.getInitialProps(ctx) 15 | const styles = CssBaseline.flush() 16 | 17 | return { 18 | ...initialProps, 19 | styles: ( 20 | <> 21 | {initialProps.styles} 22 | {styles} 23 | 24 | ) 25 | } 26 | } 27 | 28 | render() { 29 | return ( 30 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | ) 38 | } 39 | } 40 | 41 | export default MyDocument 42 | -------------------------------------------------------------------------------- /example/pages/api/preview.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from 'next' 2 | 3 | const Preview: NextApiHandler = async (req, res) => { 4 | res.setPreviewData({}) 5 | res.end('Preview mode enabled') 6 | } 7 | 8 | export default Preview 9 | -------------------------------------------------------------------------------- /example/pages/foo/bar.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Image from 'next/image' 3 | import { FC } from 'react' 4 | import { cookieReset, cookieSet } from '../../utils/cookie-control' 5 | import { useRouter } from 'next/router' 6 | import { Button, Text, Grid, Page, Spacer, Link } from '@geist-ui/react' 7 | import NextLink from 'next/link' 8 | 9 | const FooBar: FC = () => { 10 | const router = useRouter() 11 | return ( 12 | 13 | 14 | original | next-with-split 15 | 16 | 17 | 18 | 19 | 20 | This is Original Page BAR 21 | 22 | 23 | 24 | 36 | 37 | 38 | 50 | 51 | 52 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Go /foo/baz 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 93 | 94 | 95 | 96 | 97 | ) 98 | } 99 | 100 | export default FooBar 101 | -------------------------------------------------------------------------------- /example/pages/foo/baz.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Image from 'next/image' 3 | import { FC } from 'react' 4 | import { cookieReset, cookieSet } from '../../utils/cookie-control' 5 | import { useRouter } from 'next/router' 6 | import { Button, Text, Grid, Page, Link, Spacer } from '@geist-ui/react' 7 | import NextLink from 'next/link' 8 | 9 | const FooBar: FC = () => { 10 | const router = useRouter() 11 | return ( 12 | 13 | 14 | original | next-with-split 15 | 16 | 17 | 18 | 19 | 20 | This is Original Page BAZ 21 | 22 | 23 | 24 | 36 | 37 | 38 | 50 | 51 | 52 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Go /foo/bar 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 93 | 94 | 95 | 96 | 97 | ) 98 | } 99 | 100 | export default FooBar 101 | -------------------------------------------------------------------------------- /example/pages/top.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | const Top = () => { 4 | return ( 5 | 6 | /foo/bar 7 | 8 | ) 9 | } 10 | 11 | export default Top 12 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aiji42/next-with-split/443f82f1b231f3438f0d64ca71928e4f704ff5bf/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true 21 | }, 22 | "include": [ 23 | "next-env.d.ts", 24 | "**/*.ts", 25 | "**/*.tsx" 26 | ], 27 | "exclude": [ 28 | "node_modules" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /example/utils/cookie-control.ts: -------------------------------------------------------------------------------- 1 | export const cookieReset = (splitKey: string): void => { 2 | document.cookie = `x-split-key-${splitKey}=; path=/; expires=${new Date( 3 | '1999-12-31T23:59:59Z' 4 | ).toUTCString()}` 5 | } 6 | 7 | export const cookieSet = (splitKey: string, branch: string): void => { 8 | const date = new Date() 9 | document.cookie = `x-split-key-${splitKey}=${branch}; path=/; expires=${new Date( 10 | date.setDate(date.getDate() + 1) 11 | ).toUTCString()}` 12 | } 13 | -------------------------------------------------------------------------------- /example/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@babel/runtime@^7.16.7": 6 | version "7.16.7" 7 | resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa" 8 | integrity sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ== 9 | dependencies: 10 | regenerator-runtime "^0.13.4" 11 | 12 | "@geist-ui/react@^2.2.5": 13 | version "2.2.5" 14 | resolved "https://registry.yarnpkg.com/@geist-ui/react/-/react-2.2.5.tgz#769f76d70e988cd1d75ab8499e71972804535e75" 15 | integrity sha512-yPBAweYVh5HXXJ6W5ont/z6UObbfsjesg0/KTfcJzc64/zjGgp+wv2zLIdReGOTAj2R4XWoNSirNGGbQnwFe6w== 16 | dependencies: 17 | "@babel/runtime" "^7.16.7" 18 | 19 | "@next/env@12.2.2": 20 | version "12.2.2" 21 | resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.2.tgz#cc1a0a445bd254499e30f632968c03192455f4cc" 22 | integrity sha512-BqDwE4gDl1F608TpnNxZqrCn6g48MBjvmWFEmeX5wEXDXh3IkAOw6ASKUgjT8H4OUePYFqghDFUss5ZhnbOUjw== 23 | 24 | "@next/swc-android-arm-eabi@12.2.2": 25 | version "12.2.2" 26 | resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.2.tgz#f6c4111e6371f73af6bf80c9accb3d96850a92cd" 27 | integrity sha512-VHjuCHeq9qCprUZbsRxxM/VqSW8MmsUtqB5nEpGEgUNnQi/BTm/2aK8tl7R4D0twGKRh6g1AAeFuWtXzk9Z/vQ== 28 | 29 | "@next/swc-android-arm64@12.2.2": 30 | version "12.2.2" 31 | resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.2.tgz#b69de59c51e631a7600439e7a8993d6e82f3369e" 32 | integrity sha512-v5EYzXUOSv0r9mO/2PX6mOcF53k8ndlu9yeFHVAWW1Dhw2jaJcvTRcCAwYYN8Q3tDg0nH3NbEltJDLKmcJOuVA== 33 | 34 | "@next/swc-darwin-arm64@12.2.2": 35 | version "12.2.2" 36 | resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.2.tgz#80157c91668eff95b72d052428c353eab0fc4c50" 37 | integrity sha512-JCoGySHKGt+YBk7xRTFGx1QjrnCcwYxIo3yGepcOq64MoiocTM3yllQWeOAJU2/k9MH0+B5E9WUSme4rOCBbpA== 38 | 39 | "@next/swc-darwin-x64@12.2.2": 40 | version "12.2.2" 41 | resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.2.tgz#12be2f58e676fccff3d48a62921b9927ed295133" 42 | integrity sha512-dztDtvfkhUqiqpXvrWVccfGhLe44yQ5tQ7B4tBfnsOR6vxzI9DNPHTlEOgRN9qDqTAcFyPxvg86mn4l8bB9Jcw== 43 | 44 | "@next/swc-freebsd-x64@12.2.2": 45 | version "12.2.2" 46 | resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.2.tgz#de1363431a49059f1efb8c0f86ce6a79c53b3a95" 47 | integrity sha512-JUnXB+2xfxqsAvhFLPJpU1NeyDsvJrKoOjpV7g3Dxbno2Riu4tDKn3kKF886yleAuD/1qNTUCpqubTvbbT2VoA== 48 | 49 | "@next/swc-linux-arm-gnueabihf@12.2.2": 50 | version "12.2.2" 51 | resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.2.tgz#d5b8e0d1bb55bbd9db4d2fec018217471dc8b9e6" 52 | integrity sha512-XeYC/qqPLz58R4pjkb+x8sUUxuGLnx9QruC7/IGkK68yW4G17PHwKI/1njFYVfXTXUukpWjcfBuauWwxp9ke7Q== 53 | 54 | "@next/swc-linux-arm64-gnu@12.2.2": 55 | version "12.2.2" 56 | resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.2.tgz#3bc75984e1d5ec8f59eb53702cc382d8e1be2061" 57 | integrity sha512-d6jT8xgfKYFkzR7J0OHo2D+kFvY/6W8qEo6/hmdrTt6AKAqxs//rbbcdoyn3YQq1x6FVUUd39zzpezZntg9Naw== 58 | 59 | "@next/swc-linux-arm64-musl@12.2.2": 60 | version "12.2.2" 61 | resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.2.tgz#270db73e07a18d999f61e79a917943fa5bc1ef56" 62 | integrity sha512-rIZRFxI9N/502auJT1i7coas0HTHUM+HaXMyJiCpnY8Rimbo0495ir24tzzHo3nQqJwcflcPTwEh/DV17sdv9A== 63 | 64 | "@next/swc-linux-x64-gnu@12.2.2": 65 | version "12.2.2" 66 | resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.2.tgz#e6c72fa20478552e898c434f4d4c0c5e89d2ea78" 67 | integrity sha512-ir1vNadlUDj7eQk15AvfhG5BjVizuCHks9uZwBfUgT5jyeDCeRvaDCo1+Q6+0CLOAnYDR/nqSCvBgzG2UdFh9A== 68 | 69 | "@next/swc-linux-x64-musl@12.2.2": 70 | version "12.2.2" 71 | resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.2.tgz#b9ef9efe2c401839cdefa5e70402386aafdce15a" 72 | integrity sha512-bte5n2GzLN3O8JdSFYWZzMgEgDHZmRz5wiispiiDssj4ik3l8E7wq/czNi8RmIF+ioj2sYVokUNa/ekLzrESWw== 73 | 74 | "@next/swc-win32-arm64-msvc@12.2.2": 75 | version "12.2.2" 76 | resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.2.tgz#18fa7ec7248da3a7926a0601d9ececc53ac83157" 77 | integrity sha512-ZUGCmcDmdPVSAlwJ/aD+1F9lYW8vttseiv4n2+VCDv5JloxiX9aY32kYZaJJO7hmTLNrprvXkb4OvNuHdN22Jg== 78 | 79 | "@next/swc-win32-ia32-msvc@12.2.2": 80 | version "12.2.2" 81 | resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.2.tgz#54936e84f4a219441d051940354da7cd3eafbb4f" 82 | integrity sha512-v7ykeEDbr9eXiblGSZiEYYkWoig6sRhAbLKHUHQtk8vEWWVEqeXFcxmw6LRrKu5rCN1DY357UlYWToCGPQPCRA== 83 | 84 | "@next/swc-win32-x64-msvc@12.2.2": 85 | version "12.2.2" 86 | resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.2.tgz#7460be700a60d75816f01109400b51fe929d7e89" 87 | integrity sha512-2D2iinWUL6xx8D9LYVZ5qi7FP6uLAoWymt8m8aaG2Ld/Ka8/k723fJfiklfuAcwOxfufPJI+nRbT5VcgHGzHAQ== 88 | 89 | "@swc/helpers@0.4.2": 90 | version "0.4.2" 91 | resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.2.tgz#ed1f6997ffbc22396665d9ba74e2a5c0a2d782f8" 92 | integrity sha512-556Az0VX7WR6UdoTn4htt/l3zPQ7bsQWK+HqdG4swV7beUCxo/BqmvbOpUkTIm/9ih86LIf1qsUnywNL3obGHw== 93 | dependencies: 94 | tslib "^2.4.0" 95 | 96 | "@types/node@^18.0.3": 97 | version "18.0.3" 98 | resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.3.tgz#463fc47f13ec0688a33aec75d078a0541a447199" 99 | integrity sha512-HzNRZtp4eepNitP+BD6k2L6DROIDG4Q0fm4x+dwfsr6LGmROENnok75VGw40628xf+iR24WeMFcHuuBDUAzzsQ== 100 | 101 | "@types/prop-types@*": 102 | version "15.7.3" 103 | resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" 104 | integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== 105 | 106 | "@types/react@18.0.15": 107 | version "18.0.15" 108 | resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.15.tgz#d355644c26832dc27f3e6cbf0c4f4603fc4ab7fe" 109 | integrity sha512-iz3BtLuIYH1uWdsv6wXYdhozhqj20oD4/Hk2DNXIn1kFsmp9x8d9QB6FnPhfkbhd2PgEONt9Q1x/ebkwjfFLow== 110 | dependencies: 111 | "@types/prop-types" "*" 112 | "@types/scheduler" "*" 113 | csstype "^3.0.2" 114 | 115 | "@types/scheduler@*": 116 | version "0.16.1" 117 | resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275" 118 | integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA== 119 | 120 | caniuse-lite@^1.0.30001332: 121 | version "1.0.30001346" 122 | resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001346.tgz#e895551b46b9cc9cc9de852facd42f04839a8fbe" 123 | integrity sha512-q6ibZUO2t88QCIPayP/euuDREq+aMAxFE5S70PkrLh0iTDj/zEhgvJRKC2+CvXY6EWc6oQwUR48lL5vCW6jiXQ== 124 | 125 | csstype@^3.0.2: 126 | version "3.0.8" 127 | resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" 128 | integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== 129 | 130 | "js-tokens@^3.0.0 || ^4.0.0": 131 | version "4.0.0" 132 | resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" 133 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 134 | 135 | loose-envify@^1.1.0: 136 | version "1.4.0" 137 | resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" 138 | integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 139 | dependencies: 140 | js-tokens "^3.0.0 || ^4.0.0" 141 | 142 | nanoid@^3.1.30: 143 | version "3.3.1" 144 | resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35" 145 | integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw== 146 | 147 | next-with-split@5.1.0-beta.1: 148 | version "5.1.0-beta.1" 149 | resolved "https://registry.yarnpkg.com/next-with-split/-/next-with-split-5.1.0-beta.1.tgz#1f32aa5a2f0e616985f04a18308d9a03858f1754" 150 | integrity sha512-7MBt1bBTJbg3ObsHbQhmZHk6aAQqfyzdf5+g7LrhebWxxO5d/a3uKBPGS3sR//uEuB9loKjgrCP0eoW69YMa+w== 151 | 152 | next@12.2.2: 153 | version "12.2.2" 154 | resolved "https://registry.yarnpkg.com/next/-/next-12.2.2.tgz#029bf5e4a18a891ca5d05b189b7cd983fd22c072" 155 | integrity sha512-zAYFY45aBry/PlKONqtlloRFqU/We3zWYdn2NoGvDZkoYUYQSJC8WMcalS5C19MxbCZLUVCX7D7a6gTGgl2yLg== 156 | dependencies: 157 | "@next/env" "12.2.2" 158 | "@swc/helpers" "0.4.2" 159 | caniuse-lite "^1.0.30001332" 160 | postcss "8.4.5" 161 | styled-jsx "5.0.2" 162 | use-sync-external-store "1.1.0" 163 | optionalDependencies: 164 | "@next/swc-android-arm-eabi" "12.2.2" 165 | "@next/swc-android-arm64" "12.2.2" 166 | "@next/swc-darwin-arm64" "12.2.2" 167 | "@next/swc-darwin-x64" "12.2.2" 168 | "@next/swc-freebsd-x64" "12.2.2" 169 | "@next/swc-linux-arm-gnueabihf" "12.2.2" 170 | "@next/swc-linux-arm64-gnu" "12.2.2" 171 | "@next/swc-linux-arm64-musl" "12.2.2" 172 | "@next/swc-linux-x64-gnu" "12.2.2" 173 | "@next/swc-linux-x64-musl" "12.2.2" 174 | "@next/swc-win32-arm64-msvc" "12.2.2" 175 | "@next/swc-win32-ia32-msvc" "12.2.2" 176 | "@next/swc-win32-x64-msvc" "12.2.2" 177 | 178 | picocolors@^1.0.0: 179 | version "1.0.0" 180 | resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" 181 | integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== 182 | 183 | postcss@8.4.5: 184 | version "8.4.5" 185 | resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.5.tgz#bae665764dfd4c6fcc24dc0fdf7e7aa00cc77f95" 186 | integrity sha512-jBDboWM8qpaqwkMwItqTQTiFikhs/67OYVvblFFTM7MrZjt6yMKd6r2kgXizEbTTljacm4NldIlZnhbjr84QYg== 187 | dependencies: 188 | nanoid "^3.1.30" 189 | picocolors "^1.0.0" 190 | source-map-js "^1.0.1" 191 | 192 | react-dom@18.2.0: 193 | version "18.2.0" 194 | resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" 195 | integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== 196 | dependencies: 197 | loose-envify "^1.1.0" 198 | scheduler "^0.23.0" 199 | 200 | react@18.2.0: 201 | version "18.2.0" 202 | resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" 203 | integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== 204 | dependencies: 205 | loose-envify "^1.1.0" 206 | 207 | regenerator-runtime@^0.13.4: 208 | version "0.13.7" 209 | resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" 210 | integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== 211 | 212 | scheduler@^0.23.0: 213 | version "0.23.0" 214 | resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" 215 | integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== 216 | dependencies: 217 | loose-envify "^1.1.0" 218 | 219 | source-map-js@^1.0.1: 220 | version "1.0.2" 221 | resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" 222 | integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== 223 | 224 | styled-jsx@5.0.2: 225 | version "5.0.2" 226 | resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.2.tgz#ff230fd593b737e9e68b630a694d460425478729" 227 | integrity sha512-LqPQrbBh3egD57NBcHET4qcgshPks+yblyhPlH2GY8oaDgKs8SK4C3dBh3oSJjgzJ3G5t1SYEZGHkP+QEpX9EQ== 228 | 229 | tslib@^2.4.0: 230 | version "2.4.0" 231 | resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" 232 | integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== 233 | 234 | typescript@4.7.4: 235 | version "4.7.4" 236 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" 237 | integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== 238 | 239 | use-sync-external-store@1.1.0: 240 | version "1.1.0" 241 | resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz#3343c3fe7f7e404db70f8c687adf5c1652d34e82" 242 | integrity sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ== 243 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-with-split", 3 | "version": "0.0.0-development", 4 | "description": "This is a plugin for split testing (AB testing) in Next.js.", 5 | "main": "./build/index.cjs", 6 | "module": "./build/index.mjs", 7 | "types": "./build/index.d.ts", 8 | "files": [ 9 | "build" 10 | ], 11 | "publishConfig": { 12 | "access": "public" 13 | }, 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/aiji42/next-with-split.git" 18 | }, 19 | "keywords": [ 20 | "next.js", 21 | "next", 22 | "react", 23 | "plugins", 24 | "vercel", 25 | "ab-test", 26 | "split-test", 27 | "ab test", 28 | "split test" 29 | ], 30 | "author": "aiji42 (https://twitter.com/aiji42_dev)", 31 | "bugs": { 32 | "url": "https://github.com/aiji42/next-with-split/issues" 33 | }, 34 | "homepage": "https://github.com/aiji42/next-with-split#readme", 35 | "scripts": { 36 | "format": "prettier './src/**/*.ts' './src/*.ts' --write", 37 | "test": "vitest run", 38 | "test:coverage": "vitest run --coverage", 39 | "build": "node build.js && npx tsc ./src/index.ts --declaration --emitDeclarationOnly --skipLibCheck --esModuleInterop --declarationDir './build'", 40 | "semantic-release": "semantic-release", 41 | "prepack": "yarn build", 42 | "prepare": "husky install" 43 | }, 44 | "dependencies": {}, 45 | "peerDependencies": { 46 | "next": ">=13.0.0" 47 | }, 48 | "devDependencies": { 49 | "@commitlint/cli": "^17.5.1", 50 | "@commitlint/config-conventional": "^17.4.4", 51 | "@edge-runtime/vm": "^2.1.2", 52 | "@types/cookie": "^0.5.1", 53 | "@types/node": "^18.15.11", 54 | "@types/react": "^18.0.33", 55 | "@types/react-dom": "^18.0.11", 56 | "@vitest/coverage-c8": "^0.29.8", 57 | "c8": "^7.13.0", 58 | "esbuild": "^0.17.15", 59 | "husky": "^8.0.3", 60 | "lint-staged": "^13.2.0", 61 | "next": "^13.2.4", 62 | "prettier": "^2.8.7", 63 | "semantic-release": "^21.0.1", 64 | "ts-node": "^10.9.1", 65 | "typescript": "^5.0.3", 66 | "vitest": "^0.29.8" 67 | }, 68 | "lint-staged": { 69 | "*.{js,jsx,ts,tsx}": [ 70 | "prettier --write" 71 | ] 72 | }, 73 | "engines": { 74 | "node": ">=18" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /readme/00.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aiji42/next-with-split/443f82f1b231f3438f0d64ca71928e4f704ff5bf/readme/00.png -------------------------------------------------------------------------------- /readme/01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aiji42/next-with-split/443f82f1b231f3438f0d64ca71928e4f704ff5bf/readme/01.png -------------------------------------------------------------------------------- /readme/02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aiji42/next-with-split/443f82f1b231f3438f0d64ca71928e4f704ff5bf/readme/02.png -------------------------------------------------------------------------------- /src/__tests__/mddleware.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment edge-runtime 3 | */ 4 | import { vi, describe, beforeEach, afterAll, test, expect, Mock } from 'vitest' 5 | import { middleware } from '../middleware' 6 | const { NextRequest } = require('next/server') 7 | import { NextResponse, userAgent } from 'next/server' 8 | import { random } from '../random' 9 | 10 | vi.mock('next/server', () => ({ 11 | NextResponse: vi.fn(), 12 | userAgent: vi.fn() 13 | })) 14 | vi.mock('../random', () => ({ 15 | random: vi.fn() 16 | })) 17 | 18 | const runtimeConfig = { 19 | test1: { 20 | path: '/foo/*', 21 | hosts: { 22 | original: { 23 | weight: 1, 24 | host: 'https://example.com', 25 | isOriginal: true 26 | }, 27 | challenger: { 28 | weight: 1, 29 | host: 'https://challenger.example.com', 30 | isOriginal: false 31 | } 32 | }, 33 | cookie: { 34 | path: '/', 35 | maxAge: 86400000 36 | } 37 | } 38 | } 39 | 40 | const cookies = { 41 | set: vi.fn() 42 | } 43 | const OLD_ENV = process.env 44 | 45 | beforeEach(() => { 46 | vi.resetAllMocks() 47 | ;(userAgent as Mock).mockReturnValue({ isBot: false }) 48 | process.env = { 49 | ...OLD_ENV, 50 | NEXT_WITH_SPLIT_RUNTIME_CONFIG: JSON.stringify(runtimeConfig) 51 | } 52 | }) 53 | 54 | afterAll(() => { 55 | process.env = OLD_ENV 56 | }) 57 | 58 | describe('middleware', () => { 59 | describe('has runtime config', () => { 60 | test('preview mode', () => { 61 | const req = new NextRequest('https://example.com/foo/bar', {}) 62 | req.cookies.set('__prerender_bypass', true) 63 | expect(middleware(req)).toBeUndefined() 64 | }) 65 | 66 | test('path is not matched', () => { 67 | const req = new NextRequest('https://example.com/bar', {}) 68 | expect(middleware(req)).toBeUndefined() 69 | }) 70 | 71 | test('accessed by a bot', () => { 72 | const req = new NextRequest('https://example.com/foo/bar', {}) 73 | ;(userAgent as Mock).mockReturnValue({ isBot: true }) 74 | expect(middleware(req)).toBeUndefined() 75 | }) 76 | 77 | test('path is matched and has sticky cookie', () => { 78 | NextResponse.rewrite = vi.fn().mockReturnValueOnce({ cookies }) 79 | const req = new NextRequest('https://example.com/foo/bar', {}) 80 | req.cookies.set('x-split-key-test1', 'challenger') 81 | middleware(req) 82 | 83 | expect(NextResponse.rewrite).toBeCalledWith( 84 | 'https://challenger.example.com/foo/bar' 85 | ) 86 | expect(cookies.set).toBeCalledWith( 87 | 'x-split-key-test1', 88 | 'challenger', 89 | runtimeConfig.test1.cookie 90 | ) 91 | }) 92 | 93 | test('path is matched and not has sticky cookie', () => { 94 | ;(random as Mock).mockReturnValueOnce(0) 95 | NextResponse.next = vi.fn().mockReturnValueOnce({ cookies }) 96 | const req = new NextRequest('https://example.com/foo/bar', {}) 97 | middleware(req) 98 | 99 | expect(NextResponse.next).toBeCalled() 100 | expect(cookies.set).toBeCalledWith( 101 | 'x-split-key-test1', 102 | 'original', 103 | runtimeConfig.test1.cookie 104 | ) 105 | }) 106 | }) 107 | 108 | test('not has runtime config', () => { 109 | process.env = { 110 | ...process.env, 111 | NEXT_WITH_SPLIT_RUNTIME_CONFIG: '' 112 | } 113 | const req = new NextRequest('https://example.com/foo/bar', {}) 114 | 115 | expect(middleware(req)).toBeUndefined() 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /src/__tests__/with-split.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, beforeEach, afterAll, it, expect, test } from 'vitest' 2 | import { withSplit } from '../with-split' 3 | import { NextConfig } from 'next' 4 | 5 | describe('withSplit', () => { 6 | const OLD_ENV = process.env 7 | beforeEach(() => { 8 | vi.resetAllMocks() 9 | process.env = { ...OLD_ENV } 10 | }) 11 | afterAll(() => { 12 | process.env = OLD_ENV 13 | }) 14 | 15 | it('default', () => { 16 | process.env = { 17 | ...process.env, 18 | VERCEL_URL: 'vercel.example.com', 19 | VERCEL_ENV: 'production' 20 | } 21 | const conf = withSplit({})({}) 22 | expect(conf.assetPrefix).toEqual('') 23 | expect(conf.images).toEqual({ 24 | path: undefined 25 | }) 26 | expect(conf.env).toEqual({ NEXT_WITH_SPLIT_RUNTIME_CONFIG: '{}' }) 27 | }) 28 | it('must return config merged passed values', () => { 29 | process.env = { 30 | ...process.env, 31 | VERCEL_URL: 'vercel.example.com', 32 | VERCEL_ENV: 'production' 33 | } 34 | const conf = withSplit({})({ 35 | assetPrefix: 'https://hoge.com', 36 | images: { 37 | path: 'https://hoge.com/_next/image' 38 | } as NextConfig['images'], 39 | env: { 40 | foo: 'bar' 41 | } 42 | }) 43 | expect(conf.assetPrefix).toEqual('https://hoge.com') 44 | expect(conf.images).toEqual({ path: 'https://hoge.com/_next/image' }) 45 | expect(conf.env).toEqual({ 46 | foo: 'bar', 47 | NEXT_WITH_SPLIT_RUNTIME_CONFIG: '{}' 48 | }) 49 | }) 50 | it('return split test config', () => { 51 | process.env = { 52 | ...process.env, 53 | VERCEL_URL: 'vercel.example.com', 54 | VERCEL_ENV: 'production' 55 | } 56 | const conf = withSplit({ 57 | splits: { 58 | test1: { 59 | hosts: { 60 | branch1: 'https://branch1.example.com', 61 | branch2: 'https://branch2.example.com' 62 | }, 63 | path: '/foo/*' 64 | } 65 | } 66 | })({}) 67 | expect(conf.env).toEqual({ 68 | NEXT_WITH_SPLIT_RUNTIME_CONFIG: 69 | '{"test1":{"path":"/foo/*","hosts":{"branch1":{"weight":1,"host":"https://branch1.example.com","isOriginal":false},"branch2":{"weight":1,"host":"https://branch2.example.com","isOriginal":false}},"cookie":{"path":"/","maxAge":86400000}}}' 70 | }) 71 | }) 72 | it('must return config with the biases when passed the biases', () => { 73 | process.env = { 74 | ...process.env, 75 | VERCEL_URL: 'vercel.example.com', 76 | VERCEL_ENV: 'production' 77 | } 78 | const conf = withSplit({ 79 | splits: { 80 | test1: { 81 | hosts: { 82 | branch1: { host: 'https://branch1.example.com', weight: 10 }, 83 | branch2: 'https://branch2.example.com' 84 | }, 85 | path: '/foo/*' 86 | } 87 | } 88 | })({}) 89 | expect(conf.env).toEqual({ 90 | NEXT_WITH_SPLIT_RUNTIME_CONFIG: 91 | '{"test1":{"path":"/foo/*","hosts":{"branch1":{"host":"https://branch1.example.com","weight":10,"isOriginal":false},"branch2":{"weight":1,"host":"https://branch2.example.com","isOriginal":false}},"cookie":{"path":"/","maxAge":86400000}}}' 92 | }) 93 | }) 94 | it('return split test config with isOriginal === true when branch name is original | main | master', () => { 95 | process.env = { 96 | ...process.env, 97 | VERCEL_URL: 'vercel.example.com', 98 | VERCEL_ENV: 'production' 99 | } 100 | const conf1 = withSplit({ 101 | splits: { 102 | test1: { 103 | hosts: { 104 | branch1: 'https://branch1.example.com', 105 | original: 'https://original.example.com' 106 | }, 107 | path: '/foo/*' 108 | } 109 | } 110 | })({}) 111 | expect(conf1.env).toEqual({ 112 | NEXT_WITH_SPLIT_RUNTIME_CONFIG: 113 | '{"test1":{"path":"/foo/*","hosts":{"branch1":{"weight":1,"host":"https://branch1.example.com","isOriginal":false},"original":{"weight":1,"host":"https://original.example.com","isOriginal":true}},"cookie":{"path":"/","maxAge":86400000}}}' 114 | }) 115 | const conf2 = withSplit({ 116 | splits: { 117 | test1: { 118 | hosts: { 119 | branch1: 'https://branch1.example.com', 120 | main: 'https://original.example.com' 121 | }, 122 | path: '/foo/*' 123 | } 124 | } 125 | })({}) 126 | expect(conf2.env).toEqual({ 127 | NEXT_WITH_SPLIT_RUNTIME_CONFIG: 128 | '{"test1":{"path":"/foo/*","hosts":{"branch1":{"weight":1,"host":"https://branch1.example.com","isOriginal":false},"main":{"weight":1,"host":"https://original.example.com","isOriginal":true}},"cookie":{"path":"/","maxAge":86400000}}}' 129 | }) 130 | const conf3 = withSplit({ 131 | splits: { 132 | test1: { 133 | hosts: { 134 | branch1: 'https://branch1.example.com', 135 | master: 'https://original.example.com' 136 | }, 137 | path: '/foo/*' 138 | } 139 | } 140 | })({}) 141 | expect(conf3.env).toEqual({ 142 | NEXT_WITH_SPLIT_RUNTIME_CONFIG: 143 | '{"test1":{"path":"/foo/*","hosts":{"branch1":{"weight":1,"host":"https://branch1.example.com","isOriginal":false},"master":{"weight":1,"host":"https://original.example.com","isOriginal":true}},"cookie":{"path":"/","maxAge":86400000}}}' 144 | }) 145 | }) 146 | it('return empty rewrite rules when runs on not main branch', () => { 147 | process.env = { 148 | ...process.env, 149 | VERCEL_ENV: 'preview', 150 | VERCEL_URL: 'preview.example.com', 151 | VERCEL_GIT_COMMIT_REF: 'branch1' 152 | } 153 | const conf = withSplit({ 154 | splits: { 155 | test1: { 156 | hosts: { 157 | branch1: 'https://branch1.example.com', 158 | branch2: 'https://branch2.example.com' 159 | }, 160 | path: '/foo/*' 161 | } 162 | } 163 | })({}) 164 | expect(process.env.NEXT_PUBLIC_IS_TARGET_SPLIT_TESTING).toEqual('true') 165 | expect(conf.assetPrefix).toEqual('https://preview.example.com') 166 | expect(conf.images).toEqual({ 167 | path: 'https://preview.example.com/_next/image' 168 | }) 169 | expect(conf.env).toEqual({}) 170 | }) 171 | 172 | describe('manual config', () => { 173 | it('must return empty runtime config when isOriginal is set false', () => { 174 | process.env = { 175 | ...process.env, 176 | VERCEL_URL: 'vercel.example.com', 177 | VERCEL_ENV: 'production' 178 | } 179 | const conf = withSplit({ 180 | splits: { 181 | test1: { 182 | hosts: { 183 | branch1: 'https://branch1.example.com', 184 | branch2: 'https://branch2.example.com' 185 | }, 186 | path: '/foo/*' 187 | } 188 | }, 189 | isOriginal: false 190 | })({}) 191 | expect(conf.env).toEqual({}) 192 | }) 193 | 194 | it('Env variable indicate targeting when currentBranch is set target branch', () => { 195 | withSplit({ 196 | splits: { 197 | test1: { 198 | hosts: { 199 | branch1: 'https://branch1.example.com', 200 | branch2: 'https://branch2.example.com' 201 | }, 202 | path: '/foo/*' 203 | } 204 | }, 205 | currentBranch: 'branch1' 206 | })({}) 207 | expect(process.env.NEXT_PUBLIC_IS_TARGET_SPLIT_TESTING).toEqual('true') 208 | }) 209 | 210 | it('Env variable must not indicate targeting when currentBranch is set NOT targeted branch', () => { 211 | withSplit({ 212 | splits: { 213 | test1: { 214 | hosts: { 215 | branch1: 'https://branch1.example.com', 216 | branch2: 'https://branch2.example.com' 217 | }, 218 | path: '/foo/*' 219 | } 220 | }, 221 | currentBranch: 'branch3' 222 | })({}) 223 | expect(process.env.NEXT_PUBLIC_IS_TARGET_SPLIT_TESTING).toBeUndefined() 224 | }) 225 | 226 | it('must return assetPrefix and image.path when hostname is set and isOriginal is set false', () => { 227 | const conf = withSplit({ 228 | splits: { 229 | test1: { 230 | hosts: { 231 | branch1: 'https://branch1.example.com', 232 | branch2: 'https://branch2.example.com' 233 | }, 234 | path: '/foo/*' 235 | } 236 | }, 237 | hostname: 'preview.example.com', 238 | isOriginal: false 239 | })({}) 240 | expect(conf.assetPrefix).toEqual('https://preview.example.com') 241 | expect(conf.images).toEqual({ 242 | path: 'https://preview.example.com/_next/image' 243 | }) 244 | }) 245 | 246 | it('must return blank assetPrefix and image.path when hostname is set and isOriginal is set true', () => { 247 | const conf = withSplit({ 248 | splits: { 249 | test1: { 250 | hosts: { 251 | branch1: 'https://branch1.example.com', 252 | branch2: 'https://branch2.example.com' 253 | }, 254 | path: '/foo/*' 255 | } 256 | }, 257 | hostname: 'preview.example.com', 258 | isOriginal: true 259 | })({}) 260 | expect(conf.assetPrefix).toEqual('') 261 | expect(conf.images).toEqual({ path: undefined }) 262 | }) 263 | 264 | it('must through nextConfig without doing anything when SPLIT_DISABLE', () => { 265 | process.env = { ...process.env, SPLIT_DISABLE: 'true' } 266 | const conf = withSplit({ 267 | splits: { 268 | test1: { 269 | hosts: { 270 | branch1: 'https://branch1.example.com', 271 | branch2: 'https://branch2.example.com' 272 | }, 273 | path: '/foo/*' 274 | } 275 | }, 276 | hostname: 'preview.example.com', 277 | isOriginal: true 278 | })({}) 279 | expect(conf).toEqual({}) 280 | }) 281 | 282 | it('must return forced rewrite rules when SPLIT_ACTIVE', () => { 283 | process.env = { ...process.env, SPLIT_ACTIVE: 'true' } 284 | const conf = withSplit({ 285 | splits: { 286 | test1: { 287 | hosts: { 288 | branch1: 'https://branch1.example.com', 289 | branch2: 'https://branch2.example.com' 290 | }, 291 | path: '/foo/*' 292 | } 293 | }, 294 | hostname: 'preview.example.com', 295 | isOriginal: false 296 | })({}) 297 | expect(conf.env).toEqual({ 298 | NEXT_WITH_SPLIT_RUNTIME_CONFIG: 299 | '{"test1":{"path":"/foo/*","hosts":{"branch1":{"weight":1,"host":"https://branch1.example.com","isOriginal":false},"branch2":{"weight":1,"host":"https://branch2.example.com","isOriginal":false}},"cookie":{"path":"/","maxAge":86400000}}}' 300 | }) 301 | }) 302 | 303 | test('hosts with no protocol must be complemented', () => { 304 | process.env = { 305 | ...process.env, 306 | VERCEL_URL: 'vercel.example.com', 307 | VERCEL_ENV: 'production' 308 | } 309 | const conf = withSplit({ 310 | splits: { 311 | test1: { 312 | hosts: { 313 | branch1: 'branch1.example.com', 314 | branch2: 'branch2.example.com' 315 | }, 316 | path: '/foo/*' 317 | } 318 | } 319 | })({}) 320 | expect(conf.env).toEqual({ 321 | NEXT_WITH_SPLIT_RUNTIME_CONFIG: 322 | '{"test1":{"path":"/foo/*","hosts":{"branch1":{"weight":1,"host":"https://branch1.example.com","isOriginal":false},"branch2":{"weight":1,"host":"https://branch2.example.com","isOriginal":false}},"cookie":{"path":"/","maxAge":86400000}}}' 323 | }) 324 | }) 325 | 326 | test('if the host format is wrong, an error occurs', () => { 327 | process.env = { 328 | ...process.env, 329 | VERCEL_URL: 'vercel.example.com', 330 | VERCEL_ENV: 'production' 331 | } 332 | expect(() => 333 | withSplit({ 334 | splits: { 335 | test1: { 336 | hosts: { 337 | branch1: '//:branch1.example.com', 338 | branch2: 'branch2.example.com' 339 | }, 340 | path: '/foo/*' 341 | } 342 | } 343 | })({}) 344 | ).toThrow('Incorrect host format: //:branch1.example.com') 345 | }) 346 | 347 | test('if the host is set with a path, an error occurs', () => { 348 | process.env = { 349 | ...process.env, 350 | VERCEL_URL: 'vercel.example.com', 351 | VERCEL_ENV: 'production' 352 | } 353 | expect(() => 354 | withSplit({ 355 | splits: { 356 | test1: { 357 | hosts: { 358 | branch1: 'https//:branch1.example.com/', 359 | branch2: 'branch2.example.com/' 360 | }, 361 | path: '/foo/*' 362 | } 363 | } 364 | })({}) 365 | ).toThrow( 366 | "Incorrect host format: Specify only the protocol and domain (you set 'https//:branch1.example.com/')" 367 | ) 368 | }) 369 | 370 | test('if the path is not set, an error occurs', () => { 371 | process.env = { 372 | ...process.env, 373 | VERCEL_URL: 'vercel.example.com', 374 | VERCEL_ENV: 'production' 375 | } 376 | expect(() => 377 | withSplit({ 378 | splits: { 379 | test1: { 380 | hosts: { 381 | branch1: 'https//:branch1.example.com', 382 | branch2: 'branch2.example.com' 383 | }, 384 | path: '' 385 | } 386 | } 387 | })({}) 388 | ).toThrow('Invalid format: The `path` is not set on `test1`.') 389 | }) 390 | 391 | describe('using the Spectrum', () => { 392 | it('must reads the environment variables (SPLIT_CONFIG_BY_SPECTRUM) and returns config', () => { 393 | process.env = { 394 | ...process.env, 395 | VERCEL_URL: 'vercel.example.com', 396 | VERCEL_ENV: 'production', 397 | SPLIT_CONFIG_BY_SPECTRUM: JSON.stringify({ 398 | test1: { 399 | path: '/foo/bar', 400 | hosts: { 401 | original: { host: 'vercel.example.com', weight: 1 }, 402 | challenger: { host: 'challenger.vercel.example.com', weight: 1 } 403 | }, 404 | cookie: { maxAge: 60 } 405 | } 406 | }) 407 | } 408 | 409 | const conf = withSplit({})({}) 410 | expect(conf.env).toEqual({ 411 | NEXT_WITH_SPLIT_RUNTIME_CONFIG: 412 | '{"test1":{"path":"/foo/bar","hosts":{"original":{"host":"https://vercel.example.com","weight":1,"isOriginal":true},"challenger":{"host":"https://challenger.vercel.example.com","weight":1,"isOriginal":false}},"cookie":{"path":"/","maxAge":60}}}' 413 | }) 414 | }) 415 | }) 416 | }) 417 | }) 418 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ORIGINAL_DISTRIBUTION_KEYS: ReadonlyArray = [ 2 | 'original', 3 | 'master', 4 | 'main' 5 | ] 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { withSplit } from './with-split' 2 | export { middleware } from './middleware' 3 | -------------------------------------------------------------------------------- /src/make-runtime-config.ts: -------------------------------------------------------------------------------- 1 | import type { RuntimeConfig, SplitOptions } from './types' 2 | import { ORIGINAL_DISTRIBUTION_KEYS } from './constants' 3 | 4 | export const makeRuntimeConfig = (options: SplitOptions): RuntimeConfig => { 5 | return Object.entries(options).reduce( 6 | (res, [key, option]) => ({ 7 | ...res, 8 | [key]: { 9 | path: option.path, 10 | hosts: Object.fromEntries( 11 | Object.entries(option.hosts).map(([branch, host]) => [ 12 | branch, 13 | convertHost(branch, host) 14 | ]) 15 | ), 16 | cookie: { path: '/', maxAge: 60 ** 2 * 24 * 1000, ...option.cookie } 17 | } 18 | }), 19 | {} 20 | ) 21 | } 22 | 23 | const convertHost = ( 24 | branch: string, 25 | host: SplitOptions[string]['hosts'][string] 26 | ): RuntimeConfig[string]['hosts'][string] => { 27 | const isOriginal = ORIGINAL_DISTRIBUTION_KEYS.includes(branch) 28 | return typeof host === 'string' 29 | ? { 30 | weight: 1, 31 | host: correctHost(host), 32 | isOriginal 33 | } 34 | : { 35 | ...host, 36 | host: correctHost(host.host), 37 | isOriginal 38 | } 39 | } 40 | 41 | const correctHost = (host: string): string => { 42 | const newHost = /^https?:\/\/.+/.test(host) ? host : `https://${host}` 43 | try { 44 | new URL(newHost) 45 | } catch (_) { 46 | throw new Error(`Incorrect host format: ${host}`) 47 | } 48 | if (new URL(newHost).origin !== newHost) 49 | throw new Error( 50 | `Incorrect host format: Specify only the protocol and domain (you set '${host}')` 51 | ) 52 | 53 | return newHost 54 | } 55 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, userAgent } from 'next/server' 2 | import type { NextRequest } from 'next/server' 3 | import type { CookieSerializeOptions } from 'cookie' 4 | import type { RuntimeConfig } from './types' 5 | import type { NextMiddlewareResult } from 'next/dist/server/web/types' 6 | import { random } from './random' 7 | 8 | type Config = RuntimeConfig[string] 9 | 10 | export const middleware = (req: NextRequest): NextMiddlewareResult => { 11 | const [splitKey, config] = getCurrentSplitConfig(req) ?? [] 12 | if (!splitKey || !config || userAgent(req).isBot) return 13 | const branch = getBranch(req, splitKey, config) 14 | 15 | return sticky( 16 | createResponse(req, branch, config), 17 | splitKey, 18 | branch, 19 | config.cookie 20 | ) 21 | } 22 | 23 | const cookieKey = (key: string) => `x-split-key-${key}` 24 | 25 | const getCurrentSplitConfig = (req: NextRequest) => { 26 | if (req.cookies.has('__prerender_bypass')) return 27 | if (!process.env.NEXT_WITH_SPLIT_RUNTIME_CONFIG) return 28 | 29 | return Object.entries( 30 | JSON.parse(process.env.NEXT_WITH_SPLIT_RUNTIME_CONFIG) as RuntimeConfig 31 | ).find(([, { path }]) => new RegExp(path).test(req.nextUrl.href)) 32 | } 33 | 34 | const getBranch = (req: NextRequest, splitKey: string, config: Config) => { 35 | const cookieBranch = req.cookies.get(cookieKey(splitKey)) 36 | if (cookieBranch && config.hosts[cookieBranch.value]) 37 | return cookieBranch.value 38 | 39 | const branches = Object.entries(config.hosts).reduce( 40 | (res, [key, { weight }]) => [...res, ...new Array(weight).fill(key)], 41 | [] 42 | ) 43 | return branches[random(branches.length)] 44 | } 45 | 46 | const sticky = ( 47 | res: NextResponse, 48 | splitKey: string, 49 | branch: string, 50 | cookieConfig: CookieSerializeOptions 51 | ): NextResponse => { 52 | res.cookies.set(cookieKey(splitKey), branch, cookieConfig) 53 | return res 54 | } 55 | 56 | const createResponse = ( 57 | req: NextRequest, 58 | branch: string, 59 | config: Config 60 | ): NextResponse => { 61 | const rewriteTo = `${ 62 | config.hosts[branch].isOriginal ? '' : config.hosts[branch].host 63 | }${req.nextUrl.href.replace(req.nextUrl.origin, '')}` 64 | const isExternal = rewriteTo.startsWith('http') 65 | 66 | if (isExternal) return NextResponse.rewrite(rewriteTo) 67 | 68 | return NextResponse.next() 69 | } 70 | -------------------------------------------------------------------------------- /src/random.ts: -------------------------------------------------------------------------------- 1 | export const random = (length: number) => Math.floor(Math.random() * length) 2 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { CookieSerializeOptions } from 'cookie' 2 | 3 | export type SplitOptions = { 4 | [keyName: string]: { 5 | path: string 6 | hosts: { 7 | [branchName: string]: string | { host: string; weight: number } 8 | } 9 | cookie?: CookieSerializeOptions 10 | } 11 | } 12 | 13 | export type RuntimeConfig = { 14 | [keyName: string]: { 15 | path: string 16 | hosts: { 17 | [branchName: string]: { 18 | host: string 19 | weight: number 20 | isOriginal: boolean 21 | } 22 | } 23 | cookie: CookieSerializeOptions 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/with-split.ts: -------------------------------------------------------------------------------- 1 | import { makeRuntimeConfig } from './make-runtime-config' 2 | import type { SplitOptions } from './types' 3 | import type { NextConfig } from 'next/dist/server/config' 4 | 5 | type WithSplitArgs = { 6 | splits?: SplitOptions 7 | currentBranch?: string 8 | isOriginal?: boolean 9 | hostname?: string 10 | } 11 | 12 | export const withSplit = 13 | ({ splits: _splits = {}, ...manuals }: WithSplitArgs) => 14 | (nextConfig: NextConfig): NextConfig => { 15 | // Load the configuration using Spectrum. 16 | const splits: SplitOptions = 17 | Object.keys(_splits).length > 0 18 | ? _splits 19 | : JSON.parse(process.env.SPLIT_CONFIG_BY_SPECTRUM ?? '{}') 20 | 21 | if (['true', '1'].includes(process.env.SPLIT_DISABLE ?? '')) 22 | return nextConfig 23 | 24 | const isMain = 25 | ['true', '1'].includes(process.env.SPLIT_ACTIVE ?? '') || 26 | (manuals?.isOriginal ?? process.env.VERCEL_ENV === 'production') 27 | const splitting = Object.keys(splits).length > 0 && isMain 28 | const assetHost = manuals?.hostname ?? process.env.VERCEL_URL 29 | const currentBranch = 30 | manuals?.currentBranch ?? process.env.VERCEL_GIT_COMMIT_REF ?? '' 31 | 32 | if (splitting) { 33 | console.log('Split tests are active.') 34 | console.table( 35 | Object.entries(splits).map(([testKey, options]) => { 36 | if (!options.path) 37 | throw new Error( 38 | `Invalid format: The \`path\` is not set on \`${testKey}\`.` 39 | ) 40 | return { 41 | testKey, 42 | path: options.path, 43 | distributions: Object.keys(options.hosts) 44 | } 45 | }) 46 | ) 47 | } 48 | 49 | if (isSubjectedSplitTest(splits, currentBranch)) 50 | process.env.NEXT_PUBLIC_IS_TARGET_SPLIT_TESTING = 'true' 51 | 52 | return { 53 | ...nextConfig, 54 | assetPrefix: 55 | nextConfig.assetPrefix || 56 | (!isMain && assetHost ? `https://${assetHost}` : ''), 57 | images: { 58 | ...nextConfig.images, 59 | path: 60 | nextConfig.images?.path || 61 | (!isMain && assetHost 62 | ? `https://${assetHost}/_next/image` 63 | : undefined) 64 | }, 65 | env: { 66 | ...nextConfig.env, 67 | ...(isMain && { 68 | NEXT_WITH_SPLIT_RUNTIME_CONFIG: JSON.stringify( 69 | makeRuntimeConfig(splits) 70 | ) 71 | }) 72 | } 73 | } 74 | } 75 | 76 | const isSubjectedSplitTest = ( 77 | splits: SplitOptions, 78 | currentBranch: string 79 | ): boolean => { 80 | const branches = Object.values(splits).flatMap(({ hosts }) => 81 | Object.keys(hosts) 82 | ) 83 | return branches.includes(currentBranch) 84 | } 85 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true 7 | }, 8 | "include": [ 9 | "src/**/*.ts" 10 | ], 11 | "exclude": ["**/__tests__/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: {} 5 | }) 6 | --------------------------------------------------------------------------------