@moostjs/vite 0.6.28 → 0.6.30

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
@@ -122,6 +122,30 @@ In dev, the plugin adds an SSR fallback middleware that renders pages on the ser
122
122
  - **ssr** — server-side render function (`dist/server/ssr/`)
123
123
  - **server** — production Node.js server (`dist/server/server.js`)
124
124
 
125
+ ### Render Contract
126
+
127
+ Your SSR entry exports a `render(url)` that returns HTML plus optional metadata. Only `html` is required — every other field is optional, so `{ html }` (or `{ html, state }`) keeps working unchanged:
128
+
129
+ ```ts
130
+ // entry-server.ts
131
+ import type { TSSRRenderResult } from '@moostjs/vite/server'
132
+
133
+ export async function render(url: string): Promise<TSSRRenderResult> {
134
+ // ...render your app for `url`...
135
+ return {
136
+ html, // → substituted into `<!--ssr-outlet-->`
137
+ state, // → wrapped as <script>window.__SSR_STATE__=…</script> at `<!--ssr-state-->`
138
+ head, // → per-page <head> tags injected at `<!--ssr-head-->`
139
+ status, // → HTTP status code (default 200) — e.g. 404 for an unknown slug
140
+ headers, // → extra response headers — e.g. cache-control, or location with status: 301
141
+ }
142
+ }
143
+ ```
144
+
145
+ The three markers (`ssrOutlet`, `ssrState`, `ssrHead`) are plain string replacements — a marker missing from `index.html` is simply skipped. Put `<!--ssr-head-->` inside `<head>` to get crawler-visible per-page `<title>` / `<meta>` / canonical / Open Graph / JSON-LD tags. `head` is exactly the string a head manager emits (e.g. unhead's `renderSSRHead(head).headTags`).
146
+
147
+ `status` and `headers` give the render control over the response: return `status: 404` for a real not-found (crawlers treat soft-404s worse), set `cache-control`, or redirect with `status: 301` + `headers: { location: '/new-url' }`. Headers are applied after the default `Content-Type: text/html`, so a render may override it.
148
+
125
149
  ### SPA Mode
126
150
 
127
151
  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.
@@ -236,6 +260,7 @@ The plugin injects a `__VITE_ID` decorator on `@Injectable` and `@Controller` cl
236
260
  | `ssrEntry` | `string` | — | Vue/React SSR entry module (e.g. `'/src/entry-server.ts'`) |
237
261
  | `ssrOutlet` | `string` | `'<!--ssr-outlet-->'` | HTML placeholder for SSR-rendered content |
238
262
  | `ssrState` | `string` | `'<!--ssr-state-->'` | HTML placeholder for SSR state transfer script |
263
+ | `ssrHead` | `string` | `'<!--ssr-head-->'` | HTML placeholder for SSR-rendered `<head>` tags (place inside `<head>`) |
239
264
  | `serverEntry` | `string` | — | Custom production server entry file (e.g. `'./server.ts'`) |
240
265
  | `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). |
241
266
 
package/dist/index.cjs CHANGED
@@ -38,6 +38,27 @@ let moost = require("moost");
38
38
  const PLUGIN_NAME = "moost-vite";
39
39
  const DEFAULT_SSR_OUTLET = "<!--ssr-outlet-->";
40
40
  const DEFAULT_SSR_STATE = "<!--ssr-state-->";
41
+ const DEFAULT_SSR_HEAD = "<!--ssr-head-->";
42
+ /**
43
+ * Applies an SSR render result to the HTTP response: sets the status code and
44
+ * headers, then substitutes the outlet, state and head markers into the HTML
45
+ * template. Shared by all three render call sites (the dev middleware, the dev
46
+ * `createSSRServer` branch and the prod `createSSRServer` branch) so the render
47
+ * contract stays identical across them.
48
+ *
49
+ * Fully backwards compatible: every field beyond `html` is optional, a missing
50
+ * marker in the template is a no-op `String.replace`, and headers from the render
51
+ * result are applied after the default `Content-Type: text/html` so a render can
52
+ * override it. Replacements use a function argument so `$`-sequences in the
53
+ * rendered payloads (JSON state, JSON-LD `<head>` tags) are inserted literally
54
+ * rather than interpreted as `String.replace` special patterns.
55
+ */ function sendSSRResponse(res, template, markers, result) {
56
+ res.statusCode = result.status ?? 200;
57
+ res.setHeader("Content-Type", "text/html");
58
+ if (result.headers) for (const [key, value] of Object.entries(result.headers)) res.setHeader(key, value);
59
+ const stateScript = result.state ? `<script>window.__SSR_STATE__=${result.state}<\/script>` : "";
60
+ res.end(template.replace(markers.ssrOutlet, () => result.html).replace(markers.ssrState, () => stateScript).replace(markers.ssrHead, () => result.head ?? ""));
61
+ }
41
62
  function entryBasename(entry) {
42
63
  return entry.split("/").pop().replace(/\.ts$/, ".js");
43
64
  }
@@ -368,11 +389,24 @@ function moostVite(options) {
368
389
  const prefixes = normalizePrefixes(options.prefix);
369
390
  let moostMiddleware = null;
370
391
  let localFetchTeardown = null;
392
+ /**
393
+ * Boot-identity stamps (dev-only "mongrel state" diagnostic — a stale pipeline
394
+ * presents as security middleware silently switched off). `bootGeneration` is
395
+ * bumped on every eject; `bootingGeneration` records which generation the
396
+ * in-flight/most recent boot belongs to (set by runReload). When listen()
397
+ * captures a middleware for a boot that is no longer current — e.g. a delayed
398
+ * listen() from a torn boot racing a newer eject — it logs loudly.
399
+ */ let bootGeneration = 0;
400
+ let bootingGeneration = 0;
401
+ /** Whether the HTTP listen() patch has ever captured a middleware (i.e. this is an HTTP app). */ let httpCaptured = false;
402
+ /** Module IDs awaiting DI cleanup — consumed in runReload; see ejectApp. */ let pendingCleanup = null;
371
403
  /** In middleware mode: maps req → next() for the onNoMatch callback */ const pendingNextMap = /* @__PURE__ */ new WeakMap();
372
404
  const adapters = isTest ? [] : [
373
405
  createAdapterDetector("http", (MoostHttp, moduleExports) => {
374
406
  MoostHttp.prototype.listen = function(...args) {
375
407
  logger.log(`🔌 Overtaking HTTP.listen`);
408
+ if (bootingGeneration !== bootGeneration) logger.error(`⚠️ A stale Moost boot captured the HTTP middleware (boot generation ${bootingGeneration}, latest ${bootGeneration}) — an HMR reload race; a follow-up reload will replace it.`);
409
+ httpCaptured = true;
376
410
  if (options.middleware) moostMiddleware = this.getServerCb((req) => {
377
411
  pendingNextMap.get(req)?.();
378
412
  });
@@ -412,15 +446,23 @@ function moostVite(options) {
412
446
  */ const getEntryModule = (moduleGraph) => moduleGraph.getModuleByUrl(options.entry);
413
447
  /**
414
448
  * Drops the captured Moost app so the next request triggers a full reload:
415
- * releases the middleware + local fetch and ejects DI instances affected by
416
- * the changed modules.
449
+ * releases the middleware + local fetch and queues the changed module IDs for
450
+ * DI cleanup. The cleanup itself (infact ejects, wooks reset) is deferred to
451
+ * runReload, under the reload lock: running it here, per hot-update wave,
452
+ * raced in-flight requests and mid-boot imports mutating the same global
453
+ * containers — ejected-but-still-referenced instances were lazily re-created
454
+ * into the OLD pipeline, producing a mongrel half-old/half-new app. Waves
455
+ * arriving before the lazy reload (editor bulk-save storms) coalesce into one
456
+ * pending set and one cleanup.
417
457
  */ const ejectApp = (cleanupInstances) => {
458
+ bootGeneration++;
418
459
  moostMiddleware = null;
419
460
  if (localFetchTeardown) {
420
461
  localFetchTeardown();
421
462
  localFetchTeardown = null;
422
463
  }
423
- moostRestartCleanup(adapters, options.onEject, cleanupInstances);
464
+ if (pendingCleanup) for (const id of cleanupInstances) pendingCleanup.add(id);
465
+ else pendingCleanup = cleanupInstances;
424
466
  reloadRequired = true;
425
467
  };
426
468
  patchMoostHandlerLogging();
@@ -446,6 +488,7 @@ function moostVite(options) {
446
488
  serverDefines.__MOOST_SSR_ENTRY__ = JSON.stringify(`./ssr/${ssrBasename}`);
447
489
  serverDefines.__MOOST_SSR_OUTLET__ = JSON.stringify(options.ssrOutlet || DEFAULT_SSR_OUTLET);
448
490
  serverDefines.__MOOST_SSR_STATE__ = JSON.stringify(options.ssrState || DEFAULT_SSR_STATE);
491
+ serverDefines.__MOOST_SSR_HEAD__ = JSON.stringify(options.ssrHead || DEFAULT_SSR_HEAD);
449
492
  }
450
493
  const outDir = cfg.build?.outDir || "dist";
451
494
  const isBuild = env.command === "build";
@@ -534,7 +577,8 @@ function moostVite(options) {
534
577
  prefix: prefixes,
535
578
  port: options.port,
536
579
  ssrOutlet: options.ssrOutlet,
537
- ssrState: options.ssrState
580
+ ssrState: options.ssrState,
581
+ ssrHead: options.ssrHead
538
582
  };
539
583
  },
540
584
  async transform(code, id) {
@@ -572,6 +616,8 @@ function moostVite(options) {
572
616
  bootError = null;
573
617
  reloadRequired = false;
574
618
  reloadPromise = null;
619
+ pendingCleanup = null;
620
+ bootingGeneration = bootGeneration;
575
621
  const runReload = () => {
576
622
  reloadRequired = false;
577
623
  console.log();
@@ -579,11 +625,16 @@ function moostVite(options) {
579
625
  console.log();
580
626
  return (async () => {
581
627
  try {
628
+ bootingGeneration = bootGeneration;
629
+ const cleanupInstances = pendingCleanup ?? void 0;
630
+ pendingCleanup = null;
631
+ moostRestartCleanup(adapters, options.onEject, cleanupInstances);
582
632
  for (const adapter of adapters) if (adapter.detected) await adapter.init();
583
633
  const entryModule = await getEntryModule(server.environments.ssr.moduleGraph);
584
634
  if (entryModule?.transformResult) server.environments.ssr.moduleGraph.invalidateModule(entryModule);
585
635
  await ssrImport(options.entry);
586
636
  bootError = null;
637
+ if (httpCaptured && !moostMiddleware) logger.error(`⚠️ Moost app reloaded but no HTTP middleware was captured — the entry did not re-run listen().`);
587
638
  } catch (error) {
588
639
  bootError = error;
589
640
  logger.error(`✖️ Failed to reload Moost App: ${error.message}`);
@@ -650,6 +701,7 @@ function moostVite(options) {
650
701
  async configureServer(server) {
651
702
  const ssrOutlet = options.ssrOutlet || DEFAULT_SSR_OUTLET;
652
703
  const ssrState = options.ssrState || DEFAULT_SSR_STATE;
704
+ const ssrHead = options.ssrHead || DEFAULT_SSR_HEAD;
653
705
  const fs = await import("node:fs/promises");
654
706
  return () => {
655
707
  server.middlewares.use(async (req, res, next) => {
@@ -660,10 +712,12 @@ function moostVite(options) {
660
712
  let template = await fs.readFile((0, node_path.resolve)(server.config.root, "index.html"), "utf8");
661
713
  template = await server.transformIndexHtml(url, template);
662
714
  const { render } = await server.ssrLoadModule(options.ssrEntry);
663
- const { html: appHtml, state } = await render(url);
664
- res.statusCode = 200;
665
- res.setHeader("Content-Type", "text/html");
666
- res.end(template.replace(ssrOutlet, appHtml).replace(ssrState, state ? `<script>window.__SSR_STATE__=${state}<\/script>` : ""));
715
+ const result = await render(url);
716
+ sendSSRResponse(res, template, {
717
+ ssrOutlet,
718
+ ssrState,
719
+ ssrHead
720
+ }, result);
667
721
  } catch (error) {
668
722
  server.ssrFixStacktrace(error);
669
723
  console.error(error);
package/dist/index.d.ts CHANGED
@@ -117,6 +117,12 @@ interface TMoostViteDevOptions {
117
117
  * Default: `'<!--ssr-state-->'`
118
118
  */
119
119
  ssrState?: string;
120
+ /**
121
+ * HTML placeholder for SSR-rendered `<head>` tags (`<title>`, `<meta>`,
122
+ * canonical, Open Graph, JSON-LD). Place the marker inside `<head>` and return
123
+ * `head` from `render()` to inject per-page tags. Default: `'<!--ssr-head-->'`
124
+ */
125
+ ssrHead?: string;
120
126
  /**
121
127
  * Path to a custom server entry file (e.g., `'./server.ts'`).
122
128
  * When provided, this file is used as the production server build entry.
package/dist/index.mjs CHANGED
@@ -9,6 +9,27 @@ import { Moost, clearGlobalWooks, createLogger, getMoostInfact, getMoostMate } f
9
9
  const PLUGIN_NAME = "moost-vite";
10
10
  const DEFAULT_SSR_OUTLET = "<!--ssr-outlet-->";
11
11
  const DEFAULT_SSR_STATE = "<!--ssr-state-->";
12
+ const DEFAULT_SSR_HEAD = "<!--ssr-head-->";
13
+ /**
14
+ * Applies an SSR render result to the HTTP response: sets the status code and
15
+ * headers, then substitutes the outlet, state and head markers into the HTML
16
+ * template. Shared by all three render call sites (the dev middleware, the dev
17
+ * `createSSRServer` branch and the prod `createSSRServer` branch) so the render
18
+ * contract stays identical across them.
19
+ *
20
+ * Fully backwards compatible: every field beyond `html` is optional, a missing
21
+ * marker in the template is a no-op `String.replace`, and headers from the render
22
+ * result are applied after the default `Content-Type: text/html` so a render can
23
+ * override it. Replacements use a function argument so `$`-sequences in the
24
+ * rendered payloads (JSON state, JSON-LD `<head>` tags) are inserted literally
25
+ * rather than interpreted as `String.replace` special patterns.
26
+ */ function sendSSRResponse(res, template, markers, result) {
27
+ res.statusCode = result.status ?? 200;
28
+ res.setHeader("Content-Type", "text/html");
29
+ if (result.headers) for (const [key, value] of Object.entries(result.headers)) res.setHeader(key, value);
30
+ const stateScript = result.state ? `<script>window.__SSR_STATE__=${result.state}<\/script>` : "";
31
+ res.end(template.replace(markers.ssrOutlet, () => result.html).replace(markers.ssrState, () => stateScript).replace(markers.ssrHead, () => result.head ?? ""));
32
+ }
12
33
  function entryBasename(entry) {
13
34
  return entry.split("/").pop().replace(/\.ts$/, ".js");
14
35
  }
@@ -337,11 +358,24 @@ function moostVite(options) {
337
358
  const prefixes = normalizePrefixes(options.prefix);
338
359
  let moostMiddleware = null;
339
360
  let localFetchTeardown = null;
361
+ /**
362
+ * Boot-identity stamps (dev-only "mongrel state" diagnostic — a stale pipeline
363
+ * presents as security middleware silently switched off). `bootGeneration` is
364
+ * bumped on every eject; `bootingGeneration` records which generation the
365
+ * in-flight/most recent boot belongs to (set by runReload). When listen()
366
+ * captures a middleware for a boot that is no longer current — e.g. a delayed
367
+ * listen() from a torn boot racing a newer eject — it logs loudly.
368
+ */ let bootGeneration = 0;
369
+ let bootingGeneration = 0;
370
+ /** Whether the HTTP listen() patch has ever captured a middleware (i.e. this is an HTTP app). */ let httpCaptured = false;
371
+ /** Module IDs awaiting DI cleanup — consumed in runReload; see ejectApp. */ let pendingCleanup = null;
340
372
  /** In middleware mode: maps req → next() for the onNoMatch callback */ const pendingNextMap = /* @__PURE__ */ new WeakMap();
341
373
  const adapters = [
342
374
  createAdapterDetector("http", (MoostHttp, moduleExports) => {
343
375
  MoostHttp.prototype.listen = function(...args) {
344
376
  logger.log(`🔌 Overtaking HTTP.listen`);
377
+ if (bootingGeneration !== bootGeneration) logger.error(`⚠️ A stale Moost boot captured the HTTP middleware (boot generation ${bootingGeneration}, latest ${bootGeneration}) — an HMR reload race; a follow-up reload will replace it.`);
378
+ httpCaptured = true;
345
379
  if (options.middleware) moostMiddleware = this.getServerCb((req) => {
346
380
  pendingNextMap.get(req)?.();
347
381
  });
@@ -381,15 +415,23 @@ function moostVite(options) {
381
415
  */ const getEntryModule = (moduleGraph) => moduleGraph.getModuleByUrl(options.entry);
382
416
  /**
383
417
  * Drops the captured Moost app so the next request triggers a full reload:
384
- * releases the middleware + local fetch and ejects DI instances affected by
385
- * the changed modules.
418
+ * releases the middleware + local fetch and queues the changed module IDs for
419
+ * DI cleanup. The cleanup itself (infact ejects, wooks reset) is deferred to
420
+ * runReload, under the reload lock: running it here, per hot-update wave,
421
+ * raced in-flight requests and mid-boot imports mutating the same global
422
+ * containers — ejected-but-still-referenced instances were lazily re-created
423
+ * into the OLD pipeline, producing a mongrel half-old/half-new app. Waves
424
+ * arriving before the lazy reload (editor bulk-save storms) coalesce into one
425
+ * pending set and one cleanup.
386
426
  */ const ejectApp = (cleanupInstances) => {
427
+ bootGeneration++;
387
428
  moostMiddleware = null;
388
429
  if (localFetchTeardown) {
389
430
  localFetchTeardown();
390
431
  localFetchTeardown = null;
391
432
  }
392
- moostRestartCleanup(adapters, options.onEject, cleanupInstances);
433
+ if (pendingCleanup) for (const id of cleanupInstances) pendingCleanup.add(id);
434
+ else pendingCleanup = cleanupInstances;
393
435
  reloadRequired = true;
394
436
  };
395
437
  patchMoostHandlerLogging();
@@ -415,6 +457,7 @@ function moostVite(options) {
415
457
  serverDefines.__MOOST_SSR_ENTRY__ = JSON.stringify(`./ssr/${ssrBasename}`);
416
458
  serverDefines.__MOOST_SSR_OUTLET__ = JSON.stringify(options.ssrOutlet || DEFAULT_SSR_OUTLET);
417
459
  serverDefines.__MOOST_SSR_STATE__ = JSON.stringify(options.ssrState || DEFAULT_SSR_STATE);
460
+ serverDefines.__MOOST_SSR_HEAD__ = JSON.stringify(options.ssrHead || DEFAULT_SSR_HEAD);
418
461
  }
419
462
  const outDir = cfg.build?.outDir || "dist";
420
463
  const isBuild = env.command === "build";
@@ -503,7 +546,8 @@ function moostVite(options) {
503
546
  prefix: prefixes,
504
547
  port: options.port,
505
548
  ssrOutlet: options.ssrOutlet,
506
- ssrState: options.ssrState
549
+ ssrState: options.ssrState,
550
+ ssrHead: options.ssrHead
507
551
  };
508
552
  },
509
553
  async transform(code, id) {
@@ -541,6 +585,8 @@ function moostVite(options) {
541
585
  bootError = null;
542
586
  reloadRequired = false;
543
587
  reloadPromise = null;
588
+ pendingCleanup = null;
589
+ bootingGeneration = bootGeneration;
544
590
  const runReload = () => {
545
591
  reloadRequired = false;
546
592
  console.log();
@@ -548,11 +594,16 @@ function moostVite(options) {
548
594
  console.log();
549
595
  return (async () => {
550
596
  try {
597
+ bootingGeneration = bootGeneration;
598
+ const cleanupInstances = pendingCleanup ?? void 0;
599
+ pendingCleanup = null;
600
+ moostRestartCleanup(adapters, options.onEject, cleanupInstances);
551
601
  for (const adapter of adapters) if (adapter.detected) await adapter.init();
552
602
  const entryModule = await getEntryModule(server.environments.ssr.moduleGraph);
553
603
  if (entryModule?.transformResult) server.environments.ssr.moduleGraph.invalidateModule(entryModule);
554
604
  await ssrImport(options.entry);
555
605
  bootError = null;
606
+ if (httpCaptured && !moostMiddleware) logger.error(`⚠️ Moost app reloaded but no HTTP middleware was captured — the entry did not re-run listen().`);
556
607
  } catch (error) {
557
608
  bootError = error;
558
609
  logger.error(`✖️ Failed to reload Moost App: ${error.message}`);
@@ -611,6 +662,7 @@ function moostVite(options) {
611
662
  async configureServer(server) {
612
663
  const ssrOutlet = options.ssrOutlet || DEFAULT_SSR_OUTLET;
613
664
  const ssrState = options.ssrState || DEFAULT_SSR_STATE;
665
+ const ssrHead = options.ssrHead || DEFAULT_SSR_HEAD;
614
666
  const fs = await import("node:fs/promises");
615
667
  return () => {
616
668
  server.middlewares.use(async (req, res, next) => {
@@ -621,10 +673,12 @@ function moostVite(options) {
621
673
  let template = await fs.readFile(resolve(server.config.root, "index.html"), "utf8");
622
674
  template = await server.transformIndexHtml(url, template);
623
675
  const { render } = await server.ssrLoadModule(options.ssrEntry);
624
- const { html: appHtml, state } = await render(url);
625
- res.statusCode = 200;
626
- res.setHeader("Content-Type", "text/html");
627
- res.end(template.replace(ssrOutlet, appHtml).replace(ssrState, state ? `<script>window.__SSR_STATE__=${state}<\/script>` : ""));
676
+ const result = await render(url);
677
+ sendSSRResponse(res, template, {
678
+ ssrOutlet,
679
+ ssrState,
680
+ ssrHead
681
+ }, result);
628
682
  } catch (error) {
629
683
  server.ssrFixStacktrace(error);
630
684
  console.error(error);
@@ -5,6 +5,27 @@ let moost = require("moost");
5
5
  const PLUGIN_NAME = "moost-vite";
6
6
  const DEFAULT_SSR_OUTLET = "<!--ssr-outlet-->";
7
7
  const DEFAULT_SSR_STATE = "<!--ssr-state-->";
8
+ const DEFAULT_SSR_HEAD = "<!--ssr-head-->";
9
+ /**
10
+ * Applies an SSR render result to the HTTP response: sets the status code and
11
+ * headers, then substitutes the outlet, state and head markers into the HTML
12
+ * template. Shared by all three render call sites (the dev middleware, the dev
13
+ * `createSSRServer` branch and the prod `createSSRServer` branch) so the render
14
+ * contract stays identical across them.
15
+ *
16
+ * Fully backwards compatible: every field beyond `html` is optional, a missing
17
+ * marker in the template is a no-op `String.replace`, and headers from the render
18
+ * result are applied after the default `Content-Type: text/html` so a render can
19
+ * override it. Replacements use a function argument so `$`-sequences in the
20
+ * rendered payloads (JSON state, JSON-LD `<head>` tags) are inserted literally
21
+ * rather than interpreted as `String.replace` special patterns.
22
+ */ function sendSSRResponse(res, template, markers, result) {
23
+ res.statusCode = result.status ?? 200;
24
+ res.setHeader("Content-Type", "text/html");
25
+ if (result.headers) for (const [key, value] of Object.entries(result.headers)) res.setHeader(key, value);
26
+ const stateScript = result.state ? `<script>window.__SSR_STATE__=${result.state}<\/script>` : "";
27
+ res.end(template.replace(markers.ssrOutlet, () => result.html).replace(markers.ssrState, () => stateScript).replace(markers.ssrHead, () => result.head ?? ""));
28
+ }
8
29
  /**
9
30
  * Normalizes the `prefix` option (single mount or list of mounts) into a list of
10
31
  * normalized entries: leading slash ensured, trailing slash stripped.
@@ -50,6 +71,7 @@ async function createSSRServer(options) {
50
71
  const ssrEntry = config.ssrEntry;
51
72
  const ssrOutlet = config.ssrOutlet || DEFAULT_SSR_OUTLET;
52
73
  const ssrState = config.ssrState || DEFAULT_SSR_STATE;
74
+ const ssrHead = config.ssrHead || DEFAULT_SSR_HEAD;
53
75
  let ssrFallback = null;
54
76
  if (ssrEntry) {
55
77
  const fs = await import("node:fs/promises");
@@ -61,10 +83,12 @@ async function createSSRServer(options) {
61
83
  let template = await fs.readFile(path.resolve(vite.config.root, "index.html"), "utf8");
62
84
  template = await vite.transformIndexHtml(url, template);
63
85
  const { render } = await vite.ssrLoadModule(ssrEntry);
64
- const { html: appHtml, state } = await render(url);
65
- res.statusCode = 200;
66
- res.setHeader("Content-Type", "text/html");
67
- res.end(template.replace(ssrOutlet, appHtml).replace(ssrState, state ? `<script>window.__SSR_STATE__=${state}<\/script>` : ""));
86
+ const result = await render(url);
87
+ sendSSRResponse(res, template, {
88
+ ssrOutlet,
89
+ ssrState,
90
+ ssrHead
91
+ }, result);
68
92
  } catch (error) {
69
93
  vite.ssrFixStacktrace(error);
70
94
  console.error(error);
@@ -89,8 +113,9 @@ async function createSSRServer(options) {
89
113
  const path = await import("node:path");
90
114
  const { createServer: createHttpServer } = await import("node:http");
91
115
  const clientDir = opts.clientDir || path.resolve(__dirname, "../client");
92
- const ssrOutlet = opts.ssrOutlet || (__MOOST_SSR_OUTLET__ !== void 0 ? __MOOST_SSR_OUTLET__ : "<!--ssr-outlet-->");
93
- const ssrState = opts.ssrState || (__MOOST_SSR_STATE__ !== void 0 ? __MOOST_SSR_STATE__ : "<!--ssr-state-->");
116
+ const ssrOutlet = opts.ssrOutlet || (__MOOST_SSR_OUTLET__ !== void 0 ? __MOOST_SSR_OUTLET__ : DEFAULT_SSR_OUTLET);
117
+ const ssrState = opts.ssrState || (__MOOST_SSR_STATE__ !== void 0 ? __MOOST_SSR_STATE__ : DEFAULT_SSR_STATE);
118
+ const ssrHead = opts.ssrHead || (__MOOST_SSR_HEAD__ !== void 0 ? __MOOST_SSR_HEAD__ : DEFAULT_SSR_HEAD);
94
119
  const prefixes = normalizePrefixes(opts.prefix ?? __MOOST_PREFIX__);
95
120
  const defaultPort = opts.port || Number(process.env.PORT) || 3e3;
96
121
  const template = await fs.readFile(path.resolve(clientDir, "index.html"), "utf8");
@@ -140,10 +165,12 @@ async function createSSRServer(options) {
140
165
  }
141
166
  try {
142
167
  if (render) {
143
- const { html: appHtml, state } = await render(url);
144
- res.statusCode = 200;
145
- res.setHeader("Content-Type", "text/html");
146
- res.end(template.replace(ssrOutlet, appHtml).replace(ssrState, state ? `<script>window.__SSR_STATE__=${state}<\/script>` : ""));
168
+ const result = await render(url);
169
+ sendSSRResponse(res, template, {
170
+ ssrOutlet,
171
+ ssrState,
172
+ ssrHead
173
+ }, result);
147
174
  } else {
148
175
  res.statusCode = 200;
149
176
  res.setHeader("Content-Type", "text/html");
@@ -1,5 +1,30 @@
1
1
  import { IncomingMessage, ServerResponse } from 'node:http';
2
2
 
3
+ /**
4
+ * The value an SSR `render(url)` function may return. Only `html` is required;
5
+ * every other field is optional, so a render returning `{ html }` (or
6
+ * `{ html, state }`) keeps working unchanged.
7
+ */
8
+ interface TSSRRenderResult {
9
+ /** Server-rendered app markup, substituted into the `ssrOutlet` marker. */
10
+ html: string;
11
+ /**
12
+ * Serialized app state. Wrapped in `<script>window.__SSR_STATE__=…</script>`
13
+ * and substituted into the `ssrState` marker (nothing is emitted when absent).
14
+ */
15
+ state?: string;
16
+ /**
17
+ * Ready-to-insert `<head>` tags (`<title>`, `<meta>`, canonical, Open Graph,
18
+ * JSON-LD, …), substituted verbatim into the `ssrHead` marker. This is exactly
19
+ * the shape head managers emit (e.g. unhead's `renderSSRHead(head).headTags`).
20
+ */
21
+ head?: string;
22
+ /** HTTP status code for the response (default `200`) — e.g. `404` for a soft-404, `301` for a redirect. */
23
+ status?: number;
24
+ /** Extra response headers — e.g. `cache-control`, or `location` alongside `status: 301`. */
25
+ headers?: Record<string, string>;
26
+ }
27
+
3
28
  type TMiddleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void;
4
29
  interface TSSRServerOptions {
5
30
  /** Override: lazy import for the Moost app entry. */
@@ -21,6 +46,8 @@ interface TSSRServerOptions {
21
46
  ssrOutlet?: string;
22
47
  /** Override: HTML placeholder for SSR state */
23
48
  ssrState?: string;
49
+ /** Override: HTML placeholder for SSR-rendered `<head>` tags */
50
+ ssrHead?: string;
24
51
  }
25
52
  interface TSSRServer {
26
53
  /** Add Connect-compatible middleware (runs in both dev and prod) */
@@ -31,4 +58,4 @@ interface TSSRServer {
31
58
  declare function createSSRServer(options?: TSSRServerOptions): Promise<TSSRServer>;
32
59
 
33
60
  export { createSSRServer };
34
- export type { TSSRServer, TSSRServerOptions };
61
+ export type { TSSRRenderResult, TSSRServer, TSSRServerOptions };
@@ -6,6 +6,27 @@ import { createLogger } from "moost";
6
6
  const PLUGIN_NAME = "moost-vite";
7
7
  const DEFAULT_SSR_OUTLET = "<!--ssr-outlet-->";
8
8
  const DEFAULT_SSR_STATE = "<!--ssr-state-->";
9
+ const DEFAULT_SSR_HEAD = "<!--ssr-head-->";
10
+ /**
11
+ * Applies an SSR render result to the HTTP response: sets the status code and
12
+ * headers, then substitutes the outlet, state and head markers into the HTML
13
+ * template. Shared by all three render call sites (the dev middleware, the dev
14
+ * `createSSRServer` branch and the prod `createSSRServer` branch) so the render
15
+ * contract stays identical across them.
16
+ *
17
+ * Fully backwards compatible: every field beyond `html` is optional, a missing
18
+ * marker in the template is a no-op `String.replace`, and headers from the render
19
+ * result are applied after the default `Content-Type: text/html` so a render can
20
+ * override it. Replacements use a function argument so `$`-sequences in the
21
+ * rendered payloads (JSON state, JSON-LD `<head>` tags) are inserted literally
22
+ * rather than interpreted as `String.replace` special patterns.
23
+ */ function sendSSRResponse(res, template, markers, result) {
24
+ res.statusCode = result.status ?? 200;
25
+ res.setHeader("Content-Type", "text/html");
26
+ if (result.headers) for (const [key, value] of Object.entries(result.headers)) res.setHeader(key, value);
27
+ const stateScript = result.state ? `<script>window.__SSR_STATE__=${result.state}<\/script>` : "";
28
+ res.end(template.replace(markers.ssrOutlet, () => result.html).replace(markers.ssrState, () => stateScript).replace(markers.ssrHead, () => result.head ?? ""));
29
+ }
9
30
  /**
10
31
  * Normalizes the `prefix` option (single mount or list of mounts) into a list of
11
32
  * normalized entries: leading slash ensured, trailing slash stripped.
@@ -51,6 +72,7 @@ async function createSSRServer(options) {
51
72
  const ssrEntry = config.ssrEntry;
52
73
  const ssrOutlet = config.ssrOutlet || DEFAULT_SSR_OUTLET;
53
74
  const ssrState = config.ssrState || DEFAULT_SSR_STATE;
75
+ const ssrHead = config.ssrHead || DEFAULT_SSR_HEAD;
54
76
  let ssrFallback = null;
55
77
  if (ssrEntry) {
56
78
  const fs = await import("node:fs/promises");
@@ -62,10 +84,12 @@ async function createSSRServer(options) {
62
84
  let template = await fs.readFile(path.resolve(vite.config.root, "index.html"), "utf8");
63
85
  template = await vite.transformIndexHtml(url, template);
64
86
  const { render } = await vite.ssrLoadModule(ssrEntry);
65
- const { html: appHtml, state } = await render(url);
66
- res.statusCode = 200;
67
- res.setHeader("Content-Type", "text/html");
68
- res.end(template.replace(ssrOutlet, appHtml).replace(ssrState, state ? `<script>window.__SSR_STATE__=${state}<\/script>` : ""));
87
+ const result = await render(url);
88
+ sendSSRResponse(res, template, {
89
+ ssrOutlet,
90
+ ssrState,
91
+ ssrHead
92
+ }, result);
69
93
  } catch (error) {
70
94
  vite.ssrFixStacktrace(error);
71
95
  console.error(error);
@@ -90,8 +114,9 @@ async function createSSRServer(options) {
90
114
  const path = await import("node:path");
91
115
  const { createServer: createHttpServer } = await import("node:http");
92
116
  const clientDir = opts.clientDir || path.resolve(import.meta.dirname, "../client");
93
- const ssrOutlet = opts.ssrOutlet || (__MOOST_SSR_OUTLET__ !== void 0 ? __MOOST_SSR_OUTLET__ : "<!--ssr-outlet-->");
94
- const ssrState = opts.ssrState || (__MOOST_SSR_STATE__ !== void 0 ? __MOOST_SSR_STATE__ : "<!--ssr-state-->");
117
+ const ssrOutlet = opts.ssrOutlet || (__MOOST_SSR_OUTLET__ !== void 0 ? __MOOST_SSR_OUTLET__ : DEFAULT_SSR_OUTLET);
118
+ const ssrState = opts.ssrState || (__MOOST_SSR_STATE__ !== void 0 ? __MOOST_SSR_STATE__ : DEFAULT_SSR_STATE);
119
+ const ssrHead = opts.ssrHead || (__MOOST_SSR_HEAD__ !== void 0 ? __MOOST_SSR_HEAD__ : DEFAULT_SSR_HEAD);
95
120
  const prefixes = normalizePrefixes(opts.prefix ?? __MOOST_PREFIX__);
96
121
  const defaultPort = opts.port || Number(process.env.PORT) || 3e3;
97
122
  const template = await fs.readFile(path.resolve(clientDir, "index.html"), "utf8");
@@ -141,10 +166,12 @@ async function createSSRServer(options) {
141
166
  }
142
167
  try {
143
168
  if (render) {
144
- const { html: appHtml, state } = await render(url);
145
- res.statusCode = 200;
146
- res.setHeader("Content-Type", "text/html");
147
- res.end(template.replace(ssrOutlet, appHtml).replace(ssrState, state ? `<script>window.__SSR_STATE__=${state}<\/script>` : ""));
169
+ const result = await render(url);
170
+ sendSSRResponse(res, template, {
171
+ ssrOutlet,
172
+ ssrState,
173
+ ssrHead
174
+ }, result);
148
175
  } else {
149
176
  res.statusCode = 200;
150
177
  res.setHeader("Content-Type", "text/html");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moostjs/vite",
3
- "version": "0.6.28",
3
+ "version": "0.6.30",
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.28",
62
- "moost": "^0.6.28"
61
+ "@moostjs/event-http": "^0.6.30",
62
+ "moost": "^0.6.30"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "sirv": {