├── .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 | [](https://codecov.io/gh/aiji42/next-with-split)
2 | [](https://badge.fury.io/js/next-with-split)
3 |
4 | 
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 | 
21 |
22 | 
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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------