@moostjs/vite 0.6.48 → 0.6.50
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/README.md +13 -2
- package/dist/index.cjs +117 -1
- package/dist/index.d.ts +90 -1
- package/dist/index.mjs +118 -2
- package/dist/prod-server.cjs +176 -35
- package/dist/prod-server.d.ts +55 -1
- package/dist/prod-server.mjs +176 -35
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -151,9 +151,18 @@ The three markers (`ssrOutlet`, `ssrState`, `ssrHead`) are plain string replacem
|
|
|
151
151
|
|
|
152
152
|
Omit `ssrEntry` and Vite serves the app as a standard SPA. The production build still generates a server that serves static files and API routes — it just skips server-side rendering.
|
|
153
153
|
|
|
154
|
+
### Compression and Caching
|
|
155
|
+
|
|
156
|
+
Since `0.6.49`, in middleware mode the production build and the generated server handle static compression and caching by default:
|
|
157
|
+
|
|
158
|
+
- **Precompression** (`precompress`, default on): after the client build, `.br` (brotli 11) and `.gz` (gzip 9) copies are written next to compressible files in `dist/client/` (js, css, html, svg, json, txt, xml, wasm — not source maps; files of at least 1 KiB, kept only when smaller). The server serves the copy the browser accepts, with `Content-Encoding` and `Vary: Accept-Encoding`. Tune with `{ brotli, gzip, threshold, include }` or disable with `false`.
|
|
159
|
+
- **Cache headers** (`cacheControl`, default on): hashed files under `build.assetsDir` (`/assets/…`) get `public, max-age=31536000, immutable`; other static files and HTML pages (SPA fallback, SSR) get `no-cache`, and static files revalidate through their `ETag` (`304`). A `Cache-Control` set by your middleware, the app or the SSR render is never overridden. Override per class with `{ assets, assetsDir, files, html }` or disable with `false`.
|
|
160
|
+
|
|
161
|
+
`createSSRServer({ precompressed, cacheControl })` overrides both at runtime. Dynamic responses are not covered: compress API responses in the app with `new MoostHttp({ compression: true })` and `@Compress()` (`@moostjs/event-http` 0.6.49+), and SSR HTML with a compression middleware in a custom server entry. Keep unhashed files out of `public/assets/`: they would be cached as immutable. Details: [Compression and Caching](https://moost.org/webapp/vite#compression-and-caching).
|
|
162
|
+
|
|
154
163
|
### Custom Server Entry
|
|
155
164
|
|
|
156
|
-
By default, `vite build` auto-generates a minimal production server. If you need custom middleware (compression, auth, logging), provide your own server file:
|
|
165
|
+
By default, `vite build` auto-generates a minimal production server. If you need custom middleware (compression of SSR responses, auth, logging), provide your own server file:
|
|
157
166
|
|
|
158
167
|
```ts
|
|
159
168
|
moostVite({
|
|
@@ -170,7 +179,7 @@ moostVite({
|
|
|
170
179
|
import { createSSRServer } from '@moostjs/vite/server'
|
|
171
180
|
|
|
172
181
|
const app = await createSSRServer()
|
|
173
|
-
// app.use(compression())
|
|
182
|
+
// app.use(compression()) // SSR HTML; static files are precompressed, API uses MoostHttp({ compression })
|
|
174
183
|
await app.listen()
|
|
175
184
|
```
|
|
176
185
|
|
|
@@ -270,6 +279,8 @@ The plugin injects a `__VITE_ID` decorator on `@Injectable` and `@Controller` cl
|
|
|
270
279
|
| `ssrHead` | `string` | `'<!--ssr-head-->'` | HTML placeholder for SSR-rendered `<head>` tags (place inside `<head>`) |
|
|
271
280
|
| `serverEntry` | `string` | — | Custom production server entry file (e.g. `'./server.ts'`) |
|
|
272
281
|
| `ssrExternal` | `string[]` | — | Packages to keep external in the SSR build (middleware mode, `vite build` only). Concatenated with `cfg.ssr.external`. See [SSR Bundle Size](#ssr-bundle-size). |
|
|
282
|
+
| `precompress` | `boolean \| object` | `true` | Since `0.6.49`. Middleware mode: write `.br`/`.gz` copies of compressible client files after `vite build`; the generated server negotiates them. See [Compression and Caching](#compression-and-caching). |
|
|
283
|
+
| `cacheControl` | `boolean \| object` | `true` | Since `0.6.49`. Middleware mode: `Cache-Control` policy of the generated server (`immutable` hashed assets, `no-cache` for other files and HTML). See [Compression and Caching](#compression-and-caching). |
|
|
273
284
|
| `ssrExternalCheck` | `boolean \| { packages?: (string \| RegExp)[] }` | `true` | Warn after the middleware-mode SSR build when an externalized package depends on a bundled shared-state package (`moost`, `@moostjs/*`, `wooks`, `@wooksjs/*`, `@prostojs/infact`/`mate`, `@atscript/*`), and at dev-server startup when one depends on a package the SSR runner inlines; `packages` watches more. See [SSR Bundle Size](#ssr-bundle-size). |
|
|
274
285
|
|
|
275
286
|
Options marked "backend mode only" are ignored when `middleware: true` — the user's `vite.config.ts` controls build/server configuration in middleware mode.
|
package/dist/index.cjs
CHANGED
|
@@ -33,6 +33,10 @@ let magic_string = require("magic-string");
|
|
|
33
33
|
magic_string = __toESM(magic_string);
|
|
34
34
|
let node_module = require("node:module");
|
|
35
35
|
let moost = require("moost");
|
|
36
|
+
let node_fs_promises = require("node:fs/promises");
|
|
37
|
+
let node_os = require("node:os");
|
|
38
|
+
let node_util = require("node:util");
|
|
39
|
+
let node_zlib = require("node:zlib");
|
|
36
40
|
|
|
37
41
|
//#region packages/vite/src/utils.ts
|
|
38
42
|
const PLUGIN_NAME = "moost-vite";
|
|
@@ -728,6 +732,91 @@ const toArray = (v) => Array.isArray(v) ? v : [v];
|
|
|
728
732
|
].join("\n");
|
|
729
733
|
}
|
|
730
734
|
|
|
735
|
+
//#endregion
|
|
736
|
+
//#region packages/vite/src/precompress.ts
|
|
737
|
+
const brotliAsync = (0, node_util.promisify)(node_zlib.brotliCompress);
|
|
738
|
+
const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
|
|
739
|
+
const DEFAULT_PRECOMPRESS_INCLUDE = /\.(?:m?js|cjs|css|html?|svg|json|webmanifest|txt|xml|wasm|ico|ttf|otf)$/i;
|
|
740
|
+
/** Collects every file under `dir`, skipping dot entries except `.well-known` (as sirv serves them). */ async function collectFiles(dir, out = []) {
|
|
741
|
+
for (const entry of await (0, node_fs_promises.readdir)(dir, { withFileTypes: true })) {
|
|
742
|
+
if (entry.name.startsWith(".") && entry.name !== ".well-known") continue;
|
|
743
|
+
const abs = (0, node_path.join)(dir, entry.name);
|
|
744
|
+
if (entry.isDirectory()) await collectFiles(abs, out);
|
|
745
|
+
else if (entry.isFile()) out.push(abs);
|
|
746
|
+
}
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Writes `.br` / `.gz` siblings for the compressible files under `dir` that are
|
|
751
|
+
* at least `threshold` bytes, keeping a variant only when it is smaller than the
|
|
752
|
+
* original. A sibling variant at least as new as its file (e.g. from another
|
|
753
|
+
* compression plugin) is left untouched; an older, stale one is replaced.
|
|
754
|
+
*/ async function precompressDir(dir, options = {}) {
|
|
755
|
+
const stats = {
|
|
756
|
+
files: 0,
|
|
757
|
+
brotli: 0,
|
|
758
|
+
gzip: 0
|
|
759
|
+
};
|
|
760
|
+
const useBrotli = options.brotli !== false;
|
|
761
|
+
const useGzip = options.gzip !== false;
|
|
762
|
+
if (!useBrotli && !useGzip || !(0, node_fs.existsSync)(dir)) return stats;
|
|
763
|
+
const threshold = options.threshold ?? 1024;
|
|
764
|
+
const include = options.include ?? DEFAULT_PRECOMPRESS_INCLUDE;
|
|
765
|
+
const queue = (await collectFiles(dir)).filter((abs) => {
|
|
766
|
+
const rel = (0, node_path.relative)(dir, abs).split(node_path.sep).join("/");
|
|
767
|
+
return !/\.(?:br|gz)$/.test(rel) && include.test(rel) && !options.exclude?.(rel);
|
|
768
|
+
});
|
|
769
|
+
const compressFile = async (abs) => {
|
|
770
|
+
const { size, mtimeMs } = await (0, node_fs_promises.stat)(abs);
|
|
771
|
+
if (size < threshold) return;
|
|
772
|
+
const data = await (0, node_fs_promises.readFile)(abs);
|
|
773
|
+
/**
|
|
774
|
+
* Writes one variant. A sibling at least as new as the source was written
|
|
775
|
+
* by another tool for this build and is kept; an older one is stale (an
|
|
776
|
+
* earlier build into a non-emptied outDir) and is replaced — or removed
|
|
777
|
+
* when the new variant would not be smaller.
|
|
778
|
+
*/ const variant = async (ext, encode) => {
|
|
779
|
+
const target = abs + ext;
|
|
780
|
+
const sibling = await (0, node_fs_promises.stat)(target).catch(() => void 0);
|
|
781
|
+
if (sibling && sibling.mtimeMs >= mtimeMs) return false;
|
|
782
|
+
const encoded = await encode();
|
|
783
|
+
if (encoded.length >= size) {
|
|
784
|
+
if (sibling) await (0, node_fs_promises.rm)(target);
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
await (0, node_fs_promises.writeFile)(target, encoded);
|
|
788
|
+
return true;
|
|
789
|
+
};
|
|
790
|
+
const [br, gz] = await Promise.all([useBrotli && variant(".br", () => brotliAsync(data, { params: {
|
|
791
|
+
[node_zlib.constants.BROTLI_PARAM_QUALITY]: node_zlib.constants.BROTLI_MAX_QUALITY,
|
|
792
|
+
[node_zlib.constants.BROTLI_PARAM_SIZE_HINT]: size
|
|
793
|
+
} })), useGzip && variant(".gz", () => gzipAsync(data, { level: 9 }))]);
|
|
794
|
+
stats.brotli += br ? 1 : 0;
|
|
795
|
+
stats.gzip += gz ? 1 : 0;
|
|
796
|
+
stats.files += br || gz ? 1 : 0;
|
|
797
|
+
};
|
|
798
|
+
let next = 0;
|
|
799
|
+
const workers = Array.from({ length: Math.min((0, node_os.availableParallelism)(), queue.length) }, async () => {
|
|
800
|
+
while (next < queue.length) await compressFile(queue[next++]);
|
|
801
|
+
});
|
|
802
|
+
await Promise.all(workers);
|
|
803
|
+
return stats;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
//#endregion
|
|
807
|
+
//#region packages/vite/src/prod-static.ts
|
|
808
|
+
/** Resolves which precompressed variants to serve (build-time value + runtime override). */ function resolvePrecompressed(baked, override) {
|
|
809
|
+
const opts = override ?? baked ?? true;
|
|
810
|
+
if (typeof opts === "boolean") return {
|
|
811
|
+
brotli: opts,
|
|
812
|
+
gzip: opts
|
|
813
|
+
};
|
|
814
|
+
return {
|
|
815
|
+
brotli: opts.brotli !== false,
|
|
816
|
+
gzip: opts.gzip !== false
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
731
820
|
//#endregion
|
|
732
821
|
//#region packages/vite/src/moost-vite.ts
|
|
733
822
|
/**
|
|
@@ -798,6 +887,17 @@ function moostVite(options) {
|
|
|
798
887
|
const ssrOutlet = options.ssrOutlet || DEFAULT_SSR_OUTLET;
|
|
799
888
|
const ssrState = options.ssrState || DEFAULT_SSR_STATE;
|
|
800
889
|
const ssrHead = options.ssrHead || DEFAULT_SSR_HEAD;
|
|
890
|
+
const precompress = options.precompress ?? true;
|
|
891
|
+
/** The variants the generated prod server negotiates (`__MOOST_PRECOMPRESS__`). */ const precompressedVariants = precompress === false ? false : resolvePrecompressed(void 0, precompress);
|
|
892
|
+
/** The cache policy baked into the prod server (`__MOOST_CACHE_CONTROL__`). */ const bakedCacheControl = (cfg) => {
|
|
893
|
+
const cacheControl = options.cacheControl ?? true;
|
|
894
|
+
if (cacheControl === false) return false;
|
|
895
|
+
const custom = cacheControl === true ? {} : cacheControl;
|
|
896
|
+
return {
|
|
897
|
+
...custom,
|
|
898
|
+
assetsDir: custom.assetsDir ?? cfg.environments?.client?.build?.assetsDir ?? cfg.build?.assetsDir ?? "assets"
|
|
899
|
+
};
|
|
900
|
+
};
|
|
801
901
|
const splitCheckPatterns = compilePackagePatterns(typeof options.ssrExternalCheck === "object" ? options.ssrExternalCheck.packages : void 0);
|
|
802
902
|
/**
|
|
803
903
|
* Boot-identity stamps (dev-only "mongrel state" diagnostic — a stale pipeline
|
|
@@ -950,7 +1050,9 @@ function moostVite(options) {
|
|
|
950
1050
|
__MOOST_SSR_ENTRY__: "undefined",
|
|
951
1051
|
__MOOST_SSR_OUTLET__: JSON.stringify(ssrOutlet),
|
|
952
1052
|
__MOOST_SSR_STATE__: JSON.stringify(ssrState),
|
|
953
|
-
__MOOST_SSR_HEAD__: JSON.stringify(ssrHead)
|
|
1053
|
+
__MOOST_SSR_HEAD__: JSON.stringify(ssrHead),
|
|
1054
|
+
__MOOST_PRECOMPRESS__: JSON.stringify(precompressedVariants),
|
|
1055
|
+
__MOOST_CACHE_CONTROL__: JSON.stringify(bakedCacheControl(cfg))
|
|
954
1056
|
};
|
|
955
1057
|
const ssrInput = {
|
|
956
1058
|
server: options.serverEntry || generatedServerEntry(cfg.root),
|
|
@@ -1033,6 +1135,20 @@ function moostVite(options) {
|
|
|
1033
1135
|
}
|
|
1034
1136
|
};
|
|
1035
1137
|
},
|
|
1138
|
+
buildApp: {
|
|
1139
|
+
order: "post",
|
|
1140
|
+
async handler(builder) {
|
|
1141
|
+
const client = builder.environments.client;
|
|
1142
|
+
if (!options.middleware || precompress === false || !client?.isBuilt) return;
|
|
1143
|
+
const { root, build } = client.config;
|
|
1144
|
+
if (build.write === false) return;
|
|
1145
|
+
const result = await precompressDir((0, node_path.resolve)(root, build.outDir), {
|
|
1146
|
+
...precompress === true ? {} : precompress,
|
|
1147
|
+
exclude: options.ssrEntry ? (rel) => rel === "index.html" : void 0
|
|
1148
|
+
});
|
|
1149
|
+
if (result.files > 0) client.logger.info(`[${PLUGIN_NAME}] precompressed ${result.files} client files (${result.brotli} .br, ${result.gzip} .gz)`);
|
|
1150
|
+
}
|
|
1151
|
+
},
|
|
1036
1152
|
configResolved(config) {
|
|
1037
1153
|
config.__moostViteOptions = {
|
|
1038
1154
|
entry: options.entry,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
import { PluginOption } from 'vite';
|
|
2
2
|
import { IncomingMessage } from 'node:http';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Build-time precompression of the client build (`precompress` plugin option).
|
|
6
|
+
* Writes `<file>.br` / `<file>.gz` siblings the production server negotiates.
|
|
7
|
+
*/
|
|
8
|
+
interface TPrecompressOptions {
|
|
9
|
+
/** Write brotli (`.br`, quality 11) siblings. Default: `true`. */
|
|
10
|
+
brotli?: boolean;
|
|
11
|
+
/** Write gzip (`.gz`, level 9) siblings. Default: `true`. */
|
|
12
|
+
gzip?: boolean;
|
|
13
|
+
/** Skip files smaller than this many bytes. Default: `1024`. */
|
|
14
|
+
threshold?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Which files to compress, tested against the path relative to the client
|
|
17
|
+
* build root. Default: text-like assets — js/mjs/cjs, css, html, svg, json,
|
|
18
|
+
* webmanifest, txt, xml, wasm, ico, ttf, otf. Images and already compressed
|
|
19
|
+
* formats (png, jpg, webp, woff/woff2, …) gain nothing; source maps are only
|
|
20
|
+
* fetched by devtools and would dominate the build time at brotli 11.
|
|
21
|
+
*/
|
|
22
|
+
include?: RegExp;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `Cache-Control` policy of the generated production server. Each value is a
|
|
27
|
+
* full `Cache-Control` header value; `false` sends no `Cache-Control` for that
|
|
28
|
+
* class of responses. A `Cache-Control` already set on the response (by a user
|
|
29
|
+
* middleware, the app or the SSR render's `headers`) is never overridden.
|
|
30
|
+
*/
|
|
31
|
+
interface TCacheControlOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Files under the hashed assets directory (`/<assetsDir>/…`). Vite puts a
|
|
34
|
+
* content hash in every file name there, so they never change.
|
|
35
|
+
* Default: `'public, max-age=31536000, immutable'`.
|
|
36
|
+
*/
|
|
37
|
+
assets?: string | false;
|
|
38
|
+
/**
|
|
39
|
+
* The hashed assets directory, relative to the client build root.
|
|
40
|
+
* Default: the client `build.assetsDir` (`'assets'`). An empty value (assets
|
|
41
|
+
* emitted at the build root) leaves no file in the `assets` class: hashed and
|
|
42
|
+
* unhashed files cannot be told apart there, so all of them get `files`.
|
|
43
|
+
*/
|
|
44
|
+
assetsDir?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Every other static file (files copied from `public/`, `favicon.ico`, …).
|
|
47
|
+
* Revalidated through the `ETag`. Default: `'no-cache'`.
|
|
48
|
+
*/
|
|
49
|
+
files?: string | false;
|
|
50
|
+
/** HTML pages: the SPA fallback and SSR-rendered pages. Default: `'no-cache'`. */
|
|
51
|
+
html?: string | false;
|
|
52
|
+
}
|
|
53
|
+
|
|
4
54
|
interface TMoostViteDevOptions {
|
|
5
55
|
/**
|
|
6
56
|
* The entry file for the application.
|
|
@@ -202,6 +252,45 @@ interface TMoostViteDevOptions {
|
|
|
202
252
|
* Default: `true`. Set `false` to silence both checks.
|
|
203
253
|
*/
|
|
204
254
|
ssrExternalCheck?: boolean | TSSRExternalCheckOptions;
|
|
255
|
+
/**
|
|
256
|
+
* Precompress the client build (middleware mode, `vite build`): after the
|
|
257
|
+
* client environment is built, write a brotli (`.br`, quality 11) and a gzip
|
|
258
|
+
* (`.gz`, level 9) sibling next to every compressible file (js, css, html,
|
|
259
|
+
* svg, json, txt, xml, wasm, … — not source maps, images or woff fonts) of at least
|
|
260
|
+
* 1 KiB, keeping a variant only when it is smaller than the original. Files
|
|
261
|
+
* copied from `public/` are included.
|
|
262
|
+
*
|
|
263
|
+
* The generated production server serves the variant the client accepts
|
|
264
|
+
* (`Accept-Encoding`) with `Content-Encoding` and `Vary: Accept-Encoding`.
|
|
265
|
+
* Costs some build time (brotli 11 is slow but runs once) and dist size.
|
|
266
|
+
*
|
|
267
|
+
* Runs as a `buildApp` hook after the app build, so it also applies when you
|
|
268
|
+
* provide your own `builder.buildApp`. Pass `{ brotli, gzip, threshold,
|
|
269
|
+
* include }` to tune it.
|
|
270
|
+
*
|
|
271
|
+
* Default: `true`. Set `false` to skip it (and to stop the generated server
|
|
272
|
+
* from looking for variants).
|
|
273
|
+
*/
|
|
274
|
+
precompress?: boolean | TPrecompressOptions;
|
|
275
|
+
/**
|
|
276
|
+
* `Cache-Control` policy baked into the generated production server
|
|
277
|
+
* (middleware mode):
|
|
278
|
+
* - hashed files under the client `build.assetsDir` (`/assets/…`):
|
|
279
|
+
* `public, max-age=31536000, immutable`;
|
|
280
|
+
* - every other static file (`public/` copies, `favicon.ico`, …): `no-cache`,
|
|
281
|
+
* revalidated through the `ETag` (`304 Not Modified`);
|
|
282
|
+
* - HTML (SPA fallback, SSR pages): `no-cache`.
|
|
283
|
+
*
|
|
284
|
+
* A `Cache-Control` set by a user middleware, the app or the SSR render's
|
|
285
|
+
* `headers` is never overridden. Keep unhashed files out of `public/assets/`
|
|
286
|
+
* — they would be cached as immutable. Override per class with
|
|
287
|
+
* `{ assets, files, html }` (a header value, or `false` for none) or move the
|
|
288
|
+
* hashed directory with `assetsDir`; `createSSRServer({ cacheControl })`
|
|
289
|
+
* overrides it at runtime.
|
|
290
|
+
*
|
|
291
|
+
* Default: `true`. Set `false` to send no `Cache-Control` from the server.
|
|
292
|
+
*/
|
|
293
|
+
cacheControl?: boolean | TCacheControlOptions;
|
|
205
294
|
}
|
|
206
295
|
/** Extra packages the SSR split check should watch on top of its defaults. */
|
|
207
296
|
interface TSSRExternalCheckOptions {
|
|
@@ -253,4 +342,4 @@ interface TSSRRenderContext {
|
|
|
253
342
|
type TSSRRender = (url: string, ctx?: TSSRRenderContext) => Promise<TSSRRenderResult> | TSSRRenderResult;
|
|
254
343
|
|
|
255
344
|
export { moostVite };
|
|
256
|
-
export type { TMoostViteDevOptions, TSSRRender, TSSRRenderContext, TSSRRenderResult };
|
|
345
|
+
export type { TCacheControlOptions, TMoostViteDevOptions, TPrecompressOptions, TSSRRender, TSSRRenderContext, TSSRRenderResult };
|
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { createServerModuleRunner } from "vite";
|
|
4
4
|
import MagicString from "magic-string";
|
|
5
5
|
import { builtinModules } from "node:module";
|
|
6
6
|
import { Moost, clearGlobalWooks, createLogger, disposeInstances, getMoostInfact, getMoostMate } from "moost";
|
|
7
|
+
import { readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
import { availableParallelism } from "node:os";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { brotliCompress, constants, gzip } from "node:zlib";
|
|
7
11
|
|
|
8
12
|
//#region packages/vite/src/utils.ts
|
|
9
13
|
const PLUGIN_NAME = "moost-vite";
|
|
@@ -699,6 +703,91 @@ const toArray = (v) => Array.isArray(v) ? v : [v];
|
|
|
699
703
|
].join("\n");
|
|
700
704
|
}
|
|
701
705
|
|
|
706
|
+
//#endregion
|
|
707
|
+
//#region packages/vite/src/precompress.ts
|
|
708
|
+
const brotliAsync = promisify(brotliCompress);
|
|
709
|
+
const gzipAsync = promisify(gzip);
|
|
710
|
+
const DEFAULT_PRECOMPRESS_INCLUDE = /\.(?:m?js|cjs|css|html?|svg|json|webmanifest|txt|xml|wasm|ico|ttf|otf)$/i;
|
|
711
|
+
/** Collects every file under `dir`, skipping dot entries except `.well-known` (as sirv serves them). */ async function collectFiles(dir, out = []) {
|
|
712
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
713
|
+
if (entry.name.startsWith(".") && entry.name !== ".well-known") continue;
|
|
714
|
+
const abs = join(dir, entry.name);
|
|
715
|
+
if (entry.isDirectory()) await collectFiles(abs, out);
|
|
716
|
+
else if (entry.isFile()) out.push(abs);
|
|
717
|
+
}
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Writes `.br` / `.gz` siblings for the compressible files under `dir` that are
|
|
722
|
+
* at least `threshold` bytes, keeping a variant only when it is smaller than the
|
|
723
|
+
* original. A sibling variant at least as new as its file (e.g. from another
|
|
724
|
+
* compression plugin) is left untouched; an older, stale one is replaced.
|
|
725
|
+
*/ async function precompressDir(dir, options = {}) {
|
|
726
|
+
const stats = {
|
|
727
|
+
files: 0,
|
|
728
|
+
brotli: 0,
|
|
729
|
+
gzip: 0
|
|
730
|
+
};
|
|
731
|
+
const useBrotli = options.brotli !== false;
|
|
732
|
+
const useGzip = options.gzip !== false;
|
|
733
|
+
if (!useBrotli && !useGzip || !existsSync(dir)) return stats;
|
|
734
|
+
const threshold = options.threshold ?? 1024;
|
|
735
|
+
const include = options.include ?? DEFAULT_PRECOMPRESS_INCLUDE;
|
|
736
|
+
const queue = (await collectFiles(dir)).filter((abs) => {
|
|
737
|
+
const rel = relative(dir, abs).split(sep).join("/");
|
|
738
|
+
return !/\.(?:br|gz)$/.test(rel) && include.test(rel) && !options.exclude?.(rel);
|
|
739
|
+
});
|
|
740
|
+
const compressFile = async (abs) => {
|
|
741
|
+
const { size, mtimeMs } = await stat(abs);
|
|
742
|
+
if (size < threshold) return;
|
|
743
|
+
const data = await readFile(abs);
|
|
744
|
+
/**
|
|
745
|
+
* Writes one variant. A sibling at least as new as the source was written
|
|
746
|
+
* by another tool for this build and is kept; an older one is stale (an
|
|
747
|
+
* earlier build into a non-emptied outDir) and is replaced — or removed
|
|
748
|
+
* when the new variant would not be smaller.
|
|
749
|
+
*/ const variant = async (ext, encode) => {
|
|
750
|
+
const target = abs + ext;
|
|
751
|
+
const sibling = await stat(target).catch(() => void 0);
|
|
752
|
+
if (sibling && sibling.mtimeMs >= mtimeMs) return false;
|
|
753
|
+
const encoded = await encode();
|
|
754
|
+
if (encoded.length >= size) {
|
|
755
|
+
if (sibling) await rm(target);
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
await writeFile(target, encoded);
|
|
759
|
+
return true;
|
|
760
|
+
};
|
|
761
|
+
const [br, gz] = await Promise.all([useBrotli && variant(".br", () => brotliAsync(data, { params: {
|
|
762
|
+
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
|
|
763
|
+
[constants.BROTLI_PARAM_SIZE_HINT]: size
|
|
764
|
+
} })), useGzip && variant(".gz", () => gzipAsync(data, { level: 9 }))]);
|
|
765
|
+
stats.brotli += br ? 1 : 0;
|
|
766
|
+
stats.gzip += gz ? 1 : 0;
|
|
767
|
+
stats.files += br || gz ? 1 : 0;
|
|
768
|
+
};
|
|
769
|
+
let next = 0;
|
|
770
|
+
const workers = Array.from({ length: Math.min(availableParallelism(), queue.length) }, async () => {
|
|
771
|
+
while (next < queue.length) await compressFile(queue[next++]);
|
|
772
|
+
});
|
|
773
|
+
await Promise.all(workers);
|
|
774
|
+
return stats;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region packages/vite/src/prod-static.ts
|
|
779
|
+
/** Resolves which precompressed variants to serve (build-time value + runtime override). */ function resolvePrecompressed(baked, override) {
|
|
780
|
+
const opts = override ?? baked ?? true;
|
|
781
|
+
if (typeof opts === "boolean") return {
|
|
782
|
+
brotli: opts,
|
|
783
|
+
gzip: opts
|
|
784
|
+
};
|
|
785
|
+
return {
|
|
786
|
+
brotli: opts.brotli !== false,
|
|
787
|
+
gzip: opts.gzip !== false
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
702
791
|
//#endregion
|
|
703
792
|
//#region packages/vite/src/moost-vite.ts
|
|
704
793
|
/**
|
|
@@ -767,6 +856,17 @@ function moostVite(options) {
|
|
|
767
856
|
const ssrOutlet = options.ssrOutlet || DEFAULT_SSR_OUTLET;
|
|
768
857
|
const ssrState = options.ssrState || DEFAULT_SSR_STATE;
|
|
769
858
|
const ssrHead = options.ssrHead || DEFAULT_SSR_HEAD;
|
|
859
|
+
const precompress = options.precompress ?? true;
|
|
860
|
+
/** The variants the generated prod server negotiates (`__MOOST_PRECOMPRESS__`). */ const precompressedVariants = precompress === false ? false : resolvePrecompressed(void 0, precompress);
|
|
861
|
+
/** The cache policy baked into the prod server (`__MOOST_CACHE_CONTROL__`). */ const bakedCacheControl = (cfg) => {
|
|
862
|
+
const cacheControl = options.cacheControl ?? true;
|
|
863
|
+
if (cacheControl === false) return false;
|
|
864
|
+
const custom = cacheControl === true ? {} : cacheControl;
|
|
865
|
+
return {
|
|
866
|
+
...custom,
|
|
867
|
+
assetsDir: custom.assetsDir ?? cfg.environments?.client?.build?.assetsDir ?? cfg.build?.assetsDir ?? "assets"
|
|
868
|
+
};
|
|
869
|
+
};
|
|
770
870
|
const splitCheckPatterns = compilePackagePatterns(typeof options.ssrExternalCheck === "object" ? options.ssrExternalCheck.packages : void 0);
|
|
771
871
|
/**
|
|
772
872
|
* Boot-identity stamps (dev-only "mongrel state" diagnostic — a stale pipeline
|
|
@@ -919,7 +1019,9 @@ function moostVite(options) {
|
|
|
919
1019
|
__MOOST_SSR_ENTRY__: "undefined",
|
|
920
1020
|
__MOOST_SSR_OUTLET__: JSON.stringify(ssrOutlet),
|
|
921
1021
|
__MOOST_SSR_STATE__: JSON.stringify(ssrState),
|
|
922
|
-
__MOOST_SSR_HEAD__: JSON.stringify(ssrHead)
|
|
1022
|
+
__MOOST_SSR_HEAD__: JSON.stringify(ssrHead),
|
|
1023
|
+
__MOOST_PRECOMPRESS__: JSON.stringify(precompressedVariants),
|
|
1024
|
+
__MOOST_CACHE_CONTROL__: JSON.stringify(bakedCacheControl(cfg))
|
|
923
1025
|
};
|
|
924
1026
|
const ssrInput = {
|
|
925
1027
|
server: options.serverEntry || generatedServerEntry(cfg.root),
|
|
@@ -1002,6 +1104,20 @@ function moostVite(options) {
|
|
|
1002
1104
|
}
|
|
1003
1105
|
};
|
|
1004
1106
|
},
|
|
1107
|
+
buildApp: {
|
|
1108
|
+
order: "post",
|
|
1109
|
+
async handler(builder) {
|
|
1110
|
+
const client = builder.environments.client;
|
|
1111
|
+
if (!options.middleware || precompress === false || !client?.isBuilt) return;
|
|
1112
|
+
const { root, build } = client.config;
|
|
1113
|
+
if (build.write === false) return;
|
|
1114
|
+
const result = await precompressDir(resolve(root, build.outDir), {
|
|
1115
|
+
...precompress === true ? {} : precompress,
|
|
1116
|
+
exclude: options.ssrEntry ? (rel) => rel === "index.html" : void 0
|
|
1117
|
+
});
|
|
1118
|
+
if (result.files > 0) client.logger.info(`[${PLUGIN_NAME}] precompressed ${result.files} client files (${result.brotli} .br, ${result.gzip} .gz)`);
|
|
1119
|
+
}
|
|
1120
|
+
},
|
|
1005
1121
|
configResolved(config) {
|
|
1006
1122
|
config.__moostViteOptions = {
|
|
1007
1123
|
entry: options.entry,
|
package/dist/prod-server.cjs
CHANGED
|
@@ -1,7 +1,152 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
2
|
let node_events = require("node:events");
|
|
3
|
+
let node_fs_promises = require("node:fs/promises");
|
|
4
|
+
let node_path = require("node:path");
|
|
3
5
|
let moost = require("moost");
|
|
4
6
|
|
|
7
|
+
//#region packages/vite/src/prod-static.ts
|
|
8
|
+
const DEFAULT_ASSETS_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
9
|
+
const DEFAULT_REVALIDATE_CACHE_CONTROL = "no-cache";
|
|
10
|
+
/**
|
|
11
|
+
* Resolves the cache policy from the build-time value and a runtime override:
|
|
12
|
+
* an override object is merged over the baked object (or the defaults when the
|
|
13
|
+
* build disabled it), `true` keeps the baked policy, `false` disables it.
|
|
14
|
+
*/ function resolveCacheControl(baked, override) {
|
|
15
|
+
if (override === false || override === void 0 && baked === false) return null;
|
|
16
|
+
const opts = {
|
|
17
|
+
...baked || void 0,
|
|
18
|
+
...typeof override === "object" ? override : void 0
|
|
19
|
+
};
|
|
20
|
+
const assetsDir = (opts.assetsDir ?? "assets").replace(/^\/+|\/+$/g, "");
|
|
21
|
+
return {
|
|
22
|
+
assets: opts.assets ?? DEFAULT_ASSETS_CACHE_CONTROL,
|
|
23
|
+
assetsPrefix: assetsDir ? `/${assetsDir}/` : null,
|
|
24
|
+
files: opts.files ?? DEFAULT_REVALIDATE_CACHE_CONTROL,
|
|
25
|
+
html: opts.html ?? DEFAULT_REVALIDATE_CACHE_CONTROL
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Resolves which precompressed variants to serve (build-time value + runtime override). */ function resolvePrecompressed(baked, override) {
|
|
29
|
+
const opts = override ?? baked ?? true;
|
|
30
|
+
if (typeof opts === "boolean") return {
|
|
31
|
+
brotli: opts,
|
|
32
|
+
gzip: opts
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
brotli: opts.brotli !== false,
|
|
36
|
+
gzip: opts.gzip !== false
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The decoded pathname of a request url, the way sirv looks files up. */ function requestPathname(url) {
|
|
40
|
+
const end = url.search(/[?#]/);
|
|
41
|
+
const pathname = end === -1 ? url : url.slice(0, end);
|
|
42
|
+
if (!pathname.includes("%")) return pathname;
|
|
43
|
+
try {
|
|
44
|
+
return decodeURI(pathname);
|
|
45
|
+
} catch {
|
|
46
|
+
return pathname;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The root `index.html` (and precompressed siblings) is the SPA/SSR template:
|
|
51
|
+
* it is never served as a static file, but by the SPA/SSR fallback (in SSR mode
|
|
52
|
+
* a raw template would leak its unfilled `<!--ssr-outlet-->` placeholders).
|
|
53
|
+
* sirv drops one trailing slash before its lookup, so `/index.html/` matches too.
|
|
54
|
+
*/ function isIndexTemplatePath(pathname) {
|
|
55
|
+
return /^\/index\.html(?:\.br|\.gz)?\/?$/.test(pathname);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Sets a header unless the response already carries it. Returns a restore
|
|
59
|
+
* callback that removes it again only if it still holds the value set here.
|
|
60
|
+
*/ function presetHeader(res, name, value) {
|
|
61
|
+
if (res.hasHeader(name)) return () => {};
|
|
62
|
+
res.setHeader(name, value);
|
|
63
|
+
return () => {
|
|
64
|
+
if (res.getHeader(name) === value) res.removeHeader(name);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Adds `Accept-Encoding` to the response's `Vary`, keeping the tokens already
|
|
69
|
+
* there. Returns a restore callback that puts the previous value back.
|
|
70
|
+
*/ function varyAcceptEncoding(res) {
|
|
71
|
+
const prev = res.getHeader("Vary");
|
|
72
|
+
if (prev !== void 0 && /(?:^|,)\s*(?:\*|accept-encoding)\s*(?:,|$)/i.test(String(prev))) return () => {};
|
|
73
|
+
const next = prev === void 0 ? "Accept-Encoding" : `${String(prev)}, Accept-Encoding`;
|
|
74
|
+
res.setHeader("Vary", next);
|
|
75
|
+
return () => {
|
|
76
|
+
if (res.getHeader("Vary") === next) if (prev === void 0) res.removeHeader("Vary");
|
|
77
|
+
else res.setHeader("Vary", prev);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Reads the precompressed siblings of the SPA shell (`index.html.br` / `.gz`), when enabled. */ async function loadSpaShell(clientDir, html, variants) {
|
|
81
|
+
const read = (enabled, ext) => enabled ? (0, node_fs_promises.readFile)((0, node_path.join)(clientDir, `index.html${ext}`)).catch(() => void 0) : void 0;
|
|
82
|
+
const [br, gzip] = await Promise.all([read(variants.brotli, ".br"), read(variants.gzip, ".gz")]);
|
|
83
|
+
return {
|
|
84
|
+
html,
|
|
85
|
+
br,
|
|
86
|
+
gzip
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Sends the SPA shell for client-side routing, precompressed when the client accepts it. */ function sendSpaShell(req, res, shell, cache) {
|
|
90
|
+
res.statusCode = 200;
|
|
91
|
+
res.setHeader("Content-Type", "text/html");
|
|
92
|
+
if (cache?.html) presetHeader(res, "Cache-Control", cache.html);
|
|
93
|
+
let body = shell.html;
|
|
94
|
+
if (shell.br || shell.gzip) {
|
|
95
|
+
varyAcceptEncoding(res);
|
|
96
|
+
const accept = req.headers["accept-encoding"] || "";
|
|
97
|
+
const variant = shell.br && /\b(?:br|brotli)\b/i.test(accept) ? {
|
|
98
|
+
encoding: "br",
|
|
99
|
+
data: shell.br
|
|
100
|
+
} : shell.gzip && /\bgzip\b/i.test(accept) ? {
|
|
101
|
+
encoding: "gzip",
|
|
102
|
+
data: shell.gzip
|
|
103
|
+
} : void 0;
|
|
104
|
+
if (variant) {
|
|
105
|
+
res.setHeader("Content-Encoding", variant.encoding);
|
|
106
|
+
body = variant.data;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
res.end(body);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Static file serving (sirv) with the cache policy and the precompressed
|
|
113
|
+
* variants; requests that match no file — and the root `index.html` template —
|
|
114
|
+
* go to `servePage` (SSR render or SPA shell).
|
|
115
|
+
*/ async function createStaticHandler(clientDir, variants, cache, servePage) {
|
|
116
|
+
const { default: sirv } = await import("sirv");
|
|
117
|
+
const negotiate = variants.brotli || variants.gzip;
|
|
118
|
+
/** The `Vary` each response should carry: sirv overwrites it with a bare `Accept-Encoding`. */ const varyOf = /* @__PURE__ */ new WeakMap();
|
|
119
|
+
const serve = sirv(clientDir, {
|
|
120
|
+
extensions: [],
|
|
121
|
+
etag: true,
|
|
122
|
+
brotli: variants.brotli,
|
|
123
|
+
gzip: variants.gzip,
|
|
124
|
+
setHeaders: (res) => {
|
|
125
|
+
const vary = varyOf.get(res);
|
|
126
|
+
if (vary) res.setHeader("Vary", vary);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
return (req, res) => {
|
|
130
|
+
const pathname = requestPathname(req.url || "/");
|
|
131
|
+
if (isIndexTemplatePath(pathname)) {
|
|
132
|
+
servePage(req, res);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const restore = [];
|
|
136
|
+
const cacheControl = cache && (cache.assetsPrefix && pathname.startsWith(cache.assetsPrefix) ? cache.assets : cache.files);
|
|
137
|
+
if (cacheControl) restore.push(presetHeader(res, "Cache-Control", cacheControl));
|
|
138
|
+
if (negotiate) {
|
|
139
|
+
restore.push(varyAcceptEncoding(res));
|
|
140
|
+
varyOf.set(res, String(res.getHeader("Vary")));
|
|
141
|
+
}
|
|
142
|
+
serve(req, res, () => {
|
|
143
|
+
for (const undo of restore) undo();
|
|
144
|
+
servePage(req, res);
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
//#endregion
|
|
5
150
|
//#region packages/vite/src/utils.ts
|
|
6
151
|
const PLUGIN_NAME = "moost-vite";
|
|
7
152
|
const DEFAULT_SSR_OUTLET = "<!--ssr-outlet-->";
|
|
@@ -160,6 +305,8 @@ async function createSSRServer(options) {
|
|
|
160
305
|
const ssrHead = opts.ssrHead || (__MOOST_SSR_HEAD__ !== void 0 ? __MOOST_SSR_HEAD__ : DEFAULT_SSR_HEAD);
|
|
161
306
|
const prefixes = normalizePrefixes(opts.prefix ?? __MOOST_PREFIX__);
|
|
162
307
|
const ssrForwarding = opts.ssrFetchForwarding ?? __MOOST_SSR_FORWARDING__ ?? true;
|
|
308
|
+
const variants = resolvePrecompressed(__MOOST_PRECOMPRESS__, opts.precompressed);
|
|
309
|
+
const cache = resolveCacheControl(__MOOST_CACHE_CONTROL__, opts.cacheControl);
|
|
163
310
|
const defaultPort = opts.port || Number(process.env.PORT) || 3e3;
|
|
164
311
|
const defaultHost = opts.host ?? process.env.HOST;
|
|
165
312
|
const template = await fs.readFile(path.resolve(clientDir, "index.html"), "utf8");
|
|
@@ -197,42 +344,36 @@ async function createSSRServer(options) {
|
|
|
197
344
|
}
|
|
198
345
|
MoostHttp.prototype.listen = origListen;
|
|
199
346
|
if (enableLocalFetch && moostHttpRef.instance) enableLocalFetch(moostHttpRef.instance);
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
} catch (error) {
|
|
230
|
-
console.error(error);
|
|
231
|
-
res.statusCode = 500;
|
|
232
|
-
res.end(error.message);
|
|
233
|
-
}
|
|
234
|
-
});
|
|
347
|
+
const spaShell = render ? { html: template } : await loadSpaShell(clientDir, template, variants);
|
|
348
|
+
/** HTML page fallback: SSR render or the SPA shell. */ const servePage = async (req, res) => {
|
|
349
|
+
if (req.method !== "GET") {
|
|
350
|
+
res.statusCode = 404;
|
|
351
|
+
res.end();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
if (render) {
|
|
356
|
+
const result = await renderSSRPage({
|
|
357
|
+
render,
|
|
358
|
+
url: req.url || "/",
|
|
359
|
+
req,
|
|
360
|
+
res,
|
|
361
|
+
http: ssrForwarding ? moostHttpRef.instance : null
|
|
362
|
+
});
|
|
363
|
+
if (cache?.html) presetHeader(res, "Cache-Control", cache.html);
|
|
364
|
+
sendSSRResponse(res, template, {
|
|
365
|
+
ssrOutlet,
|
|
366
|
+
ssrState,
|
|
367
|
+
ssrHead
|
|
368
|
+
}, result);
|
|
369
|
+
} else sendSpaShell(req, res, spaShell, cache);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.error(error);
|
|
372
|
+
res.statusCode = 500;
|
|
373
|
+
res.end(error.message);
|
|
374
|
+
}
|
|
235
375
|
};
|
|
376
|
+
const serveStatic = await createStaticHandler(path.resolve(clientDir), variants, cache, (req, res) => void servePage(req, res));
|
|
236
377
|
return {
|
|
237
378
|
use(mw) {
|
|
238
379
|
userMiddlewares.push(mw);
|
package/dist/prod-server.d.ts
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { Server } from 'node:net';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* `Cache-Control` policy of the generated production server. Each value is a
|
|
6
|
+
* full `Cache-Control` header value; `false` sends no `Cache-Control` for that
|
|
7
|
+
* class of responses. A `Cache-Control` already set on the response (by a user
|
|
8
|
+
* middleware, the app or the SSR render's `headers`) is never overridden.
|
|
9
|
+
*/
|
|
10
|
+
interface TCacheControlOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Files under the hashed assets directory (`/<assetsDir>/…`). Vite puts a
|
|
13
|
+
* content hash in every file name there, so they never change.
|
|
14
|
+
* Default: `'public, max-age=31536000, immutable'`.
|
|
15
|
+
*/
|
|
16
|
+
assets?: string | false;
|
|
17
|
+
/**
|
|
18
|
+
* The hashed assets directory, relative to the client build root.
|
|
19
|
+
* Default: the client `build.assetsDir` (`'assets'`). An empty value (assets
|
|
20
|
+
* emitted at the build root) leaves no file in the `assets` class: hashed and
|
|
21
|
+
* unhashed files cannot be told apart there, so all of them get `files`.
|
|
22
|
+
*/
|
|
23
|
+
assetsDir?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Every other static file (files copied from `public/`, `favicon.ico`, …).
|
|
26
|
+
* Revalidated through the `ETag`. Default: `'no-cache'`.
|
|
27
|
+
*/
|
|
28
|
+
files?: string | false;
|
|
29
|
+
/** HTML pages: the SPA fallback and SSR-rendered pages. Default: `'no-cache'`. */
|
|
30
|
+
html?: string | false;
|
|
31
|
+
}
|
|
32
|
+
/** Precompressed sibling variants the production server negotiates. */
|
|
33
|
+
interface TPrecompressedOptions {
|
|
34
|
+
/** Serve `<file>.br` to clients that accept brotli. Default: `true`. */
|
|
35
|
+
brotli?: boolean;
|
|
36
|
+
/** Serve `<file>.gz` to clients that accept gzip. Default: `true`. */
|
|
37
|
+
gzip?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
4
40
|
/**
|
|
5
41
|
* The value an SSR `render(url)` function may return. Only `html` is required;
|
|
6
42
|
* every other field is optional, so a render returning `{ html }` (or
|
|
@@ -74,6 +110,24 @@ interface TSSRServerOptions {
|
|
|
74
110
|
* Set to `false` to keep SSR self-calls anonymous. Default: `true`.
|
|
75
111
|
*/
|
|
76
112
|
ssrFetchForwarding?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Production only. Serve the precompressed `<file>.br` / `<file>.gz` siblings
|
|
115
|
+
* written by the plugin's `precompress` build step to clients that accept
|
|
116
|
+
* them (`Content-Encoding` + `Vary: Accept-Encoding`). Applies to static
|
|
117
|
+
* files and the SPA fallback page.
|
|
118
|
+
* Default: baked from the plugin's `precompress` option (on).
|
|
119
|
+
*/
|
|
120
|
+
precompressed?: boolean | TPrecompressedOptions;
|
|
121
|
+
/**
|
|
122
|
+
* Production only. `Cache-Control` policy: hashed files under the assets
|
|
123
|
+
* directory are `immutable` for a year, other static files and HTML pages
|
|
124
|
+
* are `no-cache` (revalidated through the `ETag`). A `Cache-Control` already
|
|
125
|
+
* set by a middleware, the app or the SSR render is never overridden.
|
|
126
|
+
* An object is merged over the plugin's `cacheControl` option; `false` sends
|
|
127
|
+
* no `Cache-Control` from the server.
|
|
128
|
+
* Default: baked from the plugin's `cacheControl` option (on).
|
|
129
|
+
*/
|
|
130
|
+
cacheControl?: boolean | TCacheControlOptions;
|
|
77
131
|
}
|
|
78
132
|
interface TSSRServer {
|
|
79
133
|
/** Add Connect-compatible middleware (runs in both dev and prod) */
|
|
@@ -89,4 +143,4 @@ interface TSSRServer {
|
|
|
89
143
|
declare function createSSRServer(options?: TSSRServerOptions): Promise<TSSRServer>;
|
|
90
144
|
|
|
91
145
|
export { createSSRServer };
|
|
92
|
-
export type { TSSRRender, TSSRRenderContext, TSSRRenderResult, TSSRServer, TSSRServerOptions };
|
|
146
|
+
export type { TCacheControlOptions, TPrecompressedOptions, TSSRRender, TSSRRenderContext, TSSRRenderResult, TSSRServer, TSSRServerOptions };
|
package/dist/prod-server.mjs
CHANGED
|
@@ -1,8 +1,153 @@
|
|
|
1
1
|
import { once } from "node:events";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import "node:fs";
|
|
3
5
|
import "node:module";
|
|
4
6
|
import { createLogger } from "moost";
|
|
5
7
|
|
|
8
|
+
//#region packages/vite/src/prod-static.ts
|
|
9
|
+
const DEFAULT_ASSETS_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
10
|
+
const DEFAULT_REVALIDATE_CACHE_CONTROL = "no-cache";
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the cache policy from the build-time value and a runtime override:
|
|
13
|
+
* an override object is merged over the baked object (or the defaults when the
|
|
14
|
+
* build disabled it), `true` keeps the baked policy, `false` disables it.
|
|
15
|
+
*/ function resolveCacheControl(baked, override) {
|
|
16
|
+
if (override === false || override === void 0 && baked === false) return null;
|
|
17
|
+
const opts = {
|
|
18
|
+
...baked || void 0,
|
|
19
|
+
...typeof override === "object" ? override : void 0
|
|
20
|
+
};
|
|
21
|
+
const assetsDir = (opts.assetsDir ?? "assets").replace(/^\/+|\/+$/g, "");
|
|
22
|
+
return {
|
|
23
|
+
assets: opts.assets ?? DEFAULT_ASSETS_CACHE_CONTROL,
|
|
24
|
+
assetsPrefix: assetsDir ? `/${assetsDir}/` : null,
|
|
25
|
+
files: opts.files ?? DEFAULT_REVALIDATE_CACHE_CONTROL,
|
|
26
|
+
html: opts.html ?? DEFAULT_REVALIDATE_CACHE_CONTROL
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Resolves which precompressed variants to serve (build-time value + runtime override). */ function resolvePrecompressed(baked, override) {
|
|
30
|
+
const opts = override ?? baked ?? true;
|
|
31
|
+
if (typeof opts === "boolean") return {
|
|
32
|
+
brotli: opts,
|
|
33
|
+
gzip: opts
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
brotli: opts.brotli !== false,
|
|
37
|
+
gzip: opts.gzip !== false
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** The decoded pathname of a request url, the way sirv looks files up. */ function requestPathname(url) {
|
|
41
|
+
const end = url.search(/[?#]/);
|
|
42
|
+
const pathname = end === -1 ? url : url.slice(0, end);
|
|
43
|
+
if (!pathname.includes("%")) return pathname;
|
|
44
|
+
try {
|
|
45
|
+
return decodeURI(pathname);
|
|
46
|
+
} catch {
|
|
47
|
+
return pathname;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The root `index.html` (and precompressed siblings) is the SPA/SSR template:
|
|
52
|
+
* it is never served as a static file, but by the SPA/SSR fallback (in SSR mode
|
|
53
|
+
* a raw template would leak its unfilled `<!--ssr-outlet-->` placeholders).
|
|
54
|
+
* sirv drops one trailing slash before its lookup, so `/index.html/` matches too.
|
|
55
|
+
*/ function isIndexTemplatePath(pathname) {
|
|
56
|
+
return /^\/index\.html(?:\.br|\.gz)?\/?$/.test(pathname);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Sets a header unless the response already carries it. Returns a restore
|
|
60
|
+
* callback that removes it again only if it still holds the value set here.
|
|
61
|
+
*/ function presetHeader(res, name, value) {
|
|
62
|
+
if (res.hasHeader(name)) return () => {};
|
|
63
|
+
res.setHeader(name, value);
|
|
64
|
+
return () => {
|
|
65
|
+
if (res.getHeader(name) === value) res.removeHeader(name);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Adds `Accept-Encoding` to the response's `Vary`, keeping the tokens already
|
|
70
|
+
* there. Returns a restore callback that puts the previous value back.
|
|
71
|
+
*/ function varyAcceptEncoding(res) {
|
|
72
|
+
const prev = res.getHeader("Vary");
|
|
73
|
+
if (prev !== void 0 && /(?:^|,)\s*(?:\*|accept-encoding)\s*(?:,|$)/i.test(String(prev))) return () => {};
|
|
74
|
+
const next = prev === void 0 ? "Accept-Encoding" : `${String(prev)}, Accept-Encoding`;
|
|
75
|
+
res.setHeader("Vary", next);
|
|
76
|
+
return () => {
|
|
77
|
+
if (res.getHeader("Vary") === next) if (prev === void 0) res.removeHeader("Vary");
|
|
78
|
+
else res.setHeader("Vary", prev);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Reads the precompressed siblings of the SPA shell (`index.html.br` / `.gz`), when enabled. */ async function loadSpaShell(clientDir, html, variants) {
|
|
82
|
+
const read = (enabled, ext) => enabled ? readFile(join(clientDir, `index.html${ext}`)).catch(() => void 0) : void 0;
|
|
83
|
+
const [br, gzip] = await Promise.all([read(variants.brotli, ".br"), read(variants.gzip, ".gz")]);
|
|
84
|
+
return {
|
|
85
|
+
html,
|
|
86
|
+
br,
|
|
87
|
+
gzip
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Sends the SPA shell for client-side routing, precompressed when the client accepts it. */ function sendSpaShell(req, res, shell, cache) {
|
|
91
|
+
res.statusCode = 200;
|
|
92
|
+
res.setHeader("Content-Type", "text/html");
|
|
93
|
+
if (cache?.html) presetHeader(res, "Cache-Control", cache.html);
|
|
94
|
+
let body = shell.html;
|
|
95
|
+
if (shell.br || shell.gzip) {
|
|
96
|
+
varyAcceptEncoding(res);
|
|
97
|
+
const accept = req.headers["accept-encoding"] || "";
|
|
98
|
+
const variant = shell.br && /\b(?:br|brotli)\b/i.test(accept) ? {
|
|
99
|
+
encoding: "br",
|
|
100
|
+
data: shell.br
|
|
101
|
+
} : shell.gzip && /\bgzip\b/i.test(accept) ? {
|
|
102
|
+
encoding: "gzip",
|
|
103
|
+
data: shell.gzip
|
|
104
|
+
} : void 0;
|
|
105
|
+
if (variant) {
|
|
106
|
+
res.setHeader("Content-Encoding", variant.encoding);
|
|
107
|
+
body = variant.data;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
res.end(body);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Static file serving (sirv) with the cache policy and the precompressed
|
|
114
|
+
* variants; requests that match no file — and the root `index.html` template —
|
|
115
|
+
* go to `servePage` (SSR render or SPA shell).
|
|
116
|
+
*/ async function createStaticHandler(clientDir, variants, cache, servePage) {
|
|
117
|
+
const { default: sirv } = await import("sirv");
|
|
118
|
+
const negotiate = variants.brotli || variants.gzip;
|
|
119
|
+
/** The `Vary` each response should carry: sirv overwrites it with a bare `Accept-Encoding`. */ const varyOf = /* @__PURE__ */ new WeakMap();
|
|
120
|
+
const serve = sirv(clientDir, {
|
|
121
|
+
extensions: [],
|
|
122
|
+
etag: true,
|
|
123
|
+
brotli: variants.brotli,
|
|
124
|
+
gzip: variants.gzip,
|
|
125
|
+
setHeaders: (res) => {
|
|
126
|
+
const vary = varyOf.get(res);
|
|
127
|
+
if (vary) res.setHeader("Vary", vary);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return (req, res) => {
|
|
131
|
+
const pathname = requestPathname(req.url || "/");
|
|
132
|
+
if (isIndexTemplatePath(pathname)) {
|
|
133
|
+
servePage(req, res);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const restore = [];
|
|
137
|
+
const cacheControl = cache && (cache.assetsPrefix && pathname.startsWith(cache.assetsPrefix) ? cache.assets : cache.files);
|
|
138
|
+
if (cacheControl) restore.push(presetHeader(res, "Cache-Control", cacheControl));
|
|
139
|
+
if (negotiate) {
|
|
140
|
+
restore.push(varyAcceptEncoding(res));
|
|
141
|
+
varyOf.set(res, String(res.getHeader("Vary")));
|
|
142
|
+
}
|
|
143
|
+
serve(req, res, () => {
|
|
144
|
+
for (const undo of restore) undo();
|
|
145
|
+
servePage(req, res);
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
//#endregion
|
|
6
151
|
//#region packages/vite/src/utils.ts
|
|
7
152
|
const PLUGIN_NAME = "moost-vite";
|
|
8
153
|
const DEFAULT_SSR_OUTLET = "<!--ssr-outlet-->";
|
|
@@ -161,6 +306,8 @@ async function createSSRServer(options) {
|
|
|
161
306
|
const ssrHead = opts.ssrHead || (__MOOST_SSR_HEAD__ !== void 0 ? __MOOST_SSR_HEAD__ : DEFAULT_SSR_HEAD);
|
|
162
307
|
const prefixes = normalizePrefixes(opts.prefix ?? __MOOST_PREFIX__);
|
|
163
308
|
const ssrForwarding = opts.ssrFetchForwarding ?? __MOOST_SSR_FORWARDING__ ?? true;
|
|
309
|
+
const variants = resolvePrecompressed(__MOOST_PRECOMPRESS__, opts.precompressed);
|
|
310
|
+
const cache = resolveCacheControl(__MOOST_CACHE_CONTROL__, opts.cacheControl);
|
|
164
311
|
const defaultPort = opts.port || Number(process.env.PORT) || 3e3;
|
|
165
312
|
const defaultHost = opts.host ?? process.env.HOST;
|
|
166
313
|
const template = await fs.readFile(path.resolve(clientDir, "index.html"), "utf8");
|
|
@@ -198,42 +345,36 @@ async function createSSRServer(options) {
|
|
|
198
345
|
}
|
|
199
346
|
MoostHttp.prototype.listen = origListen;
|
|
200
347
|
if (enableLocalFetch && moostHttpRef.instance) enableLocalFetch(moostHttpRef.instance);
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
} catch (error) {
|
|
231
|
-
console.error(error);
|
|
232
|
-
res.statusCode = 500;
|
|
233
|
-
res.end(error.message);
|
|
234
|
-
}
|
|
235
|
-
});
|
|
348
|
+
const spaShell = render ? { html: template } : await loadSpaShell(clientDir, template, variants);
|
|
349
|
+
/** HTML page fallback: SSR render or the SPA shell. */ const servePage = async (req, res) => {
|
|
350
|
+
if (req.method !== "GET") {
|
|
351
|
+
res.statusCode = 404;
|
|
352
|
+
res.end();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
if (render) {
|
|
357
|
+
const result = await renderSSRPage({
|
|
358
|
+
render,
|
|
359
|
+
url: req.url || "/",
|
|
360
|
+
req,
|
|
361
|
+
res,
|
|
362
|
+
http: ssrForwarding ? moostHttpRef.instance : null
|
|
363
|
+
});
|
|
364
|
+
if (cache?.html) presetHeader(res, "Cache-Control", cache.html);
|
|
365
|
+
sendSSRResponse(res, template, {
|
|
366
|
+
ssrOutlet,
|
|
367
|
+
ssrState,
|
|
368
|
+
ssrHead
|
|
369
|
+
}, result);
|
|
370
|
+
} else sendSpaShell(req, res, spaShell, cache);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
console.error(error);
|
|
373
|
+
res.statusCode = 500;
|
|
374
|
+
res.end(error.message);
|
|
375
|
+
}
|
|
236
376
|
};
|
|
377
|
+
const serveStatic = await createStaticHandler(path.resolve(clientDir), variants, cache, (req, res) => void servePage(req, res));
|
|
237
378
|
return {
|
|
238
379
|
use(mw) {
|
|
239
380
|
userMiddlewares.push(mw);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moostjs/vite",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.50",
|
|
4
4
|
"description": "Vite Dev plugin for moostjs",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"composables",
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"sirv": "^3.0.0",
|
|
60
60
|
"vite": "^8.0.0",
|
|
61
|
-
"@moostjs/event-http": "^0.6.
|
|
62
|
-
"moost": "^0.6.
|
|
61
|
+
"@moostjs/event-http": "^0.6.50",
|
|
62
|
+
"moost": "^0.6.50"
|
|
63
63
|
},
|
|
64
64
|
"peerDependenciesMeta": {
|
|
65
65
|
"sirv": {
|