From 280a3fab74348697429b7bab56b3436968113d79 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Fri, 9 Dec 2022 23:49:07 +0100 Subject: refactor(frontend): split vite config Also introduces tsconfig.shared.json to keep track of source files used both and build time and in the browser. --- subprojects/frontend/.eslintrc.cjs | 8 +- .../frontend/config/backendConfigVitePlugin.ts | 27 +++ .../frontend/config/detectDevModeOptions.ts | 94 +++++++++++ .../frontend/config/fetchPackageMetadata.ts | 20 +++ subprojects/frontend/config/manifest.ts | 40 +++++ .../frontend/config/minifyHTMLVitePlugin.ts | 24 +++ .../frontend/config/preloadFontsVitePlugin.ts | 24 +++ subprojects/frontend/package.json | 5 +- subprojects/frontend/src/xtext/BackendConfig.ts | 13 ++ .../frontend/src/xtext/fetchBackendConfig.ts | 12 +- subprojects/frontend/tsconfig.json | 8 +- subprojects/frontend/tsconfig.node.json | 4 + subprojects/frontend/tsconfig.shared.json | 13 ++ subprojects/frontend/vite.config.ts | 183 ++++----------------- 14 files changed, 310 insertions(+), 165 deletions(-) create mode 100644 subprojects/frontend/config/backendConfigVitePlugin.ts create mode 100644 subprojects/frontend/config/detectDevModeOptions.ts create mode 100644 subprojects/frontend/config/fetchPackageMetadata.ts create mode 100644 subprojects/frontend/config/manifest.ts create mode 100644 subprojects/frontend/config/minifyHTMLVitePlugin.ts create mode 100644 subprojects/frontend/config/preloadFontsVitePlugin.ts create mode 100644 subprojects/frontend/src/xtext/BackendConfig.ts create mode 100644 subprojects/frontend/tsconfig.shared.json (limited to 'subprojects/frontend') diff --git a/subprojects/frontend/.eslintrc.cjs b/subprojects/frontend/.eslintrc.cjs index eadd3fb4..8a7b474a 100644 --- a/subprojects/frontend/.eslintrc.cjs +++ b/subprojects/frontend/.eslintrc.cjs @@ -4,6 +4,7 @@ const path = require('node:path'); const project = [ path.join(__dirname, 'tsconfig.json'), path.join(__dirname, 'tsconfig.node.json'), + path.join(__dirname, 'tsconfig.shared.json'), ]; /** @type {import('eslint').Linter.Config} */ @@ -86,7 +87,12 @@ module.exports = { }, }, { - files: ['.eslintrc.cjs', 'prettier.config.cjs', 'vite.config.ts'], + files: [ + '.eslintrc.cjs', + 'config/*.ts', + 'prettier.config.cjs', + 'vite.config.ts', + ], env: { browser: false, node: true, diff --git a/subprojects/frontend/config/backendConfigVitePlugin.ts b/subprojects/frontend/config/backendConfigVitePlugin.ts new file mode 100644 index 00000000..7a6bc3db --- /dev/null +++ b/subprojects/frontend/config/backendConfigVitePlugin.ts @@ -0,0 +1,27 @@ +import type { PluginOption } from 'vite'; + +import type BackendConfig from '../src/xtext/BackendConfig'; +import { ENDPOINT } from '../src/xtext/BackendConfig'; + +export default function backendConfigVitePlugin( + backendConfig: BackendConfig, +): PluginOption { + return { + name: 'backend-config', + apply: 'serve', + configureServer(server) { + const config = JSON.stringify(backendConfig); + server.middlewares.use((req, res, next) => { + if (req.url === `/${ENDPOINT}`) { + res.setHeader('Content-Type', 'application/json'); + res.end(config); + } else { + next(); + } + }); + }, + }; +} + +export type { default as BackendConfig } from '../src/xtext/BackendConfig'; +export { ENDPOINT as CONFIG_ENDPOINT } from '../src/xtext/BackendConfig'; diff --git a/subprojects/frontend/config/detectDevModeOptions.ts b/subprojects/frontend/config/detectDevModeOptions.ts new file mode 100644 index 00000000..b3696241 --- /dev/null +++ b/subprojects/frontend/config/detectDevModeOptions.ts @@ -0,0 +1,94 @@ +import type { PluginOption, ServerOptions } from 'vite'; + +import backendConfigVitePlugin, { + type BackendConfig, +} from './backendConfigVitePlugin'; + +export const API_ENDPOINT = 'xtext-service'; + +export interface DevModeOptions { + mode: string; + isDevelopment: boolean; + devModePlugins: PluginOption[]; + serverOptions: ServerOptions; +} + +interface ListenOptions { + host: string; + port: number; + secure: boolean; +} + +function detectListenOptions( + name: string, + fallbackHost: string, + fallbackPort: number, +): ListenOptions { + const host = process.env[`${name}_HOST`] ?? fallbackHost; + const rawPort = process.env[`${name}_PORT`]; + const port = rawPort === undefined ? fallbackPort : parseInt(rawPort, 10); + const secure = port === 443; + return { host, port, secure }; +} + +function listenURL( + { host, port, secure }: ListenOptions, + protocol = 'http', +): string { + return `${secure ? `${protocol}s` : protocol}://${host}:${port}`; +} + +export default function detectDevModeOptions(): DevModeOptions { + const mode = process.env['MODE'] || 'development'; + const isDevelopment = mode === 'development'; + + if (!isDevelopment) { + return { + mode, + isDevelopment, + devModePlugins: [], + serverOptions: {}, + }; + } + + const listen = detectListenOptions('LISTEN', 'localhost', 1313); + // Make sure we always use IPv4 to connect to the backend, + // because it doesn't listen on IPv6. + const api = detectListenOptions('API', '127.0.0.1', 1312); + const publicAddress = detectListenOptions('PUBLIC', listen.host, listen.port); + + const backendConfig: BackendConfig = { + webSocketURL: `${listenURL(publicAddress, 'ws')}/${API_ENDPOINT}`, + }; + + return { + mode, + isDevelopment, + devModePlugins: [backendConfigVitePlugin(backendConfig)], + serverOptions: { + host: listen.host, + port: listen.port, + strictPort: true, + https: listen.secure, + headers: { + // Enable strict origin isolation, see e.g., + // https://github.com/vitejs/vite/issues/3909#issuecomment-1065893956 + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'cross-origin', + }, + proxy: { + [`/${API_ENDPOINT}`]: { + target: listenURL(api), + ws: true, + secure: api.secure, + }, + }, + hmr: { + host: publicAddress.host, + clientPort: publicAddress.port, + path: '/vite', + }, + }, + }; +} diff --git a/subprojects/frontend/config/fetchPackageMetadata.ts b/subprojects/frontend/config/fetchPackageMetadata.ts new file mode 100644 index 00000000..50807b03 --- /dev/null +++ b/subprojects/frontend/config/fetchPackageMetadata.ts @@ -0,0 +1,20 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import z from 'zod'; + +const PackageInfo = z.object({ + name: z.string().min(1), + version: z.string().min(1), +}); + +export default async function fetchPackageMetadata( + thisDir: string, +): Promise { + const contents = await readFile(path.join(thisDir, 'package.json'), 'utf8'); + const { name: packageName, version: packageVersion } = PackageInfo.parse( + JSON.parse(contents), + ); + process.env['VITE_PACKAGE_NAME'] ??= packageName; + process.env['VITE_PACKAGE_VERSION'] ??= packageVersion; +} diff --git a/subprojects/frontend/config/manifest.ts b/subprojects/frontend/config/manifest.ts new file mode 100644 index 00000000..3cec777c --- /dev/null +++ b/subprojects/frontend/config/manifest.ts @@ -0,0 +1,40 @@ +import type { ManifestOptions } from 'vite-plugin-pwa'; + +const manifest: Partial = { + lang: 'en-US', + name: 'Refinery', + short_name: 'Refinery', + description: 'An efficient graph sovler for generating well-formed models', + theme_color: '#f5f5f5', + display_override: ['window-controls-overlay'], + display: 'standalone', + background_color: '#21252b', + icons: [ + { + src: 'icon-192x192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'any maskable', + }, + { + src: 'icon-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any maskable', + }, + { + src: 'icon-any.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any maskable', + }, + { + src: 'mask-icon.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'monochrome', + }, + ], +}; + +export default manifest; diff --git a/subprojects/frontend/config/minifyHTMLVitePlugin.ts b/subprojects/frontend/config/minifyHTMLVitePlugin.ts new file mode 100644 index 00000000..18336d4d --- /dev/null +++ b/subprojects/frontend/config/minifyHTMLVitePlugin.ts @@ -0,0 +1,24 @@ +import { minify, type Options as TerserOptions } from 'html-minifier-terser'; +import type { PluginOption } from 'vite'; + +export default function minifyHTMLVitePlugin( + options?: TerserOptions | undefined, +): PluginOption { + return { + name: 'minify-html', + apply: 'build', + enforce: 'post', + transformIndexHtml(html) { + return minify(html, { + collapseWhitespace: true, + collapseBooleanAttributes: true, + minifyCSS: true, + removeComments: true, + removeAttributeQuotes: true, + removeRedundantAttributes: true, + sortAttributes: true, + ...(options ?? {}), + }); + }, + }; +} diff --git a/subprojects/frontend/config/preloadFontsVitePlugin.ts b/subprojects/frontend/config/preloadFontsVitePlugin.ts new file mode 100644 index 00000000..bc6f8eaf --- /dev/null +++ b/subprojects/frontend/config/preloadFontsVitePlugin.ts @@ -0,0 +1,24 @@ +import micromatch from 'micromatch'; +import type { PluginOption } from 'vite'; + +export default function preloadFontsVitePlugin( + fontsGlob: string | string[], +): PluginOption { + return { + name: 'refinery-preload-fonts', + apply: 'build', + enforce: 'post', + transformIndexHtml(_html, { bundle }) { + return micromatch(Object.keys(bundle ?? {}), fontsGlob).map((href) => ({ + tag: 'link', + attrs: { + href, + rel: 'preload', + type: 'font/woff2', + as: 'font', + crossorigin: 'anonymous', + }, + })); + }, + }; +} diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json index db5d3f68..a826755d 100644 --- a/subprojects/frontend/package.json +++ b/subprojects/frontend/package.json @@ -7,7 +7,7 @@ "build": "cross-env MODE=production vite build", "serve": "cross-env MODE=development vite serve", "typegen": "xstate typegen \"src/**/*.ts?(x)\"", - "typecheck": "tsc -p tsconfig.node.json && tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.shared.json && tsc -p tsconfig.node.json && tsc -p tsconfig.json", "lint": "eslint .", "lint:ci": "eslint -f json -o build/eslint.json .", "lint:fix": "yarn run lint --fix" @@ -62,6 +62,7 @@ "@types/eslint": "^8.4.10", "@types/html-minifier-terser": "^7.0.0", "@types/lodash-es": "^4.17.6", + "@types/micromatch": "^4.0.2", "@types/ms": "^0.7.31", "@types/node": "^18.11.13", "@types/prettier": "^2.7.1", @@ -83,10 +84,10 @@ "eslint-plugin-react": "^7.31.11", "eslint-plugin-react-hooks": "^4.6.0", "html-minifier-terser": "^7.1.0", + "micromatch": "^4.0.5", "prettier": "^2.8.1", "typescript": "4.9.3", "vite": "^4.0.0", - "vite-plugin-inject-preload": "^1.1.1", "vite-plugin-pwa": "^0.13.3", "workbox-window": "^6.5.4" } diff --git a/subprojects/frontend/src/xtext/BackendConfig.ts b/subprojects/frontend/src/xtext/BackendConfig.ts new file mode 100644 index 00000000..41737c0b --- /dev/null +++ b/subprojects/frontend/src/xtext/BackendConfig.ts @@ -0,0 +1,13 @@ +/* eslint-disable @typescript-eslint/no-redeclare -- Declare types with their companion objects */ + +import { z } from 'zod'; + +export const ENDPOINT = 'config.json'; + +const BackendConfig = z.object({ + webSocketURL: z.string().url(), +}); + +type BackendConfig = z.infer; + +export default BackendConfig; diff --git a/subprojects/frontend/src/xtext/fetchBackendConfig.ts b/subprojects/frontend/src/xtext/fetchBackendConfig.ts index f8087a70..15e976d8 100644 --- a/subprojects/frontend/src/xtext/fetchBackendConfig.ts +++ b/subprojects/frontend/src/xtext/fetchBackendConfig.ts @@ -1,15 +1,7 @@ -/* eslint-disable @typescript-eslint/no-redeclare -- Declare types with their companion objects */ - -import { z } from 'zod'; - -export const BackendConfig = z.object({ - webSocketURL: z.string().url(), -}); - -export type BackendConfig = z.infer; +import BackendConfig, { ENDPOINT } from './BackendConfig'; export default async function fetchBackendConfig(): Promise { - const configURL = `${import.meta.env.BASE_URL}config.json`; + const configURL = `${import.meta.env.BASE_URL}${ENDPOINT}`; const response = await fetch(configURL); const rawConfig = (await response.json()) as unknown; return BackendConfig.parse(rawConfig); diff --git a/subprojects/frontend/tsconfig.json b/subprojects/frontend/tsconfig.json index 0dccec40..35abd789 100644 --- a/subprojects/frontend/tsconfig.json +++ b/subprojects/frontend/tsconfig.json @@ -10,8 +10,12 @@ "src", "types" ], - "exclude": ["types/node"], + "exclude": [ + "src/xtext/BackendConfig.ts", + "types/node" + ], "references": [ - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.shared.json" } ] } diff --git a/subprojects/frontend/tsconfig.node.json b/subprojects/frontend/tsconfig.node.json index c4539dbc..f4908bcb 100644 --- a/subprojects/frontend/tsconfig.node.json +++ b/subprojects/frontend/tsconfig.node.json @@ -9,8 +9,12 @@ }, "include": [ ".eslintrc.cjs", + "config/*.ts", "prettier.config.cjs", "types/node", "vite.config.ts" + ], + "references": [ + { "path": "./tsconfig.shared.json" } ] } diff --git a/subprojects/frontend/tsconfig.shared.json b/subprojects/frontend/tsconfig.shared.json new file mode 100644 index 00000000..b7e1de55 --- /dev/null +++ b/subprojects/frontend/tsconfig.shared.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "composite": true, + "lib": ["ES2022"], + "types": [], + "emitDeclarationOnly": true, + "outDir": "build/typescript" + }, + "include": [ + "src/xtext/BackendConfig.ts", + ] +} diff --git a/subprojects/frontend/vite.config.ts b/subprojects/frontend/vite.config.ts index 08b3db0c..cd9993cc 100644 --- a/subprojects/frontend/vite.config.ts +++ b/subprojects/frontend/vite.config.ts @@ -1,160 +1,61 @@ -import { setDefaultResultOrder } from 'node:dns'; -import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { lezer } from '@lezer/generator/rollup'; import react from '@vitejs/plugin-react-swc'; -import { minify } from 'html-minifier-terser'; -import { defineConfig, type PluginOption } from 'vite'; -import injectPreload from 'vite-plugin-inject-preload'; +import { defineConfig, type UserConfig as ViteConfig } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; -setDefaultResultOrder('verbatim'); +import { CONFIG_ENDPOINT } from './config/backendConfigVitePlugin'; +import detectDevModeOptions, { + API_ENDPOINT, +} from './config/detectDevModeOptions'; +import fetchPackageMetadata from './config/fetchPackageMetadata'; +import manifest from './config/manifest'; +import minifyHTMLVitePlugin from './config/minifyHTMLVitePlugin'; +import preloadFontsVitePlugin from './config/preloadFontsVitePlugin'; const thisDir = path.dirname(fileURLToPath(import.meta.url)); -const mode = process.env['MODE'] || 'development'; -const isDevelopment = mode === 'development'; -process.env['NODE_ENV'] ??= mode; - -function portNumberOrElse(envName: string, fallback: number): number { - const value = process.env[envName]; - return value ? parseInt(value, 10) : fallback; -} +const { mode, isDevelopment, devModePlugins, serverOptions } = + detectDevModeOptions(); -const listenHost = process.env['LISTEN_HOST'] || 'localhost'; -const listenPort = portNumberOrElse('LISTEN_PORT', 1313); -const apiHost = process.env['API_HOST'] || '127.0.0.1'; -const apiPort = portNumberOrElse('API_PORT', 1312); -const apiSecure = apiPort === 443; -const publicHost = process.env['PUBLIC_HOST'] || listenHost; -const publicPort = portNumberOrElse('PUBLIC_PORT', listenPort); -const publicSecure = publicPort === 443; +process.env['NODE_ENV'] ??= mode; -const { name: packageName, version: packageVersion } = JSON.parse( - readFileSync(path.join(thisDir, 'package.json'), 'utf8'), -) as { name: string; version: string }; -process.env['VITE_PACKAGE_NAME'] ??= packageName; -process.env['VITE_PACKAGE_VERSION'] ??= packageVersion; +const fontsGlob = [ + 'inter-latin-variable-wghtOnly-normal-*.woff2', + 'jetbrains-mono-latin-variable-wghtOnly-{normal,italic}-*.woff2', +]; -const minifyPlugin: PluginOption = { - name: 'minify-html', - enforce: 'post', - async transformIndexHtml(html) { - if (isDevelopment) { - return html; - } - return minify(html, { - collapseWhitespace: true, - collapseBooleanAttributes: true, - minifyCSS: true, - removeComments: true, - removeAttributeQuotes: true, - removeRedundantAttributes: true, - sortAttributes: true, - }); - }, -}; - -const backendConfigPlugin: PluginOption = { - name: 'backend-config', - configureServer(server) { - const protocol = publicSecure ? 'wss' : 'ws'; - const webSocketURL = `${protocol}://${publicHost}:${publicPort}/xtext-service`; - const config = JSON.stringify({ webSocketURL }); - server.middlewares.use((req, res, next) => { - if (req.url === '/config.json') { - res.setHeader('Content-Type', 'application/json'); - res.end(config); - } else { - next(); - } - }); - }, -}; - -export default defineConfig({ +const viteConfig: ViteConfig = { logLevel: 'info', mode, root: thisDir, cacheDir: path.join(thisDir, 'build/vite/cache'), plugins: [ - minifyPlugin, - backendConfigPlugin, react(), - injectPreload({ - files: [ - { - match: - /(?:inter-latin-variable-wghtOnly-normal|jetbrains-mono-latin-variable-wghtOnly-(?:italic|normal)).+\.woff2$/, - attributes: { - type: 'font/woff2', - as: 'font', - crossorigin: 'anonymous', - }, - }, - ], - }), lezer(), + preloadFontsVitePlugin(fontsGlob), + minifyHTMLVitePlugin(), VitePWA({ strategies: 'generateSW', registerType: 'prompt', injectRegister: null, workbox: { - globPatterns: [ - '**/*.{css,html,js}', - 'inter-latin-variable-wghtOnly-normal-*.woff2', - 'jetbrains-mono-latin-variable-wghtOnly-{normal,italic}-*.woff2', - ], + globPatterns: ['**/*.{css,html,js}', ...fontsGlob], dontCacheBustURLsMatching: /\.(?:css|js|woff2?)$/, - navigateFallbackDenylist: [/^\/xtext-service/], + navigateFallbackDenylist: [new RegExp(`^\\/${API_ENDPOINT}$`)], runtimeCaching: [ { - urlPattern: 'config.json', + urlPattern: CONFIG_ENDPOINT, handler: 'StaleWhileRevalidate', }, ], }, - includeAssets: ['apple-touch-icon.png', 'favicon.svg', 'mask-icon.svg'], - manifest: { - lang: 'en-US', - name: 'Refinery', - short_name: 'Refinery', - description: - 'An efficient graph sovler for generating well-formed models', - theme_color: '#f5f5f5', - display_override: ['window-controls-overlay'], - display: 'standalone', - background_color: '#21252b', - icons: [ - { - src: 'icon-192x192.png', - sizes: '192x192', - type: 'image/png', - purpose: 'any maskable', - }, - { - src: 'icon-512x512.png', - sizes: '512x512', - type: 'image/png', - purpose: 'any maskable', - }, - { - src: 'icon-any.svg', - sizes: 'any', - type: 'image/svg+xml', - purpose: 'any maskable', - }, - { - src: 'mask-icon.svg', - sizes: 'any', - type: 'image/svg+xml', - purpose: 'monochrome', - }, - ], - }, + includeAssets: ['apple-touch-icon.png', 'favicon.svg'], + manifest, }), + ...devModePlugins, ], base: '', define: { @@ -162,36 +63,18 @@ export default defineConfig({ }, build: { assetsDir: '.', - // If we don't control inlining manually, - // web fonts will randomly get inlined into the CSS, degrading performance. + // If we don't control inlining manually, web fonts will be randomly inlined + // into the CSS, which degrades performance. assetsInlineLimit: 0, outDir: path.join('build/vite', mode), emptyOutDir: true, sourcemap: isDevelopment, minify: !isDevelopment, }, - server: { - host: listenHost, - port: listenPort, - strictPort: true, - headers: { - // Enable strict origin isolation, see e.g., - // https://github.com/vitejs/vite/issues/3909#issuecomment-1065893956 - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', - 'Cross-Origin-Resource-Policy': 'cross-origin', - }, - proxy: { - '/xtext-service': { - target: `${apiSecure ? 'https' : 'http'}://${apiHost}:${apiPort}`, - ws: true, - secure: apiSecure, - }, - }, - hmr: { - host: publicHost, - clientPort: publicPort, - path: '/vite', - }, - }, + server: serverOptions, +}; + +export default defineConfig(async () => { + await fetchPackageMetadata(thisDir); + return viteConfig; }); -- cgit v1.2.3-70-g09d2