├── client.d.ts ├── .releaserc ├── .npmignore ├── test ├── fixtures │ ├── .eslintrc │ └── index.js ├── .eslintrc └── fastify-webpack-hot │ └── fastifyWebpack.ts ├── src ├── factories │ ├── index.ts │ └── createSyncEvents.ts ├── Logger.ts ├── types.ts ├── utilities │ ├── canRead.ts │ ├── index.ts │ ├── formatServerEvent.ts │ ├── getPublicPath.ts │ ├── defer.ts │ └── getFilenameFromUrl.ts └── index.ts ├── .README ├── fastify-webpack-hot.gif └── README.md ├── examples ├── webpack │ ├── app │ │ ├── hello.ts │ │ ├── tsconfig.json │ │ ├── main.ts │ │ └── .eslintrc.js │ ├── server │ │ ├── tsconfig.json │ │ ├── .eslintrc.js │ │ └── server.ts │ ├── package.json │ └── README.md └── react │ ├── app │ ├── tsconfig.json │ ├── index.tsx │ ├── App.tsx │ └── .eslintrc.js │ ├── server │ ├── tsconfig.json │ ├── .eslintrc.js │ └── server.ts │ ├── README.md │ └── package.json ├── .editorconfig ├── client ├── tsconfig.json ├── .eslintrc.js └── client.ts ├── .gitignore ├── .eslintrc ├── webpack.config.ts ├── tsconfig.json ├── .github └── workflows │ └── main.yaml ├── LICENSE ├── package.json └── README.md /client.d.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"] 3 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | coverage 3 | .* 4 | *.log 5 | -------------------------------------------------------------------------------- /test/fixtures/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true 3 | } -------------------------------------------------------------------------------- /test/fixtures/index.js: -------------------------------------------------------------------------------- 1 | console.log('Hello, World'); -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "canonical/ava" 4 | ] 5 | } -------------------------------------------------------------------------------- /src/factories/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | createSyncEvents, 3 | } from './createSyncEvents'; 4 | -------------------------------------------------------------------------------- /.README/fastify-webpack-hot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gajus/fastify-webpack-hot/HEAD/.README/fastify-webpack-hot.gif -------------------------------------------------------------------------------- /examples/webpack/app/hello.ts: -------------------------------------------------------------------------------- 1 | export const hello = () => { 2 | // eslint-disable-next-line no-console 3 | console.log('Hello, World!'); 4 | }; 5 | -------------------------------------------------------------------------------- /src/Logger.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Roarr, 3 | } from 'roarr'; 4 | 5 | export const Logger = Roarr.child({ 6 | package: 'fastify-webpack-hot', 7 | }); 8 | -------------------------------------------------------------------------------- /examples/webpack/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2022", 4 | "target": "es5" 5 | }, 6 | "include": [ 7 | "." 8 | ] 9 | } -------------------------------------------------------------------------------- /examples/webpack/app/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | hello, 3 | } from './hello'; 4 | 5 | hello(); 6 | 7 | import.meta.webpackHot.accept('./hello.ts', () => { 8 | hello(); 9 | }); 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /examples/react/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2022", 4 | "target": "es5", 5 | "jsx": "react-jsx" 6 | }, 7 | "include": [ 8 | "." 9 | ] 10 | } -------------------------------------------------------------------------------- /examples/react/app/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | render, 3 | } from 'react-dom'; 4 | import { 5 | App, 6 | } from './App'; 7 | 8 | render( 9 | , 10 | document.querySelector('#app'), 11 | ); 12 | -------------------------------------------------------------------------------- /examples/react/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "module": "commonjs", 5 | "target": "es5" 6 | }, 7 | "include": [ 8 | "." 9 | ] 10 | } -------------------------------------------------------------------------------- /examples/webpack/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "module": "commonjs", 5 | "target": "es5" 6 | }, 7 | "include": [ 8 | "." 9 | ] 10 | } -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "es2022", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "dom" 8 | ] 9 | }, 10 | "include": [ 11 | "." 12 | ] 13 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | StatsError, 3 | StatsAsset, 4 | } from 'webpack'; 5 | 6 | export type SyncEvent = { 7 | assets: StatsAsset[], 8 | errors: StatsError[], 9 | hash: string, 10 | modules: Record, 11 | warnings: StatsError[], 12 | }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | client.js 2 | client.js.map 3 | coverage 4 | dist 5 | node_modules 6 | *.log 7 | .* 8 | !.babelrc 9 | !.editorconfig 10 | !.eslintignore 11 | !.eslintrc 12 | !.eslintrc.js 13 | !.github 14 | !.gitignore 15 | !.npmignore 16 | !.README 17 | !.releaserc 18 | !.travis.yml 19 | -------------------------------------------------------------------------------- /src/utilities/canRead.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IFs, 3 | } from 'memfs'; 4 | 5 | export const canRead = async (fs: IFs, path: string): Promise => { 6 | try { 7 | await fs.promises.access(path, fs.constants.R_OK); 8 | 9 | return true; 10 | } catch { 11 | return false; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/utilities/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | canRead, 3 | } from './canRead'; 4 | export { 5 | defer, 6 | } from './defer'; 7 | export { 8 | formatServerEvent, 9 | } from './formatServerEvent'; 10 | export { 11 | getFilenameFromUrl, 12 | } from './getFilenameFromUrl'; 13 | export { 14 | getPublicPath, 15 | } from './getPublicPath'; 16 | -------------------------------------------------------------------------------- /src/utilities/formatServerEvent.ts: -------------------------------------------------------------------------------- 1 | type JsonObject = T & { [Key in string]?: JsonValue }; 2 | type JsonValue = Array> | JsonObject | boolean | number | string | null; 3 | 4 | export const formatServerEvent = (name: string, data: JsonObject): string => { 5 | return 'event: ' + name + '\ndata: ' + JSON.stringify(data) + '\n\n'; 6 | }; 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "overrides": [ 4 | { 5 | "files": "*.ts", 6 | "extends": [ 7 | "canonical", 8 | "canonical/node", 9 | "canonical/typescript" 10 | ], 11 | "parserOptions": { 12 | "project": "./tsconfig.json" 13 | }, 14 | "rules": { 15 | "import/no-named-as-default-member": 0 16 | } 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /examples/react/app/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useState, 3 | } from 'react'; 4 | 5 | export const App = () => { 6 | const [ 7 | count, 8 | setCount, 9 | ] = useState(0); 10 | 11 | return
12 |
Hello, World!
13 |
{count}
20 |
; 21 | }; 22 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | overrides: [ 3 | { 4 | extends: [ 5 | 'canonical', 6 | 'canonical/node', 7 | ], 8 | files: '*.js', 9 | }, 10 | { 11 | extends: [ 12 | 'canonical', 13 | 'canonical/typescript', 14 | ], 15 | files: '*.ts', 16 | parserOptions: { 17 | project: './tsconfig.json', 18 | tsconfigRootDir: __dirname, 19 | }, 20 | }, 21 | ], 22 | root: true, 23 | }; 24 | -------------------------------------------------------------------------------- /examples/react/server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | overrides: [ 3 | { 4 | extends: [ 5 | 'canonical', 6 | 'canonical/node', 7 | ], 8 | files: '*.js', 9 | }, 10 | { 11 | extends: [ 12 | 'canonical', 13 | 'canonical/typescript', 14 | ], 15 | files: '*.ts', 16 | parserOptions: { 17 | project: './tsconfig.json', 18 | tsconfigRootDir: __dirname, 19 | }, 20 | }, 21 | ], 22 | root: true, 23 | }; 24 | -------------------------------------------------------------------------------- /examples/webpack/app/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | overrides: [ 3 | { 4 | extends: [ 5 | 'canonical', 6 | 'canonical/node', 7 | ], 8 | files: '*.js', 9 | }, 10 | { 11 | extends: [ 12 | 'canonical', 13 | 'canonical/typescript', 14 | ], 15 | files: '*.ts', 16 | parserOptions: { 17 | project: './tsconfig.json', 18 | tsconfigRootDir: __dirname, 19 | }, 20 | }, 21 | ], 22 | root: true, 23 | }; 24 | -------------------------------------------------------------------------------- /examples/webpack/server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | overrides: [ 3 | { 4 | extends: [ 5 | 'canonical', 6 | 'canonical/node', 7 | ], 8 | files: '*.js', 9 | }, 10 | { 11 | extends: [ 12 | 'canonical', 13 | 'canonical/typescript', 14 | ], 15 | files: '*.ts', 16 | parserOptions: { 17 | project: './tsconfig.json', 18 | tsconfigRootDir: __dirname, 19 | }, 20 | }, 21 | ], 22 | root: true, 23 | }; 24 | -------------------------------------------------------------------------------- /examples/react/app/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | overrides: [ 3 | { 4 | extends: [ 5 | 'canonical', 6 | 'canonical/node', 7 | ], 8 | files: '*.js', 9 | }, 10 | { 11 | extends: [ 12 | 'canonical', 13 | 'canonical/typescript', 14 | 'canonical/react', 15 | ], 16 | files: '*.tsx', 17 | parserOptions: { 18 | project: './tsconfig.json', 19 | tsconfigRootDir: __dirname, 20 | }, 21 | }, 22 | ], 23 | root: true, 24 | }; 25 | -------------------------------------------------------------------------------- /examples/webpack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@roarr/browser-log-writer": "^1.1.1", 4 | "@roarr/cli": "^5.3.2", 5 | "@types/node": "^10.0.0", 6 | "@types/webpack": "^5.28.0", 7 | "@types/webpack-env": "^1.16.3", 8 | "eslint": "^8.11.0", 9 | "eslint-config-canonical": "^33.0.1", 10 | "fastify": "^3.27.4", 11 | "ts-loader": "^9.2.8", 12 | "ts-node": "^10.2.1", 13 | "typescript": "^4.4.3", 14 | "webpack": "^5.70.0" 15 | }, 16 | "private": true, 17 | "scripts": { 18 | "start": "ts-node --transpile-only server/server.ts | roarr" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utilities/getPublicPath.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Stats, 3 | } from 'webpack'; 4 | 5 | type PublicPath = { 6 | outputPath: string, 7 | publicPath: string, 8 | }; 9 | 10 | export const getPublicPath = (stats: Stats): PublicPath => { 11 | const { 12 | compilation, 13 | } = stats; 14 | 15 | const outputPath = compilation.getPath( 16 | compilation.outputOptions.path ?? '', 17 | ); 18 | 19 | const publicPath = compilation.outputOptions.publicPath ? 20 | compilation.getPath(compilation.outputOptions.publicPath) : 21 | ''; 22 | 23 | return { 24 | outputPath, 25 | publicPath, 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /examples/react/README.md: -------------------------------------------------------------------------------- 1 | # TypeScript, Fastify, Webpack, React HRM example 2 | 3 | ## Start 4 | 5 | ```bash 6 | npm install 7 | npm run start 8 | ``` 9 | 10 | The website is available at http://127.0.0.1:8080/ 11 | 12 | After the page has loaded, edit `app/App.tsx` to verify that HMR is working as expected. 13 | 14 | ## Setup 15 | 16 | The example includes quite a few elements, however, in order to replicate React compatible HMR, you only need to: 17 | 18 | 1. Add `HotModuleReplacementPlugin` Webpack plugin 19 | 1. Add `ReactRefreshWebpackPlugin` Webpack plugin 20 | 1. Add `ReactRefreshTypeScript` TypeScript transform 21 | 1. Add `fastify-webpack-hot/client` Webpack entry script -------------------------------------------------------------------------------- /examples/webpack/README.md: -------------------------------------------------------------------------------- 1 | # TypeScript, Fastify, Webpack HRM example 2 | 3 | ## Start 4 | 5 | ```bash 6 | npm install 7 | npm run start 8 | ``` 9 | 10 | The website is available at http://127.0.0.1:8080/ 11 | 12 | After the page has loaded, edit `app/hello.ts` to verify that HMR is working as expected. 13 | 14 | For more context, refer to [Webpack Hot Module Replacement documentation](https://webpack.js.org/api/hot-module-replacement/). 15 | 16 | ## Setup 17 | 18 | The example includes quite a few elements, however, in order to replicate the HMR behaviour, you only need to: 19 | 20 | 1. Add `HotModuleReplacementPlugin` Webpack plugin 21 | 1. Add `fastify-webpack-hot/client` Webpack entry script -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | 4 | export default { 5 | devtool: 'source-map', 6 | entry: path.resolve(__dirname, './client/client.ts'), 7 | mode: 'development', 8 | module: { 9 | rules: [ 10 | { 11 | exclude: /node_modules/u, 12 | test: /\.ts$/u, 13 | use: 'ts-loader', 14 | }, 15 | ], 16 | }, 17 | output: { 18 | filename: 'client.js', 19 | path: path.resolve(__dirname), 20 | }, 21 | plugins: [ 22 | new webpack.DefinePlugin({ 23 | 'import.meta.webpackHot': 'import.meta.webpackHot', 24 | }), 25 | ], 26 | resolve: { 27 | extensions: [ 28 | '.js', 29 | '.ts', 30 | ], 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "lib": [ 7 | "es2018" 8 | ], 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "noImplicitAny": false, 12 | "noImplicitReturns": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": false, 15 | "outDir": "dist", 16 | "resolveJsonModule": true, 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "target": "es2018", 20 | "useUnknownInCatchVariables": false 21 | }, 22 | "exclude": [ 23 | "dist", 24 | "node_modules" 25 | ], 26 | "include": [ 27 | "webpack.config.ts", 28 | "src", 29 | "test" 30 | ] 31 | } -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | jobs: 2 | test: 3 | runs-on: ubuntu-latest 4 | environment: release 5 | name: Test 6 | steps: 7 | - name: setup repository 8 | uses: actions/checkout@v2 9 | with: 10 | fetch-depth: 0 11 | - name: setup node.js 12 | uses: actions/setup-node@v2 13 | with: 14 | node-version: '16' 15 | cache: 'npm' 16 | - run: npm install 17 | - run: npm run lint 18 | - run: npm run test 19 | - run: npm run build 20 | - run: npx semantic-release 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 24 | timeout-minutes: 10 25 | name: Test, build and release 26 | on: 27 | push: 28 | branches: 29 | - main 30 | -------------------------------------------------------------------------------- /src/utilities/defer.ts: -------------------------------------------------------------------------------- 1 | export type DeferredPromise = { 2 | promise: Promise, 3 | reject: (reason?: unknown) => void, 4 | rejected: boolean, 5 | resolve: (value?: PromiseLike | ValueType) => void, 6 | resolved: boolean, 7 | }; 8 | 9 | export const defer = (): DeferredPromise => { 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | const deferred: any = { 12 | rejected: false, 13 | resolved: false, 14 | }; 15 | 16 | deferred.promise = new Promise((resolve, reject) => { 17 | deferred.resolve = (value) => { 18 | deferred.resolved = true; 19 | 20 | resolve(value); 21 | }; 22 | 23 | deferred.reject = (error) => { 24 | deferred.rejected = false; 25 | 26 | reject(error); 27 | }; 28 | }); 29 | 30 | return deferred; 31 | }; 32 | -------------------------------------------------------------------------------- /examples/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.4", 4 | "@roarr/browser-log-writer": "^1.1.1", 5 | "@roarr/cli": "^5.3.2", 6 | "@types/node": "^10.0.0", 7 | "@types/react": "^17.0.41", 8 | "@types/react-dom": "^17.0.14", 9 | "@types/webpack": "^5.28.0", 10 | "@types/webpack-env": "^1.16.3", 11 | "eslint": "^8.11.0", 12 | "eslint-config-canonical": "^33.0.1", 13 | "fastify": "^3.27.4", 14 | "react": "^17.0.2", 15 | "react-dom": "^17.0.2", 16 | "react-refresh": "^0.11.0", 17 | "react-refresh-typescript": "^2.0.3", 18 | "ts-loader": "^9.2.8", 19 | "ts-node": "^10.2.1", 20 | "typescript": "^4.4.3", 21 | "webpack": "^5.70.0" 22 | }, 23 | "private": true, 24 | "scripts": { 25 | "start": "ts-node --transpile-only server/server.ts | roarr" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Roarr, 3 | } from 'roarr'; 4 | import { 5 | serializeError, 6 | } from 'serialize-error'; 7 | 8 | const log = Roarr.child({ 9 | namespace: 'client', 10 | package: 'fastify-webpack-hot', 11 | }); 12 | 13 | const main = () => { 14 | const hot = import.meta.webpackHot; 15 | 16 | const eventSource = new EventSource('/__fastify_webpack_hot'); 17 | 18 | eventSource.addEventListener('sync', (event) => { 19 | const syncEvent = JSON.parse(event.data); 20 | 21 | log.debug('[fastify-webpack-hot] bundle updated %s', syncEvent.hash); 22 | 23 | if (hot.status() === 'idle') { 24 | hot.check(true, (error, outdatedModules) => { 25 | log.error({ 26 | error: serializeError(error), 27 | outdatedModules, 28 | }, '[fastify-webpack-hot] could not complete check'); 29 | }); 30 | } 31 | }); 32 | }; 33 | 34 | main(); 35 | -------------------------------------------------------------------------------- /src/factories/createSyncEvents.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Stats, 3 | StatsCompilation, 4 | } from 'webpack'; 5 | import type { 6 | SyncEvent, 7 | } from '../types'; 8 | 9 | const extractBundles = (stats: StatsCompilation) => { 10 | if (stats.modules) { 11 | return [ 12 | stats, 13 | ]; 14 | } 15 | 16 | if (stats.children) { 17 | return stats.children; 18 | } 19 | 20 | throw new Error('Unexpected state.'); 21 | }; 22 | 23 | const buildModuleMap = (modules): Record => { 24 | const map = {}; 25 | 26 | for (const module of modules) { 27 | map[module.id] = module.name; 28 | } 29 | 30 | return map; 31 | }; 32 | 33 | export const createSyncEvents = (stats: Stats): SyncEvent[] => { 34 | const syncEvents: SyncEvent[] = []; 35 | 36 | const bundles = extractBundles( 37 | stats.toJson({ 38 | all: false, 39 | assets: true, 40 | cached: true, 41 | children: true, 42 | hash: true, 43 | modules: true, 44 | timings: true, 45 | }), 46 | ); 47 | 48 | for (const bundleStats of bundles) { 49 | if (!bundleStats.hash) { 50 | throw new Error('hash is undefined'); 51 | } 52 | 53 | syncEvents.push({ 54 | assets: bundleStats.assets ?? [], 55 | errors: bundleStats.errors ?? [], 56 | hash: bundleStats.hash, 57 | modules: buildModuleMap(bundleStats.modules), 58 | warnings: bundleStats.warnings ?? [], 59 | }); 60 | } 61 | 62 | return syncEvents; 63 | }; 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022, Gajus Kuizinas (http://gajus.com/) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the Gajus Kuizinas (http://gajus.com/) nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL GAJUS KUIZINAS BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /examples/webpack/server/server.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fastify from 'fastify'; 3 | import webpack from 'webpack'; 4 | import { 5 | fastifyWebpackHot, 6 | } from '../../../dist/src'; 7 | 8 | const main = async () => { 9 | const app = fastify(); 10 | 11 | const compiler = webpack({ 12 | devtool: 'source-map', 13 | entry: [ 14 | '@roarr/browser-log-writer/init', 15 | // This would be `fastify-webpack-hot/client` in a regular application. 16 | path.resolve(__dirname, '../../../client.js'), 17 | path.resolve(__dirname, '../app/main.ts'), 18 | ], 19 | mode: 'development', 20 | module: { 21 | rules: [ 22 | { 23 | exclude: /node_modules/u, 24 | test: /\.tsx?$/u, 25 | use: [ 26 | { 27 | loader: require.resolve('ts-loader'), 28 | options: { 29 | transpileOnly: true, 30 | }, 31 | }, 32 | ], 33 | }, 34 | ], 35 | }, 36 | output: { 37 | filename: 'main.js', 38 | path: path.resolve(__dirname, '/dist'), 39 | publicPath: '/', 40 | }, 41 | plugins: [ 42 | new webpack.HotModuleReplacementPlugin(), 43 | ], 44 | resolve: { 45 | extensions: [ 46 | '.js', 47 | '.ts', 48 | ], 49 | }, 50 | }); 51 | 52 | void app.register(fastifyWebpackHot, { 53 | compiler, 54 | }); 55 | 56 | app.get('/', (request, reply) => { 57 | void reply 58 | .type('text/html') 59 | .send(` 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |
68 | 69 | 70 | `); 71 | }); 72 | 73 | await app.listen(8_080); 74 | }; 75 | 76 | void main(); 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "email": "gajus@gajus.com", 4 | "name": "Gajus Kuizinas", 5 | "url": "http://gajus.com" 6 | }, 7 | "ava": { 8 | "extensions": [ 9 | "ts" 10 | ], 11 | "files": [ 12 | "test/fastify-webpack-hot/**/*" 13 | ], 14 | "ignoredByWatcher": [ 15 | "test/fixtures/**/*" 16 | ], 17 | "require": [ 18 | "ts-node/register/transpile-only" 19 | ] 20 | }, 21 | "dependencies": { 22 | "fastify-plugin": "^3.0.1", 23 | "memfs": "^3.4.1", 24 | "mime-types": "^2.1.35", 25 | "negotiator": "^0.6.3", 26 | "roarr": "^7.10.1", 27 | "serialize-error": "^8.1.0", 28 | "typed-emitter": "^2.1.0" 29 | }, 30 | "description": "A Fastify plugin for serving files emitted by Webpack with Hot Module Replacement (HMR).", 31 | "devDependencies": { 32 | "@types/node": "^17.0.22", 33 | "@types/webpack": "^5.28.0", 34 | "@types/webpack-env": "^1.16.3", 35 | "ava": "^3.15.0", 36 | "compression-webpack-plugin": "^9.2.0", 37 | "coveralls": "^3.1.1", 38 | "eslint": "^8.11.0", 39 | "eslint-config-canonical": "^33.0.1", 40 | "fastify": "^3.27.4", 41 | "gitdown": "^3.1.4", 42 | "got": "^11.8.3", 43 | "husky": "^7.0.2", 44 | "nyc": "^15.1.0", 45 | "semantic-release": "^17.4.7", 46 | "sinon": "^11.1.2", 47 | "ts-loader": "^9.2.8", 48 | "ts-node": "^10.7.0", 49 | "typescript": "^4.4.3", 50 | "webpack": "^5.70.0", 51 | "webpack-cli": "^4.9.2" 52 | }, 53 | "engines": { 54 | "node": ">=16" 55 | }, 56 | "husky": { 57 | "hooks": { 58 | "pre-commit": "npm run lint && npm run test && npm run build" 59 | } 60 | }, 61 | "keywords": [ 62 | "fastify", 63 | "webpack" 64 | ], 65 | "license": "BSD-3-Clause", 66 | "main": "./dist/src/index.js", 67 | "name": "fastify-webpack-hot", 68 | "repository": { 69 | "type": "git", 70 | "url": "https://github.com/gajus/fastify-webpack-hot" 71 | }, 72 | "scripts": { 73 | "build": "rm -fr ./dist && tsc && webpack", 74 | "generate-readme": "gitdown ./.README/README.md --output-file ./README.md", 75 | "lint": "eslint ./src ./test && tsc --noEmit", 76 | "test": "ROARR_LOG=1 NODE_ENV=test ava --serial --verbose" 77 | }, 78 | "typings": "./dist/src/index.d.ts", 79 | "version": "1.0.0" 80 | } 81 | -------------------------------------------------------------------------------- /src/utilities/getFilenameFromUrl.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import querystring from 'node:querystring'; 3 | import { 4 | parse, 5 | } from 'node:url'; 6 | import { 7 | serializeError, 8 | } from 'serialize-error'; 9 | import { 10 | Logger, 11 | } from '../Logger'; 12 | import { 13 | getPublicPath, 14 | } from './getPublicPath'; 15 | 16 | const log = Logger.child({ 17 | namespace: 'getFilenameFromUrl', 18 | }); 19 | 20 | export const getFilenameFromUrl = async (outputFileSystem, stats, url) => { 21 | let foundFilename = null; 22 | let urlObject; 23 | 24 | try { 25 | // The `url` property of the `request` is contains only `pathname`, `search` and `hash` 26 | urlObject = parse(url, false, true); 27 | } catch (error) { 28 | log.trace({ 29 | error: serializeError(error), 30 | }, 'could not parse URL'); 31 | 32 | return null; 33 | } 34 | 35 | const { 36 | publicPath, 37 | outputPath, 38 | } = getPublicPath(stats); 39 | 40 | let filename; 41 | let publicPathObject; 42 | 43 | try { 44 | publicPathObject = parse( 45 | publicPath !== 'auto' && publicPath ? publicPath : '/', 46 | false, 47 | true, 48 | ); 49 | } catch (error) { 50 | log.trace({ 51 | error: serializeError(error), 52 | }, 'could not parse URL'); 53 | 54 | return null; 55 | } 56 | 57 | if (urlObject.pathname.startsWith(publicPathObject.pathname)) { 58 | filename = outputPath; 59 | 60 | // Strip the `pathname` property from the `publicPath` option from the start of requested url 61 | // `/complex/foo.js` => `foo.js` 62 | const pathname = urlObject.pathname.slice(publicPathObject.pathname.length); 63 | 64 | if (pathname) { 65 | filename = path.join(outputPath, querystring.unescape(pathname)); 66 | } 67 | 68 | let fsStats; 69 | 70 | try { 71 | fsStats = await outputFileSystem.promises.stat(filename); 72 | } catch (error) { 73 | if (error.message.includes('no such file or directory')) { 74 | log.trace('no such file or directory %s', filename); 75 | } else { 76 | log.trace({ 77 | error: serializeError(error), 78 | }, 'could not stat path'); 79 | } 80 | 81 | return null; 82 | } 83 | 84 | if (fsStats.isFile()) { 85 | foundFilename = filename; 86 | } 87 | } 88 | 89 | return foundFilename; 90 | }; 91 | -------------------------------------------------------------------------------- /examples/react/server/server.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; 3 | import fastify from 'fastify'; 4 | import ReactRefreshTypeScript from 'react-refresh-typescript'; 5 | import webpack from 'webpack'; 6 | import { 7 | fastifyWebpackHot, 8 | } from '../../../dist/src'; 9 | 10 | const main = async () => { 11 | const app = fastify(); 12 | 13 | const compiler = webpack({ 14 | devtool: 'source-map', 15 | entry: [ 16 | '@roarr/browser-log-writer/init', 17 | // This would be `fastify-webpack-hot/client` in a regular application. 18 | path.resolve(__dirname, '../../../client.js'), 19 | path.resolve(__dirname, '../app/index.tsx'), 20 | ], 21 | mode: 'development', 22 | module: { 23 | rules: [ 24 | { 25 | exclude: /node_modules/u, 26 | test: /\.tsx?$/u, 27 | use: [ 28 | { 29 | loader: require.resolve('ts-loader'), 30 | options: { 31 | getCustomTransformers: () => { 32 | return { 33 | before: [ 34 | ReactRefreshTypeScript(), 35 | ], 36 | }; 37 | }, 38 | transpileOnly: true, 39 | }, 40 | }, 41 | ], 42 | }, 43 | ], 44 | }, 45 | output: { 46 | filename: 'main.js', 47 | path: path.resolve(__dirname, '/dist'), 48 | publicPath: '/', 49 | }, 50 | plugins: [ 51 | new webpack.HotModuleReplacementPlugin(), 52 | new ReactRefreshWebpackPlugin(), 53 | ], 54 | resolve: { 55 | extensions: [ 56 | '.js', 57 | '.ts', 58 | '.tsx', 59 | ], 60 | }, 61 | }); 62 | 63 | void app.register(fastifyWebpackHot, { 64 | compiler, 65 | }); 66 | 67 | app.get('/', (request, reply) => { 68 | void reply 69 | .type('text/html') 70 | .send(` 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 |
79 | 80 | 81 | `); 82 | }); 83 | 84 | await app.listen(8_080); 85 | }; 86 | 87 | void main(); 88 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'node:events'; 2 | import path from 'node:path'; 3 | import fp from 'fastify-plugin'; 4 | import { 5 | createFsFromVolume, 6 | Volume, 7 | } from 'memfs'; 8 | import type { 9 | IFs, 10 | } from 'memfs'; 11 | import mime from 'mime-types'; 12 | import Negotiator from 'negotiator'; 13 | import { 14 | serializeError, 15 | } from 'serialize-error'; 16 | import type TypedEmitter from 'typed-emitter'; 17 | import type { 18 | Compiler, 19 | Stats, 20 | } from 'webpack'; 21 | import { 22 | Logger, 23 | } from './Logger'; 24 | import { 25 | createSyncEvents, 26 | } from './factories'; 27 | import type { 28 | SyncEvent, 29 | } from './types'; 30 | import { 31 | canRead, 32 | defer, 33 | formatServerEvent, 34 | getFilenameFromUrl, 35 | } from './utilities'; 36 | import { 37 | type DeferredPromise, 38 | } from './utilities/defer'; 39 | 40 | type EventHandlers = { 41 | sync: (event: SyncEvent) => void, 42 | }; 43 | 44 | const MODULE_NAME = 'fastify-webpack-hot'; 45 | 46 | const log = Logger.child({ 47 | namespace: 'fastify-webpack-hot', 48 | }); 49 | 50 | declare module 'fastify' { 51 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions 52 | interface FastifyRequest { 53 | webpack: { 54 | outputFileSystem: IFs, 55 | stats?: Stats, 56 | }; 57 | } 58 | } 59 | 60 | type Configuration = { 61 | compiler: Compiler, 62 | }; 63 | 64 | export const fastifyWebpackHot = fp(async (fastify, options) => { 65 | const eventEmitter = new EventEmitter() as TypedEmitter; 66 | 67 | const { 68 | compiler, 69 | } = options; 70 | 71 | let statsPromise: DeferredPromise = defer(); 72 | 73 | compiler.hooks.watchRun.tap(MODULE_NAME, () => { 74 | if (statsPromise.resolved) { 75 | statsPromise = defer(); 76 | } 77 | 78 | if (compiler.modifiedFiles) { 79 | const modifiedFiles = Array.from(compiler.modifiedFiles); 80 | 81 | log.info({ 82 | modifiedFiles, 83 | }, 'building a webpack bundle'); 84 | } else { 85 | log.info('building a webpack bundle'); 86 | } 87 | }); 88 | 89 | const outputFileSystem = createFsFromVolume(new Volume()); 90 | 91 | compiler.outputFileSystem = outputFileSystem; 92 | 93 | const watching = compiler.watch({ 94 | aggregateTimeout: 500, 95 | poll: false, 96 | }, (error, nextStats) => { 97 | if (error) { 98 | log.error({ 99 | error: serializeError(error), 100 | }, 'invalid build'); 101 | 102 | return; 103 | } 104 | 105 | if (!nextStats) { 106 | throw new Error('Expected nextState to be defined'); 107 | } 108 | 109 | log.debug('webpack build is ready'); 110 | 111 | statsPromise.resolve(nextStats); 112 | 113 | const syncEvents = createSyncEvents(nextStats); 114 | 115 | for (const syncEvent of syncEvents) { 116 | eventEmitter.emit('sync', syncEvent); 117 | } 118 | }); 119 | 120 | fastify.get('/__fastify_webpack_hot', (request, reply) => { 121 | const headers = { 122 | 'cache-control': 'no-store', 123 | 'content-type': 'text/event-stream', 124 | }; 125 | 126 | reply.raw.writeHead(200, headers); 127 | 128 | const sync = (event) => { 129 | void reply.raw.write(formatServerEvent('sync', event)); 130 | }; 131 | 132 | eventEmitter.addListener('sync', sync); 133 | 134 | request.raw.on('close', () => { 135 | eventEmitter.removeListener('sync', sync); 136 | }); 137 | }); 138 | 139 | fastify.addHook('onRequest', async (request, reply) => { 140 | request.webpack = { 141 | outputFileSystem, 142 | }; 143 | 144 | if (request.method !== 'GET' && request.method !== 'HEAD') { 145 | return; 146 | } 147 | 148 | if (!statsPromise.resolved) { 149 | log.info('waiting for the compiler to finish bundling'); 150 | } 151 | 152 | const stats = await statsPromise.promise; 153 | 154 | // eslint-disable-next-line require-atomic-updates 155 | request.webpack = { 156 | outputFileSystem, 157 | stats, 158 | }; 159 | 160 | const fileName = await getFilenameFromUrl(outputFileSystem, stats, request.url); 161 | 162 | if (fileName) { 163 | const contentType = mime.contentType(path.extname(fileName)); 164 | 165 | if (contentType) { 166 | void reply.header('content-type', contentType); 167 | } 168 | 169 | const negotiator = new Negotiator(request.raw); 170 | const encodings = negotiator.encodings(); 171 | 172 | if (encodings.includes('br') && await canRead(outputFileSystem, fileName + '.br')) { 173 | void reply.header('content-encoding', 'br'); 174 | void reply.send(await outputFileSystem.promises.readFile(fileName + '.br')); 175 | } else if (encodings.includes('gzip') && await canRead(outputFileSystem, fileName + '.gz')) { 176 | void reply.header('content-encoding', 'gzip'); 177 | void reply.send(await outputFileSystem.promises.readFile(fileName + '.gz')); 178 | } else { 179 | void reply.send(await outputFileSystem.promises.readFile(fileName)); 180 | } 181 | } 182 | }); 183 | 184 | fastify.addHook('onClose', (instance, done) => { 185 | watching.close(() => { 186 | done(); 187 | }); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /test/fastify-webpack-hot/fastifyWebpack.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import { 4 | setTimeout, 5 | } from 'node:timers/promises'; 6 | import test from 'ava'; 7 | import CompressionPlugin from 'compression-webpack-plugin'; 8 | import createFastify from 'fastify'; 9 | import got from 'got'; 10 | import type { 11 | Message, 12 | } from 'roarr'; 13 | import { 14 | Roarr, 15 | } from 'roarr'; 16 | import webpack from 'webpack'; 17 | import { 18 | fastifyWebpackHot, 19 | } from '../../src'; 20 | import type { 21 | SyncEvent, 22 | } from '../../src/types'; 23 | 24 | test('builds and serves bundle', async (t) => { 25 | const app = createFastify(); 26 | 27 | const compiler = webpack({ 28 | entry: path.resolve(__dirname, '../fixtures/index.js'), 29 | output: { 30 | filename: 'main.js', 31 | path: path.resolve(__dirname, '/dist'), 32 | publicPath: '/', 33 | }, 34 | }); 35 | 36 | void app.register(fastifyWebpackHot, { 37 | compiler, 38 | }); 39 | 40 | const serverAddress = await app.listen(0); 41 | 42 | const response = await got(serverAddress + '/main.js', { 43 | resolveBodyOnly: true, 44 | }); 45 | 46 | t.is(response, 'console.log("Hello, World");'); 47 | }); 48 | 49 | test('serves brotli compressed assets when available', async (t) => { 50 | const app = createFastify(); 51 | 52 | const compiler = webpack({ 53 | entry: path.resolve(__dirname, '../fixtures/index.js'), 54 | output: { 55 | filename: 'main.js', 56 | path: path.resolve(__dirname, '/dist'), 57 | publicPath: '/', 58 | }, 59 | plugins: [ 60 | new CompressionPlugin({ 61 | algorithm: 'brotliCompress', 62 | deleteOriginalAssets: false, 63 | filename: '[path][base].br', 64 | minRatio: Number.POSITIVE_INFINITY, 65 | test: /\.js$/u, 66 | threshold: 0, 67 | }), 68 | ], 69 | }); 70 | 71 | void app.register(fastifyWebpackHot, { 72 | compiler, 73 | }); 74 | 75 | const serverAddress = await app.listen(0); 76 | 77 | const response = await got(serverAddress + '/main.js', { 78 | headers: { 79 | 'accept-encoding': 'br', 80 | }, 81 | }); 82 | 83 | t.is(response.headers['content-encoding'], 'br'); 84 | }); 85 | 86 | test('serves gzip compressed assets when available', async (t) => { 87 | const app = createFastify(); 88 | 89 | const compiler = webpack({ 90 | entry: path.resolve(__dirname, '../fixtures/index.js'), 91 | output: { 92 | filename: 'main.js', 93 | path: path.resolve(__dirname, '/dist'), 94 | publicPath: '/', 95 | }, 96 | plugins: [ 97 | new CompressionPlugin({ 98 | algorithm: 'gzip', 99 | deleteOriginalAssets: false, 100 | filename: '[path][base].gz', 101 | minRatio: Number.POSITIVE_INFINITY, 102 | test: /\.js$/u, 103 | threshold: 0, 104 | }), 105 | ], 106 | }); 107 | 108 | void app.register(fastifyWebpackHot, { 109 | compiler, 110 | }); 111 | 112 | const serverAddress = await app.listen(0); 113 | 114 | const response = await got(serverAddress + '/main.js', { 115 | headers: { 116 | 'accept-encoding': 'gzip', 117 | }, 118 | }); 119 | 120 | t.is(response.headers['content-encoding'], 'gzip'); 121 | }); 122 | 123 | test('logs modified files', async (t) => { 124 | const app = createFastify(); 125 | 126 | const compiler = webpack({ 127 | entry: path.resolve(__dirname, '../fixtures/index.js'), 128 | output: { 129 | filename: 'main.js', 130 | path: path.resolve(__dirname, '/dist'), 131 | publicPath: '/', 132 | }, 133 | }); 134 | 135 | void app.register(fastifyWebpackHot, { 136 | compiler, 137 | }); 138 | 139 | const messages: Message[] = []; 140 | 141 | void Roarr.adopt(async () => { 142 | await app.listen(0); 143 | }, (message) => { 144 | messages.push(message); 145 | 146 | return message; 147 | }); 148 | 149 | await fs.utimes(path.resolve(__dirname, '../fixtures/index.js'), new Date(), new Date()); 150 | 151 | await setTimeout(1_000); 152 | 153 | const modifiedFilesLogMessage = messages.find((message) => { 154 | return message.message === 'building a webpack bundle' && message.context.modifiedFiles; 155 | }); 156 | 157 | if (!modifiedFilesLogMessage) { 158 | throw new Error('log message not found'); 159 | } 160 | 161 | t.true(Array.isArray(modifiedFilesLogMessage.context.modifiedFiles)); 162 | }); 163 | 164 | test('__fastify_webpack_hot pushes updates', async (t) => { 165 | const app = createFastify(); 166 | 167 | const compiler = webpack({ 168 | entry: path.resolve(__dirname, '../fixtures/index.js'), 169 | output: { 170 | filename: 'main.js', 171 | path: path.resolve(__dirname, '/dist'), 172 | publicPath: '/', 173 | }, 174 | }); 175 | 176 | void app.register(fastifyWebpackHot, { 177 | compiler, 178 | }); 179 | 180 | const address = await app.listen(0); 181 | 182 | const stream = got.stream(address + '/__fastify_webpack_hot'); 183 | 184 | const syncEvents: SyncEvent[] = []; 185 | 186 | stream.on('data', (data) => { 187 | if (data.toString().startsWith('event: sync')) { 188 | const syncEvent = JSON.parse(data.toString().split('data: ')[1]); 189 | 190 | syncEvents.push(syncEvent); 191 | } 192 | }); 193 | 194 | await fs.utimes(path.resolve(__dirname, '../fixtures/index.js'), new Date(), new Date()); 195 | 196 | await setTimeout(1_000); 197 | 198 | stream.destroy(); 199 | 200 | t.is(syncEvents.length, 1); 201 | 202 | t.like(syncEvents[0], { 203 | errors: [], 204 | hash: '673c6c371ec5fcb8e982', 205 | modules: { 206 | undefined: './test/fixtures/index.js', 207 | }, 208 | warnings: [], 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /.README/README.md: -------------------------------------------------------------------------------- 1 | # fastify-webpack-hot 🔥 2 | 3 | [![NPM version](http://img.shields.io/npm/v/fastify-webpack-hot.svg?style=flat-square)](https://www.npmjs.org/package/fastify-webpack-hot) 4 | [![Canonical Code Style](https://img.shields.io/badge/code%20style-canonical-blue.svg?style=flat-square)](https://github.com/gajus/canonical) 5 | [![Twitter Follow](https://img.shields.io/twitter/follow/kuizinas.svg?style=social&label=Follow)](https://twitter.com/kuizinas) 6 | 7 | A [Fastify](https://github.com/fastify/fastify) plugin for serving files emitted by [Webpack](https://github.com/webpack/webpack) with Hot Module Replacement (HMR). 8 | 9 | ![fastify-webpack-hot in action](./.README/fastify-webpack-hot.gif) 10 | 11 | (`fastify-webpack-hot` immediately propagates hot updates to the server and browser.) 12 | 13 | {"gitdown": "contents"} 14 | 15 | ## Recipes 16 | 17 | ### Basic HMR Setup 18 | 19 | All you need to enable [Webpack Hot Module Replacement](https://webpack.js.org/api/hot-module-replacement/) is: 20 | 21 | * Register `fastify-webpack-hot` Fastify plugin 22 | * Enable `HotModuleReplacementPlugin` Webpack plugin 23 | * Prepend `fastify-webpack-hot/client` entry script 24 | 25 | Example: 26 | 27 | ```ts 28 | import webpack from 'webpack'; 29 | import { 30 | fastifyWebpackHot, 31 | } from 'fastify-webpack-hot'; 32 | 33 | const compiler = webpack({ 34 | entry: [ 35 | 'fastify-webpack-hot/client', 36 | path.resolve(__dirname, '../app/main.js'), 37 | ], 38 | mode: 'development', 39 | plugins: [ 40 | new webpack.HotModuleReplacementPlugin(), 41 | ], 42 | }); 43 | 44 | void app.register(fastifyWebpackHot, { 45 | compiler, 46 | }); 47 | 48 | ``` 49 | 50 | For more thorough instructions, refer to the [Project Setup Examples](#project-setup-examples). 51 | 52 | ### Accessing Webpack Stats 53 | 54 | [Stats](https://webpack.js.org/configuration/stats/) instance is accessible under `request.webpack.stats`: 55 | 56 | ```ts 57 | app.get('*', async (request, reply) => { 58 | const stats = request.webpack.stats.toJson({ 59 | all: false, 60 | entrypoints: true, 61 | }); 62 | 63 | // ... 64 | ); 65 | ``` 66 | 67 | The most common use case for accessing stats is for identifying and constructing the entrypoint assets, e.g. 68 | 69 | ```ts 70 | for (const asset of stats.entrypoints?.main.assets ?? []) { 71 | if (asset.name.endsWith('.js')) { 72 | htmlBody += 73 | '\n'; 74 | } 75 | } 76 | ``` 77 | 78 | ### Accessing Output File System 79 | 80 | You can access Output File System by referencing `compiler.outputFileSystem`. However, this will have the type of `OutputFileSystem`, which is incompatible with [`memfs`](https://npmjs.com/package/memfs), which is used by this package. Therefore, a better way to access `outputFileSystem` is by referencing `request.webpack.outputFileSystem`: 81 | 82 | ```ts 83 | app.get('*', async (request, reply) => { 84 | const stats = JSON.parse( 85 | await request.webpack.outputFileSystem.promises.readFile( 86 | path.join(__dirname, '../dist/stats.json'), 87 | 'utf8' 88 | ) 89 | ); 90 | 91 | // ... 92 | ); 93 | ``` 94 | 95 | This example shows how you would access `stats.json` generated by [`webpack-stats-plugin`](https://www.npmjs.com/package/webpack-stats-plugin). 96 | 97 | Note: You likely won't need to use this because `fastify-webpack-hot` automatically detects which assets have been generated and serves them at `output.publicPath`. 98 | 99 | ### Compressing Response 100 | 101 | This plugin is compatible with [`compression-webpack-plugin`](https://www.npmjs.com/package/compression-webpack-plugin), i.e. This plugin will serve compressed files if the following conditions are true: 102 | 103 | * Your outputs include compressed file versions (either `.br` or `.gz`) 104 | * Request includes a matching `accept-encoding` header 105 | 106 | Example `compression-webpack-plugin` configuration: 107 | 108 | ```ts 109 | new CompressionPlugin({ 110 | algorithm: 'brotliCompress', 111 | deleteOriginalAssets: false, 112 | filename: '[path][base].br', 113 | compressionOptions: { 114 | level: zlib.constants.BROTLI_MIN_QUALITY, 115 | }, 116 | minRatio: 0.8, 117 | test: /\.(js|css|html|svg)$/, 118 | threshold: 10_240, 119 | }) 120 | ``` 121 | 122 | ## Project Setup Examples 123 | 124 | These are complete project setup examples that you can run locally to evaluate `fastify-webpack-hot` plugin: 125 | 126 | * [TypeScript, Fastify and Webpack HRM example](./examples/webpack) (uses [Webpack Hot Module Replacement API](https://webpack.js.org/api/hot-module-replacement/)) 127 | * [TypeScript, Fastify, Webpack and React HRM example](./examples/react) (uses [`ReactRefreshWebpackPlugin`](https://github.com/pmmmwh/react-refresh-webpack-plugin)) 128 | 129 | ## Difference from `webpack-dev-server` and `webpack-hot-middleware` 130 | 131 | `webpack-dev-server` and `webpack-hot-middleware` were built for [express](https://npmjs.com/package/express) framework and as such they require compatibility plugins to work with Fastify. Additionally, both libraries are being maintained with intent to support legacy webpack versions (all the way to webpack v1). As a result, they contain a lot of bloat that makes them slower and harder to maintain. 132 | 133 | `fastify-webpack-hot` is built from the ground up leveraging the latest APIs of Fastify and webpack, and it encompasses functionality of both libraries. It is faster and easier to maintain. 134 | 135 | ## Troubleshooting 136 | 137 | ### Browser Logging 138 | 139 | This project uses [`roarr`](https://www.npmjs.com/package/roarr) logger to output the application's state. 140 | 141 | In order to output logs in browser, you need to provide output interface. The easiest way of doing it is by including [`@roarr/browser-log-writer`](https://github.com/gajus/roarr-browser-log-writer) in your project. 142 | 143 | ```ts 144 | import '@roarr/browser-log-writer/init'; 145 | ``` 146 | 147 | Afterwards, to output all logs set `ROARR_LOG=true` in `localStorage`: 148 | 149 | ```ts 150 | localStorage.setItem('ROARR_LOG', 'true'); 151 | ``` 152 | 153 | Note: Ensure that you have enabled verbose logs in DevTools to see all `fastify-webpack-hot` logs. 154 | 155 | ### Node.js Logging 156 | 157 | This project uses [`roarr`](https://www.npmjs.com/package/roarr) logger to output the program's state. 158 | 159 | Export `ROARR_LOG=true` environment variable to enable log printing to `stdout`. 160 | 161 | Use [`roarr-cli`](https://github.com/gajus/roarr-cli) program to pretty-print the logs. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # fastify-webpack-hot 🔥 4 | 5 | [![NPM version](http://img.shields.io/npm/v/fastify-webpack-hot.svg?style=flat-square)](https://www.npmjs.org/package/fastify-webpack-hot) 6 | [![Canonical Code Style](https://img.shields.io/badge/code%20style-canonical-blue.svg?style=flat-square)](https://github.com/gajus/canonical) 7 | [![Twitter Follow](https://img.shields.io/twitter/follow/kuizinas.svg?style=social&label=Follow)](https://twitter.com/kuizinas) 8 | 9 | A [Fastify](https://github.com/fastify/fastify) plugin for serving files emitted by [Webpack](https://github.com/webpack/webpack) with Hot Module Replacement (HMR). 10 | 11 | ![fastify-webpack-hot in action](./.README/fastify-webpack-hot.gif) 12 | 13 | (`fastify-webpack-hot` immediately propagates hot updates to the server and browser.) 14 | 15 | * [fastify-webpack-hot 🔥](#user-content-fastify-webpack-hot) 16 | * [Recipes](#user-content-fastify-webpack-hot-recipes) 17 | * [Basic HMR Setup](#user-content-fastify-webpack-hot-recipes-basic-hmr-setup) 18 | * [Accessing Webpack Stats](#user-content-fastify-webpack-hot-recipes-accessing-webpack-stats) 19 | * [Accessing Output File System](#user-content-fastify-webpack-hot-recipes-accessing-output-file-system) 20 | * [Compressing Response](#user-content-fastify-webpack-hot-recipes-compressing-response) 21 | * [Project Setup Examples](#user-content-fastify-webpack-hot-project-setup-examples) 22 | * [Difference from `webpack-dev-server` and `webpack-hot-middleware`](#user-content-fastify-webpack-hot-difference-from-webpack-dev-server-and-webpack-hot-middleware) 23 | * [Troubleshooting](#user-content-fastify-webpack-hot-troubleshooting) 24 | * [Browser Logging](#user-content-fastify-webpack-hot-troubleshooting-browser-logging) 25 | * [Node.js Logging](#user-content-fastify-webpack-hot-troubleshooting-node-js-logging) 26 | 27 | 28 | 29 | 30 | ## Recipes 31 | 32 | 33 | 34 | ### Basic HMR Setup 35 | 36 | All you need to enable [Webpack Hot Module Replacement](https://webpack.js.org/api/hot-module-replacement/) is: 37 | 38 | * Register `fastify-webpack-hot` Fastify plugin 39 | * Enable `HotModuleReplacementPlugin` Webpack plugin 40 | * Prepend `fastify-webpack-hot/client` entry script 41 | 42 | Example: 43 | 44 | ```ts 45 | import webpack from 'webpack'; 46 | import { 47 | fastifyWebpackHot, 48 | } from 'fastify-webpack-hot'; 49 | 50 | const compiler = webpack({ 51 | entry: [ 52 | 'fastify-webpack-hot/client', 53 | path.resolve(__dirname, '../app/main.js'), 54 | ], 55 | mode: 'development', 56 | plugins: [ 57 | new webpack.HotModuleReplacementPlugin(), 58 | ], 59 | }); 60 | 61 | void app.register(fastifyWebpackHot, { 62 | compiler, 63 | }); 64 | 65 | ``` 66 | 67 | For more thorough instructions, refer to the [Project Setup Examples](#user-content-project-setup-examples). 68 | 69 | 70 | 71 | ### Accessing Webpack Stats 72 | 73 | [Stats](https://webpack.js.org/configuration/stats/) instance is accessible under `request.webpack.stats`: 74 | 75 | ```ts 76 | app.get('*', async (request, reply) => { 77 | const stats = request.webpack.stats.toJson({ 78 | all: false, 79 | entrypoints: true, 80 | }); 81 | 82 | // ... 83 | ); 84 | ``` 85 | 86 | The most common use case for accessing stats is for identifying and constructing the entrypoint assets, e.g. 87 | 88 | ```ts 89 | for (const asset of stats.entrypoints?.main.assets ?? []) { 90 | if (asset.name.endsWith('.js')) { 91 | htmlBody += 92 | '\n'; 93 | } 94 | } 95 | ``` 96 | 97 | 98 | 99 | ### Accessing Output File System 100 | 101 | You can access Output File System by referencing `compiler.outputFileSystem`. However, this will have the type of `OutputFileSystem`, which is incompatible with [`memfs`](https://npmjs.com/package/memfs), which is used by this package. Therefore, a better way to access `outputFileSystem` is by referencing `request.webpack.outputFileSystem`: 102 | 103 | ```ts 104 | app.get('*', async (request, reply) => { 105 | const stats = JSON.parse( 106 | await request.webpack.outputFileSystem.promises.readFile( 107 | path.join(__dirname, '../dist/stats.json'), 108 | 'utf8' 109 | ) 110 | ); 111 | 112 | // ... 113 | ); 114 | ``` 115 | 116 | This example shows how you would access `stats.json` generated by [`webpack-stats-plugin`](https://www.npmjs.com/package/webpack-stats-plugin). 117 | 118 | Note: You likely won't need to use this because `fastify-webpack-hot` automatically detects which assets have been generated and serves them at `output.publicPath`. 119 | 120 | 121 | 122 | ### Compressing Response 123 | 124 | This plugin is compatible with [`compression-webpack-plugin`](https://www.npmjs.com/package/compression-webpack-plugin), i.e. This plugin will serve compressed files if the following conditions are true: 125 | 126 | * Your outputs include compressed file versions (either `.br` or `.gz`) 127 | * Request includes a matching `accept-encoding` header 128 | 129 | Example `compression-webpack-plugin` configuration: 130 | 131 | ```ts 132 | new CompressionPlugin({ 133 | algorithm: 'brotliCompress', 134 | deleteOriginalAssets: false, 135 | filename: '[path][base].br', 136 | compressionOptions: { 137 | level: zlib.constants.BROTLI_MIN_QUALITY, 138 | }, 139 | minRatio: 0.8, 140 | test: /\.(js|css|html|svg)$/, 141 | threshold: 10_240, 142 | }) 143 | ``` 144 | 145 | 146 | 147 | ## Project Setup Examples 148 | 149 | These are complete project setup examples that you can run locally to evaluate `fastify-webpack-hot` plugin: 150 | 151 | * [TypeScript, Fastify and Webpack HRM example](./examples/webpack) (uses [Webpack Hot Module Replacement API](https://webpack.js.org/api/hot-module-replacement/)) 152 | * [TypeScript, Fastify, Webpack and React HRM example](./examples/react) (uses [`ReactRefreshWebpackPlugin`](https://github.com/pmmmwh/react-refresh-webpack-plugin)) 153 | 154 | 155 | 156 | ## Difference from webpack-dev-server and webpack-hot-middleware 157 | 158 | `webpack-dev-server` and `webpack-hot-middleware` were built for [express](https://npmjs.com/package/express) framework and as such they require compatibility plugins to work with Fastify. Additionally, both libraries are being maintained with intent to support legacy webpack versions (all the way to webpack v1). As a result, they contain a lot of bloat that makes them slower and harder to maintain. 159 | 160 | `fastify-webpack-hot` is built from the ground up leveraging the latest APIs of Fastify and webpack, and it encompasses functionality of both libraries. It is faster and easier to maintain. 161 | 162 | 163 | 164 | ## Troubleshooting 165 | 166 | 167 | 168 | ### Browser Logging 169 | 170 | This project uses [`roarr`](https://www.npmjs.com/package/roarr) logger to output the application's state. 171 | 172 | In order to output logs in browser, you need to provide output interface. The easiest way of doing it is by including [`@roarr/browser-log-writer`](https://github.com/gajus/roarr-browser-log-writer) in your project. 173 | 174 | ```ts 175 | import '@roarr/browser-log-writer/init'; 176 | ``` 177 | 178 | Afterwards, to output all logs set `ROARR_LOG=true` in `localStorage`: 179 | 180 | ```ts 181 | localStorage.setItem('ROARR_LOG', 'true'); 182 | ``` 183 | 184 | Note: Ensure that you have enabled verbose logs in DevTools to see all `fastify-webpack-hot` logs. 185 | 186 | 187 | 188 | ### Node.js Logging 189 | 190 | This project uses [`roarr`](https://www.npmjs.com/package/roarr) logger to output the program's state. 191 | 192 | Export `ROARR_LOG=true` environment variable to enable log printing to `stdout`. 193 | 194 | Use [`roarr-cli`](https://github.com/gajus/roarr-cli) program to pretty-print the logs. --------------------------------------------------------------------------------