hadars 0.2.0 → 0.2.2-rc.0
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 +247 -15
- package/cli-lib.ts +184 -15
- package/dist/chunk-H72BZXOA.js +332 -0
- package/dist/cli.js +596 -23
- package/dist/cloudflare.cjs +1394 -0
- package/dist/cloudflare.d.cts +64 -0
- package/dist/cloudflare.d.ts +64 -0
- package/dist/cloudflare.js +68 -0
- package/dist/{hadars-Bh-V5YXg.d.cts → hadars-mKu5txjW.d.cts} +93 -37
- package/dist/{hadars-Bh-V5YXg.d.ts → hadars-mKu5txjW.d.ts} +93 -37
- package/dist/index.cjs +140 -164
- package/dist/index.d.cts +5 -11
- package/dist/index.d.ts +5 -11
- package/dist/index.js +140 -163
- package/dist/lambda.cjs +6 -2
- package/dist/lambda.d.cts +1 -2
- package/dist/lambda.d.ts +1 -2
- package/dist/lambda.js +10 -317
- package/dist/ssr-render-worker.js +3 -2
- package/dist/utils/Head.tsx +149 -195
- package/dist/utils/clientScript.tsx +9 -0
- package/index.ts +3 -0
- package/package.json +7 -2
- package/src/build.ts +29 -0
- package/src/cloudflare.ts +139 -0
- package/src/index.tsx +3 -3
- package/src/source/context.ts +113 -0
- package/src/source/graphiql.ts +96 -0
- package/src/source/inference.ts +188 -0
- package/src/source/runner.ts +114 -0
- package/src/source/store.ts +48 -0
- package/src/ssr-render-worker.ts +4 -1
- package/src/static.ts +106 -0
- package/src/types/hadars.ts +91 -2
- package/src/utils/Head.tsx +149 -195
- package/src/utils/clientScript.tsx +9 -0
- package/src/utils/response.tsx +10 -3
package/src/static.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static site export core — rendering and file I/O.
|
|
3
|
+
*
|
|
4
|
+
* Imported by cli-lib.ts (bundled into dist/cli.js via esbuild) and by tests.
|
|
5
|
+
* Has no dependency on the rspack build pipeline — only SSR utilities.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cp, mkdir, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join, dirname, basename } from 'node:path';
|
|
10
|
+
import { parseRequest } from './utils/request';
|
|
11
|
+
import { getReactResponse, buildHeadHtml } from './utils/response';
|
|
12
|
+
import { buildSsrHtml, makePrecontentHtmlGetter } from './utils/ssrHandler';
|
|
13
|
+
import type { HadarsEntryModule, HadarsStaticContext, GraphQLExecutor } from './types/hadars';
|
|
14
|
+
|
|
15
|
+
export interface StaticRenderResult {
|
|
16
|
+
/** URL paths that were successfully rendered. */
|
|
17
|
+
rendered: string[];
|
|
18
|
+
/** Paths that failed, with the caught error. */
|
|
19
|
+
errors: Array<{ path: string; error: Error }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pre-renders a list of URL paths to HTML files and copies static assets.
|
|
24
|
+
*
|
|
25
|
+
* Pages are rendered serially — `getReactResponse` writes to
|
|
26
|
+
* `globalThis.__hadarsUnsuspend / __hadarsContext` which are not re-entrant-safe.
|
|
27
|
+
*/
|
|
28
|
+
export async function renderStaticSite(opts: {
|
|
29
|
+
ssrModule: HadarsEntryModule<any>;
|
|
30
|
+
htmlSource: string;
|
|
31
|
+
staticSrc: string;
|
|
32
|
+
paths: string[];
|
|
33
|
+
outputDir: string;
|
|
34
|
+
graphql?: GraphQLExecutor;
|
|
35
|
+
}): Promise<StaticRenderResult> {
|
|
36
|
+
const { ssrModule, htmlSource, staticSrc, paths, outputDir } = opts;
|
|
37
|
+
|
|
38
|
+
const staticCtx: HadarsStaticContext = {
|
|
39
|
+
graphql: opts.graphql ?? (() => Promise.reject(
|
|
40
|
+
new Error('[hadars] No graphql executor configured. Add a `graphql` function to your hadars.config.'),
|
|
41
|
+
)),
|
|
42
|
+
};
|
|
43
|
+
const getPrecontentHtml = makePrecontentHtmlGetter(Promise.resolve(htmlSource));
|
|
44
|
+
|
|
45
|
+
await mkdir(outputDir, { recursive: true });
|
|
46
|
+
|
|
47
|
+
const rendered: string[] = [];
|
|
48
|
+
const errors: Array<{ path: string; error: Error }> = [];
|
|
49
|
+
|
|
50
|
+
for (const urlPath of paths) {
|
|
51
|
+
try {
|
|
52
|
+
const req = parseRequest(new Request('http://localhost' + urlPath));
|
|
53
|
+
|
|
54
|
+
const { head, getAppBody, finalize } = await getReactResponse(req, {
|
|
55
|
+
document: {
|
|
56
|
+
body: ssrModule.default as any,
|
|
57
|
+
getInitProps: ssrModule.getInitProps,
|
|
58
|
+
getFinalProps: ssrModule.getFinalProps,
|
|
59
|
+
},
|
|
60
|
+
staticCtx,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const bodyHtml = await getAppBody();
|
|
64
|
+
const { clientProps } = await finalize();
|
|
65
|
+
const headHtml = buildHeadHtml(head);
|
|
66
|
+
// Inject a flag so the client knows it's a static export and should
|
|
67
|
+
// fetch index.json sidecars directly instead of hitting a live server.
|
|
68
|
+
const staticClientProps = { ...clientProps, __hadarsStatic: true };
|
|
69
|
+
const html = await buildSsrHtml(bodyHtml, staticClientProps, headHtml, getPrecontentHtml);
|
|
70
|
+
|
|
71
|
+
// '/' → <outputDir>/index.html
|
|
72
|
+
// '/about' → <outputDir>/about/index.html
|
|
73
|
+
const cleanPath = urlPath.replace(/\/$/, '');
|
|
74
|
+
const pageDir = cleanPath === '' ? outputDir : join(outputDir, cleanPath);
|
|
75
|
+
await mkdir(pageDir, { recursive: true });
|
|
76
|
+
await writeFile(join(pageDir, 'index.html'), html, 'utf-8');
|
|
77
|
+
|
|
78
|
+
// Write a JSON sidecar so useServerData can hydrate on client-side
|
|
79
|
+
// navigation without a live server. The format matches the live
|
|
80
|
+
// server's Accept: application/json response: { serverData: {...} }.
|
|
81
|
+
const serverData = (staticClientProps as any).__serverData ?? {};
|
|
82
|
+
await writeFile(
|
|
83
|
+
join(pageDir, 'index.json'),
|
|
84
|
+
JSON.stringify({ serverData }),
|
|
85
|
+
'utf-8',
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
rendered.push(urlPath);
|
|
89
|
+
} catch (err: any) {
|
|
90
|
+
errors.push({
|
|
91
|
+
path: urlPath,
|
|
92
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Copy .hadars/static/ → <outputDir>/static/, excluding the SSR template.
|
|
98
|
+
const staticDest = join(outputDir, 'static');
|
|
99
|
+
await mkdir(staticDest, { recursive: true });
|
|
100
|
+
await cp(staticSrc, staticDest, {
|
|
101
|
+
recursive: true,
|
|
102
|
+
filter: (src: string) => basename(src) !== 'out.html',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return { rendered, errors };
|
|
106
|
+
}
|
package/src/types/hadars.ts
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
import type { LinkHTMLAttributes, MetaHTMLAttributes, ScriptHTMLAttributes, StyleHTMLAttributes } from "react";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* In-process GraphQL executor passed to `getInitProps` and `paths` during
|
|
5
|
+
* `hadars export static`. Hadars is executor-agnostic — configure it in
|
|
6
|
+
* `hadars.config.ts` using any GraphQL library (e.g. `graphql-js`):
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { graphql as gql, buildSchema } from 'graphql';
|
|
10
|
+
* const schema = buildSchema(`type Query { hello: String }`);
|
|
11
|
+
* const rootValue = { hello: () => 'world' };
|
|
12
|
+
*
|
|
13
|
+
* export default {
|
|
14
|
+
* graphql: (query, variables) =>
|
|
15
|
+
* gql({ schema, rootValue, source: query, variableValues: variables }),
|
|
16
|
+
* } satisfies HadarsOptions;
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export type GraphQLExecutor = (
|
|
20
|
+
query: string,
|
|
21
|
+
variables?: Record<string, unknown>,
|
|
22
|
+
) => Promise<{ data?: any; errors?: ReadonlyArray<{ message: string }> }>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Context passed as the second argument to `getInitProps` and `paths`
|
|
26
|
+
* during `hadars export static`. Not present in dev/run mode.
|
|
27
|
+
*/
|
|
28
|
+
export interface HadarsStaticContext {
|
|
29
|
+
graphql: GraphQLExecutor;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type HadarsGetInitialProps<T extends {}> = (req: HadarsRequest, ctx?: HadarsStaticContext) => Promise<T> | T;
|
|
4
33
|
export type HadarsGetClientProps<T extends {}> = (props: T) => Promise<T> | T;
|
|
5
34
|
export type HadarsGetFinalProps<T extends {}> = (props: HadarsProps<T>) => Promise<T> | T;
|
|
6
35
|
export type HadarsApp<T extends {}> = React.FC<HadarsProps<T>>;
|
|
@@ -40,7 +69,6 @@ export interface AppContext {
|
|
|
40
69
|
|
|
41
70
|
export type HadarsEntryBase = {
|
|
42
71
|
location: string;
|
|
43
|
-
context: AppContext;
|
|
44
72
|
}
|
|
45
73
|
|
|
46
74
|
export type HadarsProps<T extends {}> = T & HadarsEntryBase;
|
|
@@ -181,6 +209,67 @@ export interface HadarsOptions {
|
|
|
181
209
|
*/
|
|
182
210
|
cache?: (req: HadarsRequest) => { key: string; ttl?: number } | null | undefined
|
|
183
211
|
| Promise<{ key: string; ttl?: number } | null | undefined>;
|
|
212
|
+
/**
|
|
213
|
+
* Static export path list. Required for `hadars export static`.
|
|
214
|
+
*
|
|
215
|
+
* Return an array of URL paths (e.g. `['/', '/about', '/blog/hello']`) that
|
|
216
|
+
* should be pre-rendered to HTML files. May be async.
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* paths: () => ['/', '/about', '/contact']
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* paths: async () => {
|
|
223
|
+
* const posts = await fetchBlogPosts();
|
|
224
|
+
* return ['/', ...posts.map(p => `/blog/${p.slug}`)];
|
|
225
|
+
* }
|
|
226
|
+
*/
|
|
227
|
+
/**
|
|
228
|
+
* In-process GraphQL executor. Supply this to use the GraphQL data layer
|
|
229
|
+
* in `paths` and `getInitProps` during `hadars export static`.
|
|
230
|
+
* Has no effect in `dev` / `run` mode.
|
|
231
|
+
*/
|
|
232
|
+
graphql?: GraphQLExecutor;
|
|
233
|
+
paths?: (ctx: HadarsStaticContext) => Promise<string[]> | string[];
|
|
234
|
+
/**
|
|
235
|
+
* Gatsby-compatible source plugins to run before `hadars export static`.
|
|
236
|
+
*
|
|
237
|
+
* Each entry mirrors Gatsby's `gatsby-config.js` plugin object format:
|
|
238
|
+
* `{ resolve: 'gatsby-source-contentful', options: { spaceId: '...', accessToken: '...' } }`
|
|
239
|
+
*
|
|
240
|
+
* The plugin must export a `sourceNodes` function with the standard Gatsby API.
|
|
241
|
+
* Hadars provides a thin shim covering the most-used surface:
|
|
242
|
+
* `actions.createNode`, `createNodeId`, `createContentDigest`, `cache`, `reporter`,
|
|
243
|
+
* `getNode`, `getNodes`, `getNodesByType`.
|
|
244
|
+
*
|
|
245
|
+
* After all sources have run, hadars auto-generates a GraphQL schema from the
|
|
246
|
+
* collected nodes and makes it available via `config.graphql` in `getInitProps`
|
|
247
|
+
* and `paths`. Requires `graphql` to be installed in the project.
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* sources: [
|
|
251
|
+
* {
|
|
252
|
+
* resolve: 'gatsby-source-filesystem',
|
|
253
|
+
* options: { name: 'posts', path: './content/posts' },
|
|
254
|
+
* },
|
|
255
|
+
* ]
|
|
256
|
+
*/
|
|
257
|
+
sources?: HadarsSourceEntry[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A Gatsby-compatible source plugin entry, matching the format used in
|
|
262
|
+
* `gatsby-config.js` / `gatsby-config.ts`.
|
|
263
|
+
*/
|
|
264
|
+
export interface HadarsSourceEntry {
|
|
265
|
+
/**
|
|
266
|
+
* Package name (e.g. `'gatsby-source-contentful'`) or a pre-imported module
|
|
267
|
+
* object that exports `sourceNodes`. Using a module object lets you pass
|
|
268
|
+
* local source plugins without publishing them to npm.
|
|
269
|
+
*/
|
|
270
|
+
resolve: string | { sourceNodes?: (ctx: any, opts?: any) => Promise<void> | void };
|
|
271
|
+
/** Plugin options forwarded as the second argument to `sourceNodes`. */
|
|
272
|
+
options?: Record<string, unknown>;
|
|
184
273
|
}
|
|
185
274
|
|
|
186
275
|
|
package/src/utils/Head.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type {
|
|
2
|
+
import type { AppHead, AppUnsuspend, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/hadars'
|
|
3
3
|
|
|
4
4
|
interface InnerContext {
|
|
5
5
|
setTitle: (title: string) => void;
|
|
@@ -41,142 +41,95 @@ function deriveKey(tag: string, props: Record<string, any>): string {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const { head } = context;
|
|
69
|
-
|
|
70
|
-
// mutate seoData
|
|
71
|
-
const setTitle = React.useCallback((title: string) => {
|
|
72
|
-
head.title = title;
|
|
73
|
-
}, [head]);
|
|
74
|
-
const addMeta = React.useCallback((props: MetaProps) => {
|
|
75
|
-
head.meta[deriveKey('meta', props as any)] = props;
|
|
76
|
-
}, [head]);
|
|
77
|
-
const addLink = React.useCallback((props: LinkProps) => {
|
|
78
|
-
head.link[deriveKey('link', props as any)] = props;
|
|
79
|
-
}, [head]);
|
|
80
|
-
const addStyle = React.useCallback((props: StyleProps) => {
|
|
81
|
-
head.style[deriveKey('style', props as any)] = props;
|
|
82
|
-
}, [head]);
|
|
83
|
-
const addScript = React.useCallback((props: ScriptProps) => {
|
|
84
|
-
head.script[deriveKey('script', props as any)] = props;
|
|
85
|
-
}, [head]);
|
|
86
|
-
|
|
87
|
-
const setStatus = React.useCallback((status: number) => {
|
|
88
|
-
head.status = status;
|
|
89
|
-
}, [head]);
|
|
90
|
-
|
|
91
|
-
const contextValue: InnerContext = React.useMemo(() => ({
|
|
92
|
-
setTitle,
|
|
93
|
-
addMeta,
|
|
94
|
-
addLink,
|
|
95
|
-
addStyle,
|
|
96
|
-
addScript,
|
|
97
|
-
setStatus,
|
|
98
|
-
}), [ setTitle, addMeta, addLink, addStyle, addScript, setStatus]);
|
|
99
|
-
return (
|
|
100
|
-
<AppContext.Provider value={contextValue}>
|
|
101
|
-
{children}
|
|
102
|
-
</AppContext.Provider>
|
|
103
|
-
);
|
|
104
|
-
} );
|
|
44
|
+
// ── Head context resolution ──────────────────────────────────────────────────
|
|
45
|
+
//
|
|
46
|
+
// On the server, HadarsHead reads from globalThis.__hadarsContext.head which
|
|
47
|
+
// the SSR render worker populates before every renderToString call.
|
|
48
|
+
// On the client, HadarsHead directly manipulates the DOM.
|
|
49
|
+
// This approach means users do NOT need to wrap their App with HadarsContext.
|
|
50
|
+
|
|
51
|
+
const LINK_ATTR: Record<string, string> = {
|
|
52
|
+
crossOrigin: 'crossorigin',
|
|
53
|
+
referrerPolicy: 'referrerpolicy',
|
|
54
|
+
fetchPriority: 'fetchpriority',
|
|
55
|
+
hrefLang: 'hreflang',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function makeServerCtx(head: AppHead): InnerContext {
|
|
59
|
+
return {
|
|
60
|
+
setTitle: (t) => { head.title = t; },
|
|
61
|
+
addMeta: (p) => { head.meta[deriveKey('meta', p as any)] = p; },
|
|
62
|
+
addLink: (p) => { head.link[deriveKey('link', p as any)] = p; },
|
|
63
|
+
addStyle: (p) => { head.style[deriveKey('style', p as any)] = p; },
|
|
64
|
+
addScript: (p) => { head.script[deriveKey('script', p as any)] = p; },
|
|
65
|
+
setStatus: (s) => { head.status = s; },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
105
68
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}, []);
|
|
162
|
-
|
|
163
|
-
const contextValue: InnerContext = React.useMemo(() => ({
|
|
164
|
-
setTitle,
|
|
165
|
-
addMeta,
|
|
166
|
-
addLink,
|
|
167
|
-
addStyle,
|
|
168
|
-
addScript,
|
|
169
|
-
setStatus: () => { },
|
|
170
|
-
}), [setTitle, addMeta, addLink, addStyle, addScript]);
|
|
171
|
-
|
|
172
|
-
return (
|
|
173
|
-
<AppContext.Provider value={contextValue}>
|
|
174
|
-
{children}
|
|
175
|
-
</AppContext.Provider>
|
|
176
|
-
);
|
|
177
|
-
} );
|
|
69
|
+
// Lazy singleton for the client-side DOM context.
|
|
70
|
+
let _cliCtx: InnerContext | null = null;
|
|
71
|
+
|
|
72
|
+
function makeClientCtx(): InnerContext {
|
|
73
|
+
if (_cliCtx) return _cliCtx;
|
|
74
|
+
_cliCtx = {
|
|
75
|
+
setTitle: (title) => { document.title = title; },
|
|
76
|
+
setStatus: () => { /* no-op on client */ },
|
|
77
|
+
addMeta: (props) => {
|
|
78
|
+
const p = props as Record<string, any>;
|
|
79
|
+
let meta: HTMLMetaElement | null = null;
|
|
80
|
+
if (p.name) meta = document.querySelector(`meta[name="${CSS.escape(p.name)}"]`);
|
|
81
|
+
else if (p.property) meta = document.querySelector(`meta[property="${CSS.escape(p.property)}"]`);
|
|
82
|
+
else if (p.httpEquiv ?? p['http-equiv']) meta = document.querySelector(`meta[http-equiv="${CSS.escape(p.httpEquiv ?? p['http-equiv'])}"]`);
|
|
83
|
+
else if ('charSet' in p || 'charset' in p) meta = document.querySelector('meta[charset]');
|
|
84
|
+
if (!meta) { meta = document.createElement('meta'); document.head.appendChild(meta); }
|
|
85
|
+
for (const [k, v] of Object.entries(p)) {
|
|
86
|
+
if (v != null && v !== false) meta.setAttribute(k === 'charSet' ? 'charset' : k === 'httpEquiv' ? 'http-equiv' : k, String(v));
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
addLink: (props) => {
|
|
90
|
+
const p = props as Record<string, any>;
|
|
91
|
+
let link: HTMLLinkElement | null = null;
|
|
92
|
+
const asSel = p.as ? `[as="${CSS.escape(p.as)}"]` : '';
|
|
93
|
+
if (p.rel && p.href) link = document.querySelector(`link[rel="${CSS.escape(p.rel)}"][href="${CSS.escape(p.href)}"]${asSel}`);
|
|
94
|
+
else if (p.rel) link = document.querySelector(`link[rel="${CSS.escape(p.rel)}"]${asSel}`);
|
|
95
|
+
if (!link) { link = document.createElement('link'); document.head.appendChild(link); }
|
|
96
|
+
for (const [k, v] of Object.entries(p)) {
|
|
97
|
+
if (v != null && v !== false) link.setAttribute(LINK_ATTR[k] ?? k, String(v));
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
addStyle: (props) => {
|
|
101
|
+
const p = props as Record<string, any>;
|
|
102
|
+
let style: HTMLStyleElement | null = null;
|
|
103
|
+
if (p['data-id']) style = document.querySelector(`style[data-id="${CSS.escape(p['data-id'])}"]`);
|
|
104
|
+
if (!style) { style = document.createElement('style'); document.head.appendChild(style); }
|
|
105
|
+
for (const [k, v] of Object.entries(p)) {
|
|
106
|
+
if (k === 'dangerouslySetInnerHTML') { style.innerHTML = (v as any).__html ?? ''; continue; }
|
|
107
|
+
if (v != null && v !== false) style.setAttribute(k, String(v));
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
addScript: (props) => {
|
|
111
|
+
const p = props as Record<string, any>;
|
|
112
|
+
let script: HTMLScriptElement | null = null;
|
|
113
|
+
if (p.src) script = document.querySelector(`script[src="${CSS.escape(p.src)}"]`);
|
|
114
|
+
else if (p['data-id']) script = document.querySelector(`script[data-id="${CSS.escape(p['data-id'])}"]`);
|
|
115
|
+
if (!script) { script = document.createElement('script'); document.body.appendChild(script); }
|
|
116
|
+
for (const [k, v] of Object.entries(p)) {
|
|
117
|
+
if (k === 'dangerouslySetInnerHTML') { script.innerHTML = (v as any).__html ?? ''; continue; }
|
|
118
|
+
if (v != null && v !== false) script.setAttribute(k, String(v));
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
return _cliCtx;
|
|
123
|
+
}
|
|
178
124
|
|
|
179
|
-
|
|
125
|
+
function getCtx(): InnerContext | null {
|
|
126
|
+
if (typeof window === 'undefined') {
|
|
127
|
+
const head: AppHead | undefined = (globalThis as any).__hadarsContext?.head;
|
|
128
|
+
if (!head) return null;
|
|
129
|
+
return makeServerCtx(head);
|
|
130
|
+
}
|
|
131
|
+
return makeClientCtx();
|
|
132
|
+
}
|
|
180
133
|
|
|
181
134
|
// ── useServerData ─────────────────────────────────────────────────────────────
|
|
182
135
|
//
|
|
@@ -293,14 +246,23 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
293
246
|
// render is registered against the same deferred before the fetch starts.
|
|
294
247
|
queueMicrotask(async () => {
|
|
295
248
|
try {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
249
|
+
let json: { serverData: Record<string, unknown> } | null = null;
|
|
250
|
+
|
|
251
|
+
if ((globalThis as any).__hadarsStatic) {
|
|
252
|
+
// Static export: the __hadarsStatic flag was embedded in the
|
|
253
|
+
// page by `hadars export static`. Fetch the pre-generated
|
|
254
|
+
// index.json sidecar directly — no live SSR server exists.
|
|
255
|
+
const sidecarUrl = pathKey.replace(/\/$/, '') + '/index.json';
|
|
256
|
+
const res = await fetch(sidecarUrl).catch(() => null);
|
|
257
|
+
if (res?.ok) json = await res.json().catch(() => null);
|
|
258
|
+
} else {
|
|
259
|
+
// Live server: request the current URL with Accept: application/json.
|
|
260
|
+
const res = await fetch(pathKey, { headers: { 'Accept': 'application/json' } });
|
|
261
|
+
if (res.ok) json = await res.json();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const [k, v] of Object.entries(json?.serverData ?? {})) {
|
|
265
|
+
clientServerDataCache.set(k, v);
|
|
304
266
|
}
|
|
305
267
|
} finally {
|
|
306
268
|
fetchedPaths.add(pathKey);
|
|
@@ -319,54 +281,23 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
319
281
|
const unsuspend: AppUnsuspend | undefined = (globalThis as any).__hadarsUnsuspend;
|
|
320
282
|
if (!unsuspend) return undefined;
|
|
321
283
|
|
|
322
|
-
// ──
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
// A key is unstable when a key that WAS seen in the previous pass is now
|
|
328
|
-
// absent from the current pass while a new key appears instead. This means
|
|
329
|
-
// a component produced a different key string between passes (e.g. Date.now()
|
|
330
|
-
// in the key). We fire immediately — there is no need to wait for other
|
|
331
|
-
// entries to settle first, because a legitimately-new component always extends
|
|
332
|
-
// seenLastPass (all previous keys remain present in seenThisPass).
|
|
284
|
+
// ── unstable-key detection ───────────────────────────────────────────────
|
|
285
|
+
// Track the last key thrown as a pending promise and whether it was accessed
|
|
286
|
+
// as a cache hit in the current pass. If a new pending entry appears while
|
|
287
|
+
// the previous pending key resolved but was never requested, the key is
|
|
288
|
+
// changing between passes (e.g. Date.now() or Math.random() in the key).
|
|
333
289
|
const _u = unsuspend as any;
|
|
334
|
-
if (!_u.
|
|
335
|
-
if (!_u.seenLastPass) _u.seenLastPass = new Set<string>();
|
|
336
|
-
|
|
337
|
-
if (_u.newPassStarting) {
|
|
338
|
-
// This is the first useServerData call after a thrown promise — rotate.
|
|
339
|
-
_u.seenLastPass = new Set(_u.seenThisPass);
|
|
340
|
-
_u.seenThisPass.clear();
|
|
341
|
-
_u.newPassStarting = false;
|
|
342
|
-
}
|
|
343
|
-
_u.seenThisPass.add(cacheKey);
|
|
290
|
+
if (!_u.pendingCreated) _u.pendingCreated = 0;
|
|
344
291
|
// ────────────────────────────────────────────────────────────────────────
|
|
345
292
|
|
|
346
293
|
const existing = unsuspend.cache.get(cacheKey);
|
|
347
294
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
//
|
|
353
|
-
// We intentionally do NOT fire when seenLastPass is empty (first pass
|
|
354
|
-
// ever) or when all previous keys are still present (legitimate
|
|
355
|
-
// "new component reached for the first time" scenario).
|
|
356
|
-
if (_u.seenLastPass.size > 0) {
|
|
357
|
-
const hasVanishedKey = [..._u.seenLastPass as Set<string>].some(
|
|
358
|
-
(k: string) => !(_u.seenThisPass as Set<string>).has(k),
|
|
359
|
-
);
|
|
360
|
-
if (hasVanishedKey) {
|
|
361
|
-
throw new Error(
|
|
362
|
-
`[hadars] useServerData: key ${JSON.stringify(cacheKey)} appeared in this pass ` +
|
|
363
|
-
`but a key that was present in the previous pass is now missing. This means ` +
|
|
364
|
-
`the key is not stable across render passes (e.g. it contains Date.now(), ` +
|
|
365
|
-
`Math.random(), or a value that changes on every render). Keys must be deterministic.`,
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
295
|
+
// Mark the previous pending key as accessed when it appears as a cache hit.
|
|
296
|
+
if (existing?.status === 'fulfilled' && _u.lastPendingKey === cacheKey) {
|
|
297
|
+
_u.lastPendingKeyAccessed = true;
|
|
298
|
+
}
|
|
369
299
|
|
|
300
|
+
if (!existing) {
|
|
370
301
|
// First encounter — call fn(), which may:
|
|
371
302
|
// (a) return a Promise<T> — async usage (serialised for the client)
|
|
372
303
|
// (b) return T synchronously — e.g. a sync data source
|
|
@@ -382,16 +313,43 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
382
313
|
}
|
|
383
314
|
|
|
384
315
|
// (a) Async Promise — standard useServerData usage.
|
|
316
|
+
|
|
317
|
+
// Unstable-key detection: the previous pending key resolved but was never
|
|
318
|
+
// requested in the current pass — a new key replaced it, which means the
|
|
319
|
+
// key is not stable between render passes.
|
|
320
|
+
if (_u.lastPendingKey != null && !_u.lastPendingKeyAccessed) {
|
|
321
|
+
const prev = unsuspend.cache.get(_u.lastPendingKey);
|
|
322
|
+
if (prev?.status === 'fulfilled') {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`[hadars] useServerData: key ${JSON.stringify(cacheKey)} is not stable between render passes. ` +
|
|
325
|
+
`The previous pass resolved ${JSON.stringify(_u.lastPendingKey)} but it was not ` +
|
|
326
|
+
`requested in this pass — the key is changing between renders. ` +
|
|
327
|
+
`Avoid dynamic values in keys (e.g. Date.now() or Math.random()); ` +
|
|
328
|
+
`use stable, deterministic identifiers instead.`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
_u.pendingCreated++;
|
|
334
|
+
if (_u.pendingCreated > 100) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`[hadars] useServerData: more than 100 async keys created in a single render. ` +
|
|
337
|
+
`This usually means a key is not stable between renders (e.g. it contains ` +
|
|
338
|
+
`Date.now() or Math.random()). Currently offending key: ${JSON.stringify(cacheKey)}.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
_u.lastPendingKey = cacheKey;
|
|
343
|
+
_u.lastPendingKeyAccessed = false;
|
|
344
|
+
|
|
385
345
|
const promise = (result as Promise<T>).then(
|
|
386
346
|
value => { unsuspend.cache.set(cacheKey, { status: 'fulfilled', value }); },
|
|
387
347
|
reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
|
|
388
348
|
);
|
|
389
349
|
unsuspend.cache.set(cacheKey, { status: 'pending', promise });
|
|
390
|
-
_u.newPassStarting = true; // next useServerData call opens a new pass
|
|
391
350
|
throw promise; // slim-react will await and retry
|
|
392
351
|
}
|
|
393
352
|
if (existing.status === 'pending') {
|
|
394
|
-
_u.newPassStarting = true;
|
|
395
353
|
throw existing.promise; // slim-react will await and retry
|
|
396
354
|
}
|
|
397
355
|
if (existing.status === 'rejected') throw existing.reason;
|
|
@@ -404,14 +362,10 @@ export const Head: React.FC<{
|
|
|
404
362
|
status?: number;
|
|
405
363
|
}> = React.memo( ({ children, status }) => {
|
|
406
364
|
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
addLink,
|
|
412
|
-
addStyle,
|
|
413
|
-
addScript,
|
|
414
|
-
} = useApp();
|
|
365
|
+
const ctx = getCtx();
|
|
366
|
+
if (!ctx) return null;
|
|
367
|
+
|
|
368
|
+
const { setStatus, setTitle, addMeta, addLink, addStyle, addScript } = ctx;
|
|
415
369
|
|
|
416
370
|
if ( status ) {
|
|
417
371
|
setStatus(status);
|
|
@@ -22,6 +22,15 @@ const getProps = () => {
|
|
|
22
22
|
const main = async () => {
|
|
23
23
|
let props = getProps();
|
|
24
24
|
|
|
25
|
+
// Extract the static-export flag before it reaches user code. When set,
|
|
26
|
+
// useServerData fetches index.json sidecars directly on client navigation
|
|
27
|
+
// instead of requesting the live SSR server with Accept: application/json.
|
|
28
|
+
if ((props as any).__hadarsStatic) {
|
|
29
|
+
(globalThis as any).__hadarsStatic = true;
|
|
30
|
+
const { __hadarsStatic: _, ...rest } = props as any;
|
|
31
|
+
props = rest;
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
// Seed the useServerData client cache from server-resolved values before
|
|
26
35
|
// hydration so that hooks return the same data on the first render.
|
|
27
36
|
if (props.__serverData && typeof props.__serverData === 'object') {
|