@greendrake/vite-preset 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/package.json +56 -0
- package/src/apiTarget.ts +84 -0
- package/src/env.ts +54 -0
- package/src/hmr.ts +41 -0
- package/src/html.ts +40 -0
- package/src/main.ts +155 -0
- package/src/sourceDeps.ts +95 -0
- package/src/staticRoots.ts +84 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eugene Greendrake
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# @greendrake/vite-preset
|
|
2
|
+
|
|
3
|
+
A Vite config factory and build-env helpers for Vue 3 single-page apps. `vueSpa()` owns the conventions an app would otherwise hand-copy: the Vue plugin, the strict `VITE_PORT` pin, the `@` alias, the dedupe list, unhashed font asset names and an HMR guard against circular imports. Apps pass app-specific config through `overrides`, which is deep-merged last.
|
|
4
|
+
|
|
5
|
+
The package is Node-only, except the `./api-target` entry, which is pure and also runs in the browser bundle. It ships TypeScript, and Node does not type-strip files under `node_modules`, so a `vite.config.ts` importing it runs under bun: `bun --bun vite …`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun add -d @greendrake/vite-preset vite
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`@vitejs/plugin-vue` comes with the package; `vite` (≥ 8) is a peer dependency.
|
|
14
|
+
|
|
15
|
+
## `vueSpa(options)`
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { defineConfig } from 'vite'
|
|
19
|
+
import { vueSpa } from '@greendrake/vite-preset'
|
|
20
|
+
|
|
21
|
+
export default defineConfig(
|
|
22
|
+
vueSpa({
|
|
23
|
+
dirname: import.meta.dirname,
|
|
24
|
+
define: { __API_URL__: process.env.API_URL },
|
|
25
|
+
overrides: { plugins: [myPlugin()] }
|
|
26
|
+
})
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
| Option | Effect |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `dirname` | The app root (`import.meta.dirname` of the vite config); anchors the `@` alias at `<dirname>/src`. |
|
|
33
|
+
| `define` | Compile-time globals. Values are serialised for you: a string becomes a string literal, a boolean `true`/`false`, an object an object literal. |
|
|
34
|
+
| `scssLoadPaths` | Extra `@use` resolution roots for Sass (e.g. the app's `src/scss`). |
|
|
35
|
+
| `port` | `'env'` (default): `VITE_PORT` is required whenever a server starts. A number pins that port. `false` disables pinning, for an app served on any free port. |
|
|
36
|
+
| `htmlPlaceholders` | Literal substitutions applied to `index.html` before Vite processes it (see `htmlPlaceholders` below). |
|
|
37
|
+
| `htmlMinify` | Minify `index.html`, inline scripts and styles included, in production builds. |
|
|
38
|
+
| `staticRoots` | Directories outside the app served at public URL prefixes (see `staticRoots` below). |
|
|
39
|
+
| `overrides` | A `UserConfig` deep-merged last — the escape hatch for `server.proxy`, extra plugins, build tweaks. |
|
|
40
|
+
|
|
41
|
+
What the preset sets:
|
|
42
|
+
|
|
43
|
+
- plugins: `@vitejs/plugin-vue` and the HMR circular-import guard, plus the `staticRoots`, `htmlPlaceholders` and `htmlMinify` plugins when their options are given;
|
|
44
|
+
- `server.port`/`preview.port` from `VITE_PORT` with `strictPort`, for `vite dev` and `vite preview` only — builds run in deploy contexts that never define a dev port;
|
|
45
|
+
- `server.fs.allow`: the parent of the app root (a workspace), and every static root's directory;
|
|
46
|
+
- `resolve.alias`: `@` → `<dirname>/src`; `resolve.dedupe`: `vue`, `pinia`, `vue-router` — each must also be a direct dependency of the app, or Vite fails to resolve it;
|
|
47
|
+
- `optimizeDeps`: every source-shipped dependency of the app is excluded from pre-bundling, and `vue`, `pinia` and `vue-router` (those the app declares) are pinned into it. A source-shipped package is TypeScript and SFC rather than a build; pre-bundling one from `node_modules` would inline its TypeScript modules and their imports while externalising its `.vue` files, leaving the app with two copies of Pinia and everything else. Served raw, a package resolves to one copy of itself and meets the app on the pre-bundled framework modules. Workspace-linked packages are never pre-bundled, so in a monorepo the exclusion decides no pre-bundling either way — it still matters for what the scanner sees, below. Which dependencies those are is the `sourcePackages` option — an entry ending in `/` matches a whole scope, anything else an exact package name. It defaults to `['@greendrake/']`; an app with source packages of its own lists them, and lists that scope again if it uses any:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
vueSpa({ dirname: import.meta.dirname, sourcePackages: ['@greendrake/', '@acme/ui', 'my-design-system'] })
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Excluding a package also hides it from Vite's dependency scanner, which externalises every excluded id and so never reads what that package imports. The third-party packages behind a source package are therefore pinned into the pre-bundle too, or the first browser request for one is a discovery: Vite re-runs the optimiser mid-session and full-reloads every open page — which under a parallel E2E run lands in the middle of tests in flight. The preset walks the app's runtime dependencies, follows the source packages among them, and pins each package they depend on, plus each peer they declare that the app itself supplies at runtime. Each is named in Vite's `parent > child` form unless the app declares it too, in which case its plain name — the id the scanner itself would produce — is used instead. Two things are left out: a package published only under subpaths, which no `optimizeDeps` entry can name and which Vite reaches through the subpath ids that import it anyway; and anything behind a source package the app keeps among its dev dependencies, which by the app's own reckoning is tooling (a build-tool integration shipped beside a package's components, a native-build helper) that browser code never reaches. That split is what an app is asserting when it puts a package in one list rather than the other, so put build-time source packages in `devDependencies`.
|
|
54
|
+
|
|
55
|
+
- `build`: font files (`woff2`, `woff`, `eot`, `ttf`, `otf`) are emitted unhashed under `assets/fonts/`, so `index.html` can prefetch them by a stable name; everything else is hashed.
|
|
56
|
+
|
|
57
|
+
`vueSpa` returns the *function* form of a Vite config (`ConfigEnv → UserConfig`). An app that needs the `ConfigEnv` before building its options wraps the call itself:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
export default defineConfig(configEnv =>
|
|
61
|
+
vueSpa({
|
|
62
|
+
dirname: import.meta.dirname,
|
|
63
|
+
define: { __API_TARGET_ENV__: apiTargetEnv(configEnv.command === 'serve') }
|
|
64
|
+
})(configEnv)
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### HMR circular-import guard
|
|
69
|
+
|
|
70
|
+
An HMR update whose accepted module sits inside an import cycle cannot be re-executed reliably; Vite's client masks that with a silent full-page reload. The preset intercepts such updates on the dev server and replaces them with an error overlay naming the offending modules, so the cycle gets broken rather than papered over. `vite --debug hmr` logs its path.
|
|
71
|
+
|
|
72
|
+
## Env helpers
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { loadAppEnv, requirePort, envBool, deriveImageBaseUrl, apiTargetEnv, buildHash } from '@greendrake/vite-preset'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- `loadAppEnv(mode, dirname)` — the app's `.env` / `.env.<mode>` files, every key (not only `VITE_`-prefixed ones): they carry plain build inputs, and the `export` prefix they may be written with is stripped so the same file can be shell-sourced. `process.env` outranks the file. Do not name the files `.env.development` / `.env.production`: bun auto-loads those into `process.env` at startup, and whichever one it picked would then outrank the mode's file. `@greendrake/dash/vite` builds its dashboard convention on this.
|
|
79
|
+
- `requirePort()` — `VITE_PORT`, required; the single port convention.
|
|
80
|
+
- `envBool(value)` — `"1"`, `"t"`, `"true"` (any case) are true; `"0"`, `"false"`, unset and everything else are false. `Boolean(process.env.X)` would take the literal string `"0"` as true.
|
|
81
|
+
- `deriveImageBaseUrl(env = process.env)` — the prefix for object-storage image URLs. A defined `S3_PREFIX` wins outright, even when empty; otherwise `AWS_S3_ENDPOINT/AWS_S3_BUCKET`; otherwise `''`.
|
|
82
|
+
- `apiTargetEnv(dev, paths?)` — the `ApiTargetEnv` of `resolveApiTarget`, read from `API_PORT`, `PUBLIC_FE_HOST`, `PUBLIC_API_HOST`, with optional `apiPath`/`wsPath`. Bake it into one define and feed the same object to anything else that resolves the host.
|
|
83
|
+
- `buildHash()` — `git rev-parse --short HEAD`, for a `__BUILD_HASH__` define. If git fails the build fails.
|
|
84
|
+
|
|
85
|
+
## Plugins
|
|
86
|
+
|
|
87
|
+
### `htmlPlaceholders(map)`
|
|
88
|
+
|
|
89
|
+
Substitutes build-time values into `index.html` before Vite processes it — a generated inline script, a build hash, PWA metadata. Keys are matched literally (convention: `__NAME__`); an empty map is an error. `vueSpa`'s `htmlPlaceholders` option installs it.
|
|
90
|
+
|
|
91
|
+
### `htmlMinify()`
|
|
92
|
+
|
|
93
|
+
Production-only: collapses whitespace, strips comments, minifies inline JS and CSS in `index.html`. Dev keeps the source readable. Installed by the `htmlMinify` option.
|
|
94
|
+
|
|
95
|
+
### `staticRoots(roots)` and `staticRootFiles(root)`
|
|
96
|
+
|
|
97
|
+
Some assets belong to the product rather than to the frontend — a locale catalogue a backend renders from too, prose a content page shows. A `StaticRoot` is `{ dir, urlPath, transform? }`: an absolute directory, the URL prefix it appears under (e.g. `/i18n`), and an optional per-file rewrite applied on the way out. The plugin serves the root by middleware in dev and copies it into `dist/` at build, so the two modes publish the same bytes; `.yaml`/`.yml` and `.json` get their content types, anything else is `text/plain`. Requests cannot climb out of the root. `staticRootFiles(root)` lists what a root publishes as URL paths, for a service worker's precache list. Installed by the `staticRoots` option.
|
|
98
|
+
|
|
99
|
+
## `./api-target`
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { resolveApiTarget, browserRuntime } from '@greendrake/vite-preset/api-target'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The single source of truth for where the API lives, pure and environment-free, so the same rules serve a vite config and the browser bundle. `resolveApiTarget(env, runtime?)` returns `{ apiUrl, wsUrl }`:
|
|
106
|
+
|
|
107
|
+
- dev (`env.dev`): `http`/`ws` against `localhost:<env.apiPort>`; with `env.publicApiHost` set instead, `https`/`wss` against that host (a dev server pointed at a remote API). Neither set is an error.
|
|
108
|
+
- prod: the same bundle can be served on two hosts. On `env.publicFeHost` — where a CDN may not proxy WebSockets — the API is `env.publicApiHost`; on any other host it is same-origin, i.e. `runtime.host`. The scheme follows `runtime.https`. `runtime` is required, and `publicApiHost` is required when serving on `publicFeHost`.
|
|
109
|
+
- `env.apiPath` and `env.wsPath` replace the default `/v1/api` and `/v1/ws`; a backend that routes at the host root sets `apiPath: ''`.
|
|
110
|
+
|
|
111
|
+
`browserRuntime()` is the `runtime` argument as a browser bundle supplies it: `{ host: location.host, https: location.protocol === 'https:' }`.
|
|
112
|
+
|
|
113
|
+
## Sass: import shared style packages by bare specifier
|
|
114
|
+
|
|
115
|
+
`@use '@greendrake/font-inter'` — never `@use 'pkg:@greendrake/font-inter'`, and do not register Sass's `NodePackageImporter`.
|
|
116
|
+
|
|
117
|
+
Vite resolves bare specifiers with its own Sass importer and rebases relative `url()` references inside the imported package against that package's own source location, so `@font-face { src: url('./fonts/X.woff2') }` in a font package resolves, gets emitted into `dist/assets/`, and the emitted CSS points at the built asset.
|
|
118
|
+
|
|
119
|
+
`NodePackageImporter` resolves the same `@use` to a path Vite's CSS pipeline does not rebase against. The `url()` then survives verbatim into the output, no font asset is emitted, and the build warns `didn't resolve at build time, it will remain unchanged to be resolved at runtime` — every font 404s at runtime. This is a property of the importer, not of any Vite version.
|
|
120
|
+
|
|
121
|
+
The corollary, enforced by `@greendrake/scss-kit`'s `_font-face.scss`: the `url()` literal must be written in the owning package's own `.scss`, because that is the file the rebasing is relative to.
|
|
122
|
+
|
|
123
|
+
If a future Vite drops bare-specifier support, migrate to `loadPaths` or move the `@font-face` blocks into the consuming apps — not to `pkg:`.
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@greendrake/vite-preset",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vite config factory and build-env helpers for Vue 3 single-page apps: plugin set, strict dev port, alias, dedupe and source-package pre-bundling rules",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Eugene Greendrake <eugene@greendrake.nz>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/greendrake/lib.git",
|
|
10
|
+
"directory": "vite-preset"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/greendrake/lib/tree/main/vite-preset#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/greendrake/lib/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/main.ts",
|
|
18
|
+
"types": "src/main.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./src/main.ts",
|
|
22
|
+
"default": "./src/main.ts"
|
|
23
|
+
},
|
|
24
|
+
"./api-target": {
|
|
25
|
+
"types": "./src/apiTarget.ts",
|
|
26
|
+
"default": "./src/apiTarget.ts"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"check": "tsc --noEmit"
|
|
38
|
+
},
|
|
39
|
+
"prettier": "@greendrake/dev-config/prettier",
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@greendrake/util": "0.1.0",
|
|
42
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
43
|
+
"html-minifier-terser": "^7.2.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"vite": ">=8"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@greendrake/dev-config": "0.1.0",
|
|
50
|
+
"@types/bun": "^1.3.5",
|
|
51
|
+
"@types/html-minifier-terser": "^7.0.2",
|
|
52
|
+
"@types/node": "^24.0.0",
|
|
53
|
+
"typescript": "6.0.3",
|
|
54
|
+
"vite": "^8.2.1"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/apiTarget.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Single source of truth for where the API lives. Pure and environment-free:
|
|
2
|
+
// build-time inputs (env) and runtime inputs (the page's location) are
|
|
3
|
+
// explicit arguments, so the same rules serve the Node side (vite configs,
|
|
4
|
+
// inline bootstrap generation) and the browser bundle — import via the
|
|
5
|
+
// ./api-target subpath there; the package's main entry is Node-only.
|
|
6
|
+
//
|
|
7
|
+
// Host resolution:
|
|
8
|
+
// Prod — the same bundle is deployed to two hosts. On the public FE host
|
|
9
|
+
// (e.g. example.com) a CDN may not proxy WS, so the API is on its own host
|
|
10
|
+
// (e.g. api.example.com). On any other host (e.g. an internal staging host) the API is
|
|
11
|
+
// proxied same-origin, so use the page's host. Proto follows the page's
|
|
12
|
+
// (http→ws, https→wss).
|
|
13
|
+
// Dev — the local backend on apiPort over http/ws; unless publicApiHost is
|
|
14
|
+
// set (point the dev server at a remote API, e.g. prod), in which case that
|
|
15
|
+
// host over https/wss.
|
|
16
|
+
|
|
17
|
+
export interface ApiTargetEnv {
|
|
18
|
+
// True under `vite dev` (command === 'serve'); false in production builds.
|
|
19
|
+
dev: boolean
|
|
20
|
+
apiPort?: string
|
|
21
|
+
publicFeHost?: string
|
|
22
|
+
publicApiHost?: string
|
|
23
|
+
// Endpoint paths appended to the resolved origin (defaults /v1/api and
|
|
24
|
+
// /v1/ws). A backend that routes POST at the host root sets apiPath: ''.
|
|
25
|
+
apiPath?: string
|
|
26
|
+
wsPath?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ApiTargetRuntime {
|
|
30
|
+
// The page's location.host / location.protocol === 'https:'. Only
|
|
31
|
+
// consulted in prod — dev targets are fully determined by env.
|
|
32
|
+
host: string
|
|
33
|
+
https: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ApiTarget {
|
|
37
|
+
apiUrl: string
|
|
38
|
+
wsUrl: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const resolveApiTarget = (env: ApiTargetEnv, runtime?: ApiTargetRuntime): ApiTarget => {
|
|
42
|
+
let host: string
|
|
43
|
+
let secure: boolean
|
|
44
|
+
if (env.dev) {
|
|
45
|
+
if (env.publicApiHost) {
|
|
46
|
+
host = env.publicApiHost
|
|
47
|
+
secure = true
|
|
48
|
+
} else {
|
|
49
|
+
if (!env.apiPort) {
|
|
50
|
+
throw new Error('resolveApiTarget: apiPort is required in local dev (no publicApiHost)')
|
|
51
|
+
}
|
|
52
|
+
host = `localhost:${env.apiPort}`
|
|
53
|
+
secure = false
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
if (!runtime) {
|
|
57
|
+
throw new Error('resolveApiTarget: runtime location is required in prod (the dual-host rule resolves against it)')
|
|
58
|
+
}
|
|
59
|
+
if (env.publicFeHost && runtime.host === env.publicFeHost) {
|
|
60
|
+
if (!env.publicApiHost) {
|
|
61
|
+
throw new Error('resolveApiTarget: publicApiHost is required when serving on publicFeHost')
|
|
62
|
+
}
|
|
63
|
+
host = env.publicApiHost
|
|
64
|
+
} else {
|
|
65
|
+
host = runtime.host
|
|
66
|
+
}
|
|
67
|
+
secure = runtime.https
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
apiUrl: `${secure ? 'https' : 'http'}://${host}${env.apiPath ?? '/v1/api'}`,
|
|
71
|
+
wsUrl: `${secure ? 'wss' : 'ws'}://${host}${env.wsPath ?? '/v1/ws'}`
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// browserRuntime executes only in browser bundles, but this package's
|
|
76
|
+
// tsconfig is Node-typed (no DOM lib) — declare the two location fields read.
|
|
77
|
+
declare const location: { host: string; protocol: string }
|
|
78
|
+
|
|
79
|
+
// The runtime argument as a browser bundle supplies it. Apps with no special
|
|
80
|
+
// runtime (native shells etc.) pass this directly.
|
|
81
|
+
export const browserRuntime = (): ApiTargetRuntime => ({
|
|
82
|
+
host: location.host,
|
|
83
|
+
https: location.protocol === 'https:'
|
|
84
|
+
})
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Build-time (Node) environment helpers shared by app vite configs.
|
|
2
|
+
|
|
3
|
+
import { execSync } from 'node:child_process'
|
|
4
|
+
import { loadEnv } from 'vite'
|
|
5
|
+
import type { ApiTargetEnv } from './apiTarget'
|
|
6
|
+
|
|
7
|
+
// The app's own .env / .env.<mode> files, every key (not just VITE_-prefixed
|
|
8
|
+
// ones) — these files carry plain build inputs, and dotenv strips the `export`
|
|
9
|
+
// prefix they are written with so the same file can also be shell-sourced.
|
|
10
|
+
// process.env outranks the file, which makes a deliberate shell export an
|
|
11
|
+
// override but also means an app must not name files `.env.development` /
|
|
12
|
+
// `.env.production`: bun auto-loads those at startup, and whichever one it
|
|
13
|
+
// picked would then outrank the mode's file.
|
|
14
|
+
// Config factories take vite's whole build-env surface through this package so
|
|
15
|
+
// that a downstream factory never resolves its own vite copy.
|
|
16
|
+
export const loadAppEnv = (mode: string, dirname: string): Record<string, string> => loadEnv(mode, dirname, '')
|
|
17
|
+
|
|
18
|
+
// The build-time inputs of resolveApiTarget, read from the conventional env
|
|
19
|
+
// vars (API_PORT, PUBLIC_FE_HOST, PUBLIC_API_HOST). Apps bake the result into
|
|
20
|
+
// a single define (`__API_TARGET_ENV__`) and feed the same object to anything
|
|
21
|
+
// else that resolves the host, so all of it resolves from one source.
|
|
22
|
+
export const apiTargetEnv = (dev: boolean, paths?: Pick<ApiTargetEnv, 'apiPath' | 'wsPath'>): ApiTargetEnv => ({
|
|
23
|
+
dev,
|
|
24
|
+
apiPort: process.env.API_PORT,
|
|
25
|
+
publicFeHost: process.env.PUBLIC_FE_HOST,
|
|
26
|
+
publicApiHost: process.env.PUBLIC_API_HOST,
|
|
27
|
+
...paths
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// `Boolean(process.env.X)` would treat the literal string "0" as true (it's a
|
|
31
|
+
// non-empty string). Parse as a Go-flavoured boolean instead so "0" / "false"
|
|
32
|
+
// / unset all register as off (Go strconv.ParseBool semantics).
|
|
33
|
+
export const envBool = (v: string | undefined): boolean => /^(1|t|true)$/i.test(v ?? '')
|
|
34
|
+
|
|
35
|
+
// imageBaseUrl is prepended to S3 object keys to form absolute image URLs. An
|
|
36
|
+
// explicitly defined S3_PREFIX wins outright — including when set to empty,
|
|
37
|
+
// which forces the base empty regardless of the AWS_S3_* vars. The check is
|
|
38
|
+
// for definedness (not truthiness) precisely so an empty S3_PREFIX overrides.
|
|
39
|
+
// When S3_PREFIX is unset, derive the base from the S3 endpoint + bucket.
|
|
40
|
+
export const deriveImageBaseUrl = (env: NodeJS.ProcessEnv = process.env): string =>
|
|
41
|
+
env.S3_PREFIX !== undefined ? env.S3_PREFIX : env.AWS_S3_ENDPOINT && env.AWS_S3_BUCKET ? `${env.AWS_S3_ENDPOINT}/${env.AWS_S3_BUCKET}` : ''
|
|
42
|
+
|
|
43
|
+
// FE build identifier for a `__BUILD_HASH__` define. If git fails, the
|
|
44
|
+
// build fails (no defensive fallbacks for environmental issues — fix the
|
|
45
|
+
// build environment).
|
|
46
|
+
export const buildHash = (): string => execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim()
|
|
47
|
+
|
|
48
|
+
// The single port convention: VITE_PORT, required, strict.
|
|
49
|
+
export const requirePort = (): number => {
|
|
50
|
+
if (!process.env.VITE_PORT) {
|
|
51
|
+
throw new Error('VITE_PORT environment variable must be set')
|
|
52
|
+
}
|
|
53
|
+
return parseInt(process.env.VITE_PORT, 10)
|
|
54
|
+
}
|
package/src/hmr.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { HotPayload, Plugin } from 'vite'
|
|
2
|
+
|
|
3
|
+
// An HMR update whose accepted module sits inside an import cycle cannot be
|
|
4
|
+
// re-executed reliably; Vite's client masks that by silently reloading the
|
|
5
|
+
// whole page ("failed to apply HMR as it's within a circular import"), which
|
|
6
|
+
// hides the cycle until it bites in subtler ways. This guard intercepts such
|
|
7
|
+
// updates server-side and replaces them with an error overlay naming the
|
|
8
|
+
// offending modules — the cycle must be broken, not papered over. The client's
|
|
9
|
+
// reload lives outside any public hook (it never emits vite:beforeFullReload),
|
|
10
|
+
// so the payload send is the one interception point.
|
|
11
|
+
export const hmrCircularImportGuard = (): Plugin => ({
|
|
12
|
+
name: 'greendrake:hmr-circular-import-guard',
|
|
13
|
+
apply: 'serve',
|
|
14
|
+
configureServer(server) {
|
|
15
|
+
const hot = server.environments.client.hot
|
|
16
|
+
const send = hot.send.bind(hot)
|
|
17
|
+
hot.send = (payloadOrEvent: HotPayload | string, data?: unknown): void => {
|
|
18
|
+
if (typeof payloadOrEvent === 'string') {
|
|
19
|
+
send(payloadOrEvent, data)
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
if (payloadOrEvent.type === 'update') {
|
|
23
|
+
const circular = payloadOrEvent.updates.filter(u => u.isWithinCircularImport)
|
|
24
|
+
if (circular.length) {
|
|
25
|
+
send({
|
|
26
|
+
type: 'error',
|
|
27
|
+
err: {
|
|
28
|
+
message:
|
|
29
|
+
`HMR blocked: the update to ${circular.map(u => u.acceptedPath).join(', ')} is within a circular import, ` +
|
|
30
|
+
`which cannot hot-apply (Vite would mask this with a full page reload). ` +
|
|
31
|
+
`Break the cycle — run \`vite --debug hmr\` to log its path.`,
|
|
32
|
+
stack: ''
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
send(payloadOrEvent)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
})
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { minify } from 'html-minifier-terser'
|
|
2
|
+
import type { Plugin } from 'vite'
|
|
3
|
+
|
|
4
|
+
// Substitutes build-time values into index.html before Vite processes it —
|
|
5
|
+
// e.g. a generated bootstrap snippet, a build hash, PWA metadata. Keys are
|
|
6
|
+
// matched literally (convention: __NAME__).
|
|
7
|
+
export const htmlPlaceholders = (map: Record<string, string>): Plugin => {
|
|
8
|
+
const keys = Object.keys(map)
|
|
9
|
+
if (!keys.length) {
|
|
10
|
+
throw new Error('htmlPlaceholders: empty placeholder map')
|
|
11
|
+
}
|
|
12
|
+
const pattern = new RegExp(keys.map(key => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g')
|
|
13
|
+
return {
|
|
14
|
+
name: 'greendrake-html-placeholders',
|
|
15
|
+
transformIndexHtml: {
|
|
16
|
+
order: 'pre',
|
|
17
|
+
handler: html => html.replace(pattern, match => map[match])
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Production-only HTML minification — strips comments and collapses
|
|
23
|
+
// whitespace in index.html, and runs inline scripts/styles through
|
|
24
|
+
// terser/CSS minification. Dev keeps the source readable.
|
|
25
|
+
export const htmlMinify = (): Plugin => ({
|
|
26
|
+
name: 'greendrake-html-minify',
|
|
27
|
+
apply: 'build',
|
|
28
|
+
transformIndexHtml: {
|
|
29
|
+
order: 'post',
|
|
30
|
+
handler: html =>
|
|
31
|
+
minify(html, {
|
|
32
|
+
collapseWhitespace: true,
|
|
33
|
+
removeComments: true,
|
|
34
|
+
minifyJS: true,
|
|
35
|
+
minifyCSS: true,
|
|
36
|
+
useShortDoctype: true,
|
|
37
|
+
removeRedundantAttributes: true
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
})
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import vue from '@vitejs/plugin-vue'
|
|
4
|
+
import { mergeConfig, type ConfigEnv, type UserConfig, type UserConfigFnObject } from 'vite'
|
|
5
|
+
|
|
6
|
+
export { envBool, deriveImageBaseUrl, loadAppEnv, requirePort, apiTargetEnv, buildHash } from './env'
|
|
7
|
+
// Re-exported so config factories building on vueSpa (e.g.
|
|
8
|
+
// @greendrake/dash/vite) speak this package's vite types: a second vite
|
|
9
|
+
// copy resolved downstream is a structurally unrelated set of the same names.
|
|
10
|
+
export type { ConfigEnv, UserConfig, UserConfigFnObject } from 'vite'
|
|
11
|
+
export { htmlPlaceholders, htmlMinify } from './html'
|
|
12
|
+
export { resolveApiTarget, browserRuntime, type ApiTarget, type ApiTargetEnv, type ApiTargetRuntime } from './apiTarget'
|
|
13
|
+
export { staticRoots, staticRootFiles, type StaticRoot } from './staticRoots'
|
|
14
|
+
import { requirePort } from './env'
|
|
15
|
+
import { htmlPlaceholders, htmlMinify } from './html'
|
|
16
|
+
import { hmrCircularImportGuard } from './hmr'
|
|
17
|
+
import { nestedDependencies, type DeclaredDependencies } from './sourceDeps'
|
|
18
|
+
import { staticRoots as staticRootsPlugin, type StaticRoot } from './staticRoots'
|
|
19
|
+
|
|
20
|
+
export interface VueSpaOptions {
|
|
21
|
+
// The app root (import.meta.dirname of the vite config) — anchors the @ alias.
|
|
22
|
+
dirname: string
|
|
23
|
+
// Compile-time globals; values are serialized automatically (strings become
|
|
24
|
+
// string literals, booleans become true/false, objects become object
|
|
25
|
+
// literals — e.g. the ApiTargetEnv carried by __API_TARGET_ENV__).
|
|
26
|
+
define?: Record<string, string | number | boolean | object | null | undefined>
|
|
27
|
+
// Extra scss @use resolution roots (e.g. the app's src/scss).
|
|
28
|
+
scssLoadPaths?: string[]
|
|
29
|
+
// 'env' (default): the strict VITE_PORT convention, required when the dev
|
|
30
|
+
// server starts. A number: that port verbatim, for callers that resolved
|
|
31
|
+
// the app's env themselves (see @greendrake/dash/vite). false: no
|
|
32
|
+
// port pinning — for simple apps served on any free port.
|
|
33
|
+
port?: 'env' | number | false
|
|
34
|
+
// Build-time placeholder substitution in index.html (see the
|
|
35
|
+
// htmlPlaceholders plugin) — e.g. injecting an inline bootstrap script.
|
|
36
|
+
htmlPlaceholders?: Record<string, string>
|
|
37
|
+
// Minify index.html (incl. inline JS/CSS) in production builds.
|
|
38
|
+
htmlMinify?: boolean
|
|
39
|
+
// Directories outside the app served at a public URL prefix — product
|
|
40
|
+
// assets both stacks own (locale catalogues, content prose). Served by
|
|
41
|
+
// middleware in dev and copied into dist/ at build, so the two modes
|
|
42
|
+
// publish the same bytes.
|
|
43
|
+
staticRoots?: StaticRoot[]
|
|
44
|
+
// Dependencies that ship TypeScript and SFC source rather than a build, and
|
|
45
|
+
// so must not be pre-bundled (see optimizeDeps below). An entry ending in
|
|
46
|
+
// `/` matches every package under that scope; anything else matches a
|
|
47
|
+
// package by exact name. Defaults to the @greendrake scope, which is
|
|
48
|
+
// source-shipped by construction — an app with source packages of its own
|
|
49
|
+
// names them here, and must name the @greendrake scope again if it uses
|
|
50
|
+
// any.
|
|
51
|
+
sourcePackages?: string[]
|
|
52
|
+
// Deep-merged last over the preset — the escape hatch for anything
|
|
53
|
+
// app-specific (server.proxy, extra plugins, build tweaks).
|
|
54
|
+
overrides?: UserConfig
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The libraries the app and every component package it renders must share one
|
|
58
|
+
// instance of.
|
|
59
|
+
const FRAMEWORK = ['vue', 'pinia', 'vue-router']
|
|
60
|
+
|
|
61
|
+
// Source-shipped by construction, so an app that names none of its own still
|
|
62
|
+
// gets the rule that matters for the packages this preset ships beside.
|
|
63
|
+
const SOURCE_PACKAGES = ['@greendrake/']
|
|
64
|
+
|
|
65
|
+
// The app's own package.json dependencies, split the way it splits them.
|
|
66
|
+
const declaredDependencies = (dirname: string): DeclaredDependencies => {
|
|
67
|
+
const pkg = JSON.parse(readFileSync(path.join(dirname, 'package.json'), 'utf-8')) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
68
|
+
return { runtime: Object.keys(pkg.dependencies ?? {}), dev: Object.keys(pkg.devDependencies ?? {}) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The Vite config a Vue 3 single-page app would otherwise assemble for itself:
|
|
72
|
+
// the vue plugin, the strict VITE_PORT pin, workspace fs.allow, the @ alias,
|
|
73
|
+
// the dedupe list (each entry must also be a direct dependency of the app, or
|
|
74
|
+
// Vite fails to resolve it), the pre-bundling rules a source-shipped package
|
|
75
|
+
// needs, and unhashed font asset names so index.html can prefetch them by a
|
|
76
|
+
// stable name.
|
|
77
|
+
//
|
|
78
|
+
// Returns the function form of a Vite config so the VITE_PORT requirement
|
|
79
|
+
// applies only to `vite dev`: a production build runs in deploy contexts that
|
|
80
|
+
// define no dev port.
|
|
81
|
+
export const vueSpa =
|
|
82
|
+
(options: VueSpaOptions): UserConfigFnObject =>
|
|
83
|
+
({ command }: ConfigEnv): UserConfig => {
|
|
84
|
+
const deps = declaredDependencies(options.dirname)
|
|
85
|
+
const allDeps = [...deps.runtime, ...deps.dev]
|
|
86
|
+
const sourcePackages = options.sourcePackages ?? SOURCE_PACKAGES
|
|
87
|
+
const isSourcePackage = (name: string): boolean => sourcePackages.some(pattern => (pattern.endsWith('/') ? name.startsWith(pattern) : name === pattern))
|
|
88
|
+
// Only the dev server and preview pin a port, so an app that supplies
|
|
89
|
+
// none (and never serves) must not be made to produce one.
|
|
90
|
+
const portPin = command === 'serve' && options.port !== false ? { port: typeof options.port === 'number' ? options.port : requirePort(), strictPort: true } : undefined
|
|
91
|
+
const base: UserConfig = {
|
|
92
|
+
define: options.define && Object.fromEntries(Object.entries(options.define).map(([key, value]) => [key, JSON.stringify(value)])),
|
|
93
|
+
plugins: [vue(), hmrCircularImportGuard(), ...(options.staticRoots?.length ? [staticRootsPlugin(options.staticRoots)] : []), ...(options.htmlPlaceholders ? [htmlPlaceholders(options.htmlPlaceholders)] : []), ...(options.htmlMinify ? [htmlMinify()] : [])],
|
|
94
|
+
server:
|
|
95
|
+
command === 'serve'
|
|
96
|
+
? {
|
|
97
|
+
...portPin,
|
|
98
|
+
// '..' reaches the workspace an app sits directly
|
|
99
|
+
// inside. Anything the dev server must read from
|
|
100
|
+
// further afield — a static root beside the backend,
|
|
101
|
+
// package source a level or two above an app nested
|
|
102
|
+
// deeper — needs its own entry (server.fs.allow in
|
|
103
|
+
// the app's overrides), or dev requests for it are
|
|
104
|
+
// refused.
|
|
105
|
+
fs: { allow: ['..', ...(options.staticRoots ?? []).map(root => root.dir)] }
|
|
106
|
+
}
|
|
107
|
+
: undefined,
|
|
108
|
+
// vite preview also loads with command 'serve'; pin it to the same
|
|
109
|
+
// port convention so E2E runs are deterministic.
|
|
110
|
+
preview: portPin,
|
|
111
|
+
resolve: {
|
|
112
|
+
alias: { '@': path.resolve(options.dirname, './src') },
|
|
113
|
+
dedupe: FRAMEWORK
|
|
114
|
+
},
|
|
115
|
+
// A source-shipped package (see sourcePackages) is TypeScript
|
|
116
|
+
// and SFC rather than a build. Pre-bundling one from node_modules
|
|
117
|
+
// splits it in two: esbuild inlines the
|
|
118
|
+
// TypeScript modules — and whatever they import — into the
|
|
119
|
+
// pre-bundle, but externalises the .vue files, which then load the
|
|
120
|
+
// originals and import their own copies of everything (two Pinia
|
|
121
|
+
// instances, one of which createPinia never registered). Served raw
|
|
122
|
+
// instead, a source package resolves to one copy of itself, and the
|
|
123
|
+
// framework libraries it shares with the app are pinned into the
|
|
124
|
+
// pre-bundle so raw and app-side imports meet on the same optimised
|
|
125
|
+
// module. Workspace-linked packages are never pre-bundled, so inside
|
|
126
|
+
// a monorepo the exclusion decides no pre-bundling either way — but
|
|
127
|
+
// it does hide the package from the dependency scanner, which is why
|
|
128
|
+
// the third-party packages it imports are named too (see
|
|
129
|
+
// nestedDependencies).
|
|
130
|
+
optimizeDeps: {
|
|
131
|
+
exclude: allDeps.filter(isSourcePackage),
|
|
132
|
+
include: [...new Set([...FRAMEWORK.filter(name => allDeps.includes(name)), ...nestedDependencies(options.dirname, deps, isSourcePackage)])]
|
|
133
|
+
},
|
|
134
|
+
css: options.scssLoadPaths
|
|
135
|
+
? {
|
|
136
|
+
preprocessorOptions: {
|
|
137
|
+
scss: { loadPaths: options.scssLoadPaths }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
: undefined,
|
|
141
|
+
build: {
|
|
142
|
+
rolldownOptions: {
|
|
143
|
+
output: {
|
|
144
|
+
assetFileNames: assetInfo => {
|
|
145
|
+
if (assetInfo.names?.[0]?.match(/\.(woff2?|eot|ttf|otf)$/)) {
|
|
146
|
+
return 'assets/fonts/[name][extname]'
|
|
147
|
+
}
|
|
148
|
+
return 'assets/[name]-[hash][extname]'
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return options.overrides ? mergeConfig(base, options.overrides) : base
|
|
155
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// The parts of a package.json this walk reads.
|
|
5
|
+
export interface Manifest {
|
|
6
|
+
dependencies?: Record<string, string>
|
|
7
|
+
peerDependencies?: Record<string, string>
|
|
8
|
+
exports?: string | Record<string, unknown>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// What an app declares, split the way the app itself splits it: what it ships
|
|
12
|
+
// to the browser, and what only its tooling reaches.
|
|
13
|
+
export interface DeclaredDependencies {
|
|
14
|
+
runtime: string[]
|
|
15
|
+
dev: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Where the installer put a package: the nearest node_modules at or above
|
|
19
|
+
// `from` holding it.
|
|
20
|
+
const packageDir = (from: string, name: string): string => {
|
|
21
|
+
for (let dir = from; ; dir = path.dirname(dir)) {
|
|
22
|
+
const candidate = path.join(dir, 'node_modules', name)
|
|
23
|
+
if (existsSync(path.join(candidate, 'package.json'))) return candidate
|
|
24
|
+
if (path.dirname(dir) === dir) throw new Error(`${name} is declared but not installed anywhere above ${from}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const manifest = (dir: string): Manifest => JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf-8')) as Manifest
|
|
29
|
+
|
|
30
|
+
// An `optimizeDeps` entry names a package, so a package published only under
|
|
31
|
+
// subpaths (it maps every entry it has, and none of them is `.`) cannot be one.
|
|
32
|
+
// Nor does it need to be: Vite meets such a package through the subpath ids that
|
|
33
|
+
// import it, and whatever pre-bundles those carries it along.
|
|
34
|
+
const hasRootExport = (pkg: Manifest): boolean => {
|
|
35
|
+
if (pkg.exports === undefined || typeof pkg.exports === 'string') return true
|
|
36
|
+
const keys = Object.keys(pkg.exports)
|
|
37
|
+
return keys.includes('.') || !keys.some(key => key.startsWith('.'))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The third-party packages a source package imports, as `optimizeDeps.include`
|
|
41
|
+
// entries.
|
|
42
|
+
//
|
|
43
|
+
// Vite's dependency scanner externalises every id in `optimizeDeps.exclude`, so
|
|
44
|
+
// it never reads a source package and never sees what that package imports. The
|
|
45
|
+
// first browser request for one of those imports is then a discovery: Vite
|
|
46
|
+
// re-runs the optimiser mid-session and full-reloads every open page. Under a
|
|
47
|
+
// parallel E2E run that reload lands in the middle of whatever tests are in
|
|
48
|
+
// flight, and they fail on a page that vanished under them. Naming the imports
|
|
49
|
+
// up front makes the first optimisation complete instead.
|
|
50
|
+
//
|
|
51
|
+
// The walk starts at the app's runtime dependencies and follows source packages
|
|
52
|
+
// only, since the scanner reads everything else itself.
|
|
53
|
+
export const nestedDependencies = (dirname: string, declared: DeclaredDependencies, isSourcePackage: (name: string) => boolean): string[] => {
|
|
54
|
+
const entries = new Set<string>()
|
|
55
|
+
const visited = new Set<string>()
|
|
56
|
+
const pin = (chain: string[], from: string, name: string): void => {
|
|
57
|
+
if (!hasRootExport(manifest(packageDir(from, name)))) return
|
|
58
|
+
// A package the app declares itself resolves from the app root, and its
|
|
59
|
+
// plain name is the id the scanner would produce for it; naming it
|
|
60
|
+
// nested as well would pre-bundle the same module twice, once per id.
|
|
61
|
+
// Anything else is named in Vite's `a > b > c` form, and names the whole
|
|
62
|
+
// chain from the dependency the app declares: Vite resolves each segment
|
|
63
|
+
// from the one before it, starting at the app root, and an intermediate
|
|
64
|
+
// package the app never declares need not be reachable from there.
|
|
65
|
+
entries.add(declared.runtime.includes(name) ? name : [...chain, name].join(' > '))
|
|
66
|
+
}
|
|
67
|
+
const walk = (name: string, chain: string[], from: string): void => {
|
|
68
|
+
// A source package the app keeps among its dev dependencies is tooling
|
|
69
|
+
// by the app's own reckoning — a vite integration a package re-exports
|
|
70
|
+
// beside its components, a native-build helper — and what such a package
|
|
71
|
+
// carries is unbundleable for a browser that never reaches it anyway.
|
|
72
|
+
if (visited.has(name) || declared.dev.includes(name)) return
|
|
73
|
+
visited.add(name)
|
|
74
|
+
const dir = packageDir(from, name)
|
|
75
|
+
const pkg = manifest(dir)
|
|
76
|
+
// A package the app declares resolves from the app root, so it anchors
|
|
77
|
+
// its own chain however it was reached. Without that, the chain a
|
|
78
|
+
// package gets would depend on which branch of the walk arrived first.
|
|
79
|
+
const here = declared.runtime.includes(name) ? [name] : [...chain, name]
|
|
80
|
+
for (const dep of Object.keys(pkg.dependencies ?? {})) {
|
|
81
|
+
if (isSourcePackage(dep)) walk(dep, here, dir)
|
|
82
|
+
else pin(here, dir, dep)
|
|
83
|
+
}
|
|
84
|
+
// A peer is the app's to supply, so it counts only where the app does,
|
|
85
|
+
// at runtime — the same reckoning, applied to what a package leaves to
|
|
86
|
+
// its consumer.
|
|
87
|
+
for (const peer of Object.keys(pkg.peerDependencies ?? {})) {
|
|
88
|
+
if (declared.runtime.includes(peer)) pin(here, dirname, peer)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const name of declared.runtime) {
|
|
92
|
+
if (isSourcePackage(name)) walk(name, [], dirname)
|
|
93
|
+
}
|
|
94
|
+
return [...entries]
|
|
95
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { Plugin } from 'vite'
|
|
4
|
+
|
|
5
|
+
// Serving files that live outside the app: an app's `public/` is its own, but
|
|
6
|
+
// some assets belong to the product rather than to the frontend — a locale
|
|
7
|
+
// catalogue the backend renders from too, the prose a content page shows. Those
|
|
8
|
+
// live in shared config, and this plugin puts them at their public URLs in both
|
|
9
|
+
// modes: middleware in dev, a copy into `dist/` at build. One declaration, so
|
|
10
|
+
// the two cannot drift into serving different bytes.
|
|
11
|
+
|
|
12
|
+
export interface StaticRoot {
|
|
13
|
+
// Absolute directory to serve.
|
|
14
|
+
dir: string
|
|
15
|
+
// URL prefix it appears under, e.g. '/catalogue'.
|
|
16
|
+
urlPath: string
|
|
17
|
+
// Rewrites a file's contents on the way out, in both modes. Used for the
|
|
18
|
+
// locale catalogues, which are served merged over the default locale.
|
|
19
|
+
transform?: (relativePath: string, contents: string) => string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const contentTypeFor = (file: string): string => {
|
|
23
|
+
if (file.endsWith('.yaml') || file.endsWith('.yml')) return 'text/yaml; charset=utf-8'
|
|
24
|
+
if (file.endsWith('.json')) return 'application/json; charset=utf-8'
|
|
25
|
+
return 'text/plain; charset=utf-8'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Every file under dir, as paths relative to it.
|
|
29
|
+
const walk = (dir: string): string[] =>
|
|
30
|
+
readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
31
|
+
.filter(entry => entry.isFile())
|
|
32
|
+
.map(entry => path.relative(dir, path.join(entry.parentPath, entry.name)))
|
|
33
|
+
|
|
34
|
+
// staticRootFiles lists what a root publishes, as scope-relative URL paths —
|
|
35
|
+
// the service worker's precache list needs to name these without duplicating
|
|
36
|
+
// the walk.
|
|
37
|
+
export const staticRootFiles = (root: StaticRoot): string[] =>
|
|
38
|
+
walk(root.dir).map(rel => `${root.urlPath}/${rel.split(path.sep).join('/')}`)
|
|
39
|
+
|
|
40
|
+
export const staticRoots = (roots: StaticRoot[]): Plugin => ({
|
|
41
|
+
name: 'greendrake-static-roots',
|
|
42
|
+
|
|
43
|
+
configureServer(server) {
|
|
44
|
+
server.middlewares.use((req, res, next) => {
|
|
45
|
+
const url = (req.url ?? '').split('?')[0]
|
|
46
|
+
const root = roots.find(r => url.startsWith(`${r.urlPath}/`))
|
|
47
|
+
if (!root) {
|
|
48
|
+
next()
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
const rel = url.slice(root.urlPath.length + 1)
|
|
52
|
+
// Nothing climbs out of the root: the URL is attacker-controlled in
|
|
53
|
+
// dev too, and the roots sit outside the workspace fs.allow list.
|
|
54
|
+
const file = path.resolve(root.dir, rel)
|
|
55
|
+
if (!file.startsWith(path.resolve(root.dir) + path.sep) || !existsSync(file)) {
|
|
56
|
+
next()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
const body = readFileSync(file, 'utf-8')
|
|
60
|
+
res.setHeader('Content-Type', contentTypeFor(file))
|
|
61
|
+
res.end(root.transform ? root.transform(rel, body) : body)
|
|
62
|
+
})
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// After the bundle, so nothing in the pipeline treats these as app assets.
|
|
66
|
+
closeBundle: {
|
|
67
|
+
sequential: true,
|
|
68
|
+
handler() {
|
|
69
|
+
for (const root of roots) {
|
|
70
|
+
const dest = path.resolve(this.environment.config.build.outDir, root.urlPath.replace(/^\//, ''))
|
|
71
|
+
mkdirSync(dest, { recursive: true })
|
|
72
|
+
if (!root.transform) {
|
|
73
|
+
cpSync(root.dir, dest, { recursive: true })
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
for (const rel of walk(root.dir)) {
|
|
77
|
+
const target = path.join(dest, rel)
|
|
78
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
79
|
+
writeFileSync(target, root.transform(rel, readFileSync(path.join(root.dir, rel), 'utf-8')))
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
})
|