hadars 0.1.33 → 0.1.35
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 +3 -2
- package/dist/cli.js +19 -3
- package/dist/index.cjs +79 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +79 -1
- package/dist/loader.cjs +95 -3
- package/dist/ssr-watch.js +4 -2
- package/dist/utils/Head.tsx +137 -4
- package/package.json +1 -1
- package/src/build.ts +24 -1
- package/src/utils/Head.tsx +137 -4
- package/src/utils/loader.ts +137 -17
- package/src/utils/rspack.ts +2 -0
package/README.md
CHANGED
|
@@ -116,8 +116,9 @@ const UserCard = ({ userId }: { userId: string }) => {
|
|
|
116
116
|
```
|
|
117
117
|
|
|
118
118
|
- **`key`** - string or string array; must be stable and unique within the page
|
|
119
|
-
- **Server** - calls `fn()`, awaits the result across render iterations, returns `undefined` until resolved
|
|
120
|
-
- **Client** - reads the pre-resolved value from the hydration cache serialised by the server; `fn()` is never called in the browser
|
|
119
|
+
- **Server (SSR)** - calls `fn()`, awaits the result across render iterations, returns `undefined` until resolved
|
|
120
|
+
- **Client (hydration)** - reads the pre-resolved value from the hydration cache serialised by the server; `fn()` is never called in the browser
|
|
121
|
+
- **Client (navigation)** - when a component mounts during client-side navigation and its key is not in the cache, hadars fires a single `GET <current-url>` with `Accept: application/json`; all `useServerData` calls within the same render are batched into one request and suspended via React Suspense until the server returns the JSON data map
|
|
121
122
|
|
|
122
123
|
## Data lifecycle hooks
|
|
123
124
|
|
package/dist/cli.js
CHANGED
|
@@ -1181,7 +1181,8 @@ var getConfigBase = (mode, isServerBuild = false) => {
|
|
|
1181
1181
|
// Transforms loadModule('./path') based on build target.
|
|
1182
1182
|
// Runs before swc-loader (loaders execute right-to-left).
|
|
1183
1183
|
{
|
|
1184
|
-
loader: loaderPath
|
|
1184
|
+
loader: loaderPath,
|
|
1185
|
+
options: { server: isServerBuild }
|
|
1185
1186
|
},
|
|
1186
1187
|
{
|
|
1187
1188
|
loader: "builtin:swc-loader",
|
|
@@ -1212,7 +1213,8 @@ var getConfigBase = (mode, isServerBuild = false) => {
|
|
|
1212
1213
|
exclude: [loaderPath],
|
|
1213
1214
|
use: [
|
|
1214
1215
|
{
|
|
1215
|
-
loader: loaderPath
|
|
1216
|
+
loader: loaderPath,
|
|
1217
|
+
options: { server: isServerBuild }
|
|
1216
1218
|
},
|
|
1217
1219
|
{
|
|
1218
1220
|
loader: "builtin:swc-loader",
|
|
@@ -2206,6 +2208,13 @@ var dev = async (options) => {
|
|
|
2206
2208
|
getFinalProps
|
|
2207
2209
|
}
|
|
2208
2210
|
});
|
|
2211
|
+
if (request.headers.get("Accept") === "application/json") {
|
|
2212
|
+
const serverData = clientProps.__serverData ?? {};
|
|
2213
|
+
return new Response(JSON.stringify({ serverData }), {
|
|
2214
|
+
status,
|
|
2215
|
+
headers: { "Content-Type": "application/json; charset=utf-8" }
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2209
2218
|
return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
|
|
2210
2219
|
} catch (err) {
|
|
2211
2220
|
console.error("[hadars] SSR render error:", err);
|
|
@@ -2342,7 +2351,7 @@ var run = async (options) => {
|
|
|
2342
2351
|
getAfterRenderProps,
|
|
2343
2352
|
getFinalProps
|
|
2344
2353
|
} = await import(componentPath);
|
|
2345
|
-
if (renderPool) {
|
|
2354
|
+
if (renderPool && request.headers.get("Accept") !== "application/json") {
|
|
2346
2355
|
const serialReq = await serializeRequest(request);
|
|
2347
2356
|
const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
|
|
2348
2357
|
const [precontentHtml, postContent] = await getPrecontentHtml(wHead);
|
|
@@ -2360,6 +2369,13 @@ var run = async (options) => {
|
|
|
2360
2369
|
getFinalProps
|
|
2361
2370
|
}
|
|
2362
2371
|
});
|
|
2372
|
+
if (request.headers.get("Accept") === "application/json") {
|
|
2373
|
+
const serverData = clientProps.__serverData ?? {};
|
|
2374
|
+
return new Response(JSON.stringify({ serverData }), {
|
|
2375
|
+
status,
|
|
2376
|
+
headers: { "Content-Type": "application/json; charset=utf-8" }
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2363
2379
|
return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
|
|
2364
2380
|
} catch (err) {
|
|
2365
2381
|
console.error("[hadars] SSR render error:", err);
|
package/dist/index.cjs
CHANGED
|
@@ -174,22 +174,98 @@ var AppProviderCSR = import_react.default.memo(({ children }) => {
|
|
|
174
174
|
});
|
|
175
175
|
var useApp = () => import_react.default.useContext(AppContext);
|
|
176
176
|
var clientServerDataCache = /* @__PURE__ */ new Map();
|
|
177
|
+
var pendingDataFetch = /* @__PURE__ */ new Map();
|
|
178
|
+
var fetchedPaths = /* @__PURE__ */ new Set();
|
|
179
|
+
var ssrInitialKeys = null;
|
|
180
|
+
var unclaimedKeyCheckScheduled = false;
|
|
181
|
+
function scheduleUnclaimedKeyCheck() {
|
|
182
|
+
if (unclaimedKeyCheckScheduled)
|
|
183
|
+
return;
|
|
184
|
+
unclaimedKeyCheckScheduled = true;
|
|
185
|
+
setTimeout(() => {
|
|
186
|
+
unclaimedKeyCheckScheduled = false;
|
|
187
|
+
if (ssrInitialKeys && ssrInitialKeys.size > 0) {
|
|
188
|
+
console.warn(
|
|
189
|
+
`[hadars] useServerData: ${ssrInitialKeys.size} server-resolved key(s) were never claimed during client hydration: ${[...ssrInitialKeys].map((k) => JSON.stringify(k)).join(", ")}. This usually means the key passed to useServerData was different on the server than on the client (e.g. it contains Date.now(), Math.random(), or another value that changes between renders). Keys must be stable and deterministic.`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
ssrInitialKeys = null;
|
|
193
|
+
}, 0);
|
|
194
|
+
}
|
|
177
195
|
function initServerDataCache(data) {
|
|
178
196
|
clientServerDataCache.clear();
|
|
197
|
+
ssrInitialKeys = /* @__PURE__ */ new Set();
|
|
179
198
|
for (const [k, v] of Object.entries(data)) {
|
|
180
199
|
clientServerDataCache.set(k, v);
|
|
200
|
+
ssrInitialKeys.add(k);
|
|
181
201
|
}
|
|
182
202
|
}
|
|
183
203
|
function useServerData(key, fn) {
|
|
184
204
|
const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
|
|
185
205
|
if (typeof window !== "undefined") {
|
|
186
|
-
|
|
206
|
+
if (clientServerDataCache.has(cacheKey)) {
|
|
207
|
+
ssrInitialKeys?.delete(cacheKey);
|
|
208
|
+
return clientServerDataCache.get(cacheKey);
|
|
209
|
+
}
|
|
210
|
+
if (ssrInitialKeys !== null && ssrInitialKeys.size > 0) {
|
|
211
|
+
scheduleUnclaimedKeyCheck();
|
|
212
|
+
}
|
|
213
|
+
const pathKey = window.location.pathname + window.location.search;
|
|
214
|
+
if (fetchedPaths.has(pathKey)) {
|
|
215
|
+
return void 0;
|
|
216
|
+
}
|
|
217
|
+
if (!pendingDataFetch.has(pathKey)) {
|
|
218
|
+
let resolve;
|
|
219
|
+
const p = new Promise((res) => {
|
|
220
|
+
resolve = res;
|
|
221
|
+
});
|
|
222
|
+
pendingDataFetch.set(pathKey, p);
|
|
223
|
+
queueMicrotask(async () => {
|
|
224
|
+
try {
|
|
225
|
+
const res = await fetch(pathKey, {
|
|
226
|
+
headers: { "Accept": "application/json" }
|
|
227
|
+
});
|
|
228
|
+
if (res.ok) {
|
|
229
|
+
const json = await res.json();
|
|
230
|
+
for (const [k, v] of Object.entries(json.serverData ?? {})) {
|
|
231
|
+
clientServerDataCache.set(k, v);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
fetchedPaths.add(pathKey);
|
|
236
|
+
pendingDataFetch.delete(pathKey);
|
|
237
|
+
resolve();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
throw pendingDataFetch.get(pathKey);
|
|
187
242
|
}
|
|
188
243
|
const unsuspend = globalThis.__hadarsUnsuspend;
|
|
189
244
|
if (!unsuspend)
|
|
190
245
|
return void 0;
|
|
246
|
+
const _u = unsuspend;
|
|
247
|
+
if (!_u.seenThisPass)
|
|
248
|
+
_u.seenThisPass = /* @__PURE__ */ new Set();
|
|
249
|
+
if (!_u.seenLastPass)
|
|
250
|
+
_u.seenLastPass = /* @__PURE__ */ new Set();
|
|
251
|
+
if (_u.newPassStarting) {
|
|
252
|
+
_u.seenLastPass = new Set(_u.seenThisPass);
|
|
253
|
+
_u.seenThisPass.clear();
|
|
254
|
+
_u.newPassStarting = false;
|
|
255
|
+
}
|
|
256
|
+
_u.seenThisPass.add(cacheKey);
|
|
191
257
|
const existing = unsuspend.cache.get(cacheKey);
|
|
192
258
|
if (!existing) {
|
|
259
|
+
if (_u.seenLastPass.size > 0) {
|
|
260
|
+
const hasVanishedKey = [..._u.seenLastPass].some(
|
|
261
|
+
(k) => !_u.seenThisPass.has(k)
|
|
262
|
+
);
|
|
263
|
+
if (hasVanishedKey) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`[hadars] useServerData: key ${JSON.stringify(cacheKey)} appeared in this pass but a key that was present in the previous pass is now missing. This means the key is not stable across render passes (e.g. it contains Date.now(), Math.random(), or a value that changes on every render). Keys must be deterministic.`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
193
269
|
const result = fn();
|
|
194
270
|
const isThenable = result !== null && typeof result === "object" && typeof result.then === "function";
|
|
195
271
|
if (!isThenable) {
|
|
@@ -206,9 +282,11 @@ function useServerData(key, fn) {
|
|
|
206
282
|
}
|
|
207
283
|
);
|
|
208
284
|
unsuspend.cache.set(cacheKey, { status: "pending", promise });
|
|
285
|
+
_u.newPassStarting = true;
|
|
209
286
|
throw promise;
|
|
210
287
|
}
|
|
211
288
|
if (existing.status === "pending") {
|
|
289
|
+
_u.newPassStarting = true;
|
|
212
290
|
throw existing.promise;
|
|
213
291
|
}
|
|
214
292
|
if (existing.status === "rejected")
|
package/dist/index.d.ts
CHANGED
|
@@ -195,7 +195,10 @@ declare function initServerDataCache(data: Record<string, unknown>): void;
|
|
|
195
195
|
* The resolved value is serialised into `__serverData` and returned from cache
|
|
196
196
|
* during hydration.
|
|
197
197
|
*
|
|
198
|
-
* `fn` is **server-only**: it is never called in the browser.
|
|
198
|
+
* `fn` is **server-only**: it is never called in the browser. On client-side
|
|
199
|
+
* navigation (after the initial SSR load), hadars automatically fires a
|
|
200
|
+
* data-only request to the current URL (`X-Hadars-Data: 1`) and suspends via
|
|
201
|
+
* React Suspense until the server returns the JSON map of resolved values.
|
|
199
202
|
*
|
|
200
203
|
* @example
|
|
201
204
|
* const user = useServerData('current_user', () => db.getUser(id));
|
package/dist/index.js
CHANGED
|
@@ -131,22 +131,98 @@ var AppProviderCSR = React.memo(({ children }) => {
|
|
|
131
131
|
});
|
|
132
132
|
var useApp = () => React.useContext(AppContext);
|
|
133
133
|
var clientServerDataCache = /* @__PURE__ */ new Map();
|
|
134
|
+
var pendingDataFetch = /* @__PURE__ */ new Map();
|
|
135
|
+
var fetchedPaths = /* @__PURE__ */ new Set();
|
|
136
|
+
var ssrInitialKeys = null;
|
|
137
|
+
var unclaimedKeyCheckScheduled = false;
|
|
138
|
+
function scheduleUnclaimedKeyCheck() {
|
|
139
|
+
if (unclaimedKeyCheckScheduled)
|
|
140
|
+
return;
|
|
141
|
+
unclaimedKeyCheckScheduled = true;
|
|
142
|
+
setTimeout(() => {
|
|
143
|
+
unclaimedKeyCheckScheduled = false;
|
|
144
|
+
if (ssrInitialKeys && ssrInitialKeys.size > 0) {
|
|
145
|
+
console.warn(
|
|
146
|
+
`[hadars] useServerData: ${ssrInitialKeys.size} server-resolved key(s) were never claimed during client hydration: ${[...ssrInitialKeys].map((k) => JSON.stringify(k)).join(", ")}. This usually means the key passed to useServerData was different on the server than on the client (e.g. it contains Date.now(), Math.random(), or another value that changes between renders). Keys must be stable and deterministic.`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
ssrInitialKeys = null;
|
|
150
|
+
}, 0);
|
|
151
|
+
}
|
|
134
152
|
function initServerDataCache(data) {
|
|
135
153
|
clientServerDataCache.clear();
|
|
154
|
+
ssrInitialKeys = /* @__PURE__ */ new Set();
|
|
136
155
|
for (const [k, v] of Object.entries(data)) {
|
|
137
156
|
clientServerDataCache.set(k, v);
|
|
157
|
+
ssrInitialKeys.add(k);
|
|
138
158
|
}
|
|
139
159
|
}
|
|
140
160
|
function useServerData(key, fn) {
|
|
141
161
|
const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
|
|
142
162
|
if (typeof window !== "undefined") {
|
|
143
|
-
|
|
163
|
+
if (clientServerDataCache.has(cacheKey)) {
|
|
164
|
+
ssrInitialKeys?.delete(cacheKey);
|
|
165
|
+
return clientServerDataCache.get(cacheKey);
|
|
166
|
+
}
|
|
167
|
+
if (ssrInitialKeys !== null && ssrInitialKeys.size > 0) {
|
|
168
|
+
scheduleUnclaimedKeyCheck();
|
|
169
|
+
}
|
|
170
|
+
const pathKey = window.location.pathname + window.location.search;
|
|
171
|
+
if (fetchedPaths.has(pathKey)) {
|
|
172
|
+
return void 0;
|
|
173
|
+
}
|
|
174
|
+
if (!pendingDataFetch.has(pathKey)) {
|
|
175
|
+
let resolve;
|
|
176
|
+
const p = new Promise((res) => {
|
|
177
|
+
resolve = res;
|
|
178
|
+
});
|
|
179
|
+
pendingDataFetch.set(pathKey, p);
|
|
180
|
+
queueMicrotask(async () => {
|
|
181
|
+
try {
|
|
182
|
+
const res = await fetch(pathKey, {
|
|
183
|
+
headers: { "Accept": "application/json" }
|
|
184
|
+
});
|
|
185
|
+
if (res.ok) {
|
|
186
|
+
const json = await res.json();
|
|
187
|
+
for (const [k, v] of Object.entries(json.serverData ?? {})) {
|
|
188
|
+
clientServerDataCache.set(k, v);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
fetchedPaths.add(pathKey);
|
|
193
|
+
pendingDataFetch.delete(pathKey);
|
|
194
|
+
resolve();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
throw pendingDataFetch.get(pathKey);
|
|
144
199
|
}
|
|
145
200
|
const unsuspend = globalThis.__hadarsUnsuspend;
|
|
146
201
|
if (!unsuspend)
|
|
147
202
|
return void 0;
|
|
203
|
+
const _u = unsuspend;
|
|
204
|
+
if (!_u.seenThisPass)
|
|
205
|
+
_u.seenThisPass = /* @__PURE__ */ new Set();
|
|
206
|
+
if (!_u.seenLastPass)
|
|
207
|
+
_u.seenLastPass = /* @__PURE__ */ new Set();
|
|
208
|
+
if (_u.newPassStarting) {
|
|
209
|
+
_u.seenLastPass = new Set(_u.seenThisPass);
|
|
210
|
+
_u.seenThisPass.clear();
|
|
211
|
+
_u.newPassStarting = false;
|
|
212
|
+
}
|
|
213
|
+
_u.seenThisPass.add(cacheKey);
|
|
148
214
|
const existing = unsuspend.cache.get(cacheKey);
|
|
149
215
|
if (!existing) {
|
|
216
|
+
if (_u.seenLastPass.size > 0) {
|
|
217
|
+
const hasVanishedKey = [..._u.seenLastPass].some(
|
|
218
|
+
(k) => !_u.seenThisPass.has(k)
|
|
219
|
+
);
|
|
220
|
+
if (hasVanishedKey) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`[hadars] useServerData: key ${JSON.stringify(cacheKey)} appeared in this pass but a key that was present in the previous pass is now missing. This means the key is not stable across render passes (e.g. it contains Date.now(), Math.random(), or a value that changes on every render). Keys must be deterministic.`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
150
226
|
const result = fn();
|
|
151
227
|
const isThenable = result !== null && typeof result === "object" && typeof result.then === "function";
|
|
152
228
|
if (!isThenable) {
|
|
@@ -163,9 +239,11 @@ function useServerData(key, fn) {
|
|
|
163
239
|
}
|
|
164
240
|
);
|
|
165
241
|
unsuspend.cache.set(cacheKey, { status: "pending", promise });
|
|
242
|
+
_u.newPassStarting = true;
|
|
166
243
|
throw promise;
|
|
167
244
|
}
|
|
168
245
|
if (existing.status === "pending") {
|
|
246
|
+
_u.newPassStarting = true;
|
|
169
247
|
throw existing.promise;
|
|
170
248
|
}
|
|
171
249
|
if (existing.status === "rejected")
|
package/dist/loader.cjs
CHANGED
|
@@ -22,7 +22,8 @@ __export(loader_exports, {
|
|
|
22
22
|
});
|
|
23
23
|
module.exports = __toCommonJS(loader_exports);
|
|
24
24
|
function loader(source) {
|
|
25
|
-
const
|
|
25
|
+
const opts = this.getOptions?.() ?? {};
|
|
26
|
+
const isServer = typeof opts.server === "boolean" ? opts.server : this.target === "node" || this.target === "async-node";
|
|
26
27
|
const resourcePath = this.resourcePath ?? this.resource ?? "(unknown)";
|
|
27
28
|
let swc;
|
|
28
29
|
try {
|
|
@@ -51,7 +52,22 @@ function swcTransform(swc, source, isServer, resourcePath) {
|
|
|
51
52
|
if (node.type !== "CallExpression")
|
|
52
53
|
return;
|
|
53
54
|
const callee = node.callee;
|
|
54
|
-
if (!callee || callee.type !== "Identifier"
|
|
55
|
+
if (!callee || callee.type !== "Identifier")
|
|
56
|
+
return;
|
|
57
|
+
const name = callee.value;
|
|
58
|
+
if (!isServer && name === "useServerData") {
|
|
59
|
+
const args2 = node.arguments;
|
|
60
|
+
if (!args2 || args2.length < 2)
|
|
61
|
+
return;
|
|
62
|
+
const fnArg = args2[1].expression ?? args2[1];
|
|
63
|
+
replacements.push({
|
|
64
|
+
start: fnArg.span.start - fileOffset,
|
|
65
|
+
end: fnArg.span.end - fileOffset,
|
|
66
|
+
replacement: "()=>undefined"
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (name !== "loadModule")
|
|
55
71
|
return;
|
|
56
72
|
const args = node.arguments;
|
|
57
73
|
if (!args || args.length === 0)
|
|
@@ -136,11 +152,87 @@ function countLeadingNonCodeBytes(source) {
|
|
|
136
152
|
}
|
|
137
153
|
const LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
|
|
138
154
|
const DYNAMIC_LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
|
|
155
|
+
function scanExpressionEnd(source, pos) {
|
|
156
|
+
let depth = 0;
|
|
157
|
+
let i = pos;
|
|
158
|
+
while (i < source.length) {
|
|
159
|
+
const ch = source[i];
|
|
160
|
+
if (ch === "(" || ch === "[" || ch === "{") {
|
|
161
|
+
depth++;
|
|
162
|
+
i++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (ch === ")" || ch === "]" || ch === "}") {
|
|
166
|
+
if (depth === 0)
|
|
167
|
+
break;
|
|
168
|
+
depth--;
|
|
169
|
+
i++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (ch === "," && depth === 0)
|
|
173
|
+
break;
|
|
174
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
175
|
+
const q = ch;
|
|
176
|
+
i++;
|
|
177
|
+
while (i < source.length && source[i] !== q) {
|
|
178
|
+
if (source[i] === "\\")
|
|
179
|
+
i++;
|
|
180
|
+
i++;
|
|
181
|
+
}
|
|
182
|
+
i++;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (ch === "/" && source[i + 1] === "/") {
|
|
186
|
+
while (i < source.length && source[i] !== "\n")
|
|
187
|
+
i++;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (ch === "/" && source[i + 1] === "*") {
|
|
191
|
+
i += 2;
|
|
192
|
+
while (i + 1 < source.length && !(source[i] === "*" && source[i + 1] === "/"))
|
|
193
|
+
i++;
|
|
194
|
+
i += 2;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
i++;
|
|
198
|
+
}
|
|
199
|
+
return i;
|
|
200
|
+
}
|
|
201
|
+
function stripUseServerDataFns(source) {
|
|
202
|
+
const CALL_RE = /\buseServerData\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
|
|
203
|
+
let result = "";
|
|
204
|
+
let lastIndex = 0;
|
|
205
|
+
let match;
|
|
206
|
+
CALL_RE.lastIndex = 0;
|
|
207
|
+
while ((match = CALL_RE.exec(source)) !== null) {
|
|
208
|
+
const callStart = match.index;
|
|
209
|
+
let i = match.index + match[0].length;
|
|
210
|
+
while (i < source.length && /\s/.test(source[i]))
|
|
211
|
+
i++;
|
|
212
|
+
i = scanExpressionEnd(source, i);
|
|
213
|
+
if (i >= source.length || source[i] !== ",")
|
|
214
|
+
continue;
|
|
215
|
+
i++;
|
|
216
|
+
while (i < source.length && /\s/.test(source[i]))
|
|
217
|
+
i++;
|
|
218
|
+
const fnStart = i;
|
|
219
|
+
const fnEnd = scanExpressionEnd(source, i);
|
|
220
|
+
if (fnEnd <= fnStart)
|
|
221
|
+
continue;
|
|
222
|
+
result += source.slice(lastIndex, fnStart) + "()=>undefined";
|
|
223
|
+
lastIndex = fnEnd;
|
|
224
|
+
CALL_RE.lastIndex = fnEnd;
|
|
225
|
+
}
|
|
226
|
+
return lastIndex === 0 ? source : result + source.slice(lastIndex);
|
|
227
|
+
}
|
|
139
228
|
function regexTransform(source, isServer, resourcePath) {
|
|
140
|
-
|
|
229
|
+
let transformed = source.replace(
|
|
141
230
|
LOAD_MODULE_RE,
|
|
142
231
|
(_match, quote, modulePath) => isServer ? `Promise.resolve(require(${quote}${modulePath}${quote}))` : `import(${quote}${modulePath}${quote})`
|
|
143
232
|
);
|
|
233
|
+
if (!isServer) {
|
|
234
|
+
transformed = stripUseServerDataFns(transformed);
|
|
235
|
+
}
|
|
144
236
|
let match;
|
|
145
237
|
DYNAMIC_LOAD_MODULE_RE.lastIndex = 0;
|
|
146
238
|
while ((match = DYNAMIC_LOAD_MODULE_RE.exec(transformed)) !== null) {
|
package/dist/ssr-watch.js
CHANGED
|
@@ -52,7 +52,8 @@ var getConfigBase = (mode, isServerBuild = false) => {
|
|
|
52
52
|
// Transforms loadModule('./path') based on build target.
|
|
53
53
|
// Runs before swc-loader (loaders execute right-to-left).
|
|
54
54
|
{
|
|
55
|
-
loader: loaderPath
|
|
55
|
+
loader: loaderPath,
|
|
56
|
+
options: { server: isServerBuild }
|
|
56
57
|
},
|
|
57
58
|
{
|
|
58
59
|
loader: "builtin:swc-loader",
|
|
@@ -83,7 +84,8 @@ var getConfigBase = (mode, isServerBuild = false) => {
|
|
|
83
84
|
exclude: [loaderPath],
|
|
84
85
|
use: [
|
|
85
86
|
{
|
|
86
|
-
loader: loaderPath
|
|
87
|
+
loader: loaderPath,
|
|
88
|
+
options: { server: isServerBuild }
|
|
87
89
|
},
|
|
88
90
|
{
|
|
89
91
|
loader: "builtin:swc-loader",
|
package/dist/utils/Head.tsx
CHANGED
|
@@ -174,13 +174,46 @@ export const useApp = () => React.useContext(AppContext);
|
|
|
174
174
|
// hydration. Keyed by the same React useId() values that the server used.
|
|
175
175
|
const clientServerDataCache = new Map<string, unknown>();
|
|
176
176
|
|
|
177
|
+
// Tracks in-flight data-only requests keyed by pathname+search so that all
|
|
178
|
+
// useServerData calls within a single Suspense pass share one network request.
|
|
179
|
+
const pendingDataFetch = new Map<string, Promise<void>>();
|
|
180
|
+
// Paths for which a client data fetch has already completed. Prevents re-fetching
|
|
181
|
+
// when a key is genuinely absent from the server response for this path.
|
|
182
|
+
const fetchedPaths = new Set<string>();
|
|
183
|
+
|
|
184
|
+
// Keys that were seeded from SSR data and not yet claimed by any useServerData
|
|
185
|
+
// call on the client. Used to detect server↔client key mismatches.
|
|
186
|
+
let ssrInitialKeys: Set<string> | null = null;
|
|
187
|
+
let unclaimedKeyCheckScheduled = false;
|
|
188
|
+
|
|
189
|
+
function scheduleUnclaimedKeyCheck() {
|
|
190
|
+
if (unclaimedKeyCheckScheduled) return;
|
|
191
|
+
unclaimedKeyCheckScheduled = true;
|
|
192
|
+
// Wait for the current synchronous hydration pass to finish, then check.
|
|
193
|
+
setTimeout(() => {
|
|
194
|
+
unclaimedKeyCheckScheduled = false;
|
|
195
|
+
if (ssrInitialKeys && ssrInitialKeys.size > 0) {
|
|
196
|
+
console.warn(
|
|
197
|
+
`[hadars] useServerData: ${ssrInitialKeys.size} server-resolved key(s) were ` +
|
|
198
|
+
`never claimed during client hydration: ${[...ssrInitialKeys].map(k => JSON.stringify(k)).join(', ')}. ` +
|
|
199
|
+
`This usually means the key passed to useServerData was different on the server ` +
|
|
200
|
+
`than on the client (e.g. it contains Date.now(), Math.random(), or another ` +
|
|
201
|
+
`value that changes between renders). Keys must be stable and deterministic.`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
ssrInitialKeys = null;
|
|
205
|
+
}, 0);
|
|
206
|
+
}
|
|
207
|
+
|
|
177
208
|
/** Call this before hydrating to seed the client cache from the server's data.
|
|
178
209
|
* Invoked automatically by the hadars client bootstrap.
|
|
179
210
|
* Always clears the existing cache before populating — call with `{}` to just clear. */
|
|
180
211
|
export function initServerDataCache(data: Record<string, unknown>) {
|
|
181
212
|
clientServerDataCache.clear();
|
|
213
|
+
ssrInitialKeys = new Set<string>();
|
|
182
214
|
for (const [k, v] of Object.entries(data)) {
|
|
183
215
|
clientServerDataCache.set(k, v);
|
|
216
|
+
ssrInitialKeys.add(k);
|
|
184
217
|
}
|
|
185
218
|
}
|
|
186
219
|
|
|
@@ -200,7 +233,10 @@ export function initServerDataCache(data: Record<string, unknown>) {
|
|
|
200
233
|
* The resolved value is serialised into `__serverData` and returned from cache
|
|
201
234
|
* during hydration.
|
|
202
235
|
*
|
|
203
|
-
* `fn` is **server-only**: it is never called in the browser.
|
|
236
|
+
* `fn` is **server-only**: it is never called in the browser. On client-side
|
|
237
|
+
* navigation (after the initial SSR load), hadars automatically fires a
|
|
238
|
+
* data-only request to the current URL (`X-Hadars-Data: 1`) and suspends via
|
|
239
|
+
* React Suspense until the server returns the JSON map of resolved values.
|
|
204
240
|
*
|
|
205
241
|
* @example
|
|
206
242
|
* const user = useServerData('current_user', () => db.getUser(id));
|
|
@@ -211,9 +247,59 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
211
247
|
const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
|
|
212
248
|
|
|
213
249
|
if (typeof window !== 'undefined') {
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
250
|
+
// Cache hit — return the server-resolved value directly (covers both initial
|
|
251
|
+
// SSR hydration and values fetched during client-side navigation).
|
|
252
|
+
if (clientServerDataCache.has(cacheKey)) {
|
|
253
|
+
// Mark this SSR key as claimed so the unclaimed-key check doesn't warn about it.
|
|
254
|
+
ssrInitialKeys?.delete(cacheKey);
|
|
255
|
+
return clientServerDataCache.get(cacheKey) as T;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Cache miss during the initial hydration pass (SSR data is present but
|
|
259
|
+
// this key wasn't in it) — schedule a deferred check for orphaned SSR keys
|
|
260
|
+
// which would signal a server↔client key mismatch.
|
|
261
|
+
if (ssrInitialKeys !== null && ssrInitialKeys.size > 0) {
|
|
262
|
+
scheduleUnclaimedKeyCheck();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Cache miss — this component is mounting during client-side navigation
|
|
266
|
+
// (the server hasn't sent data for this path yet). Fire a data-only
|
|
267
|
+
// request to the server at the current URL and suspend via React Suspense
|
|
268
|
+
// until it completes. All useServerData calls within the same React render
|
|
269
|
+
// share one Promise so only one network request is made per navigation.
|
|
270
|
+
const pathKey = window.location.pathname + window.location.search;
|
|
271
|
+
|
|
272
|
+
// If we already fetched this path and the key is still missing, the server
|
|
273
|
+
// doesn't produce a value for it — return undefined rather than looping.
|
|
274
|
+
if (fetchedPaths.has(pathKey)) {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!pendingDataFetch.has(pathKey)) {
|
|
279
|
+
let resolve!: () => void;
|
|
280
|
+
const p = new Promise<void>(res => { resolve = res; });
|
|
281
|
+
pendingDataFetch.set(pathKey, p);
|
|
282
|
+
// Fire in a microtask so that every useServerData call in this React
|
|
283
|
+
// render is registered against the same deferred before the fetch starts.
|
|
284
|
+
queueMicrotask(async () => {
|
|
285
|
+
try {
|
|
286
|
+
const res = await fetch(pathKey, {
|
|
287
|
+
headers: { 'Accept': 'application/json' },
|
|
288
|
+
});
|
|
289
|
+
if (res.ok) {
|
|
290
|
+
const json = await res.json() as { serverData: Record<string, unknown> };
|
|
291
|
+
for (const [k, v] of Object.entries(json.serverData ?? {})) {
|
|
292
|
+
clientServerDataCache.set(k, v);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} finally {
|
|
296
|
+
fetchedPaths.add(pathKey);
|
|
297
|
+
pendingDataFetch.delete(pathKey);
|
|
298
|
+
resolve();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
throw pendingDataFetch.get(pathKey)!;
|
|
217
303
|
}
|
|
218
304
|
|
|
219
305
|
// Server: communicate via globalThis.__hadarsUnsuspend which is set by the
|
|
@@ -223,9 +309,54 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
223
309
|
const unsuspend: AppUnsuspend | undefined = (globalThis as any).__hadarsUnsuspend;
|
|
224
310
|
if (!unsuspend) return undefined;
|
|
225
311
|
|
|
312
|
+
// ── per-pass key tracking ────────────────────────────────────────────────
|
|
313
|
+
// We keep two sets: keys seen in the current pass and keys seen in the
|
|
314
|
+
// previous pass. When a pass throws a promise, the *next* call to
|
|
315
|
+
// useServerData marks the start of a new pass and rotates the sets.
|
|
316
|
+
//
|
|
317
|
+
// A key is unstable when a key that WAS seen in the previous pass is now
|
|
318
|
+
// absent from the current pass while a new key appears instead. This means
|
|
319
|
+
// a component produced a different key string between passes (e.g. Date.now()
|
|
320
|
+
// in the key). We fire immediately — there is no need to wait for other
|
|
321
|
+
// entries to settle first, because a legitimately-new component always extends
|
|
322
|
+
// seenLastPass (all previous keys remain present in seenThisPass).
|
|
323
|
+
const _u = unsuspend as any;
|
|
324
|
+
if (!_u.seenThisPass) _u.seenThisPass = new Set<string>();
|
|
325
|
+
if (!_u.seenLastPass) _u.seenLastPass = new Set<string>();
|
|
326
|
+
|
|
327
|
+
if (_u.newPassStarting) {
|
|
328
|
+
// This is the first useServerData call after a thrown promise — rotate.
|
|
329
|
+
_u.seenLastPass = new Set(_u.seenThisPass);
|
|
330
|
+
_u.seenThisPass.clear();
|
|
331
|
+
_u.newPassStarting = false;
|
|
332
|
+
}
|
|
333
|
+
_u.seenThisPass.add(cacheKey);
|
|
334
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
226
336
|
const existing = unsuspend.cache.get(cacheKey);
|
|
227
337
|
|
|
228
338
|
if (!existing) {
|
|
339
|
+
// Detect an unstable key: a key that was called in the previous pass is
|
|
340
|
+
// now absent while a new key has appeared. This means a component
|
|
341
|
+
// generated a different key between passes — it will loop forever.
|
|
342
|
+
//
|
|
343
|
+
// We intentionally do NOT fire when seenLastPass is empty (first pass
|
|
344
|
+
// ever) or when all previous keys are still present (legitimate
|
|
345
|
+
// "new component reached for the first time" scenario).
|
|
346
|
+
if (_u.seenLastPass.size > 0) {
|
|
347
|
+
const hasVanishedKey = [..._u.seenLastPass as Set<string>].some(
|
|
348
|
+
(k: string) => !(_u.seenThisPass as Set<string>).has(k),
|
|
349
|
+
);
|
|
350
|
+
if (hasVanishedKey) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`[hadars] useServerData: key ${JSON.stringify(cacheKey)} appeared in this pass ` +
|
|
353
|
+
`but a key that was present in the previous pass is now missing. This means ` +
|
|
354
|
+
`the key is not stable across render passes (e.g. it contains Date.now(), ` +
|
|
355
|
+
`Math.random(), or a value that changes on every render). Keys must be deterministic.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
229
360
|
// First encounter — call fn(), which may:
|
|
230
361
|
// (a) return a Promise<T> — async usage (serialised for the client)
|
|
231
362
|
// (b) return T synchronously — e.g. a sync data source
|
|
@@ -246,9 +377,11 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
246
377
|
reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
|
|
247
378
|
);
|
|
248
379
|
unsuspend.cache.set(cacheKey, { status: 'pending', promise });
|
|
380
|
+
_u.newPassStarting = true; // next useServerData call opens a new pass
|
|
249
381
|
throw promise; // slim-react will await and retry
|
|
250
382
|
}
|
|
251
383
|
if (existing.status === 'pending') {
|
|
384
|
+
_u.newPassStarting = true;
|
|
252
385
|
throw existing.promise; // slim-react will await and retry
|
|
253
386
|
}
|
|
254
387
|
if (existing.status === 'rejected') throw existing.reason;
|
package/package.json
CHANGED
package/src/build.ts
CHANGED
|
@@ -689,6 +689,18 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
689
689
|
},
|
|
690
690
|
});
|
|
691
691
|
|
|
692
|
+
// Content negotiation: if the client only accepts JSON (client-side
|
|
693
|
+
// navigation via useServerData), return the resolved data map as JSON
|
|
694
|
+
// instead of a full HTML page. The same auth context applies — cookies
|
|
695
|
+
// and headers are forwarded unchanged, so no new attack surface is created.
|
|
696
|
+
if (request.headers.get('Accept') === 'application/json') {
|
|
697
|
+
const serverData = (clientProps as any).__serverData ?? {};
|
|
698
|
+
return new Response(JSON.stringify({ serverData }), {
|
|
699
|
+
status,
|
|
700
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
692
704
|
return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
|
|
693
705
|
} catch (err: any) {
|
|
694
706
|
console.error('[hadars] SSR render error:', err);
|
|
@@ -858,7 +870,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
858
870
|
getFinalProps,
|
|
859
871
|
} = (await import(componentPath)) as HadarsEntryModule<any>;
|
|
860
872
|
|
|
861
|
-
if (renderPool) {
|
|
873
|
+
if (renderPool && request.headers.get('Accept') !== 'application/json') {
|
|
862
874
|
// Worker runs the full lifecycle — no non-serializable objects cross the thread boundary.
|
|
863
875
|
const serialReq = await serializeRequest(request);
|
|
864
876
|
const { html, headHtml: wHead, status: wStatus } = await renderPool.renderFull(serialReq);
|
|
@@ -879,6 +891,17 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
879
891
|
},
|
|
880
892
|
});
|
|
881
893
|
|
|
894
|
+
// Content negotiation: if the client only accepts JSON (client-side
|
|
895
|
+
// navigation via useServerData), return the resolved data map as JSON
|
|
896
|
+
// instead of a full HTML page.
|
|
897
|
+
if (request.headers.get('Accept') === 'application/json') {
|
|
898
|
+
const serverData = (clientProps as any).__serverData ?? {};
|
|
899
|
+
return new Response(JSON.stringify({ serverData }), {
|
|
900
|
+
status,
|
|
901
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
882
905
|
return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
|
|
883
906
|
} catch (err: any) {
|
|
884
907
|
console.error('[hadars] SSR render error:', err);
|
package/src/utils/Head.tsx
CHANGED
|
@@ -174,13 +174,46 @@ export const useApp = () => React.useContext(AppContext);
|
|
|
174
174
|
// hydration. Keyed by the same React useId() values that the server used.
|
|
175
175
|
const clientServerDataCache = new Map<string, unknown>();
|
|
176
176
|
|
|
177
|
+
// Tracks in-flight data-only requests keyed by pathname+search so that all
|
|
178
|
+
// useServerData calls within a single Suspense pass share one network request.
|
|
179
|
+
const pendingDataFetch = new Map<string, Promise<void>>();
|
|
180
|
+
// Paths for which a client data fetch has already completed. Prevents re-fetching
|
|
181
|
+
// when a key is genuinely absent from the server response for this path.
|
|
182
|
+
const fetchedPaths = new Set<string>();
|
|
183
|
+
|
|
184
|
+
// Keys that were seeded from SSR data and not yet claimed by any useServerData
|
|
185
|
+
// call on the client. Used to detect server↔client key mismatches.
|
|
186
|
+
let ssrInitialKeys: Set<string> | null = null;
|
|
187
|
+
let unclaimedKeyCheckScheduled = false;
|
|
188
|
+
|
|
189
|
+
function scheduleUnclaimedKeyCheck() {
|
|
190
|
+
if (unclaimedKeyCheckScheduled) return;
|
|
191
|
+
unclaimedKeyCheckScheduled = true;
|
|
192
|
+
// Wait for the current synchronous hydration pass to finish, then check.
|
|
193
|
+
setTimeout(() => {
|
|
194
|
+
unclaimedKeyCheckScheduled = false;
|
|
195
|
+
if (ssrInitialKeys && ssrInitialKeys.size > 0) {
|
|
196
|
+
console.warn(
|
|
197
|
+
`[hadars] useServerData: ${ssrInitialKeys.size} server-resolved key(s) were ` +
|
|
198
|
+
`never claimed during client hydration: ${[...ssrInitialKeys].map(k => JSON.stringify(k)).join(', ')}. ` +
|
|
199
|
+
`This usually means the key passed to useServerData was different on the server ` +
|
|
200
|
+
`than on the client (e.g. it contains Date.now(), Math.random(), or another ` +
|
|
201
|
+
`value that changes between renders). Keys must be stable and deterministic.`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
ssrInitialKeys = null;
|
|
205
|
+
}, 0);
|
|
206
|
+
}
|
|
207
|
+
|
|
177
208
|
/** Call this before hydrating to seed the client cache from the server's data.
|
|
178
209
|
* Invoked automatically by the hadars client bootstrap.
|
|
179
210
|
* Always clears the existing cache before populating — call with `{}` to just clear. */
|
|
180
211
|
export function initServerDataCache(data: Record<string, unknown>) {
|
|
181
212
|
clientServerDataCache.clear();
|
|
213
|
+
ssrInitialKeys = new Set<string>();
|
|
182
214
|
for (const [k, v] of Object.entries(data)) {
|
|
183
215
|
clientServerDataCache.set(k, v);
|
|
216
|
+
ssrInitialKeys.add(k);
|
|
184
217
|
}
|
|
185
218
|
}
|
|
186
219
|
|
|
@@ -200,7 +233,10 @@ export function initServerDataCache(data: Record<string, unknown>) {
|
|
|
200
233
|
* The resolved value is serialised into `__serverData` and returned from cache
|
|
201
234
|
* during hydration.
|
|
202
235
|
*
|
|
203
|
-
* `fn` is **server-only**: it is never called in the browser.
|
|
236
|
+
* `fn` is **server-only**: it is never called in the browser. On client-side
|
|
237
|
+
* navigation (after the initial SSR load), hadars automatically fires a
|
|
238
|
+
* data-only request to the current URL (`X-Hadars-Data: 1`) and suspends via
|
|
239
|
+
* React Suspense until the server returns the JSON map of resolved values.
|
|
204
240
|
*
|
|
205
241
|
* @example
|
|
206
242
|
* const user = useServerData('current_user', () => db.getUser(id));
|
|
@@ -211,9 +247,59 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
211
247
|
const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
|
|
212
248
|
|
|
213
249
|
if (typeof window !== 'undefined') {
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
250
|
+
// Cache hit — return the server-resolved value directly (covers both initial
|
|
251
|
+
// SSR hydration and values fetched during client-side navigation).
|
|
252
|
+
if (clientServerDataCache.has(cacheKey)) {
|
|
253
|
+
// Mark this SSR key as claimed so the unclaimed-key check doesn't warn about it.
|
|
254
|
+
ssrInitialKeys?.delete(cacheKey);
|
|
255
|
+
return clientServerDataCache.get(cacheKey) as T;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Cache miss during the initial hydration pass (SSR data is present but
|
|
259
|
+
// this key wasn't in it) — schedule a deferred check for orphaned SSR keys
|
|
260
|
+
// which would signal a server↔client key mismatch.
|
|
261
|
+
if (ssrInitialKeys !== null && ssrInitialKeys.size > 0) {
|
|
262
|
+
scheduleUnclaimedKeyCheck();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Cache miss — this component is mounting during client-side navigation
|
|
266
|
+
// (the server hasn't sent data for this path yet). Fire a data-only
|
|
267
|
+
// request to the server at the current URL and suspend via React Suspense
|
|
268
|
+
// until it completes. All useServerData calls within the same React render
|
|
269
|
+
// share one Promise so only one network request is made per navigation.
|
|
270
|
+
const pathKey = window.location.pathname + window.location.search;
|
|
271
|
+
|
|
272
|
+
// If we already fetched this path and the key is still missing, the server
|
|
273
|
+
// doesn't produce a value for it — return undefined rather than looping.
|
|
274
|
+
if (fetchedPaths.has(pathKey)) {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!pendingDataFetch.has(pathKey)) {
|
|
279
|
+
let resolve!: () => void;
|
|
280
|
+
const p = new Promise<void>(res => { resolve = res; });
|
|
281
|
+
pendingDataFetch.set(pathKey, p);
|
|
282
|
+
// Fire in a microtask so that every useServerData call in this React
|
|
283
|
+
// render is registered against the same deferred before the fetch starts.
|
|
284
|
+
queueMicrotask(async () => {
|
|
285
|
+
try {
|
|
286
|
+
const res = await fetch(pathKey, {
|
|
287
|
+
headers: { 'Accept': 'application/json' },
|
|
288
|
+
});
|
|
289
|
+
if (res.ok) {
|
|
290
|
+
const json = await res.json() as { serverData: Record<string, unknown> };
|
|
291
|
+
for (const [k, v] of Object.entries(json.serverData ?? {})) {
|
|
292
|
+
clientServerDataCache.set(k, v);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} finally {
|
|
296
|
+
fetchedPaths.add(pathKey);
|
|
297
|
+
pendingDataFetch.delete(pathKey);
|
|
298
|
+
resolve();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
throw pendingDataFetch.get(pathKey)!;
|
|
217
303
|
}
|
|
218
304
|
|
|
219
305
|
// Server: communicate via globalThis.__hadarsUnsuspend which is set by the
|
|
@@ -223,9 +309,54 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
223
309
|
const unsuspend: AppUnsuspend | undefined = (globalThis as any).__hadarsUnsuspend;
|
|
224
310
|
if (!unsuspend) return undefined;
|
|
225
311
|
|
|
312
|
+
// ── per-pass key tracking ────────────────────────────────────────────────
|
|
313
|
+
// We keep two sets: keys seen in the current pass and keys seen in the
|
|
314
|
+
// previous pass. When a pass throws a promise, the *next* call to
|
|
315
|
+
// useServerData marks the start of a new pass and rotates the sets.
|
|
316
|
+
//
|
|
317
|
+
// A key is unstable when a key that WAS seen in the previous pass is now
|
|
318
|
+
// absent from the current pass while a new key appears instead. This means
|
|
319
|
+
// a component produced a different key string between passes (e.g. Date.now()
|
|
320
|
+
// in the key). We fire immediately — there is no need to wait for other
|
|
321
|
+
// entries to settle first, because a legitimately-new component always extends
|
|
322
|
+
// seenLastPass (all previous keys remain present in seenThisPass).
|
|
323
|
+
const _u = unsuspend as any;
|
|
324
|
+
if (!_u.seenThisPass) _u.seenThisPass = new Set<string>();
|
|
325
|
+
if (!_u.seenLastPass) _u.seenLastPass = new Set<string>();
|
|
326
|
+
|
|
327
|
+
if (_u.newPassStarting) {
|
|
328
|
+
// This is the first useServerData call after a thrown promise — rotate.
|
|
329
|
+
_u.seenLastPass = new Set(_u.seenThisPass);
|
|
330
|
+
_u.seenThisPass.clear();
|
|
331
|
+
_u.newPassStarting = false;
|
|
332
|
+
}
|
|
333
|
+
_u.seenThisPass.add(cacheKey);
|
|
334
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
226
336
|
const existing = unsuspend.cache.get(cacheKey);
|
|
227
337
|
|
|
228
338
|
if (!existing) {
|
|
339
|
+
// Detect an unstable key: a key that was called in the previous pass is
|
|
340
|
+
// now absent while a new key has appeared. This means a component
|
|
341
|
+
// generated a different key between passes — it will loop forever.
|
|
342
|
+
//
|
|
343
|
+
// We intentionally do NOT fire when seenLastPass is empty (first pass
|
|
344
|
+
// ever) or when all previous keys are still present (legitimate
|
|
345
|
+
// "new component reached for the first time" scenario).
|
|
346
|
+
if (_u.seenLastPass.size > 0) {
|
|
347
|
+
const hasVanishedKey = [..._u.seenLastPass as Set<string>].some(
|
|
348
|
+
(k: string) => !(_u.seenThisPass as Set<string>).has(k),
|
|
349
|
+
);
|
|
350
|
+
if (hasVanishedKey) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`[hadars] useServerData: key ${JSON.stringify(cacheKey)} appeared in this pass ` +
|
|
353
|
+
`but a key that was present in the previous pass is now missing. This means ` +
|
|
354
|
+
`the key is not stable across render passes (e.g. it contains Date.now(), ` +
|
|
355
|
+
`Math.random(), or a value that changes on every render). Keys must be deterministic.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
229
360
|
// First encounter — call fn(), which may:
|
|
230
361
|
// (a) return a Promise<T> — async usage (serialised for the client)
|
|
231
362
|
// (b) return T synchronously — e.g. a sync data source
|
|
@@ -246,9 +377,11 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
246
377
|
reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
|
|
247
378
|
);
|
|
248
379
|
unsuspend.cache.set(cacheKey, { status: 'pending', promise });
|
|
380
|
+
_u.newPassStarting = true; // next useServerData call opens a new pass
|
|
249
381
|
throw promise; // slim-react will await and retry
|
|
250
382
|
}
|
|
251
383
|
if (existing.status === 'pending') {
|
|
384
|
+
_u.newPassStarting = true;
|
|
252
385
|
throw existing.promise; // slim-react will await and retry
|
|
253
386
|
}
|
|
254
387
|
if (existing.status === 'rejected') throw existing.reason;
|
package/src/utils/loader.ts
CHANGED
|
@@ -1,34 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rspack/webpack loader that
|
|
3
|
-
*
|
|
2
|
+
* Rspack/webpack loader that applies two source-level transforms based on the
|
|
3
|
+
* compilation target (web vs node):
|
|
4
4
|
*
|
|
5
|
+
* ── loadModule('path') ────────────────────────────────────────────────────────
|
|
5
6
|
* - web (browser): replaced with `import('./path')` — rspack treats this as
|
|
6
7
|
* a true dynamic import and splits the module into a separate chunk.
|
|
7
|
-
*
|
|
8
8
|
* - node (SSR): replaced with `Promise.resolve(require('./path'))` —
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* bundled statically, wrapped in Promise.resolve to keep the API shape.
|
|
10
|
+
*
|
|
11
|
+
* ── useServerData(key, fn) ───────────────────────────────────────────────────
|
|
12
|
+
* - web (browser): the second argument `fn` is replaced with `()=>undefined`.
|
|
13
|
+
* `fn` is a server-only callback that may reference internal endpoints,
|
|
14
|
+
* credentials, or other sensitive information. It is never called in the
|
|
15
|
+
* browser (the hook returns the SSR-cached value immediately), but without
|
|
16
|
+
* this transform it would still be compiled into the client bundle — exposing
|
|
17
|
+
* those details to anyone who inspects the JS. Stripping it at bundle time
|
|
18
|
+
* prevents the leak entirely.
|
|
19
|
+
* - node (SSR): kept as-is — the real fn is needed to fetch data.
|
|
12
20
|
*
|
|
13
21
|
* Transformation strategy:
|
|
14
22
|
* Primary — SWC AST parsing via @swc/core. Handles any valid TS/JS syntax
|
|
15
23
|
* including arbitrarily-nested generics, comments, and string
|
|
16
|
-
* literals that contain the
|
|
17
|
-
* Fallback —
|
|
24
|
+
* literals that contain the function names.
|
|
25
|
+
* Fallback — Scanner-based transform used when @swc/core is unavailable.
|
|
18
26
|
*
|
|
19
|
-
* Example
|
|
27
|
+
* Example:
|
|
20
28
|
*
|
|
21
|
-
*
|
|
29
|
+
* // Source (shared component):
|
|
30
|
+
* const user = useServerData('user', () => db.getUser(req.userId));
|
|
22
31
|
*
|
|
23
|
-
* //
|
|
24
|
-
* const
|
|
32
|
+
* // Client bundle after transform:
|
|
33
|
+
* const user = useServerData('user', ()=>undefined);
|
|
25
34
|
*
|
|
26
|
-
* //
|
|
27
|
-
* const
|
|
35
|
+
* // Server bundle (unchanged):
|
|
36
|
+
* const user = useServerData('user', () => db.getUser(req.userId));
|
|
28
37
|
*/
|
|
29
38
|
|
|
30
39
|
export default function loader(this: any, source: string): string {
|
|
31
|
-
|
|
40
|
+
// Prefer the explicit `server` option injected by rspack.ts over the legacy
|
|
41
|
+
// `this.target` heuristic (which is unreliable when `target` is not set in
|
|
42
|
+
// the rspack config — rspack then reports 'web' for every build).
|
|
43
|
+
const opts = this.getOptions?.() ?? {};
|
|
44
|
+
const isServer: boolean = (typeof opts.server === 'boolean')
|
|
45
|
+
? opts.server
|
|
46
|
+
: (this.target === 'node' || this.target === 'async-node');
|
|
32
47
|
const resourcePath: string = this.resourcePath ?? this.resource ?? '(unknown)';
|
|
33
48
|
|
|
34
49
|
let swc: any;
|
|
@@ -80,7 +95,26 @@ function swcTransform(this: any, swc: any, source: string, isServer: boolean, re
|
|
|
80
95
|
if (node.type !== 'CallExpression') return;
|
|
81
96
|
|
|
82
97
|
const callee = node.callee;
|
|
83
|
-
if (!callee || callee.type !== 'Identifier'
|
|
98
|
+
if (!callee || callee.type !== 'Identifier') return;
|
|
99
|
+
|
|
100
|
+
const name: string = callee.value;
|
|
101
|
+
|
|
102
|
+
// ── useServerData(key, fn) — strip fn on client builds ────────────────
|
|
103
|
+
if (!isServer && name === 'useServerData') {
|
|
104
|
+
const args: any[] = node.arguments;
|
|
105
|
+
if (!args || args.length < 2) return;
|
|
106
|
+
const fnArg = args[1].expression ?? args[1];
|
|
107
|
+
// Normalise to 0-based local byte offsets and replace with stub.
|
|
108
|
+
replacements.push({
|
|
109
|
+
start: fnArg.span.start - fileOffset,
|
|
110
|
+
end: fnArg.span.end - fileOffset,
|
|
111
|
+
replacement: '()=>undefined',
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── loadModule(path) ─────────────────────────────────────────────────
|
|
117
|
+
if (name !== 'loadModule') return;
|
|
84
118
|
|
|
85
119
|
const args: any[] = node.arguments;
|
|
86
120
|
if (!args || args.length === 0) return;
|
|
@@ -199,13 +233,99 @@ const LOAD_MODULE_RE =
|
|
|
199
233
|
// (i.e. a dynamic / non-literal path argument).
|
|
200
234
|
const DYNAMIC_LOAD_MODULE_RE = /\bloadModule\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
|
|
201
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Scan forward from `pos` in `source`, skipping over a balanced JS expression
|
|
238
|
+
* (handles nested parens/brackets/braces and string literals).
|
|
239
|
+
* Returns the index of the first character AFTER the expression
|
|
240
|
+
* (i.e. the position of the trailing `,` or `)` at depth 0).
|
|
241
|
+
*/
|
|
242
|
+
function scanExpressionEnd(source: string, pos: number): number {
|
|
243
|
+
let depth = 0;
|
|
244
|
+
let i = pos;
|
|
245
|
+
while (i < source.length) {
|
|
246
|
+
const ch = source[i]!;
|
|
247
|
+
if (ch === '(' || ch === '[' || ch === '{') { depth++; i++; continue; }
|
|
248
|
+
if (ch === ')' || ch === ']' || ch === '}') {
|
|
249
|
+
if (depth === 0) break; // end of expression — closing delimiter of outer call
|
|
250
|
+
depth--; i++; continue;
|
|
251
|
+
}
|
|
252
|
+
if (ch === ',' && depth === 0) break; // end of expression — next argument
|
|
253
|
+
// String / template literals
|
|
254
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
255
|
+
const q = ch; i++;
|
|
256
|
+
while (i < source.length && source[i] !== q) {
|
|
257
|
+
if (source[i] === '\\') i++; // escape sequence
|
|
258
|
+
i++;
|
|
259
|
+
}
|
|
260
|
+
i++; // closing quote
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
// Line comment
|
|
264
|
+
if (ch === '/' && source[i + 1] === '/') {
|
|
265
|
+
while (i < source.length && source[i] !== '\n') i++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
// Block comment
|
|
269
|
+
if (ch === '/' && source[i + 1] === '*') {
|
|
270
|
+
i += 2;
|
|
271
|
+
while (i + 1 < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++;
|
|
272
|
+
i += 2;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
i++;
|
|
276
|
+
}
|
|
277
|
+
return i;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Strip the `fn` argument from `useServerData(key, fn)` calls in client builds.
|
|
282
|
+
* Uses a character-level scanner to handle arbitrary fn expressions (arrow
|
|
283
|
+
* functions with nested calls, async functions, object literals, etc.).
|
|
284
|
+
*/
|
|
285
|
+
function stripUseServerDataFns(source: string): string {
|
|
286
|
+
// Match `useServerData` + optional generic + opening paren
|
|
287
|
+
const CALL_RE = /\buseServerData\s*(?:<(?:[^<>]|<[^<>]*>)*>\s*)?\(/g;
|
|
288
|
+
let result = '';
|
|
289
|
+
let lastIndex = 0;
|
|
290
|
+
let match: RegExpExecArray | null;
|
|
291
|
+
CALL_RE.lastIndex = 0;
|
|
292
|
+
while ((match = CALL_RE.exec(source)) !== null) {
|
|
293
|
+
const callStart = match.index;
|
|
294
|
+
let i = match.index + match[0].length;
|
|
295
|
+
// Skip whitespace before first arg
|
|
296
|
+
while (i < source.length && /\s/.test(source[i]!)) i++;
|
|
297
|
+
// Skip first argument (key: string or array)
|
|
298
|
+
i = scanExpressionEnd(source, i);
|
|
299
|
+
// Expect comma separator
|
|
300
|
+
if (i >= source.length || source[i] !== ',') continue;
|
|
301
|
+
i++; // skip comma
|
|
302
|
+
// Skip whitespace before fn
|
|
303
|
+
while (i < source.length && /\s/.test(source[i]!)) i++;
|
|
304
|
+
const fnStart = i;
|
|
305
|
+
// Scan to end of fn argument
|
|
306
|
+
const fnEnd = scanExpressionEnd(source, i);
|
|
307
|
+
if (fnEnd <= fnStart) continue;
|
|
308
|
+
// Emit everything up to fn, then the stub, skip the original fn
|
|
309
|
+
result += source.slice(lastIndex, fnStart) + '()=>undefined';
|
|
310
|
+
lastIndex = fnEnd;
|
|
311
|
+
// Advance regex past this call to avoid re-matching
|
|
312
|
+
CALL_RE.lastIndex = fnEnd;
|
|
313
|
+
}
|
|
314
|
+
return lastIndex === 0 ? source : result + source.slice(lastIndex);
|
|
315
|
+
}
|
|
316
|
+
|
|
202
317
|
function regexTransform(this: any, source: string, isServer: boolean, resourcePath: string): string {
|
|
203
|
-
|
|
318
|
+
let transformed = source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) =>
|
|
204
319
|
isServer
|
|
205
320
|
? `Promise.resolve(require(${quote}${modulePath}${quote}))`
|
|
206
321
|
: `import(${quote}${modulePath}${quote})`
|
|
207
322
|
);
|
|
208
323
|
|
|
324
|
+
// Strip server-only fn arguments from useServerData on client builds.
|
|
325
|
+
if (!isServer) {
|
|
326
|
+
transformed = stripUseServerDataFns(transformed);
|
|
327
|
+
}
|
|
328
|
+
|
|
209
329
|
// Warn for any remaining dynamic calls
|
|
210
330
|
let match: RegExpExecArray | null;
|
|
211
331
|
DYNAMIC_LOAD_MODULE_RE.lastIndex = 0;
|
package/src/utils/rspack.ts
CHANGED
|
@@ -59,6 +59,7 @@ const getConfigBase = (mode: "development" | "production", isServerBuild = false
|
|
|
59
59
|
// Runs before swc-loader (loaders execute right-to-left).
|
|
60
60
|
{
|
|
61
61
|
loader: loaderPath,
|
|
62
|
+
options: { server: isServerBuild },
|
|
62
63
|
},
|
|
63
64
|
{
|
|
64
65
|
loader: 'builtin:swc-loader',
|
|
@@ -90,6 +91,7 @@ const getConfigBase = (mode: "development" | "production", isServerBuild = false
|
|
|
90
91
|
use: [
|
|
91
92
|
{
|
|
92
93
|
loader: loaderPath,
|
|
94
|
+
options: { server: isServerBuild },
|
|
93
95
|
},
|
|
94
96
|
{
|
|
95
97
|
loader: 'builtin:swc-loader',
|