@moostjs/vite 0.6.48 → 0.6.49

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 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,
@@ -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 sirv = (await import("sirv")).default;
201
- const serve = sirv(path.resolve(clientDir), { extensions: [] });
202
- const serveStatic = (req, res) => {
203
- const url = req.url || "/";
204
- serve(req, res, async () => {
205
- if (req.method !== "GET") {
206
- res.statusCode = 404;
207
- res.end();
208
- return;
209
- }
210
- try {
211
- if (render) {
212
- const result = await renderSSRPage({
213
- render,
214
- url,
215
- req,
216
- res,
217
- http: ssrForwarding ? moostHttpRef.instance : null
218
- });
219
- sendSSRResponse(res, template, {
220
- ssrOutlet,
221
- ssrState,
222
- ssrHead
223
- }, result);
224
- } else {
225
- res.statusCode = 200;
226
- res.setHeader("Content-Type", "text/html");
227
- res.end(template);
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);
@@ -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 };
@@ -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 sirv = (await import("sirv")).default;
202
- const serve = sirv(path.resolve(clientDir), { extensions: [] });
203
- const serveStatic = (req, res) => {
204
- const url = req.url || "/";
205
- serve(req, res, async () => {
206
- if (req.method !== "GET") {
207
- res.statusCode = 404;
208
- res.end();
209
- return;
210
- }
211
- try {
212
- if (render) {
213
- const result = await renderSSRPage({
214
- render,
215
- url,
216
- req,
217
- res,
218
- http: ssrForwarding ? moostHttpRef.instance : null
219
- });
220
- sendSSRResponse(res, template, {
221
- ssrOutlet,
222
- ssrState,
223
- ssrHead
224
- }, result);
225
- } else {
226
- res.statusCode = 200;
227
- res.setHeader("Content-Type", "text/html");
228
- res.end(template);
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.48",
3
+ "version": "0.6.49",
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.48",
62
- "moost": "^0.6.48"
61
+ "@moostjs/event-http": "^0.6.49",
62
+ "moost": "^0.6.49"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "sirv": {