weifuwu 0.31.1 → 0.32.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.
@@ -1,11 +1,6 @@
1
1
  // src/react/client.ts
2
- import {
3
- createElement as createElement2,
4
- useEffect,
5
- useCallback,
6
- useSyncExternalStore
7
- } from "react";
8
- import { hydrateRoot } from "react-dom/client";
2
+ import { createElement } from "react";
3
+ import { hydrateRoot, createRoot } from "react-dom/client";
9
4
 
10
5
  // src/react/context.ts
11
6
  import { createContext } from "react";
@@ -20,132 +15,12 @@ function getServerDataContext() {
20
15
  }
21
16
  var ServerDataContext = getServerDataContext();
22
17
 
23
- // src/react/navigation.ts
24
- import {
25
- createElement,
26
- createContext as createContext2,
27
- useContext as useContext2
28
- } from "react";
29
-
30
18
  // src/react/hooks.ts
31
19
  import { useContext } from "react";
32
20
  function useServerData() {
33
21
  return useContext(ServerDataContext);
34
22
  }
35
23
 
36
- // src/react/error-boundary.ts
37
- import { Component } from "react";
38
- var ErrorBoundary = class extends Component {
39
- state = { error: null };
40
- static getDerivedStateFromError(error) {
41
- return { error };
42
- }
43
- componentDidCatch(error, info) {
44
- this.props.onError?.(error, info);
45
- }
46
- render() {
47
- if (this.state.error) {
48
- return this.props.fallback;
49
- }
50
- return this.props.children;
51
- }
52
- };
53
-
54
- // src/react/route-utils.ts
55
- function defineRoute(config) {
56
- return config;
57
- }
58
-
59
- // src/react/navigation.ts
60
- var RouterContext = createContext2(null);
61
- RouterContext.displayName = "ClientRouter";
62
- function useParams() {
63
- return useContext2(RouterContext)?.params ?? {};
64
- }
65
- function useNavigate() {
66
- const ctx = useContext2(RouterContext);
67
- if (!ctx) throw new Error("useNavigate() must be used within a client router");
68
- return ctx.navigate;
69
- }
70
- function useNavigation() {
71
- const ctx = useContext2(RouterContext);
72
- return { state: ctx?.state ?? "idle" };
73
- }
74
- function Link({ href, children, ...props }) {
75
- const router = useContext2(RouterContext);
76
- const isExternal = href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//") || href.startsWith("mailto:") || href.startsWith("tel:");
77
- if (!router || isExternal || props.target !== void 0) {
78
- return createElement("a", { href, ...props }, children);
79
- }
80
- const handleClick = (e) => {
81
- if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
82
- if (e.button !== 0) return;
83
- e.preventDefault();
84
- router.navigate(href);
85
- };
86
- return createElement("a", { href, onClick: handleClick, ...props }, children);
87
- }
88
- function useRevalidate() {
89
- const ctx = useContext2(RouterContext);
90
- if (!ctx) throw new Error("useRevalidate() must be used within a client router");
91
- return ctx.revalidate;
92
- }
93
- function Form({ method = "post", action, children, ...props }) {
94
- const router = useContext2(RouterContext);
95
- if (!router) {
96
- return createElement("form", { method, action, ...props }, children);
97
- }
98
- const handleSubmit = async (e) => {
99
- e.preventDefault();
100
- const form = e.target;
101
- const formData = new FormData(form);
102
- const url = action || window.location.pathname;
103
- const fetchMethod = method === "get" ? "GET" : method.toUpperCase();
104
- try {
105
- const res = await fetch(url, {
106
- method: fetchMethod,
107
- body: fetchMethod === "GET" ? void 0 : formData,
108
- redirect: "manual"
109
- });
110
- if (res.status >= 300 && res.status < 400) {
111
- const loc = res.headers.get("Location");
112
- if (loc) {
113
- router.navigate(loc);
114
- return;
115
- }
116
- }
117
- await router.revalidate();
118
- } catch (err) {
119
- console.error("[weifuwu/react] Form submit failed:", err);
120
- }
121
- };
122
- return createElement(
123
- "form",
124
- { method, action, ...props, onSubmit: handleSubmit },
125
- children
126
- );
127
- }
128
- function matchPath(pathname, pattern) {
129
- const patParts = pattern.split("/").filter(Boolean);
130
- const pathParts = pathname.split("/").filter(Boolean);
131
- const hasWildcard = patParts[patParts.length - 1] === "*";
132
- if (!hasWildcard && patParts.length !== pathParts.length) return null;
133
- if (hasWildcard && pathParts.length < patParts.length - 1) return null;
134
- const params = {};
135
- for (let i = 0; i < patParts.length; i++) {
136
- if (patParts[i] === "*") {
137
- params["*"] = pathParts.slice(i).join("/");
138
- break;
139
- }
140
- if (patParts[i].startsWith(":")) {
141
- params[patParts[i].slice(1)] = decodeURIComponent(pathParts[i]);
142
- } else if (patParts[i] !== pathParts[i]) {
143
- return null;
144
- }
145
- }
146
- return params;
147
- }
148
-
149
24
  // src/react/client.ts
150
25
  function readInitialData() {
151
26
  if (typeof document === "undefined") return {};
@@ -158,143 +33,150 @@ function readInitialData() {
158
33
  }
159
34
  return {};
160
35
  }
161
- function createClientRouter(routes) {
162
- function findRoute(pathname) {
163
- for (const route of routes) {
164
- const params = matchPath(pathname, route.path);
165
- if (params) return { route, params };
36
+ async function fetchPageData(url) {
37
+ const res = await fetch(url);
38
+ const html = await res.text();
39
+ const match = html.match(/<script[^>]*id="__WEIFUWU_DATA__"[^>]*>([\s\S]*?)<\/script>/);
40
+ if (match) {
41
+ try {
42
+ return JSON.parse(match[1]);
43
+ } catch {
44
+ }
45
+ }
46
+ return {};
47
+ }
48
+ function compilePattern(pattern) {
49
+ const paramNames = [];
50
+ const regexStr = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/:([^/]+)/g, (_, name) => {
51
+ paramNames.push(name);
52
+ return "([^/]+)";
53
+ });
54
+ return { regex: new RegExp(`^${regexStr}$`), paramNames };
55
+ }
56
+ function matchRoute(pathname, routes) {
57
+ for (const route of routes) {
58
+ const m = pathname.match(route.regex);
59
+ if (m) {
60
+ const params = {};
61
+ route.paramNames.forEach((name, i) => {
62
+ params[name] = m[i + 1];
63
+ });
64
+ return { route, params };
166
65
  }
167
- return null;
168
66
  }
169
- let state = {
170
- location: typeof window !== "undefined" ? window.location.pathname : "/",
171
- data: readInitialData(),
172
- navId: 0,
173
- state: "idle"
174
- };
175
- const listeners = /* @__PURE__ */ new Set();
176
- function getSnapshot() {
177
- return state;
67
+ return null;
68
+ }
69
+ function extractComponent(mod) {
70
+ if (mod.default && typeof mod.default === "function") return mod.default;
71
+ for (const val of Object.values(mod)) {
72
+ if (typeof val === "function") return val;
73
+ }
74
+ throw new Error("No component export found in module");
75
+ }
76
+ function hydrate(App, opts) {
77
+ const container = opts?.container ?? document.getElementById("root");
78
+ const data = readInitialData();
79
+ let element = createElement(App);
80
+ if (opts?.layout) {
81
+ element = createElement(opts.layout, { children: element });
82
+ }
83
+ hydrateRoot(
84
+ container,
85
+ createElement(ServerDataContext.Provider, { value: data }, element)
86
+ );
87
+ }
88
+ var _root = null;
89
+ function renderPage(container, layout, Component, data, isInitial) {
90
+ let element = createElement(Component);
91
+ if (layout) {
92
+ element = createElement(layout, { children: element });
178
93
  }
179
- function subscribe(fn) {
180
- listeners.add(fn);
181
- return () => {
182
- listeners.delete(fn);
183
- };
94
+ const wrapped = createElement(ServerDataContext.Provider, { value: data }, element);
95
+ if (isInitial) {
96
+ _root = hydrateRoot(container, wrapped);
97
+ } else {
98
+ if (!_root) _root = createRoot(container);
99
+ _root.render(wrapped);
184
100
  }
185
- function emit() {
186
- listeners.forEach((fn) => fn());
101
+ }
102
+ var _router = null;
103
+ function createBrowserRouter(opts) {
104
+ if (_router) return _router;
105
+ const container = opts.container ?? document.getElementById("root");
106
+ const layout = opts.layout;
107
+ const routes = Object.entries(opts.routes).map(([pattern, loader]) => {
108
+ const { regex, paramNames } = compilePattern(pattern);
109
+ return { pattern, regex, paramNames, loader };
110
+ });
111
+ let fallback = null;
112
+ if (opts.fallback) {
113
+ const { regex, paramNames } = compilePattern("*");
114
+ fallback = { pattern: "*", regex, paramNames, loader: opts.fallback };
187
115
  }
188
- async function navigate(url, { push = true } = {}) {
189
- const match = findRoute(url);
190
- if (!match) {
191
- window.location.href = url;
116
+ async function renderPath(pathname, isInitial, data) {
117
+ const matched = matchRoute(pathname, routes);
118
+ const route = matched?.route ?? fallback;
119
+ if (!route) {
120
+ window.location.href = pathname;
192
121
  return;
193
122
  }
194
- const id = state.navId + 1;
195
- state = { ...state, navId: id, state: match.route.loader ? "loading" : "idle" };
196
- emit();
197
- if (match.route.loader) {
198
- try {
199
- const newData = await match.route.loader(match.params);
200
- if (id < state.navId) return;
201
- state = { ...state, data: newData, state: "idle" };
202
- } catch (err) {
203
- console.error("[weifuwu/react] loader failed:", err);
204
- state = { ...state, state: "idle" };
205
- emit();
206
- return;
207
- }
208
- }
209
- if (push) history.pushState({ weifuwu: true }, "", url);
210
- state = { ...state, location: url, state: "idle" };
211
- emit();
212
- }
213
- function RouterApp() {
214
- const { location, data, state: navState } = useSyncExternalStore(
215
- subscribe,
216
- getSnapshot
217
- );
218
- const match = findRoute(location);
219
- useEffect(() => {
220
- const handler = () => navigate(window.location.pathname, { push: false });
221
- window.addEventListener("popstate", handler);
222
- return () => window.removeEventListener("popstate", handler);
223
- }, []);
224
- const ctxNavigate = useCallback(
225
- (url) => navigate(url, { push: true }),
226
- []
227
- );
228
- const ctxRevalidate = useCallback(async () => {
229
- const currentMatch = findRoute(window.location.pathname);
230
- if (currentMatch?.route.loader) {
231
- state = { ...state, state: "loading" };
232
- emit();
123
+ try {
124
+ const mod = await route.loader();
125
+ const Component = extractComponent(mod);
126
+ const pageData = data ?? (isInitial ? readInitialData() : await fetchPageData(pathname));
127
+ renderPage(container, layout, Component, pageData, isInitial);
128
+ } catch (err) {
129
+ console.error("[weifuwu] router error:", err);
130
+ if (fallback && route !== fallback) {
233
131
  try {
234
- const newData = await currentMatch.route.loader(currentMatch.params);
235
- state = { ...state, data: newData, state: "idle" };
236
- emit();
237
- } catch (err) {
238
- console.error("[weifuwu/react] revalidate failed:", err);
239
- state = { ...state, state: "idle" };
240
- emit();
132
+ const fallbackMod = await fallback.loader();
133
+ const FallbackComponent = extractComponent(fallbackMod);
134
+ renderPage(container, layout, FallbackComponent, { error: String(err) }, false);
135
+ } catch {
136
+ container.innerHTML = "<h1>Something went wrong</h1>";
241
137
  }
138
+ } else {
139
+ container.innerHTML = "<h1>Something went wrong</h1>";
242
140
  }
243
- }, []);
244
- const ctxValue = {
245
- params: match?.params ?? {},
246
- navigate: ctxNavigate,
247
- revalidate: ctxRevalidate,
248
- state: navState
249
- };
250
- const content = match ? createElement2(match.route.component) : createElement2("div", null, "Page not found");
251
- return createElement2(
252
- RouterContext.Provider,
253
- { value: ctxValue },
254
- createElement2(
255
- ServerDataContext.Provider,
256
- { value: data },
257
- content
258
- )
259
- );
141
+ }
260
142
  }
261
- function RouterLink(props) {
262
- return createElement2(Link, props);
143
+ async function navigate2(url) {
144
+ const u = new URL(url, window.location.origin);
145
+ if (u.origin !== window.location.origin || u.pathname === window.location.pathname) {
146
+ return;
147
+ }
148
+ history.pushState({}, "", url);
149
+ scrollTo(0, 0);
150
+ await renderPath(u.pathname, false);
263
151
  }
264
- return {
265
- App: RouterApp,
266
- Link: RouterLink,
267
- useParams,
268
- navigate: (url) => navigate(url, { push: true })
269
- };
152
+ window.addEventListener("popstate", () => {
153
+ renderPath(window.location.pathname, false);
154
+ });
155
+ document.addEventListener("click", (e) => {
156
+ const target = e.target.closest("a");
157
+ if (!target) return;
158
+ const href = target.getAttribute("href");
159
+ if (!href) return;
160
+ const u = new URL(href, window.location.origin);
161
+ if (u.origin !== window.location.origin || target.hasAttribute("download") || target.target === "_blank" || target.getAttribute("rel") === "external") return;
162
+ e.preventDefault();
163
+ navigate2(href);
164
+ });
165
+ const initialData = readInitialData();
166
+ renderPath(window.location.pathname, true, initialData);
167
+ _router = { navigate: navigate2, renderPath };
168
+ return _router;
270
169
  }
271
- function hydrate(App, opts) {
272
- const container = opts?.container ?? document.getElementById("root");
273
- if (!container) {
274
- throw new Error(
275
- "weifuwu/react: hydrate() \u2014 no container element found."
276
- );
170
+ function navigate(url) {
171
+ if (_router) {
172
+ _router.navigate(url);
173
+ } else {
174
+ window.location.href = url;
277
175
  }
278
- const serverData = readInitialData();
279
- hydrateRoot(
280
- container,
281
- createElement2(
282
- ServerDataContext.Provider,
283
- { value: serverData },
284
- createElement2(App)
285
- )
286
- );
287
176
  }
288
177
  export {
289
- ErrorBoundary,
290
- Form,
291
- Link,
292
- createClientRouter,
293
- defineRoute,
178
+ createBrowserRouter,
294
179
  hydrate,
295
- useNavigate,
296
- useNavigation,
297
- useParams,
298
- useRevalidate,
180
+ navigate,
299
181
  useServerData
300
182
  };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * On-the-fly TSX compilation for ctx.render().
3
+ *
4
+ * Compiles .tsx files with esbuild, caches by source-content hash.
5
+ * Externalizes react/react-dom — the framework's own instance is used.
6
+ * Persisted to disk for fast restarts.
7
+ */
8
+ import type { ComponentType } from 'react';
9
+ export declare function setReactCacheDir(dir: string): void;
10
+ /**
11
+ * Load a .tsx/.ts module, compiling on-the-fly with esbuild.
12
+ * Results are:
13
+ * 1. Cached in memory by source-content hash
14
+ * 2. Persisted to disk ({cacheDir}/{sourceHash}.mjs) for instant restarts
15
+ */
16
+ export declare function loadTsxModule(entryPath: string): Promise<Record<string, unknown>>;
17
+ export declare function loadTsxComponent(entryPath: string): Promise<ComponentType>;
@@ -1,31 +1,30 @@
1
+ import { Component, type ReactNode } from 'react';
2
+ interface ErrorBoundaryProps {
3
+ children: ReactNode;
4
+ /** Fallback UI rendered when a child component throws. */
5
+ fallback?: ReactNode;
6
+ /** Called with the error (client-side only). */
7
+ onError?: (error: Error) => void;
8
+ }
9
+ interface ErrorBoundaryState {
10
+ hasError: boolean;
11
+ error: Error | null;
12
+ }
1
13
  /**
2
- * SSR-safe error boundary.
3
- *
4
- * Catches React render errors on both server and client:
5
- * - Server (SSR): renders fallback HTML instead of crashing
6
- * - Client (hydration): catches errors, shows fallback
14
+ * Catch render errors in child components.
15
+ * Works during SSR (renders fallback) and client-side hydration.
7
16
  *
8
- * Usage:
17
+ * @example
9
18
  * ```tsx
10
- * <ErrorBoundary fallback={<div>Something went wrong</div>}>
11
- * <UserProfile />
19
+ * <ErrorBoundary fallback={<p>Something went wrong</p>}>
20
+ * <RiskyComponent />
12
21
  * </ErrorBoundary>
13
22
  * ```
14
23
  */
15
- import { Component, type ReactNode, type ErrorInfo } from 'react';
16
- export interface ErrorBoundaryProps {
17
- fallback: ReactNode;
18
- children: ReactNode;
19
- /** Called when an error is caught (both SSR and client). */
20
- onError?: (error: Error, info: ErrorInfo) => void;
21
- }
22
- interface ErrorBoundaryState {
23
- error: Error | null;
24
- }
25
24
  export declare class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
26
25
  state: ErrorBoundaryState;
27
26
  static getDerivedStateFromError(error: Error): ErrorBoundaryState;
28
- componentDidCatch(error: Error, info: ErrorInfo): void;
27
+ componentDidCatch(error: Error): void;
29
28
  render(): ReactNode;
30
29
  }
31
30
  export {};
@@ -1,28 +1,92 @@
1
- import type { ReactOptions, ReactMiddleware } from './types.ts';
1
+ import { type ReactElement, type ComponentType } from 'react';
2
+ import { type Middleware } from '../types.ts';
3
+ import type { Router } from '../core/router.ts';
4
+ import type { ReactOptions, ReactRouterOptions, ReactAppOptions } from './types.ts';
2
5
  /**
3
- * React SSR middleware.
6
+ * React SSR middleware — injects `ctx.render(path, opts?)`.
4
7
  *
5
- * Injects ctx.render() and ctx.renderStream() for server-side rendering
6
- * React components to HTML. Supports layout composition via mount nesting.
8
+ * Options:
9
+ * - `layout`: Wrap every page in a shared layout component (nav, footer, etc.)
10
+ *
11
+ * For Tailwind CSS: use `tailwindDev` middleware.
12
+ * For client bundles: use `esbuildDev` middleware.
7
13
  *
8
14
  * @example
9
15
  * ```ts
10
- * app.use(react({
11
- * layout: ({ children }) => (
12
- * <html><body><div id="root">{children}</div></body></html>
13
- * ),
16
+ * app.use(tailwindDev({ '/assets/tailwind.css': { entry: './styles/input.css' } }))
17
+ * app.use(esbuildDev({ '/assets/client.js': { entry: './client.ts', ... } }))
18
+ * app.use(react({ layout: './components/PageShell.tsx' }))
19
+ * app.get('/', (_req, ctx) => ctx.render('./pages/HomePage.tsx', {
20
+ * stylesheets: ['/assets/tailwind.css'],
21
+ * bootstrapModules: ['/assets/client.js'],
14
22
  * }))
23
+ * ```
24
+ */
25
+ /**
26
+ * React SSR middleware — injects `ctx.render(path, opts?)`.
27
+ *
28
+ * **Lightweight mode** (no `pages`): returns middleware for `app.use()`.
29
+ * Use with manual `app.get()` + `ctx.render()`.
15
30
  *
16
- * app.get('/', async (req, ctx) => {
17
- * return ctx.render(<Home />, { data: { title: 'Home' } })
31
+ * **Full mode** (has `pages`): returns a plugin for `app.plugin()`.
32
+ * Handles routing, data loading, Tailwind, client bundle, and error pages.
33
+ */
34
+ export declare function react(opts?: ReactOptions): Middleware;
35
+ export declare function react(opts: ReactAppOptions): (app: Router) => void;
36
+ /**
37
+ * Auto-register routes from a shared route config.
38
+ *
39
+ * Use a single routes file shared between server and client to eliminate duplication.
40
+ * Routes with data dependencies use the `loaders` option — request ctx is passed
41
+ * to the loader, which can throw `HttpError` for non-200 status codes.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * // routes.ts — shared by server and client
46
+ * export const routes = {
47
+ * '/': () => import('./pages/Home.tsx'),
48
+ * '/users': () => import('./pages/Users.tsx'),
49
+ * '/users/:id': () => import('./pages/UserDetail.tsx'),
50
+ * }
51
+ *
52
+ * // server.ts
53
+ * import { reactRouter } from 'weifuwu/react'
54
+ * import { routes } from './routes.ts'
55
+ *
56
+ * reactRouter(app, routes, {
57
+ * layout: './layouts/Root.tsx',
58
+ * stylesheets: ['/assets/tailwind.css'],
59
+ * bootstrapModules: ['/assets/client.js'],
60
+ * loaders: {
61
+ * '/users': async (ctx) => ({ users: await db.listUsers() }),
62
+ * '/users/:id': async (ctx) => {
63
+ * const user = await db.findUser(ctx.params.id)
64
+ * if (!user) throw new HttpError('Not found', 404)
65
+ * return { user }
66
+ * },
67
+ * },
18
68
  * })
69
+ *
70
+ * // client.ts — same routes config, no loaders
71
+ * import { createBrowserRouter } from 'weifuwu/react/client'
72
+ * import { routes } from './routes.ts'
73
+ *
74
+ * createBrowserRouter({ layout: Root, routes })
19
75
  * ```
20
76
  */
21
- export declare function react(opts?: ReactOptions): ReactMiddleware;
77
+ export declare function reactRouter(app: Router, routes: Record<string, () => Promise<{
78
+ default: ComponentType;
79
+ }>>, opts?: ReactRouterOptions): void;
80
+ /**
81
+ * Client-side navigation link. Renders as `<a>` on the server.
82
+ * On the client, intercepted by `createBrowserRouter` for SPA navigation.
83
+ */
84
+ export declare function Link({ href, children, ...props }: {
85
+ href: string;
86
+ children: React.ReactNode;
87
+ [key: string]: unknown;
88
+ }): ReactElement;
89
+ export { ErrorBoundary } from './error-boundary.ts';
22
90
  export { useServerData } from './hooks.ts';
23
91
  export { ServerDataContext } from './context.ts';
24
- export { Link, useParams, useNavigate, useRevalidate, Form, useNavigation } from './navigation.ts';
25
- export type { LinkProps, FormProps, NavigationState } from './navigation.ts';
26
- export { ErrorBoundary } from './error-boundary.ts';
27
- export type { ErrorBoundaryProps } from './error-boundary.ts';
28
- export type { ReactOptions, RenderOptions, ReactInjected } from './types.ts';
92
+ export type { ReactOptions, RenderOptions, ReactRouterOptions, ReactAppOptions } from './types.ts';