weifuwu 0.30.2 → 0.31.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 +120 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +348 -7
- package/dist/react/client.d.ts +39 -0
- package/dist/react/client.js +300 -0
- package/dist/react/context.d.ts +2 -0
- package/dist/react/error-boundary.d.ts +31 -0
- package/dist/react/hooks.d.ts +6 -0
- package/dist/react/index.d.ts +28 -0
- package/dist/react/index.js +342 -0
- package/dist/react/navigation.d.ts +63 -0
- package/dist/react/navigation.js +154 -0
- package/dist/react/render.d.ts +8 -0
- package/dist/react/route-utils.d.ts +31 -0
- package/dist/react/types.d.ts +46 -0
- package/package.json +31 -2
package/README.md
CHANGED
|
@@ -169,6 +169,121 @@ const gql = graphql((req, ctx) => ({
|
|
|
169
169
|
app.mount('/graphql', gql)
|
|
170
170
|
```
|
|
171
171
|
|
|
172
|
+
### React SSR
|
|
173
|
+
|
|
174
|
+
> Requires `react >= 19`, `react-dom >= 19` (optional peerDependencies).
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npm install react react-dom
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { react, Link, useServerData, ErrorBoundary } from 'weifuwu'
|
|
182
|
+
import { createElement as h } from 'react'
|
|
183
|
+
|
|
184
|
+
// ── Server ──────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
app.use(react({
|
|
187
|
+
layout: ({ children }) =>
|
|
188
|
+
h('html', null,
|
|
189
|
+
h('head', null, h('title', null, 'My App')),
|
|
190
|
+
h('body', null, h('div', { id: 'root' }, children)),
|
|
191
|
+
),
|
|
192
|
+
}))
|
|
193
|
+
|
|
194
|
+
// Render React components to HTML
|
|
195
|
+
app.get('/', (_req, ctx) =>
|
|
196
|
+
ctx.render(h('h1', null, 'Hello SSR'), {
|
|
197
|
+
head: { title: 'Home' },
|
|
198
|
+
data: { greeting: 'Hello' },
|
|
199
|
+
})
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
// Streaming SSR
|
|
203
|
+
app.get('/dashboard', (_req, ctx) =>
|
|
204
|
+
ctx.renderStream(h(Dashboard))
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
// Layout nesting via mount
|
|
208
|
+
const admin = new Router()
|
|
209
|
+
admin.use(react({ layout: AdminLayout }))
|
|
210
|
+
admin.get('/dashboard', (_req, ctx) => ctx.render(h(AdminDashboard)))
|
|
211
|
+
app.mount('/admin', admin)
|
|
212
|
+
|
|
213
|
+
// ── Shared components ──────────────────────────────────
|
|
214
|
+
|
|
215
|
+
// Use the same component on server and client
|
|
216
|
+
function Page() {
|
|
217
|
+
const { greeting } = useServerData<{ greeting: string }>()
|
|
218
|
+
return h('h1', null, greeting)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Link works on both sides: <a> on server, SPA <a> on client
|
|
222
|
+
function Nav() {
|
|
223
|
+
return h('nav', null,
|
|
224
|
+
h(Link, { href: '/' }, 'Home'),
|
|
225
|
+
h(Link, { href: '/users' }, 'Users'),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ErrorBoundary catches client-side render errors
|
|
230
|
+
function SafeUserProfile() {
|
|
231
|
+
return h(ErrorBoundary, { fallback: h('div', null, 'Error') },
|
|
232
|
+
h(UserProfile),
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Client (SPA navigation) ────────────────────────────
|
|
237
|
+
|
|
238
|
+
// client.ts — bundled separately with esbuild
|
|
239
|
+
import { hydrate, createClientRouter, defineRoute } from 'weifuwu/react/client'
|
|
240
|
+
|
|
241
|
+
const userRoute = defineRoute({
|
|
242
|
+
path: '/users/:id',
|
|
243
|
+
component: UserPage,
|
|
244
|
+
loader: (params) => fetch(`/users/${params.id}?_data`).then(r => r.json()),
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const router = createClientRouter([
|
|
248
|
+
{ path: '/', component: HomePage },
|
|
249
|
+
userRoute,
|
|
250
|
+
])
|
|
251
|
+
|
|
252
|
+
hydrate(router.App)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Key concepts:**
|
|
256
|
+
|
|
257
|
+
| Feature | Description |
|
|
258
|
+
|---|---|
|
|
259
|
+
| `ctx.render(el, opts)` | Render React to HTML. Auto-detects `?_data` → returns JSON. |
|
|
260
|
+
| `ctx.renderStream(el)` | Streaming SSR via `renderToReadableStream`. |
|
|
261
|
+
| `useServerData<T>()` | Access data on both server (via `ctx.render({ data })`) and client (via `loader`). |
|
|
262
|
+
| `Link` | Shared component — `<a>` on server, SPA `<a>` on client. |
|
|
263
|
+
| `Form` | Shared — `<form>` on server, `fetch` + revalidate on client. |
|
|
264
|
+
| `ErrorBoundary` | Catches render errors on client (SSR uses `onError`). |
|
|
265
|
+
| `useNavigation()` | `{ state: 'idle' \| 'loading' }` for progress indicators. |
|
|
266
|
+
| `useParams()` / `useNavigate()` / `useRevalidate()` | Router hooks. |
|
|
267
|
+
| `defineRoute()` | Type-safe route config — captures loader return type. |
|
|
268
|
+
| `createClientRouter()` | Client-side SPA router with loader-based data fetching. |
|
|
269
|
+
| `hydrate(App)` | Hydrate server-rendered HTML on the client. |
|
|
270
|
+
| `head: { title, meta }` | Inject dynamic `<title>` and `<meta>` tags. |
|
|
271
|
+
|
|
272
|
+
**Import paths:**
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
// Server-side
|
|
276
|
+
import { react, Link, Form, ErrorBoundary, useServerData } from 'weifuwu'
|
|
277
|
+
|
|
278
|
+
// Client-side (for browser bundles)
|
|
279
|
+
import { hydrate, createClientRouter, defineRoute, Link } from 'weifuwu/react/client'
|
|
280
|
+
|
|
281
|
+
// Shared primitives (safe for both, pure React — no react-dom import)
|
|
282
|
+
import { Link, Form, useServerData, useParams, useNavigation } from 'weifuwu/react/navigation'
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
See [examples/react-ssr/](examples/react-ssr/) for a complete demo.
|
|
286
|
+
|
|
172
287
|
### Types
|
|
173
288
|
|
|
174
289
|
| Export | Description |
|
|
@@ -290,10 +405,13 @@ weifuwu/
|
|
|
290
405
|
│ ├── middleware/ ← cors, helmet, compress, rate-limit, static, upload
|
|
291
406
|
│ ├── postgres/
|
|
292
407
|
│ ├── redis/
|
|
408
|
+
│ ├── react/ ← react SSR (render, navigation, client)
|
|
293
409
|
│ ├── queue/ ← cron, index, types
|
|
294
410
|
│ ├── graphql.ts
|
|
295
411
|
│ ├── hub.ts
|
|
296
|
-
│ └── test/ ←
|
|
412
|
+
│ └── test/ ← 131 tests (18 files)
|
|
413
|
+
├── examples/
|
|
414
|
+
│ └── react-ssr/ ← full SPA demo
|
|
297
415
|
└── dist/
|
|
298
416
|
```
|
|
299
417
|
|
|
@@ -302,5 +420,5 @@ weifuwu/
|
|
|
302
420
|
```bash
|
|
303
421
|
npm run build # esbuild → dist/index.js
|
|
304
422
|
npm run typecheck # tsc --noEmit
|
|
305
|
-
npm test #
|
|
423
|
+
npm test # 131 tests (requires docker compose)
|
|
306
424
|
```
|
package/dist/index.d.ts
CHANGED
|
@@ -30,3 +30,10 @@ export { createHub } from './hub.ts';
|
|
|
30
30
|
export type { Hub, HubOptions } from './hub.ts';
|
|
31
31
|
export { queue } from './queue/index.ts';
|
|
32
32
|
export type { QueueOptions, QueueJob, Queue, QueueInjected } from './queue/types.ts';
|
|
33
|
+
export { react } from './react/index.ts';
|
|
34
|
+
export type { ReactOptions, RenderOptions, ReactInjected } from './react/types.ts';
|
|
35
|
+
export { useServerData, ServerDataContext } from './react/index.ts';
|
|
36
|
+
export { Link, useParams, useNavigate, useRevalidate, Form, useNavigation } from './react/index.ts';
|
|
37
|
+
export type { LinkProps, FormProps, NavigationState } from './react/index.ts';
|
|
38
|
+
export { ErrorBoundary } from './react/index.ts';
|
|
39
|
+
export type { ErrorBoundaryProps } from './react/index.ts';
|
package/dist/index.js
CHANGED
|
@@ -1111,7 +1111,7 @@ function defaultKey(_req, _ctx) {
|
|
|
1111
1111
|
}
|
|
1112
1112
|
function rateLimit(options) {
|
|
1113
1113
|
const max = options?.max ?? 100;
|
|
1114
|
-
const
|
|
1114
|
+
const window2 = options?.window ?? 6e4;
|
|
1115
1115
|
const getKey = options?.key ?? defaultKey;
|
|
1116
1116
|
const message = options?.message ?? "Too Many Requests";
|
|
1117
1117
|
const storeType = options?.store ?? "memory";
|
|
@@ -1138,7 +1138,7 @@ function rateLimit(options) {
|
|
|
1138
1138
|
}
|
|
1139
1139
|
}
|
|
1140
1140
|
},
|
|
1141
|
-
Math.min(
|
|
1141
|
+
Math.min(window2, 3e4)
|
|
1142
1142
|
) : null;
|
|
1143
1143
|
if (interval?.unref) interval.unref();
|
|
1144
1144
|
async function checkAndIncrement(key) {
|
|
@@ -1147,16 +1147,16 @@ function rateLimit(options) {
|
|
|
1147
1147
|
const redisKey = `${keyPrefix}${key}`;
|
|
1148
1148
|
const count = await redis2.incr(redisKey);
|
|
1149
1149
|
if (count === 1) {
|
|
1150
|
-
await redis2.pexpire(redisKey,
|
|
1150
|
+
await redis2.pexpire(redisKey, window2);
|
|
1151
1151
|
}
|
|
1152
1152
|
const pttl = await redis2.pttl(redisKey);
|
|
1153
|
-
const reset = pttl > 0 ? now + pttl : now +
|
|
1153
|
+
const reset = pttl > 0 ? now + pttl : now + window2;
|
|
1154
1154
|
return { count, reset };
|
|
1155
1155
|
}
|
|
1156
1156
|
const entry = hits.get(key);
|
|
1157
1157
|
if (!entry || entry.reset < now) {
|
|
1158
|
-
hits.set(key, { count: 1, reset: now +
|
|
1159
|
-
return { count: 1, reset: now +
|
|
1158
|
+
hits.set(key, { count: 1, reset: now + window2 });
|
|
1159
|
+
return { count: 1, reset: now + window2 };
|
|
1160
1160
|
}
|
|
1161
1161
|
entry.count++;
|
|
1162
1162
|
return { count: entry.count, reset: entry.reset };
|
|
@@ -1877,11 +1877,346 @@ function queue(opts) {
|
|
|
1877
1877
|
};
|
|
1878
1878
|
return q;
|
|
1879
1879
|
}
|
|
1880
|
+
|
|
1881
|
+
// src/react/index.ts
|
|
1882
|
+
import { Fragment as Fragment2 } from "react";
|
|
1883
|
+
|
|
1884
|
+
// src/react/render.ts
|
|
1885
|
+
import {
|
|
1886
|
+
createElement,
|
|
1887
|
+
Fragment
|
|
1888
|
+
} from "react";
|
|
1889
|
+
import { renderToString, renderToReadableStream } from "react-dom/server";
|
|
1890
|
+
|
|
1891
|
+
// src/react/context.ts
|
|
1892
|
+
import { createContext } from "react";
|
|
1893
|
+
var CTX_KEY = /* @__PURE__ */ Symbol.for("weifuwu.react.ServerDataContext");
|
|
1894
|
+
function getServerDataContext() {
|
|
1895
|
+
const globalStore = globalThis;
|
|
1896
|
+
if (globalStore[CTX_KEY]) return globalStore[CTX_KEY];
|
|
1897
|
+
const ctx = createContext({});
|
|
1898
|
+
ctx.displayName = "ServerData";
|
|
1899
|
+
globalStore[CTX_KEY] = ctx;
|
|
1900
|
+
return ctx;
|
|
1901
|
+
}
|
|
1902
|
+
var ServerDataContext = getServerDataContext();
|
|
1903
|
+
|
|
1904
|
+
// src/react/render.ts
|
|
1905
|
+
var encoder = new TextEncoder();
|
|
1906
|
+
var decoder = new TextDecoder();
|
|
1907
|
+
function escapeAttr(s) {
|
|
1908
|
+
return s.replace(/&/g, "&").replace(/"/g, """);
|
|
1909
|
+
}
|
|
1910
|
+
function buildHeadTags(head) {
|
|
1911
|
+
if (!head) return "";
|
|
1912
|
+
const tags = [];
|
|
1913
|
+
if (head.title) tags.push(`<title>${escapeAttr(head.title)}</title>`);
|
|
1914
|
+
if (head.meta) {
|
|
1915
|
+
for (const [name, content] of Object.entries(head.meta)) {
|
|
1916
|
+
tags.push(`<meta name="${escapeAttr(name)}" content="${escapeAttr(content)}">`);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
if (head.links) {
|
|
1920
|
+
for (const link of head.links) {
|
|
1921
|
+
const attrs = Object.entries(link).map(([k, v]) => `${k}="${escapeAttr(v)}"`).join(" ");
|
|
1922
|
+
tags.push(`<link ${attrs}>`);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
if (head.scripts) {
|
|
1926
|
+
for (const script of head.scripts) {
|
|
1927
|
+
const entries = Object.entries(script).filter(([, v]) => v !== void 0);
|
|
1928
|
+
const attrs = entries.map(([k, v]) => `${k}="${escapeAttr(v)}"`).join(" ");
|
|
1929
|
+
tags.push(`<script ${attrs}></script>`);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
return tags.join("");
|
|
1933
|
+
}
|
|
1934
|
+
function injectHeadIntoHtml(html, headTags) {
|
|
1935
|
+
if (!headTags) return html;
|
|
1936
|
+
let result = html;
|
|
1937
|
+
if (headTags.includes("<title>")) {
|
|
1938
|
+
const titleMatch = headTags.match(/<title>[^<]*<\/title>/);
|
|
1939
|
+
if (titleMatch) {
|
|
1940
|
+
result = result.replace(/<title>[^<]*<\/title>/, titleMatch[0]);
|
|
1941
|
+
headTags = headTags.replace(titleMatch[0], "");
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
if (headTags) {
|
|
1945
|
+
result = result.replace("</head>", headTags + "</head>");
|
|
1946
|
+
}
|
|
1947
|
+
return result;
|
|
1948
|
+
}
|
|
1949
|
+
function injectHeadIntoStream(sourceStream, headTags) {
|
|
1950
|
+
if (!headTags) return sourceStream;
|
|
1951
|
+
let titleTag = "";
|
|
1952
|
+
let rest = headTags;
|
|
1953
|
+
const titleMatch = headTags.match(/<title>[^<]*<\/title>/);
|
|
1954
|
+
if (titleMatch) {
|
|
1955
|
+
titleTag = titleMatch[0];
|
|
1956
|
+
rest = headTags.replace(titleMatch[0], "");
|
|
1957
|
+
}
|
|
1958
|
+
let titleInjected = !titleTag;
|
|
1959
|
+
let headInjected = !rest;
|
|
1960
|
+
return new ReadableStream({
|
|
1961
|
+
async start(controller) {
|
|
1962
|
+
const reader = sourceStream.getReader();
|
|
1963
|
+
let buffer = "";
|
|
1964
|
+
try {
|
|
1965
|
+
while (true) {
|
|
1966
|
+
const { done, value } = await reader.read();
|
|
1967
|
+
if (done) break;
|
|
1968
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1969
|
+
if (!titleInjected) {
|
|
1970
|
+
const idx = buffer.indexOf("<title>");
|
|
1971
|
+
const endIdx = buffer.indexOf("</title>", idx);
|
|
1972
|
+
if (idx !== -1 && endIdx !== -1) {
|
|
1973
|
+
controller.enqueue(
|
|
1974
|
+
encoder.encode(buffer.slice(0, idx) + titleTag + buffer.slice(endIdx + 8))
|
|
1975
|
+
);
|
|
1976
|
+
buffer = "";
|
|
1977
|
+
titleInjected = true;
|
|
1978
|
+
continue;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
if (!headInjected) {
|
|
1982
|
+
const idx = buffer.indexOf("</head>");
|
|
1983
|
+
if (idx !== -1) {
|
|
1984
|
+
controller.enqueue(
|
|
1985
|
+
encoder.encode(buffer.slice(0, idx) + rest + buffer.slice(idx))
|
|
1986
|
+
);
|
|
1987
|
+
buffer = "";
|
|
1988
|
+
headInjected = true;
|
|
1989
|
+
continue;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
if (titleInjected && headInjected && buffer) {
|
|
1993
|
+
controller.enqueue(encoder.encode(buffer));
|
|
1994
|
+
buffer = "";
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
if (buffer) controller.enqueue(encoder.encode(buffer));
|
|
1998
|
+
controller.close();
|
|
1999
|
+
} catch (err) {
|
|
2000
|
+
controller.error(err);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
function wrapElement(element, layouts, data) {
|
|
2006
|
+
let wrapped = element;
|
|
2007
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
2008
|
+
wrapped = createElement(layouts[i], null, wrapped);
|
|
2009
|
+
}
|
|
2010
|
+
const json = JSON.stringify(data).replace(/</g, "\\u003c");
|
|
2011
|
+
const dataScript = createElement("script", {
|
|
2012
|
+
id: "__WEIFUWU_DATA__",
|
|
2013
|
+
type: "application/json",
|
|
2014
|
+
dangerouslySetInnerHTML: { __html: json }
|
|
2015
|
+
});
|
|
2016
|
+
return createElement(
|
|
2017
|
+
ServerDataContext.Provider,
|
|
2018
|
+
{ value: data },
|
|
2019
|
+
createElement(Fragment, null, wrapped, dataScript)
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
function render(element, layouts, options = {}) {
|
|
2023
|
+
const data = options.data ?? {};
|
|
2024
|
+
const wrapped = wrapElement(element, layouts, data);
|
|
2025
|
+
let html = renderToString(wrapped);
|
|
2026
|
+
const headTags = buildHeadTags(options.head);
|
|
2027
|
+
html = injectHeadIntoHtml(html, headTags);
|
|
2028
|
+
return new Response("<!DOCTYPE html>\n" + html, {
|
|
2029
|
+
status: options.status ?? 200,
|
|
2030
|
+
headers: {
|
|
2031
|
+
"content-type": "text/html; charset=utf-8",
|
|
2032
|
+
...options.headers
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
async function renderStream(element, layouts, options = {}) {
|
|
2037
|
+
const data = options.data ?? {};
|
|
2038
|
+
const wrapped = wrapElement(element, layouts, data);
|
|
2039
|
+
const headTags = buildHeadTags(options.head);
|
|
2040
|
+
let stream = await renderToReadableStream(wrapped);
|
|
2041
|
+
stream = injectHeadIntoStream(stream, headTags);
|
|
2042
|
+
const doctype = encoder.encode("<!DOCTYPE html>\n");
|
|
2043
|
+
const combined = new ReadableStream({
|
|
2044
|
+
async start(controller) {
|
|
2045
|
+
controller.enqueue(doctype);
|
|
2046
|
+
try {
|
|
2047
|
+
const reader = stream.getReader();
|
|
2048
|
+
while (true) {
|
|
2049
|
+
const { done, value } = await reader.read();
|
|
2050
|
+
if (done) {
|
|
2051
|
+
controller.close();
|
|
2052
|
+
break;
|
|
2053
|
+
}
|
|
2054
|
+
controller.enqueue(value);
|
|
2055
|
+
}
|
|
2056
|
+
} catch (err) {
|
|
2057
|
+
controller.error(err);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
});
|
|
2061
|
+
return new Response(combined, {
|
|
2062
|
+
status: options.status ?? 200,
|
|
2063
|
+
headers: {
|
|
2064
|
+
"content-type": "text/html; charset=utf-8",
|
|
2065
|
+
"transfer-encoding": "chunked",
|
|
2066
|
+
...options.headers
|
|
2067
|
+
}
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// src/react/hooks.ts
|
|
2072
|
+
import { useContext } from "react";
|
|
2073
|
+
function useServerData() {
|
|
2074
|
+
return useContext(ServerDataContext);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
// src/react/navigation.ts
|
|
2078
|
+
import {
|
|
2079
|
+
createElement as createElement2,
|
|
2080
|
+
createContext as createContext2,
|
|
2081
|
+
useContext as useContext2
|
|
2082
|
+
} from "react";
|
|
2083
|
+
|
|
2084
|
+
// src/react/error-boundary.ts
|
|
2085
|
+
import { Component } from "react";
|
|
2086
|
+
var ErrorBoundary = class extends Component {
|
|
2087
|
+
state = { error: null };
|
|
2088
|
+
static getDerivedStateFromError(error) {
|
|
2089
|
+
return { error };
|
|
2090
|
+
}
|
|
2091
|
+
componentDidCatch(error, info) {
|
|
2092
|
+
this.props.onError?.(error, info);
|
|
2093
|
+
}
|
|
2094
|
+
render() {
|
|
2095
|
+
if (this.state.error) {
|
|
2096
|
+
return this.props.fallback;
|
|
2097
|
+
}
|
|
2098
|
+
return this.props.children;
|
|
2099
|
+
}
|
|
2100
|
+
};
|
|
2101
|
+
|
|
2102
|
+
// src/react/navigation.ts
|
|
2103
|
+
var RouterContext = createContext2(null);
|
|
2104
|
+
RouterContext.displayName = "ClientRouter";
|
|
2105
|
+
function useParams() {
|
|
2106
|
+
return useContext2(RouterContext)?.params ?? {};
|
|
2107
|
+
}
|
|
2108
|
+
function useNavigate() {
|
|
2109
|
+
const ctx = useContext2(RouterContext);
|
|
2110
|
+
if (!ctx) throw new Error("useNavigate() must be used within a client router");
|
|
2111
|
+
return ctx.navigate;
|
|
2112
|
+
}
|
|
2113
|
+
function useNavigation() {
|
|
2114
|
+
const ctx = useContext2(RouterContext);
|
|
2115
|
+
return { state: ctx?.state ?? "idle" };
|
|
2116
|
+
}
|
|
2117
|
+
function Link({ href, children, ...props }) {
|
|
2118
|
+
const router = useContext2(RouterContext);
|
|
2119
|
+
const isExternal = href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//") || href.startsWith("mailto:") || href.startsWith("tel:");
|
|
2120
|
+
if (!router || isExternal || props.target !== void 0) {
|
|
2121
|
+
return createElement2("a", { href, ...props }, children);
|
|
2122
|
+
}
|
|
2123
|
+
const handleClick = (e) => {
|
|
2124
|
+
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
|
|
2125
|
+
if (e.button !== 0) return;
|
|
2126
|
+
e.preventDefault();
|
|
2127
|
+
router.navigate(href);
|
|
2128
|
+
};
|
|
2129
|
+
return createElement2("a", { href, onClick: handleClick, ...props }, children);
|
|
2130
|
+
}
|
|
2131
|
+
function useRevalidate() {
|
|
2132
|
+
const ctx = useContext2(RouterContext);
|
|
2133
|
+
if (!ctx) throw new Error("useRevalidate() must be used within a client router");
|
|
2134
|
+
return ctx.revalidate;
|
|
2135
|
+
}
|
|
2136
|
+
function Form({ method = "post", action, children, ...props }) {
|
|
2137
|
+
const router = useContext2(RouterContext);
|
|
2138
|
+
if (!router) {
|
|
2139
|
+
return createElement2("form", { method, action, ...props }, children);
|
|
2140
|
+
}
|
|
2141
|
+
const handleSubmit = async (e) => {
|
|
2142
|
+
e.preventDefault();
|
|
2143
|
+
const form = e.target;
|
|
2144
|
+
const formData = new FormData(form);
|
|
2145
|
+
const url = action || window.location.pathname;
|
|
2146
|
+
const fetchMethod = method === "get" ? "GET" : method.toUpperCase();
|
|
2147
|
+
try {
|
|
2148
|
+
const res = await fetch(url, {
|
|
2149
|
+
method: fetchMethod,
|
|
2150
|
+
body: fetchMethod === "GET" ? void 0 : formData,
|
|
2151
|
+
redirect: "manual"
|
|
2152
|
+
});
|
|
2153
|
+
if (res.status >= 300 && res.status < 400) {
|
|
2154
|
+
const loc = res.headers.get("Location");
|
|
2155
|
+
if (loc) {
|
|
2156
|
+
router.navigate(loc);
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
await router.revalidate();
|
|
2161
|
+
} catch (err) {
|
|
2162
|
+
console.error("[weifuwu/react] Form submit failed:", err);
|
|
2163
|
+
}
|
|
2164
|
+
};
|
|
2165
|
+
return createElement2(
|
|
2166
|
+
"form",
|
|
2167
|
+
{ method, action, ...props, onSubmit: handleSubmit },
|
|
2168
|
+
children
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
// src/react/index.ts
|
|
2173
|
+
var LAYOUTS_KEY = /* @__PURE__ */ Symbol.for("weifuwu:react:layouts");
|
|
2174
|
+
var SETUP_KEY = /* @__PURE__ */ Symbol.for("weifuwu:react:setup");
|
|
2175
|
+
function bag(ctx) {
|
|
2176
|
+
return ctx;
|
|
2177
|
+
}
|
|
2178
|
+
function isDataRequest(req) {
|
|
2179
|
+
try {
|
|
2180
|
+
return new URL(req.url).searchParams.has("_data");
|
|
2181
|
+
} catch {
|
|
2182
|
+
return false;
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
function react(opts = {}) {
|
|
2186
|
+
const layout = opts.layout ?? Fragment2;
|
|
2187
|
+
const mw = (req, ctx, next) => {
|
|
2188
|
+
const b = bag(ctx);
|
|
2189
|
+
const layouts = b[LAYOUTS_KEY] ?? [];
|
|
2190
|
+
b[LAYOUTS_KEY] = layouts;
|
|
2191
|
+
layouts.push(layout);
|
|
2192
|
+
if (!b[SETUP_KEY]) {
|
|
2193
|
+
b[SETUP_KEY] = true;
|
|
2194
|
+
ctx.render = (element, renderOpts) => {
|
|
2195
|
+
if (renderOpts?.data && isDataRequest(req)) {
|
|
2196
|
+
return Promise.resolve(Response.json(renderOpts.data));
|
|
2197
|
+
}
|
|
2198
|
+
return Promise.resolve(render(element, layouts, renderOpts));
|
|
2199
|
+
};
|
|
2200
|
+
ctx.renderStream = (element, renderOpts) => {
|
|
2201
|
+
if (renderOpts?.data && isDataRequest(req)) {
|
|
2202
|
+
return Promise.resolve(Response.json(renderOpts.data));
|
|
2203
|
+
}
|
|
2204
|
+
return renderStream(element, layouts, renderOpts);
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
return next(req, ctx);
|
|
2208
|
+
};
|
|
2209
|
+
return mw;
|
|
2210
|
+
}
|
|
1880
2211
|
export {
|
|
1881
2212
|
DEFAULT_MAX_BODY,
|
|
2213
|
+
ErrorBoundary,
|
|
2214
|
+
Form,
|
|
1882
2215
|
HttpError,
|
|
2216
|
+
Link,
|
|
1883
2217
|
MIGRATIONS_TABLE,
|
|
1884
2218
|
Router,
|
|
2219
|
+
ServerDataContext,
|
|
1885
2220
|
compress,
|
|
1886
2221
|
cors,
|
|
1887
2222
|
createHub,
|
|
@@ -1893,11 +2228,17 @@ export {
|
|
|
1893
2228
|
postgres,
|
|
1894
2229
|
queue,
|
|
1895
2230
|
rateLimit,
|
|
2231
|
+
react,
|
|
1896
2232
|
redis,
|
|
1897
2233
|
runWithTrace,
|
|
1898
2234
|
serve,
|
|
1899
2235
|
serveStatic,
|
|
1900
2236
|
trace,
|
|
1901
2237
|
traceElapsed,
|
|
1902
|
-
upload
|
|
2238
|
+
upload,
|
|
2239
|
+
useNavigate,
|
|
2240
|
+
useNavigation,
|
|
2241
|
+
useParams,
|
|
2242
|
+
useRevalidate,
|
|
2243
|
+
useServerData
|
|
1903
2244
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side entry for React hydration + SPA navigation.
|
|
3
|
+
*
|
|
4
|
+
* Usage (in a client bundle):
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { hydrate, createClientRouter } from 'weifuwu/react/client'
|
|
7
|
+
* import HomePage from './pages/HomePage.js'
|
|
8
|
+
*
|
|
9
|
+
* const router = createClientRouter([
|
|
10
|
+
* { path: '/', component: HomePage },
|
|
11
|
+
* { path: '/users/:id', component: UserPage, loader: ... },
|
|
12
|
+
* ])
|
|
13
|
+
* hydrate(router.App)
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import { type ComponentType, type ReactElement } from 'react';
|
|
17
|
+
import { type LinkProps } from './navigation.ts';
|
|
18
|
+
export interface HydrateOptions {
|
|
19
|
+
container?: HTMLElement;
|
|
20
|
+
}
|
|
21
|
+
export interface ClientRoute {
|
|
22
|
+
path: string;
|
|
23
|
+
component: ComponentType;
|
|
24
|
+
loader?: (params: Record<string, string>) => Promise<Record<string, unknown>>;
|
|
25
|
+
}
|
|
26
|
+
export interface ClientRouter {
|
|
27
|
+
App: ComponentType;
|
|
28
|
+
Link: (props: LinkProps) => ReactElement;
|
|
29
|
+
useParams: () => Record<string, string>;
|
|
30
|
+
navigate: (url: string) => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export { Link, useParams, useNavigate, useRevalidate, Form, useNavigation } from './navigation.ts';
|
|
33
|
+
export type { LinkProps, FormProps, NavigationState } from './navigation.ts';
|
|
34
|
+
export { ErrorBoundary } from './error-boundary.ts';
|
|
35
|
+
export type { ErrorBoundaryProps } from './error-boundary.ts';
|
|
36
|
+
export { defineRoute } from './route-utils.ts';
|
|
37
|
+
export declare function createClientRouter(routes: ClientRoute[]): ClientRouter;
|
|
38
|
+
export declare function hydrate(App: ComponentType, opts?: HydrateOptions): void;
|
|
39
|
+
export { useServerData } from './hooks.ts';
|