├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── package.json
├── packages
├── example
│ ├── .eslintrc
│ ├── .gitignore
│ ├── README.md
│ ├── next-env.d.ts
│ ├── next.config.js
│ ├── package.json
│ ├── public
│ │ └── favicon.ico
│ ├── src
│ │ ├── components
│ │ │ ├── Button
│ │ │ │ ├── Button.module.scss
│ │ │ │ ├── Button.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Counter
│ │ │ │ ├── Counter.client.tsx
│ │ │ │ ├── Counter.module.scss
│ │ │ │ ├── Counter.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Image
│ │ │ │ ├── CssImage.module.scss
│ │ │ │ ├── CssImage.tsx
│ │ │ │ ├── Image.tsx
│ │ │ │ ├── image.svg
│ │ │ │ └── index.ts
│ │ │ ├── Layout
│ │ │ │ ├── Layout.module.scss
│ │ │ │ ├── Layout.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Navigation
│ │ │ │ ├── Navigation.module.scss
│ │ │ │ ├── Navigation.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── ReactComponent
│ │ │ │ ├── ReactComponent.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Text
│ │ │ │ ├── Text.module.scss
│ │ │ │ ├── Text.tsx
│ │ │ │ └── index.tsx
│ │ │ └── Toggler
│ │ │ │ ├── Toggler.client.tsx
│ │ │ │ ├── Toggler.module.scss
│ │ │ │ ├── Toggler.tsx
│ │ │ │ └── index.tsx
│ │ └── pages
│ │ │ ├── _document.page.tsx
│ │ │ ├── dynamic
│ │ │ ├── [initialCount].client.tsx
│ │ │ └── [initialCount].page.tsx
│ │ │ ├── index.client.tsx
│ │ │ ├── index.page.tsx
│ │ │ └── tests
│ │ │ ├── data.page.tsx
│ │ │ ├── index.client.tsx
│ │ │ ├── index.page.tsx
│ │ │ ├── nested.client.tsx
│ │ │ ├── nested.page.tsx
│ │ │ └── react.page.tsx
│ ├── tsconfig.json
│ ├── types
│ │ └── assets.d.ts
│ └── yarn.lock
└── next-client-script
│ ├── .eslintrc
│ ├── package.json
│ ├── rollup.config.js
│ ├── src
│ ├── ClientScript.tsx
│ ├── ClientScriptsByPath.tsx
│ ├── ClientWidget.tsx
│ ├── initWidgets.tsx
│ └── withClientScripts.tsx
│ ├── tsconfig.json
│ └── types
│ └── next-transpile-modules.d.ts
└── yarn.lock
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [push]
3 | jobs:
4 | build:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v2
8 | - uses: actions/setup-node@v1
9 | with:
10 | node-version: 12.x
11 | - uses: actions/cache@v1
12 | with:
13 | path: node_modules
14 | key: nodeModules-${{ hashFiles('**/yarn.lock') }}
15 | restore-keys: |
16 | nodeModules-
17 | - run: yarn install --frozen-lockfile
18 | env:
19 | CI: true
20 | - run: cd packages/next-client-script && yarn build
21 | - run: yarn lint
22 | env:
23 | CI: true
24 | - run: cd packages/example && yarn build
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | dist
4 | .next
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 0.1.0
4 |
5 | Compatibility with `next@^9.5.0`. Older versions are no longer supported.
6 |
7 | ## 0.0.6
8 |
9 | First working version that's compatible with `next@^9.4.0`.
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Jan Amann
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > [!WARNING]
2 | > This project is in maintainence mode now, check out [React Server Components](https://beta.nextjs.org/docs/rendering/server-and-client-components#server-components) instead!
3 |
4 | # next-client-script
5 |
6 | > Supercharge the performance of your Next.js apps by using a minimal client runtime that avoids full-blown hydration. 🚀
7 |
8 | ## The problem
9 |
10 | By default, Next.js adds the code to your client bundle that is necessary to execute your whole page. At a minimum this includes React itself, the components to render the markup and if relevant, the data that is necessary to rehydrate the markup (result from `getInitialProps` and friends).
11 |
12 | For content heavy sites this [can cause performance issues](https://developers.google.com/web/updates/2019/02/rendering-on-the-web#rehydration) since the page is unresponsive while the client bundle is being executed.
13 |
14 | Recently, an [early version of removing the client side bundle](https://github.com/vercel/next.js/pull/11949) was shipped to Next.js which doesn't suffer from performance problems caused by hydration. However, for a typical website you'll likely still need some JavaScript on the client side to deliver a reasonable user experience.
15 |
16 | ## This solution
17 |
18 | This is a Next.js plugin that is intended to be used in conjunction with disabled runtime JavaScript. You can add client bundles on a per-page basis that only sprinkle a tiny bit of JavaScript over otherwise completely static pages.
19 |
20 | This allows for the same [architecture that Netflix has chosen for their public pages](https://medium.com/dev-channel/a-netflix-web-performance-case-study-c0bcde26a9d9).
21 |
22 | **Benefits:**
23 |
24 | - Keep the React component model for rendering your markup server side
25 | - Use the Next.js development experience and build pipeline for optimizing the server response
26 | - A client side runtime for components is opt-in
27 | - Serializing data for the client is opt-in
28 |
29 | The tradeoff is that you can't use any client-side features of React (state, effects, event handlers, …). Note that some features of Next.js might not be available (yet) – e.g. code splitting via `dynamic` within a page.
30 |
31 | → [Demo deployment](https://next-client-script.vercel.app/) ([source](https://github.com/amannn/next-client-script/tree/master/packages/example))
32 |
33 |
34 | ## Compatibility
35 |
36 | ⚠️ **Important:** To achieve the desired effect, this plugin modifies the webpack configuration that Next.js consumes. Similar as with other Next.js plugins, it's possible that this plugin will break when there are updates to Next.js. I'm keeping the plugin updated so that it continues to work with new versions of Next.js.
37 |
38 | | Next.js version | Plugin version |
39 | | ------------- | ------------- |
40 | | ^10.0.0, ^9.5.0 | 0.1.0 |
41 | | ~9.4.0 | 0.0.6 |
42 |
43 | Latest version tested: 10.0.5
44 |
45 | ## Getting started
46 |
47 | ### Minimum setup
48 |
49 | 1. Add a client script for a page.
50 |
51 | ```js
52 | // ./src/client/index.ts
53 | console.log('Hello from client.');
54 | ```
55 |
56 | 2. Add this plugin to your `next.config.js` and reference your client script.
57 |
58 | ```js
59 | const withClientScripts = require('next-client-script/withClientScripts');
60 |
61 | // Define which paths will cause which scripts to load
62 | module.exports = withClientScripts({
63 | '/': './src/client/index.ts',
64 | // You can use parameters as provided by path-to-regexp to match routes dynamically.
65 | '/products/:id': './src/client/product.ts'
66 | })();
67 | ```
68 |
69 | 3. Add a [custom document to your app](https://nextjs.org/docs/advanced-features/custom-document) and add the `` component as the last child in the body.
70 |
71 | ```diff
72 | + import ClientScript from 'next-client-script/ClientScript';
73 |
74 | // ...
75 |
76 | +
77 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/example/src/pages/dynamic/[initialCount].client.tsx:
--------------------------------------------------------------------------------
1 | import initWidgets from 'next-client-script/dist/initWidgets';
2 | import Counter from 'components/Counter/Counter.client';
3 |
4 | initWidgets([Counter]);
5 |
--------------------------------------------------------------------------------
/packages/example/src/pages/dynamic/[initialCount].page.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | PageConfig,
3 | GetServerSidePropsContext,
4 | GetServerSidePropsResult
5 | } from 'next';
6 | import Counter from 'components/Counter';
7 | import Layout from 'components/Layout';
8 | import Text from 'components/Text';
9 |
10 | export const config: PageConfig = {
11 | unstable_runtimeJS: false
12 | };
13 |
14 | type Props = {
15 | initialCount: number;
16 | };
17 |
18 | export function getServerSideProps(
19 | context: GetServerSidePropsContext
20 | ): GetServerSidePropsResult {
21 | if (typeof context.params?.initialCount !== 'string') {
22 | throw new Error(`Invalid initialCount: ${context.params?.initialCount}`);
23 | }
24 |
25 | return {props: {initialCount: parseInt(context.params.initialCount)}};
26 | }
27 |
28 | export default function Dynamic({initialCount}: Props) {
29 | return (
30 |
31 |
32 | Dynamic matching with path-to-regexp
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/packages/example/src/pages/index.client.tsx:
--------------------------------------------------------------------------------
1 | import initWidgets from 'next-client-script/dist/initWidgets';
2 | import Counter from 'components/Counter/Counter.client';
3 |
4 | initWidgets([Counter]);
5 |
--------------------------------------------------------------------------------
/packages/example/src/pages/index.page.tsx:
--------------------------------------------------------------------------------
1 | import {PageConfig} from 'next';
2 | import Counter from 'components/Counter';
3 | import Layout from 'components/Layout';
4 | import Text from 'components/Text';
5 |
6 | export const config: PageConfig = {
7 | unstable_runtimeJS: false
8 | };
9 |
10 | export default function Home() {
11 | return (
12 |
13 |
14 | next-client-script
15 |
16 |
17 | This Next.js app uses a minimal runtime to make this counter interactive
18 | and avoids full-blown hydration.
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/packages/example/src/pages/tests/data.page.tsx:
--------------------------------------------------------------------------------
1 | import {GetServerSidePropsResult, PageConfig} from 'next';
2 | import Layout from 'components/Layout';
3 | import Text from 'components/Text';
4 |
5 | export const config: PageConfig = {
6 | unstable_runtimeJS: false
7 | };
8 |
9 | type Props = {
10 | time: number;
11 | };
12 |
13 | export function getServerSideProps(): GetServerSidePropsResult {
14 | return {
15 | props: {
16 | time: Date.now()
17 | }
18 | };
19 | }
20 |
21 | export default function Data({time}: Props) {
22 | return (
23 |
24 |
25 | Dynamic data from server
26 |
27 | Server time: {new Date(time).toISOString()}
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/packages/example/src/pages/tests/index.client.tsx:
--------------------------------------------------------------------------------
1 | import initWidgets from 'next-client-script/dist/initWidgets';
2 | import Toggler from 'components/Toggler/Toggler.client';
3 |
4 | initWidgets([Toggler]);
5 |
--------------------------------------------------------------------------------
/packages/example/src/pages/tests/index.page.tsx:
--------------------------------------------------------------------------------
1 | import {PageConfig} from 'next';
2 | import Image, {CssImage} from 'components/Image';
3 | import Layout from 'components/Layout';
4 | import Text from 'components/Text';
5 | import Toggler from 'components/Toggler';
6 |
7 | export const config: PageConfig = {
8 | unstable_runtimeJS: false
9 | };
10 |
11 | export default function Tests() {
12 | return (
13 |
14 |
15 | Tests
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/packages/example/src/pages/tests/nested.client.tsx:
--------------------------------------------------------------------------------
1 | import initWidgets from 'next-client-script/dist/initWidgets';
2 | import Counter from 'components/Counter/Counter.client';
3 |
4 | initWidgets([Counter]);
5 |
--------------------------------------------------------------------------------
/packages/example/src/pages/tests/nested.page.tsx:
--------------------------------------------------------------------------------
1 | import {PageConfig} from 'next';
2 | import Counter from 'components/Counter';
3 | import Layout from 'components/Layout';
4 | import Text from 'components/Text';
5 |
6 | export const config: PageConfig = {
7 | unstable_runtimeJS: false
8 | };
9 |
10 | export default function Nested() {
11 | return (
12 |
13 |
14 | Nested page
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/packages/example/src/pages/tests/react.page.tsx:
--------------------------------------------------------------------------------
1 | import Layout from 'components/Layout';
2 | import ReactComponent from 'components/ReactComponent';
3 | import Text from 'components/Text';
4 |
5 | export default function ReactPage() {
6 | return (
7 |
8 |
9 | React on the client side
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/packages/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "eslint-config-molindo/tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "baseUrl": "src",
6 | "esModuleInterop": true,
7 | "isolatedModules": true,
8 | "jsx": "preserve",
9 | "lib": ["dom", "dom.iterable", "esnext"],
10 | "module": "esnext",
11 | "moduleResolution": "node",
12 | "noEmit": true,
13 | "resolveJsonModule": true,
14 | "skipLibCheck": true,
15 | "target": "es5"
16 | },
17 | "exclude": [
18 | "node_modules"
19 | ],
20 | "include": [
21 | "next-env.d.ts",
22 | "next.config.js",
23 | "**/*.ts",
24 | "**/*.tsx"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/packages/example/types/assets.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.svg' {
2 | const content: string;
3 | export default content;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/next-client-script/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["molindo/react", "molindo/typescript"],
3 | "env": {
4 | "node": true
5 | },
6 | "globals": {
7 | "CLIENT_SCRIPTS_BY_PATH": false
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/next-client-script/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-client-script",
3 | "version": "0.1.0",
4 | "description": "Add a separate client entry point to your Next.js pages.",
5 | "repository": "https://github.com/amannn/next-client-script",
6 | "author": "Jan Amann ",
7 | "license": "MIT",
8 | "files": [
9 | "ClientScript.*",
10 | "ClientScriptsByPath.*",
11 | "ClientWidget.*",
12 | "initWidgets.*",
13 | "withClientScripts.*",
14 | "tslib.*"
15 | ],
16 | "engines": {
17 | "node": ">=10"
18 | },
19 | "scripts": {
20 | "dev": "rm -rf ./dist && rollup -c rollup.config.js --watch",
21 | "build": "rm -rf ./dist && rollup -c rollup.config.js",
22 | "lint": "eslint \"src/**/*.{ts,tsx}\" && tsc --noEmit",
23 | "prepublishOnly": "yarn lint && yarn build && cp dist/* . && cp ../../README.md . && cp ../../CHANGELOG.md ."
24 | },
25 | "dependencies": {
26 | "chalk": "^4.0.0",
27 | "next-transpile-modules": "^4.1.0",
28 | "path-to-regexp": "6.1.0",
29 | "webpack": "^4.44.1"
30 | },
31 | "peerDependencies": {
32 | "next": "^9.5.0",
33 | "react": "^16.8.0"
34 | },
35 | "devDependencies": {
36 | "@rollup/plugin-commonjs": "13.0.0",
37 | "@rollup/plugin-typescript": "5.0.1",
38 | "@types/mini-css-extract-plugin": "0.9.1",
39 | "@types/next": "9.0.0",
40 | "@types/react": "16.9.41",
41 | "@types/react-dom": "16.9.8",
42 | "@types/webpack": "4.41.18",
43 | "eslint": "7.4.0",
44 | "eslint-config-molindo": "5.0.0-alpha.10",
45 | "rollup": "2.18.2",
46 | "tslib": "2.0.0",
47 | "typescript": "3.9.6"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/packages/next-client-script/rollup.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import commonjs from '@rollup/plugin-commonjs';
3 | import typescript from '@rollup/plugin-typescript';
4 | import pkg from './package.json';
5 |
6 | export default {
7 | input: [
8 | 'src/withClientScripts.tsx',
9 | 'src/ClientScript.tsx',
10 | 'src/ClientWidget.tsx',
11 | 'src/initWidgets.tsx'
12 | ],
13 | output: {
14 | dir: 'dist',
15 | format: 'cjs',
16 | sourcemap: true
17 | },
18 | plugins: [typescript(), commonjs()],
19 | external: Object.keys(pkg.dependencies)
20 | .concat(Object.keys(pkg.peerDependencies))
21 | .concat('next/dist/next-server/lib/document-context', 'path')
22 | };
23 |
--------------------------------------------------------------------------------
/packages/next-client-script/src/ClientScript.tsx:
--------------------------------------------------------------------------------
1 | import {DocumentContext} from 'next/dist/next-server/lib/document-context';
2 | import {pathToRegexp} from 'path-to-regexp';
3 | import React, {useContext, ScriptHTMLAttributes} from 'react';
4 | import ClientScriptsByPath from './ClientScriptsByPath';
5 |
6 | declare const CLIENT_SCRIPTS_BY_PATH: ClientScriptsByPath;
7 |
8 | const clientScriptsByPathRegex = Object.entries(CLIENT_SCRIPTS_BY_PATH).map(
9 | ([path, clientScript]) => ({
10 | pathRegex: pathToRegexp(path),
11 | clientScript
12 | })
13 | );
14 |
15 | export default function ClientScript({
16 | async = true,
17 | type = 'text/javascript',
18 | ...rest
19 | }: ScriptHTMLAttributes) {
20 | const context = useContext(DocumentContext);
21 |
22 | // Query params and hashes are already removed from this path.
23 | const pagePath = context.__NEXT_DATA__.page;
24 |
25 | const match = clientScriptsByPathRegex.find((cur) =>
26 | cur.pathRegex.test(pagePath)
27 | );
28 |
29 | if (!match) {
30 | return null;
31 | }
32 |
33 | return (
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/packages/next-client-script/src/ClientScriptsByPath.tsx:
--------------------------------------------------------------------------------
1 | type ClientScriptsByPath = {
2 | [path: string]: string;
3 | };
4 |
5 | export default ClientScriptsByPath;
6 |
--------------------------------------------------------------------------------
/packages/next-client-script/src/ClientWidget.tsx:
--------------------------------------------------------------------------------
1 | import React, {ReactNode, DetailedHTMLProps, HTMLAttributes} from 'react';
2 |
3 | type Props = DetailedHTMLProps<
4 | HTMLAttributes,
5 | HTMLDivElement
6 | > & {
7 | data?: T;
8 | children: ReactNode;
9 | };
10 |
11 | export default function ClientWidget({children, data, ...rest}: Props) {
12 | return (
13 |
14 | {data && (
15 |
20 | )}
21 | {children}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/packages/next-client-script/src/initWidgets.tsx:
--------------------------------------------------------------------------------
1 | interface Widget {
2 | (node: HTMLElement, data?: any): void;
3 | selector: string;
4 | }
5 |
6 | export default function initWidgets(widgets: Array) {
7 | function runInitialization() {
8 | widgets.forEach((widget) => {
9 | const nodes = document.querySelectorAll(widget.selector);
10 |
11 | for (let index = 0; index < nodes.length; index++) {
12 | const node = nodes[index];
13 |
14 | if (node instanceof HTMLElement) {
15 | let data: any;
16 | if (node.children) {
17 | const dataChild = Array.from(node.children).find(
18 | (child) =>
19 | child instanceof HTMLElement &&
20 | child.dataset.widgetData === 'true'
21 | );
22 |
23 | if (dataChild?.textContent) {
24 | try {
25 | data = JSON.parse(dataChild.textContent);
26 | } catch (error) {
27 | console.error('Data unparseable: ' + dataChild.textContent);
28 | }
29 | }
30 | }
31 |
32 | widget(node, data);
33 | }
34 | }
35 | });
36 | }
37 |
38 | if (document.readyState === 'complete') {
39 | runInitialization();
40 | } else {
41 | window.addEventListener('load', runInitialization);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/next-client-script/src/withClientScripts.tsx:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import chalk from 'chalk';
3 | import withTM from 'next-transpile-modules';
4 | import webpack, {Compiler} from 'webpack';
5 | import ClientScriptsByPath from './ClientScriptsByPath';
6 |
7 | const NEXT_PATH = '/_next';
8 | const NEXT_BUILD_PATH = './.next/';
9 |
10 | function encodePath(scriptPath: string) {
11 | return scriptPath.replace(/\W/g, '-').replace(/-(js|jsx|ts|tsx)$/, '');
12 | }
13 |
14 | module.exports = function withHydrationInitializer(scriptsByPath: {
15 | [path: string]: string;
16 | }) {
17 | return function withHydration(receivedConfig: webpack.Configuration = {}) {
18 | // Since we use the `DefinePlugin` to inject the config into `ClientScript`,
19 | // we need to make sure that this package gets transpiled. Probably it would
20 | // also be possible to configure the loaders/plugin directly here without
21 | // using this dependency.
22 | return withTM(['next-client-script'])({
23 | ...receivedConfig,
24 | webpack(
25 | config: webpack.Configuration,
26 | options: {
27 | buildId: string;
28 | dev: boolean;
29 | isServer: boolean;
30 | webpack: any;
31 | }
32 | ) {
33 | const {buildId, dev, isServer, webpack: nextWebpack} = options;
34 |
35 | // By using this folder, we get immutable caching headers
36 | const PUBLIC_BASE_PATH = `/static/chunks/`;
37 |
38 | const clientEntries: {[path: string]: string} = {};
39 | const clientScriptsByPath: ClientScriptsByPath = {};
40 | Object.entries(scriptsByPath).forEach(([pagePath, scriptPath]) => {
41 | const encodedScriptPath = encodePath(scriptPath);
42 |
43 | // Compute `clientEntries`
44 | let entryName, scriptName;
45 | if (dev) {
46 | scriptName = entryName = encodedScriptPath;
47 | } else {
48 | // TODO: Can we utilize contenthash?
49 | scriptName = buildId + '-' + encodedScriptPath;
50 | entryName = PUBLIC_BASE_PATH.substring(1) + scriptName;
51 | }
52 | clientEntries[entryName] = scriptPath;
53 |
54 | // Compute `clientScriptsByPath`
55 | const publicPath = PUBLIC_BASE_PATH + scriptName + '.js';
56 | clientScriptsByPath[pagePath] = NEXT_PATH + publicPath;
57 | });
58 |
59 | config = {
60 | ...config,
61 | plugins: config.plugins?.concat(
62 | new nextWebpack.DefinePlugin({
63 | CLIENT_SCRIPTS_BY_PATH: JSON.stringify(clientScriptsByPath)
64 | })
65 | )
66 | };
67 |
68 | if (isServer) {
69 | return config;
70 | }
71 |
72 | if (dev) {
73 | // In development, we can handle the script simply as another entry point
74 | const originalEntry = config.entry as any;
75 | config.entry = () =>
76 | originalEntry().then((entry: any) => ({
77 | ...entry,
78 | ...clientEntries
79 | }));
80 | } else {
81 | // For the production build, we have to trigger a separate build where
82 | // only the client entries are compiled. A child compiler didn't work
83 | // during initial tests, as nothing was written to the file system.
84 |
85 | const clientConfig = {
86 | ...config,
87 | entry: clientEntries,
88 | output: {
89 | path: path.resolve(process.cwd(), NEXT_BUILD_PATH)
90 | },
91 | optimization: {
92 | ...config.optimization,
93 | // Output only a single JavaScript asset that
94 | // contains all necessary client code.
95 | runtimeChunk: undefined as undefined
96 | },
97 | plugins: (config.plugins || []).filter(
98 | (plugin: any) =>
99 | ![
100 | // The manifest from the regular build should be used
101 | 'ReactLoadablePlugin',
102 | 'BuildManifestPlugin'
103 | ].includes(plugin.constructor.name)
104 | )
105 | };
106 |
107 | const compiler: Compiler = nextWebpack(clientConfig);
108 | compiler.run((compilerError, stats) => {
109 | let errorMessage;
110 | if (compilerError) {
111 | errorMessage = compilerError.message;
112 | }
113 | if (stats.compilation.errors.length > 0) {
114 | errorMessage = stats.compilation.errors
115 | .map((compilationError) => {
116 | let message;
117 | if (compilationError.message) {
118 | message = compilationError.message;
119 | if (compilationError.stack) {
120 | message += '\n' + compilationError.stack;
121 | }
122 | } else {
123 | try {
124 | message = JSON.stringify(compilationError);
125 | } catch (error) {
126 | message = 'An unknown error happened.';
127 | }
128 | }
129 | return message;
130 | })
131 | .join('\n\n');
132 | }
133 | if (errorMessage) {
134 | console.error(errorMessage);
135 | process.exit(1);
136 | }
137 |
138 | /* eslint-disable no-console */
139 | console.log(chalk.green('\nCreated client scripts:'));
140 | Object.entries(scriptsByPath).forEach(([scriptPath, script]) => {
141 | console.log(`${scriptPath}: ${script}`);
142 | });
143 | console.log('\n');
144 | /* eslint-enable no-console */
145 | });
146 | }
147 |
148 | // Overload the Webpack config if it was already overloaded
149 | if (typeof (receivedConfig as any).webpack === 'function') {
150 | return (receivedConfig as any).webpack(config, options);
151 | }
152 |
153 | return config;
154 | }
155 | });
156 | };
157 | };
158 |
--------------------------------------------------------------------------------
/packages/next-client-script/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "eslint-config-molindo/tsconfig.json",
3 | "include": ["."],
4 | "compilerOptions": {
5 | "module": "esnext",
6 | "lib": ["dom", "esnext"],
7 | "importHelpers": true,
8 | "declaration": true,
9 | "sourceMap": true,
10 | "rootDir": "./src",
11 | "outDir": "./dist",
12 | "moduleResolution": "node",
13 | "baseUrl": "./",
14 | "jsx": "react",
15 | "esModuleInterop": true,
16 | "noUnusedParameters": true
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/packages/next-client-script/types/next-transpile-modules.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'next-transpile-modules';
2 |
--------------------------------------------------------------------------------