@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.
- package/README.md +63 -7
- package/dist/client/index.js +39 -1
- package/dist/client/index.js.map +1 -0
- package/dist/components/Head.d.ts +3 -4
- package/dist/components/Head.d.ts.map +1 -1
- package/dist/components/Head.js.map +1 -1
- package/dist/context/ssr.d.ts +1 -2
- package/dist/context/ssr.d.ts.map +1 -1
- package/dist/hooks/isPrerendering.d.ts +12 -0
- package/dist/hooks/isPrerendering.d.ts.map +1 -0
- package/dist/hooks/isPrerendering.js +17 -0
- package/dist/hooks/isPrerendering.js.map +1 -0
- package/dist/hooks/useEvent.d.ts +1 -2
- package/dist/hooks/useEvent.d.ts.map +1 -1
- package/dist/hooks/useHead.d.ts +10 -5
- package/dist/hooks/useHead.d.ts.map +1 -1
- package/dist/hooks/useHead.js +46 -4
- package/dist/hooks/useHead.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -1
- package/dist/island/client.js +44 -36
- package/dist/island/client.js.map +1 -1
- package/dist/island/types.d.ts +2 -3
- package/dist/island/types.d.ts.map +1 -1
- package/dist/page.d.ts +32 -6
- package/dist/page.d.ts.map +1 -1
- package/dist/page.js +60 -6
- package/dist/page.js.map +1 -1
- package/dist/safeAsync.d.ts +19 -0
- package/dist/safeAsync.d.ts.map +1 -0
- package/dist/safeAsync.js +34 -0
- package/dist/safeAsync.js.map +1 -0
- package/dist/server/entry.d.mts +12 -4
- package/dist/server/entry.d.mts.map +1 -1
- package/dist/server/entry.mjs +9 -3
- package/dist/server/entry.mjs.map +1 -1
- package/dist/server/island/server.d.mts +2 -3
- package/dist/server/island/server.d.mts.map +1 -1
- package/dist/server/island/server.mjs +13 -3
- package/dist/server/island/server.mjs.map +1 -1
- package/dist/server/island/types.d.mts +1 -2
- package/dist/server/island/types.d.mts.map +1 -1
- package/dist/server/shared/assets.d.mts +1 -2
- package/dist/server/shared/assets.d.mts.map +1 -1
- package/dist/server/shared/head.d.mts +1 -2
- package/dist/server/shared/head.d.mts.map +1 -1
- package/dist/shared/assets.d.ts +2 -3
- package/dist/shared/assets.d.ts.map +1 -1
- package/dist/shared/head.d.ts +1 -2
- package/dist/shared/head.d.ts.map +1 -1
- package/dist/vite/index.d.mts +2 -2
- package/dist/vite/index.d.mts.map +1 -1
- package/dist/vite/index.mjs +13 -5
- package/dist/vite/index.mjs.map +1 -1
- package/dist/vite/islands.mjs +34 -5
- package/dist/vite/islands.mjs.map +1 -1
- package/dist/vite/prerender.mjs +16 -0
- package/dist/vite/prerender.mjs.map +1 -0
- package/dist/vite/virtual-assets.mjs +1 -1
- package/dist/vite/virtual-assets.mjs.map +1 -1
- package/dist/vite/virtual-error-handler.mjs +1 -1
- package/dist/vite/virtual-error-handler.mjs.map +1 -1
- package/dist/vite/virtual-pages.mjs +1 -1
- package/dist/vite/virtual-pages.mjs.map +1 -1
- package/dist/vite/virtual-root.mjs +12 -5
- package/dist/vite/virtual-root.mjs.map +1 -1
- package/dist/vite/virtual-template.mjs +9 -4
- package/dist/vite/virtual-template.mjs.map +1 -1
- package/package.json +27 -20
- package/src/client/index.ts +51 -1
- package/src/components/Head.tsx +2 -2
- package/src/hooks/isPrerendering.ts +13 -0
- package/src/hooks/useHead.tsx +98 -9
- package/src/index.ts +10 -1
- package/src/island/client.tsx +73 -65
- package/src/island/server.tsx +31 -6
- package/src/page.tsx +176 -11
- package/src/safeAsync.tsx +46 -0
- package/src/server/entry.tsx +25 -1
- package/src/vite/index.ts +30 -3
- package/src/vite/islands.ts +68 -8
- package/src/vite/prerender.ts +27 -0
- package/src/vite/virtual-root.ts +14 -4
- package/src/vite/virtual-template.ts +15 -3
package/src/island/client.tsx
CHANGED
|
@@ -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
|
|
7
|
+
declare let __yamf_raw_import__: <T>(file: string) => Promise<T>;
|
|
8
8
|
|
|
9
|
-
const listeners
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
35
|
+
if (typeof ric === "function") {
|
|
36
|
+
ric(callback);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
+
};
|
package/src/island/server.tsx
CHANGED
|
@@ -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"
|
|
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 } =
|
|
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={
|
|
59
|
+
island-props={islandProps}
|
|
34
60
|
island-src={assets.entry}
|
|
35
61
|
island-entry={exportName}
|
|
36
|
-
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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, """)}"` : "";
|
|
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
|
-
|
|
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(
|
|
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
|
+
};
|
package/src/server/entry.tsx
CHANGED
|
@@ -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
|
-
|
|
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",
|