@pajecawav/yamf 0.0.7 → 0.0.9

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.
Files changed (84) hide show
  1. package/README.md +63 -7
  2. package/dist/client/index.js +39 -1
  3. package/dist/client/index.js.map +1 -0
  4. package/dist/components/Head.d.ts +3 -4
  5. package/dist/components/Head.d.ts.map +1 -1
  6. package/dist/components/Head.js.map +1 -1
  7. package/dist/context/ssr.d.ts +1 -2
  8. package/dist/context/ssr.d.ts.map +1 -1
  9. package/dist/hooks/isPrerendering.d.ts +12 -0
  10. package/dist/hooks/isPrerendering.d.ts.map +1 -0
  11. package/dist/hooks/isPrerendering.js +17 -0
  12. package/dist/hooks/isPrerendering.js.map +1 -0
  13. package/dist/hooks/useEvent.d.ts +1 -2
  14. package/dist/hooks/useEvent.d.ts.map +1 -1
  15. package/dist/hooks/useHead.d.ts +10 -5
  16. package/dist/hooks/useHead.d.ts.map +1 -1
  17. package/dist/hooks/useHead.js +46 -4
  18. package/dist/hooks/useHead.js.map +1 -1
  19. package/dist/index.d.ts +6 -2
  20. package/dist/index.js +3 -1
  21. package/dist/island/client.js +44 -36
  22. package/dist/island/client.js.map +1 -1
  23. package/dist/island/types.d.ts +2 -3
  24. package/dist/island/types.d.ts.map +1 -1
  25. package/dist/page.d.ts +32 -6
  26. package/dist/page.d.ts.map +1 -1
  27. package/dist/page.js +60 -6
  28. package/dist/page.js.map +1 -1
  29. package/dist/safeAsync.d.ts +19 -0
  30. package/dist/safeAsync.d.ts.map +1 -0
  31. package/dist/safeAsync.js +34 -0
  32. package/dist/safeAsync.js.map +1 -0
  33. package/dist/server/entry.d.mts +12 -4
  34. package/dist/server/entry.d.mts.map +1 -1
  35. package/dist/server/entry.mjs +9 -3
  36. package/dist/server/entry.mjs.map +1 -1
  37. package/dist/server/island/server.d.mts +2 -3
  38. package/dist/server/island/server.d.mts.map +1 -1
  39. package/dist/server/island/server.mjs +13 -3
  40. package/dist/server/island/server.mjs.map +1 -1
  41. package/dist/server/island/types.d.mts +1 -2
  42. package/dist/server/island/types.d.mts.map +1 -1
  43. package/dist/server/shared/assets.d.mts +1 -2
  44. package/dist/server/shared/assets.d.mts.map +1 -1
  45. package/dist/server/shared/head.d.mts +1 -2
  46. package/dist/server/shared/head.d.mts.map +1 -1
  47. package/dist/shared/assets.d.ts +2 -3
  48. package/dist/shared/assets.d.ts.map +1 -1
  49. package/dist/shared/head.d.ts +1 -2
  50. package/dist/shared/head.d.ts.map +1 -1
  51. package/dist/vite/index.d.mts +2 -2
  52. package/dist/vite/index.d.mts.map +1 -1
  53. package/dist/vite/index.mjs +13 -5
  54. package/dist/vite/index.mjs.map +1 -1
  55. package/dist/vite/islands.mjs +34 -5
  56. package/dist/vite/islands.mjs.map +1 -1
  57. package/dist/vite/prerender.mjs +16 -0
  58. package/dist/vite/prerender.mjs.map +1 -0
  59. package/dist/vite/virtual-assets.mjs +1 -1
  60. package/dist/vite/virtual-assets.mjs.map +1 -1
  61. package/dist/vite/virtual-error-handler.mjs +1 -1
  62. package/dist/vite/virtual-error-handler.mjs.map +1 -1
  63. package/dist/vite/virtual-pages.mjs +1 -1
  64. package/dist/vite/virtual-pages.mjs.map +1 -1
  65. package/dist/vite/virtual-root.mjs +12 -5
  66. package/dist/vite/virtual-root.mjs.map +1 -1
  67. package/dist/vite/virtual-template.mjs +9 -4
  68. package/dist/vite/virtual-template.mjs.map +1 -1
  69. package/package.json +27 -20
  70. package/src/client/index.ts +51 -1
  71. package/src/components/Head.tsx +2 -2
  72. package/src/hooks/isPrerendering.ts +13 -0
  73. package/src/hooks/useHead.tsx +98 -9
  74. package/src/index.ts +10 -1
  75. package/src/island/client.tsx +73 -65
  76. package/src/island/server.tsx +31 -6
  77. package/src/page.tsx +176 -11
  78. package/src/safeAsync.tsx +46 -0
  79. package/src/server/entry.tsx +25 -1
  80. package/src/vite/index.ts +30 -3
  81. package/src/vite/islands.ts +68 -8
  82. package/src/vite/prerender.ts +27 -0
  83. package/src/vite/virtual-root.ts +14 -4
  84. package/src/vite/virtual-template.ts +15 -3
@@ -4,11 +4,11 @@ import { hydrateRoot } from "hono/jsx/dom/client";
4
4
  import { withLeadingSlash } from "ufo";
5
5
  import type { IslandClientDirectiveSerialized } from "./types";
6
6
 
7
- declare let __island_raw_import__: <T>(file: string) => Promise<T>;
7
+ declare let __yamf_raw_import__: <T>(file: string) => Promise<T>;
8
8
 
9
- const listeners = new WeakMap<Element, VoidFunction>();
9
+ const listeners: WeakMap<Element, VoidFunction> = new WeakMap();
10
10
 
11
- const observer = new IntersectionObserver(entries => {
11
+ const observer: IntersectionObserver = new IntersectionObserver(entries => {
12
12
  for (const entry of entries) {
13
13
  if (entry.isIntersecting) {
14
14
  listeners.get(entry.target)?.();
@@ -27,69 +27,77 @@ const unobserve = (target: Element) => {
27
27
  listeners.delete(target);
28
28
  };
29
29
 
30
- customElements.define(
31
- "yamf-island",
32
- class extends HTMLElement {
33
- public connectedCallback() {
34
- const islandProps = parse(this.getAttribute("island-props") ?? "{}");
35
- const islandSrc = this.getAttribute("island-src");
36
- const islandEntry = this.getAttribute("island-entry");
37
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion
38
- const islandClient = (this.getAttribute("island-client") ??
39
- "load") as IslandClientDirectiveSerialized;
40
-
41
- if (!islandSrc) {
42
- throw new Error("Missing island-src attribute");
43
- }
30
+ // Safari does not enable requestIdleCallback by default in any stable
31
+ // release — without a fallback, client:idle islands never hydrate there
32
+ const requestIdle = (callback: () => void): void => {
33
+ const ric = window.requestIdleCallback;
44
34
 
45
- if (!islandEntry) {
46
- throw new Error("Missing island-entry attribute");
47
- }
35
+ if (typeof ric === "function") {
36
+ ric(callback);
37
+ return;
38
+ }
48
39
 
49
- const hydrateIsland = (mod: Record<string, FC>) => {
50
- const Comp = mod[islandEntry];
51
-
52
- if (!Comp) {
53
- throw new Error(`Missing island entry ${islandEntry} in ${islandSrc}`);
54
- }
55
-
56
- hydrateRoot(this, <Comp {...islandProps} />);
57
- };
58
-
59
- const initIsland = () => {
60
- const src = withLeadingSlash(islandSrc);
61
- void __island_raw_import__<Record<string, FC>>(src).then(hydrateIsland);
62
- };
63
-
64
- switch (islandClient) {
65
- case "true":
66
- case "load":
67
- initIsland();
68
- break;
69
- case "idle":
70
- requestIdleCallback(initIsland);
71
- break;
72
- case "visible":
73
- // yamf-island has `display: contents` which breaks IntersectionObserver
74
- // so we have to observe the first child instead if it exists
75
- if (this.firstElementChild) {
76
- observe(this.firstElementChild, initIsland);
77
- } else {
78
- initIsland();
79
- }
80
- break;
81
- case "false":
82
- case "skip":
83
- break;
84
- default:
85
- islandClient satisfies never;
86
- // oxlint-disable-next-line typescript/restrict-template-expressions
87
- throw new Error(`Invalid island-client value: ${islandClient}`);
88
- }
89
- }
40
+ setTimeout(callback, 1);
41
+ };
42
+
43
+ /**
44
+ * Hydrates a single <yamf-island> island. Called by the bootstrap custom
45
+ * element (src/client/index.ts) once this runtime module has been loaded.
46
+ */
47
+ export const hydrateIsland = (island: HTMLElement): void => {
48
+ const islandProps = parse(island.getAttribute("island-props") ?? "{}");
49
+ const islandSrc = island.getAttribute("island-src");
50
+ const islandEntry = island.getAttribute("island-entry");
51
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
52
+ const islandClient = (island.getAttribute("island-client") ??
53
+ "load") as IslandClientDirectiveSerialized;
54
+
55
+ if (!islandSrc) {
56
+ throw new Error("Missing island-src attribute");
57
+ }
90
58
 
91
- public disconnectedCallback() {
92
- unobserve(this);
59
+ if (!islandEntry) {
60
+ throw new Error("Missing island-entry attribute");
61
+ }
62
+
63
+ const hydrate = (mod: Record<string, FC>) => {
64
+ const Comp = mod[islandEntry];
65
+
66
+ if (!Comp) {
67
+ throw new Error(`Missing island entry ${islandEntry} in ${islandSrc}`);
93
68
  }
94
- },
95
- );
69
+
70
+ hydrateRoot(island, <Comp {...islandProps} />);
71
+ };
72
+
73
+ const initIsland = () => {
74
+ const src = withLeadingSlash(islandSrc);
75
+ void __yamf_raw_import__<Record<string, FC>>(src).then(hydrate);
76
+ };
77
+
78
+ switch (islandClient) {
79
+ case "true":
80
+ case "load":
81
+ initIsland();
82
+ break;
83
+ case "idle":
84
+ requestIdle(initIsland);
85
+ break;
86
+ case "visible":
87
+ // yamf-island has `display: contents` which breaks IntersectionObserver
88
+ // so we have to observe the first child instead if it exists
89
+ if (island.firstElementChild) {
90
+ observe(island.firstElementChild, initIsland);
91
+ } else {
92
+ initIsland();
93
+ }
94
+ break;
95
+ case "false":
96
+ case "skip":
97
+ break;
98
+ default:
99
+ islandClient satisfies never;
100
+ // oxlint-disable-next-line typescript/restrict-template-expressions
101
+ throw new Error(`Invalid island-client value: ${islandClient}`);
102
+ }
103
+ };
@@ -1,7 +1,10 @@
1
1
  import { stringify } from "devalue";
2
2
  import type { Child, FC } from "hono/jsx";
3
3
  import type { ImportAssetsResultRaw } from "#/shared/assets";
4
- import type { IslandClientDirective } from "./types";
4
+ import type { IslandClientDirective, IslandProps } from "./types";
5
+
6
+ // configurable via the YAMF_ISLAND_PROPS_LIMIT env / define
7
+ const PROPS_WARNING_LIMIT = Number(import.meta.env["YAMF_ISLAND_PROPS_LIMIT"]) || 16 * 1024;
5
8
 
6
9
  declare module "hono/jsx" {
7
10
  namespace JSX {
@@ -10,7 +13,7 @@ declare module "hono/jsx" {
10
13
  "island-props"?: string;
11
14
  "island-src": string;
12
15
  "island-entry": string;
13
- "island-client": IslandClientDirective;
16
+ "island-client"?: IslandClientDirective;
14
17
  children: Child;
15
18
  style?: JSX.CSSProperties;
16
19
  };
@@ -18,23 +21,45 @@ declare module "hono/jsx" {
18
21
  }
19
22
  }
20
23
 
24
+ const isSkipDirective = (directive: IslandClientDirective | undefined): boolean => {
25
+ return directive === false || directive === "skip";
26
+ };
27
+
21
28
  export const createIsland = (
22
29
  Component: FC,
23
30
  exportName: string,
24
31
  assets: ImportAssetsResultRaw,
25
32
  ): FC => {
26
- const ComponentWrapper: FC & { name: string } = props => {
33
+ const ComponentWrapper: FC<IslandProps> & { name: string } = ({
34
+ "yamf-client": clientDirective,
35
+ ...props
36
+ }) => {
27
37
  if (!assets.entry) {
28
38
  throw new Error(`Missing island entry for island ${Component.name}`);
29
39
  }
30
40
 
41
+ // skip islands are never hydrated — serializing their props into the
42
+ // attribute would be pure dead weight in the HTML
43
+ const skip = isSkipDirective(clientDirective);
44
+
45
+ let islandProps: string | undefined;
46
+
47
+ if (!skip) {
48
+ islandProps = stringify(props);
49
+
50
+ if (import.meta.env.DEV && islandProps.length > PROPS_WARNING_LIMIT) {
51
+ console.warn(
52
+ `[yamf] island "${exportName}" receives ${(islandProps.length / 1024).toFixed(1)} KB of serialized props — consider slimming them down (limit: ${PROPS_WARNING_LIMIT} bytes, configurable via YAMF_ISLAND_PROPS_LIMIT)`,
53
+ );
54
+ }
55
+ }
56
+
31
57
  return (
32
58
  <yamf-island
33
- island-props={stringify(props)}
59
+ island-props={islandProps}
34
60
  island-src={assets.entry}
35
61
  island-entry={exportName}
36
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion
37
- island-client={props["yamf-client"] as IslandClientDirective}
62
+ island-client={clientDirective}
38
63
  // TODO: export CSS?
39
64
  style={{ display: "contents" }}
40
65
  >
package/src/page.tsx CHANGED
@@ -1,13 +1,14 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
1
2
  import { Fragment, type Child } from "hono/jsx";
2
3
  import { renderToReadableStream } from "hono/jsx/streaming";
3
4
  import { Hono } from "hono/tiny";
4
5
  import type { EventHandlerResponse, H3Event } from "nitro/h3";
5
- import { HTTPResponse, withServerTiming } from "nitro/h3";
6
+ import { HTTPError, HTTPResponse, withServerTiming } from "nitro/h3";
6
7
  import { useSeoMeta } from "unhead";
7
8
  import type { Unhead } from "unhead/server";
8
9
  import { transformHtmlTemplate } from "unhead/server";
9
- import { createStreamableHead, wrapStream } from "unhead/stream/server";
10
- import type { ResolvableLink, UseSeoMetaInput } from "unhead/types";
10
+ import { createStreamableHead, renderSSRHeadSuspenseChunk, wrapStream } from "unhead/stream/server";
11
+ import type { ResolvableHead, ResolvableLink, UseSeoMetaInput } from "unhead/types";
11
12
  import { Root as RootComponent } from "virtual:yamf:root";
12
13
  import { template } from "virtual:yamf:template";
13
14
  import { Router } from "wouter";
@@ -20,24 +21,160 @@ export type PageHandler = (
20
21
  params: {
21
22
  assets: ImportAssetsResult;
22
23
  head?: YamfHead;
24
+ nonce?: string;
23
25
  },
24
26
  ) => EventHandlerResponse;
25
27
 
26
- export type PageRenderer = (
28
+ export type PageRenderer<P = unknown, Q = unknown> = (
27
29
  event: H3Event,
28
30
  params: {
29
31
  head: Unhead;
30
32
  seoHead: (input: UseSeoMetaInput) => void;
33
+ params: P;
34
+ query: Q;
31
35
  },
32
36
  ) => HTTPResponse | Child | Promise<Child | HTTPResponse>;
33
37
 
34
- interface DefinePageOptions {
35
- render: PageRenderer;
38
+ export interface PageCacheOptions {
39
+ maxAge: number;
40
+ swr?: number;
41
+ private?: boolean;
42
+ }
43
+
44
+ interface DefinePageOptions<P = unknown, Q = unknown> {
45
+ render: PageRenderer<P, Q>;
36
46
  stream?: boolean;
47
+ /**
48
+ * Cache-Control for the page response. `60` is shorthand for
49
+ * `{ maxAge: 60 }` → `Cache-Control: public, max-age=60`; the object form
50
+ * adds `stale-while-revalidate` and `private`.
51
+ */
52
+ cache?: number | PageCacheOptions;
53
+ /**
54
+ * Standard-schema validator (zod, valibot, arktype, …) for the route
55
+ * params. On failure the page throws HTTPError 404 before render; the
56
+ * validated, typed output is passed to render as `params`.
57
+ */
58
+ params?: StandardSchemaV1<unknown, P>;
59
+ /**
60
+ * Standard-schema validator for the search params. On failure the page
61
+ * throws HTTPError 400 before render; the validated, typed output is
62
+ * passed to render as `query`. Repeated keys collapse to the last value.
63
+ */
64
+ query?: StandardSchemaV1<unknown, Q>;
37
65
  }
38
66
 
39
- export const definePage = (options: DefinePageOptions): PageHandler => {
40
- return async (event, { assets, head: headInit }) => {
67
+ const cacheControlValue = (cache: number | PageCacheOptions): string => {
68
+ const options = typeof cache === "number" ? { maxAge: cache } : cache;
69
+
70
+ const parts = [options.private ? "private" : "public", `max-age=${options.maxAge}`];
71
+
72
+ if (options.swr !== undefined) {
73
+ parts.push(`stale-while-revalidate=${options.swr}`);
74
+ }
75
+
76
+ return parts.join(", ");
77
+ };
78
+
79
+ // resolvable head inputs may be functions — resolve them for serialization
80
+ const unwrapEntryInput = (input: unknown): ResolvableHead => {
81
+ return (
82
+ typeof input === "function" ? (input as () => ResolvableHead)() : input
83
+ ) as ResolvableHead;
84
+ };
85
+
86
+ const collectHeadInputs = (head: Unhead): ResolvableHead[] => {
87
+ const inputs: ResolvableHead[] = [];
88
+
89
+ for (const entry of head.entries.values()) {
90
+ inputs.push(unwrapEntryInput(entry.input));
91
+ }
92
+
93
+ return inputs;
94
+ };
95
+
96
+ const serializeHeadPayload = (inputs: ResolvableHead[]): string => {
97
+ return JSON.stringify(inputs)
98
+ .replace(/</g, "\\u003c")
99
+ .replace(/>/g, "\\u003e")
100
+ .replace(/&/g, "\\u0026");
101
+ };
102
+
103
+ const scriptNonceAttr = (nonce?: string): string => {
104
+ return nonce ? ` nonce="${nonce.replace(/"/g, "&quot;")}"` : "";
105
+ };
106
+
107
+ // client handshake: re-register the server head entries (titleTemplate,
108
+ // htmlAttrs, page-level head) into the client head via the unhead stream
109
+ // queue, so post-hydration updates keep templates and adopt SSR tags
110
+ // instead of duplicating them
111
+ const headPayloadScript = (inputs: ResolvableHead[], nonce?: string): string => {
112
+ if (!inputs.length) {
113
+ return "";
114
+ }
115
+
116
+ return `<script${scriptNonceAttr(nonce)}>window.__unhead__&&window.__unhead__.push(${serializeHeadPayload(inputs)})</script>`;
117
+ };
118
+
119
+ // injected at the start of <body>: unhead rebuilds the html/head tags and
120
+ // appends its head tags (including the stream bootstrap shim) inside <head>,
121
+ // so the payload must come after them for window.__unhead__ to exist
122
+ const injectHeadPayload = (template: string, script: string): string => {
123
+ if (!script) {
124
+ return template;
125
+ }
126
+
127
+ if (/<body[^>]*>/.test(template)) {
128
+ return template.replace(/<body[^>]*>/, match => `${match}${script}`);
129
+ }
130
+
131
+ return template.replace("</head>", `</head>${script}`);
132
+ };
133
+
134
+ const validateWithSchema = async <T,>(
135
+ schema: StandardSchemaV1<unknown, T> | undefined,
136
+ value: unknown,
137
+ status: 400 | 404,
138
+ what: string,
139
+ ): Promise<T> => {
140
+ if (!schema) {
141
+ return value as T;
142
+ }
143
+
144
+ const result = await schema["~standard"].validate(value);
145
+
146
+ if (result.issues) {
147
+ throw new HTTPError(`Invalid ${what}`, {
148
+ status,
149
+ cause: new Error(result.issues.map(issue => issue.message).join("; ")),
150
+ });
151
+ }
152
+
153
+ return result.value;
154
+ };
155
+
156
+ export const definePage = <P = unknown, Q = unknown>(
157
+ options: DefinePageOptions<P, Q>,
158
+ ): PageHandler => {
159
+ return async (event, { assets, head: headInit, nonce }) => {
160
+ if (options.cache !== undefined) {
161
+ event.res.headers.set("cache-control", cacheControlValue(options.cache));
162
+ }
163
+
164
+ const params = await validateWithSchema(
165
+ options.params,
166
+ event.context.params,
167
+ 404,
168
+ "path params",
169
+ );
170
+
171
+ const query = await validateWithSchema(
172
+ options.query,
173
+ Object.fromEntries(event.url.searchParams),
174
+ 400,
175
+ "query params",
176
+ );
177
+
41
178
  const { head } = createStreamableHead({ init: [headInit] });
42
179
  const seoHead = (input?: UseSeoMetaInput) => useSeoMeta(head, input);
43
180
  seoHead(headInit?.seo);
@@ -50,12 +187,17 @@ export const definePage = (options: DefinePageOptions): PageHandler => {
50
187
  script: [{ type: "module", src: assets.entry }],
51
188
  });
52
189
 
53
- const content = await options.render(event, { head, seoHead });
190
+ const content = await options.render(event, { head, seoHead, params, query });
54
191
 
55
192
  if (content instanceof HTTPResponse) {
56
193
  return content;
57
194
  }
58
195
 
196
+ // must be collected before wrapStream — it clears entries after the shell
197
+ const payloadScript = headPayloadScript(collectHeadInputs(head), nonce);
198
+
199
+ const templateWithPayload = injectHeadPayload(template, payloadScript);
200
+
59
201
  const Root = RootComponent ?? Fragment;
60
202
 
61
203
  const App = async () => (
@@ -83,7 +225,7 @@ export const definePage = (options: DefinePageOptions): PageHandler => {
83
225
 
84
226
  html = transformHtmlTemplate(
85
227
  head,
86
- template.replace("<!--ssr-outlet-->", html ?? ""),
228
+ templateWithPayload.replace("<!--ssr-outlet-->", html ?? ""),
87
229
  );
88
230
 
89
231
  return new Response(html, responseInit);
@@ -92,7 +234,30 @@ export const definePage = (options: DefinePageOptions): PageHandler => {
92
234
  .request("/");
93
235
  }
94
236
 
95
- const stream = wrapStream(head, renderToReadableStream(<App />), template);
237
+ const stream = wrapStream(
238
+ head,
239
+ renderToReadableStream(<App />),
240
+ templateWithPayload,
241
+ undefined,
242
+ {
243
+ // same as unhead's default flushChunk, but with CSP nonce support
244
+ flushChunk: () => {
245
+ let chunk: string;
246
+
247
+ try {
248
+ chunk = renderSSRHeadSuspenseChunk(head);
249
+ } catch {
250
+ return "";
251
+ }
252
+
253
+ if (!chunk) {
254
+ return "";
255
+ }
256
+
257
+ return `<script${scriptNonceAttr(nonce)}>window.__unhead__&&(${chunk});document.currentScript.remove()</script>`;
258
+ },
259
+ },
260
+ );
96
261
 
97
262
  return new Response(stream, responseInit);
98
263
  };
@@ -0,0 +1,46 @@
1
+ import type { Child, FC } from "hono/jsx";
2
+
3
+ export interface SafeAsyncFallbackProps {
4
+ error: unknown;
5
+ }
6
+
7
+ const DefaultFallback: FC<SafeAsyncFallbackProps> = () => (
8
+ <div
9
+ role="alert"
10
+ data-testid="yamf-error"
11
+ style="padding: 1rem; border: 1px solid currentColor"
12
+ >
13
+ Something went wrong.
14
+ </div>
15
+ );
16
+
17
+ /**
18
+ * Wraps an async server component with error handling for streaming SSR.
19
+ *
20
+ * When an async component inside a Suspense boundary rejects after the shell
21
+ * has been flushed, the streaming renderer swallows the error: the browser
22
+ * keeps the loading fallback forever with a 200 status, and nothing reaches
23
+ * the error handler. `safeAsync` awaits the component directly, so a
24
+ * rejection is caught here and rendered as `fallback` instead.
25
+ *
26
+ * Catches errors of the wrapped component itself, not of its subtree.
27
+ */
28
+ export const safeAsync = <P extends object>(
29
+ Component: (props: P) => Child | Promise<Child>,
30
+ fallback: FC<SafeAsyncFallbackProps> = DefaultFallback,
31
+ ): FC<P> => {
32
+ const Safe = async (props: P): Promise<Child> => {
33
+ try {
34
+ return await Component(props);
35
+ } catch (error) {
36
+ console.error("[yamf] safeAsync: async component rejected", error);
37
+
38
+ return fallback({ error });
39
+ }
40
+ };
41
+
42
+ // hono's FC type does not include Promise<Child> in its return type,
43
+ // but the JSX renderer awaits component results
44
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
45
+ return Safe as FC<P>;
46
+ };
@@ -24,6 +24,8 @@ interface Route {
24
24
 
25
25
  const router = createRouter<Route>();
26
26
 
27
+ const registeredRoutes = new Map<string, string>();
28
+
27
29
  for (const [relativePath, handler] of Object.entries(pages).toSorted((a, b) =>
28
30
  a[0].localeCompare(b[0]),
29
31
  )) {
@@ -46,6 +48,16 @@ for (const [relativePath, handler] of Object.entries(pages).toSorted((a, b) =>
46
48
 
47
49
  route = withLeadingSlash(withoutTrailingSlash(route));
48
50
 
51
+ const conflictingFile = registeredRoutes.get(route);
52
+
53
+ if (conflictingFile && conflictingFile !== relativePath) {
54
+ console.warn(
55
+ `[yamf] duplicate page route "${route}": ${conflictingFile} and ${relativePath} both map to it — the routes conflict`,
56
+ );
57
+ }
58
+
59
+ registeredRoutes.set(route, relativePath);
60
+
49
61
  addRoute(router, "GET", route, {
50
62
  handler,
51
63
  serverAssets: pagesServerAssets[relativePath],
@@ -57,6 +69,15 @@ export type ServerEntry = EventHandlerWithFetch<EventHandlerRequest, Promise<unk
57
69
  export interface DefineServerEntryOptions {
58
70
  head?: YamfHead | ((event: H3Event) => YamfHead);
59
71
  disableEarlyHints?: boolean;
72
+ /**
73
+ * CSP nonce for the inline scripts yamf injects (head handshake payload
74
+ * and streamed head patches).
75
+ *
76
+ * Note: unhead's stream bootstrap script (`window.__unhead__||(…)`) does
77
+ * not support a nonce in unhead 3.x — with a strict `script-src` CSP it
78
+ * must be allowed separately (e.g. via a hash).
79
+ */
80
+ nonce?: string | ((event: H3Event) => string | undefined);
60
81
  }
61
82
 
62
83
  export const defineServerEntry = (options?: DefineServerEntryOptions): ServerEntry => {
@@ -72,7 +93,9 @@ export const defineServerEntry = (options?: DefineServerEntryOptions): ServerEnt
72
93
 
73
94
  const assets = clientAssets.merge(...[rootAssets, serverAssets].filter(x => !!x));
74
95
 
75
- if (!options?.disableEarlyHints) {
96
+ // early hints are pointless inside the prerender worker — nitro's
97
+ // prerenderer buffers the response to a file and discards them
98
+ if (!options?.disableEarlyHints && !event.req.headers.has("x-nitro-prerender")) {
76
99
  const link = [
77
100
  ...assets.js.map(script => `<${script.href}>; rel=modulepreload`),
78
101
  ...assets.css.map(style => `<${style.href}>; rel=preload; as=style`),
@@ -86,6 +109,7 @@ export const defineServerEntry = (options?: DefineServerEntryOptions): ServerEnt
86
109
  return (await handler())(event, {
87
110
  assets,
88
111
  head: typeof options?.head === "function" ? options.head(event) : options?.head,
112
+ nonce: typeof options?.nonce === "function" ? options.nonce(event) : options?.nonce,
89
113
  });
90
114
  };
91
115
 
package/src/vite/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
2
3
  import type { NitroPluginConfig } from "nitro/vite";
3
4
  import { nitro } from "nitro/vite";
4
5
  import type { EnvironmentOptions, PluginOption } from "vite";
5
6
  import { islands } from "./islands";
7
+ import { prerenderModule } from "./prerender";
6
8
  import { virtualAssets } from "./virtual-assets";
7
9
  import { virtualErrorHandler } from "./virtual-error-handler";
8
10
  import { virtualPages } from "./virtual-pages";
@@ -19,7 +21,11 @@ const yamf = (options?: YamfOptions): PluginOption[] => {
19
21
  // TODO: extendable config
20
22
  plugins.push({
21
23
  name: "yamf:config",
22
- config() {
24
+ config(config) {
25
+ // resolved against the vite root (not cwd) so running vite from
26
+ // another working directory does not silently drop the ssr entry
27
+ const root = config.root ?? process.cwd();
28
+
23
29
  const ssrEnv: EnvironmentOptions = {
24
30
  build: {
25
31
  // TODO: figure out if this should be true
@@ -61,7 +67,7 @@ const yamf = (options?: YamfOptions): PluginOption[] => {
61
67
  ],
62
68
  },
63
69
  environments: {
64
- ...(existsSync("./src/server.tsx") ? { ssr: ssrEnv } : {}),
70
+ ...(existsSync(resolve(root, "./src/server.tsx")) ? { ssr: ssrEnv } : {}),
65
71
  client: {
66
72
  build: {
67
73
  rolldownOptions: {
@@ -82,16 +88,37 @@ const yamf = (options?: YamfOptions): PluginOption[] => {
82
88
  plugins.push(virtualTemplate());
83
89
  plugins.push(virtualRoot());
84
90
 
91
+ // registers the nitro module that makes prerender work (see prerenderModule)
92
+ plugins.push({
93
+ name: "yamf:prerender",
94
+ nitro: prerenderModule,
95
+ });
96
+
97
+ // reserved nitro keys are yamf-controlled and intentionally win over user
98
+ // values; everything else the user passes through wins. user publicAssets
99
+ // are merged with yamf's instead of being dropped
100
+ const {
101
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
102
+ serverDir: _serverDir,
103
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
104
+ renderer: _renderer,
105
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
106
+ compressPublicAssets: _compressPublicAssets,
107
+ publicAssets: userPublicAssets,
108
+ ...userNitro
109
+ } = options?.nitro ?? {};
110
+
85
111
  plugins.push(
86
112
  nitro({
113
+ ...userNitro,
87
114
  serverDir: "./src",
88
115
  renderer: false,
89
- ...options?.nitro,
90
116
  compressPublicAssets: {
91
117
  gzip: true,
92
118
  brotli: true,
93
119
  },
94
120
  publicAssets: [
121
+ ...(userPublicAssets ?? []),
95
122
  {
96
123
  baseURL: "assets",
97
124
  dir: "./public/assets",