weifuwu 0.31.0 → 0.31.2

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,46 +1,146 @@
1
- import type { ReactElement, ComponentType, ReactNode } from 'react';
2
- import type { Context, Middleware } from '../types.ts';
1
+ import type { Context } from '../types.ts';
3
2
  declare module '../types.ts' {
4
3
  interface Context {
5
- /** Server-render a React element to HTML (renderToString). */
6
- render(element: ReactElement, opts?: RenderOptions): Promise<Response>;
7
- /** Streaming server-render a React element to HTML (renderToReadableStream). */
8
- renderStream(element: ReactElement, opts?: RenderOptions): Promise<Response>;
4
+ /**
5
+ * Compile and render a .tsx file to HTML.
6
+ * The component should render a full page.
7
+ */
8
+ render(path: string, opts?: RenderOptions): Promise<Response>;
9
9
  }
10
10
  }
11
- export interface HeadOptions {
12
- /** Page title — injected as <title>. */
13
- title?: string;
14
- /** Meta tags: name content. */
15
- meta?: Record<string, string>;
16
- /** Link tags (stylesheet, canonical, etc). */
17
- links?: Array<{
18
- rel: string;
19
- href: string;
20
- [key: string]: string;
21
- }>;
22
- /** Additional <script> tags in <head>. */
23
- scripts?: Array<{
24
- src?: string;
25
- [key: string]: string | undefined;
26
- }>;
11
+ /** Options for the react() middleware. */
12
+ export interface ReactOptions {
13
+ /**
14
+ * Path to a shared layout component (relative to cwd or absolute).
15
+ * The layout wraps every rendered page as its `children`.
16
+ * Layout + page are wrapped in HtmlShell for <html>/<head>/<body> structure.
17
+ *
18
+ * The layout component receives `{ children: ReactNode }`.
19
+ * Both layout and page can use `useServerData()`.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * // layouts/Root.tsx
24
+ * export default function Root({ children }: { children: ReactNode }) {
25
+ * const { user } = useServerData()
26
+ * return <><Nav user={user} /><main>{children}</main><Footer /></>
27
+ * }
28
+ * ```
29
+ */
30
+ layout?: string;
31
+ /**
32
+ * Directory for compiled .tsx module cache.
33
+ * Default: node_modules/.weifuwu/react
34
+ * Persisted across restarts — source changes trigger recompilation.
35
+ */
36
+ cacheDir?: string;
37
+ }
38
+ export interface BootstrapScriptDescriptor {
39
+ src: string;
40
+ crossOrigin?: string;
41
+ integrity?: string;
27
42
  }
28
43
  export interface RenderOptions {
29
- /** Data serialized to client available via useServerData(). */
44
+ /** Props passed directly to the page component. */
45
+ props?: Record<string, unknown>;
46
+ /** Data passed to useServerData() in the component tree. */
30
47
  data?: Record<string, unknown>;
31
- /** Head tags to inject into <head>. Supported in render() and renderStream(). */
32
- head?: HeadOptions;
48
+ /**
49
+ * Async loader that runs before render.
50
+ * Receives ctx (with params, query) and returns data merged into useServerData().
51
+ * Throw HttpError for non-200 status codes.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * app.get('/users/:id', (_req, ctx) => ctx.render('./UserPage.tsx', {
56
+ * loader: async (ctx) => {
57
+ * const user = await db.findUser(ctx.params.id)
58
+ * if (!user) throw new HttpError('Not found', 404)
59
+ * return { user }
60
+ * },
61
+ * }))
62
+ * ```
63
+ */
64
+ loader?: (ctx: Context) => Promise<Record<string, unknown>>;
65
+ /** HTTP status code (default 200). */
33
66
  status?: number;
67
+ /** Extra response headers. */
34
68
  headers?: Record<string, string>;
69
+ /** Classic scripts injected by React. */
70
+ bootstrapScripts?: Array<string | BootstrapScriptDescriptor>;
71
+ /** ES module scripts injected by React. */
72
+ bootstrapModules?: Array<string | BootstrapScriptDescriptor>;
73
+ /**
74
+ * Import map for ES module resolution.
75
+ * Rendered as <script type="importmap"> in <head>.
76
+ */
77
+ importMap?: {
78
+ imports?: Record<string, string>;
79
+ };
80
+ /** Stylesheet URLs injected as <link rel="stylesheet"> in <head>. */
81
+ stylesheets?: string[];
82
+ /**
83
+ * Enable streaming SSR (default: true).
84
+ * When true, the response starts sending immediately and Suspense
85
+ * boundaries are streamed as they resolve.
86
+ * When false, waits for all Suspense boundaries before sending.
87
+ */
88
+ stream?: boolean;
35
89
  }
36
- export interface ReactOptions {
37
- /** Layout component wrapping rendered content. Multiple react() calls via mount accumulate layouts from inner → outer. */
38
- layout?: ComponentType<{
39
- children: ReactNode;
40
- }>;
90
+ /** Options for the reactRouter() helper. */
91
+ export interface ReactRouterOptions extends Omit<RenderOptions, 'component' | 'loader'> {
92
+ /**
93
+ * Shared layout component path (relative to cwd).
94
+ * Same as react()'s layout option.
95
+ */
96
+ layout?: string;
97
+ /**
98
+ * Per-route loaders. Keys must match paths in the routes config.
99
+ * Each loader receives ctx and returns data merged into useServerData().
100
+ * Throw HttpError for non-200 status codes.
101
+ */
102
+ loaders?: Record<string, (ctx: Context) => Promise<Record<string, unknown>>>;
41
103
  }
42
- export interface ReactInjected {
43
- render: (element: ReactElement, opts?: RenderOptions) => Promise<Response>;
44
- renderStream: (element: ReactElement, opts?: RenderOptions) => Promise<Response>;
104
+ /** Options for createReactApp — one call to set up SSR routing + client bundle. */
105
+ export interface ReactAppOptions {
106
+ /** Page route component path mapping for both server and client. */
107
+ pages: Record<string, string>;
108
+ /** Per-route data loaders. Keys match page route paths. */
109
+ loaders?: Record<string, (ctx: Context) => Promise<Record<string, unknown>>>;
110
+ /** Shared layout component path (relative to cwd). */
111
+ layout: string;
112
+ /** 404 fallback component path (relative to cwd). */
113
+ notFound?: string;
114
+ /** Stylesheet URLs injected in <head>. */
115
+ stylesheets?: string[];
116
+ /** ES module bootstrap scripts. */
117
+ bootstrapModules?: Array<string | BootstrapScriptDescriptor>;
118
+ /**
119
+ * Client bundle config.
120
+ * - `false` to disable client-side JS (static SSR only)
121
+ * - `true` or omitted for defaults
122
+ * - `{ ... }` to customize
123
+ */
124
+ client?: boolean | {
125
+ /** URL path for the client bundle (default: '/assets/client.js'). */
126
+ path?: string;
127
+ /** Minify output (default: false). */
128
+ minify?: boolean;
129
+ /** Code splitting (default: true). */
130
+ splitting?: boolean;
131
+ };
132
+ /**
133
+ * Tailwind CSS config. When provided, tailwindDev middleware is set up
134
+ * and the output path is automatically added to stylesheets.
135
+ */
136
+ tailwind?: {
137
+ /** URL path for the compiled CSS (default: '/assets/tailwind.css'). */
138
+ path?: string;
139
+ /** Source CSS entry point (default: './styles/input.css'). */
140
+ entry?: string;
141
+ };
142
+ /** Compilation cache directory. */
143
+ cacheDir?: string;
144
+ /** Enable streaming SSR (default: true). */
145
+ stream?: boolean;
45
146
  }
46
- export type ReactMiddleware = Middleware<Context, Context & ReactInjected>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "weifuwu",
3
3
  "type": "module",
4
- "version": "0.31.0",
4
+ "version": "0.31.2",
5
5
  "description": "Web-standard HTTP microframework for Node.js — (req, ctx) => Response",
6
6
  "exports": {
7
7
  ".": {
@@ -15,10 +15,6 @@
15
15
  "./react/client": {
16
16
  "types": "./dist/react/client.d.ts",
17
17
  "default": "./dist/react/client.js"
18
- },
19
- "./react/navigation": {
20
- "types": "./dist/react/navigation.d.ts",
21
- "default": "./dist/react/navigation.js"
22
18
  }
23
19
  },
24
20
  "files": [
@@ -42,19 +38,32 @@
42
38
  "ws": "^8"
43
39
  },
44
40
  "peerDependencies": {
41
+ "@tailwindcss/node": "^4",
42
+ "esbuild": "^0.28",
45
43
  "react": "19",
46
- "react-dom": "19"
44
+ "react-dom": "19",
45
+ "tailwindcss": "^4"
47
46
  },
48
47
  "peerDependenciesMeta": {
48
+ "@tailwindcss/node": {
49
+ "optional": true
50
+ },
51
+ "esbuild": {
52
+ "optional": true
53
+ },
49
54
  "react": {
50
55
  "optional": true
51
56
  },
52
57
  "react-dom": {
53
58
  "optional": true
59
+ },
60
+ "tailwindcss": {
61
+ "optional": true
54
62
  }
55
63
  },
56
64
  "devDependencies": {
57
65
  "@eslint/js": "^10.0.1",
66
+ "@tailwindcss/node": "^4.3.2",
58
67
  "@types/node": "^25",
59
68
  "@types/react": "^19.2.17",
60
69
  "@types/react-dom": "^19.2.3",
@@ -63,6 +72,7 @@
63
72
  "globals": "^17.7.0",
64
73
  "react": "^19.2.7",
65
74
  "react-dom": "^19.2.7",
75
+ "tailwindcss": "^4.3.2",
66
76
  "typescript-eslint": "^8.62.1"
67
77
  },
68
78
  "sideEffects": false
@@ -1,63 +0,0 @@
1
- /**
2
- * Shared navigation primitives — usable on both server and client.
3
- *
4
- * - Link: renders <a> on server (no RouterContext), SPA <a> on client
5
- * - useParams / useNavigate: client hooks (throw friendly error on server)
6
- */
7
- import { type ReactNode, type ReactElement } from 'react';
8
- export type NavigationState = 'idle' | 'loading';
9
- export interface RouterContextValue {
10
- params: Record<string, string>;
11
- navigate: (url: string) => Promise<void>;
12
- revalidate: () => Promise<void>;
13
- state: NavigationState;
14
- }
15
- export declare const RouterContext: import("react").Context<RouterContextValue | null>;
16
- /** Get current route params. Returns {} when no router context (SSR / outside router). */
17
- export declare function useParams(): Record<string, string>;
18
- /** Programmatic navigation. Throws if used outside a client router. */
19
- export declare function useNavigate(): (url: string) => Promise<void>;
20
- /** Navigation state — 'idle' or 'loading'. Use for progress bars / disabled states. */
21
- export declare function useNavigation(): {
22
- state: NavigationState;
23
- };
24
- export interface LinkProps {
25
- href: string;
26
- children: ReactNode;
27
- [key: string]: unknown;
28
- }
29
- /**
30
- * Navigation link.
31
- *
32
- * When inside a client router context: intercepts clicks for SPA navigation.
33
- * When outside (SSR / plain React): renders a plain <a> tag.
34
- *
35
- * Both cases produce identical DOM output — safe for hydration.
36
- */
37
- export declare function Link({ href, children, ...props }: LinkProps): ReactElement;
38
- export interface FormProps {
39
- method?: 'get' | 'post' | 'put' | 'delete' | 'patch';
40
- action?: string;
41
- children: ReactNode;
42
- [key: string]: unknown;
43
- }
44
- /** Revalidate the current route's data. */
45
- export declare function useRevalidate(): () => Promise<void>;
46
- /**
47
- * SPA form — submits via fetch instead of full page reload.
48
- *
49
- * On submit:
50
- * 1. Serializes form data via FormData
51
- * 2. fetch(action, { method, body })
52
- * 3. On redirect (3xx): SPA-navigates to the Location header
53
- * 4. On success: revalidates the current route's loader
54
- *
55
- * On the server (no RouterContext): renders a plain <form>.
56
- */
57
- export declare function Form({ method, action, children, ...props }: FormProps): ReactElement;
58
- export declare function matchPath(pathname: string, pattern: string): Record<string, string> | null;
59
- export { useServerData } from './hooks.ts';
60
- export { ServerDataContext } from './context.ts';
61
- export { ErrorBoundary } from './error-boundary.ts';
62
- export type { ErrorBoundaryProps } from './error-boundary.ts';
63
- export { defineRoute } from './route-utils.ts';
@@ -1,154 +0,0 @@
1
- // src/react/navigation.ts
2
- import {
3
- createElement,
4
- createContext as createContext2,
5
- useContext as useContext2
6
- } from "react";
7
-
8
- // src/react/hooks.ts
9
- import { useContext } from "react";
10
-
11
- // src/react/context.ts
12
- import { createContext } from "react";
13
- var CTX_KEY = /* @__PURE__ */ Symbol.for("weifuwu.react.ServerDataContext");
14
- function getServerDataContext() {
15
- const globalStore = globalThis;
16
- if (globalStore[CTX_KEY]) return globalStore[CTX_KEY];
17
- const ctx = createContext({});
18
- ctx.displayName = "ServerData";
19
- globalStore[CTX_KEY] = ctx;
20
- return ctx;
21
- }
22
- var ServerDataContext = getServerDataContext();
23
-
24
- // src/react/hooks.ts
25
- function useServerData() {
26
- return useContext(ServerDataContext);
27
- }
28
-
29
- // src/react/error-boundary.ts
30
- import { Component } from "react";
31
- var ErrorBoundary = class extends Component {
32
- state = { error: null };
33
- static getDerivedStateFromError(error) {
34
- return { error };
35
- }
36
- componentDidCatch(error, info) {
37
- this.props.onError?.(error, info);
38
- }
39
- render() {
40
- if (this.state.error) {
41
- return this.props.fallback;
42
- }
43
- return this.props.children;
44
- }
45
- };
46
-
47
- // src/react/route-utils.ts
48
- function defineRoute(config) {
49
- return config;
50
- }
51
-
52
- // src/react/navigation.ts
53
- var RouterContext = createContext2(null);
54
- RouterContext.displayName = "ClientRouter";
55
- function useParams() {
56
- return useContext2(RouterContext)?.params ?? {};
57
- }
58
- function useNavigate() {
59
- const ctx = useContext2(RouterContext);
60
- if (!ctx) throw new Error("useNavigate() must be used within a client router");
61
- return ctx.navigate;
62
- }
63
- function useNavigation() {
64
- const ctx = useContext2(RouterContext);
65
- return { state: ctx?.state ?? "idle" };
66
- }
67
- function Link({ href, children, ...props }) {
68
- const router = useContext2(RouterContext);
69
- const isExternal = href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//") || href.startsWith("mailto:") || href.startsWith("tel:");
70
- if (!router || isExternal || props.target !== void 0) {
71
- return createElement("a", { href, ...props }, children);
72
- }
73
- const handleClick = (e) => {
74
- if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
75
- if (e.button !== 0) return;
76
- e.preventDefault();
77
- router.navigate(href);
78
- };
79
- return createElement("a", { href, onClick: handleClick, ...props }, children);
80
- }
81
- function useRevalidate() {
82
- const ctx = useContext2(RouterContext);
83
- if (!ctx) throw new Error("useRevalidate() must be used within a client router");
84
- return ctx.revalidate;
85
- }
86
- function Form({ method = "post", action, children, ...props }) {
87
- const router = useContext2(RouterContext);
88
- if (!router) {
89
- return createElement("form", { method, action, ...props }, children);
90
- }
91
- const handleSubmit = async (e) => {
92
- e.preventDefault();
93
- const form = e.target;
94
- const formData = new FormData(form);
95
- const url = action || window.location.pathname;
96
- const fetchMethod = method === "get" ? "GET" : method.toUpperCase();
97
- try {
98
- const res = await fetch(url, {
99
- method: fetchMethod,
100
- body: fetchMethod === "GET" ? void 0 : formData,
101
- redirect: "manual"
102
- });
103
- if (res.status >= 300 && res.status < 400) {
104
- const loc = res.headers.get("Location");
105
- if (loc) {
106
- router.navigate(loc);
107
- return;
108
- }
109
- }
110
- await router.revalidate();
111
- } catch (err) {
112
- console.error("[weifuwu/react] Form submit failed:", err);
113
- }
114
- };
115
- return createElement(
116
- "form",
117
- { method, action, ...props, onSubmit: handleSubmit },
118
- children
119
- );
120
- }
121
- function matchPath(pathname, pattern) {
122
- const patParts = pattern.split("/").filter(Boolean);
123
- const pathParts = pathname.split("/").filter(Boolean);
124
- const hasWildcard = patParts[patParts.length - 1] === "*";
125
- if (!hasWildcard && patParts.length !== pathParts.length) return null;
126
- if (hasWildcard && pathParts.length < patParts.length - 1) return null;
127
- const params = {};
128
- for (let i = 0; i < patParts.length; i++) {
129
- if (patParts[i] === "*") {
130
- params["*"] = pathParts.slice(i).join("/");
131
- break;
132
- }
133
- if (patParts[i].startsWith(":")) {
134
- params[patParts[i].slice(1)] = decodeURIComponent(pathParts[i]);
135
- } else if (patParts[i] !== pathParts[i]) {
136
- return null;
137
- }
138
- }
139
- return params;
140
- }
141
- export {
142
- ErrorBoundary,
143
- Form,
144
- Link,
145
- RouterContext,
146
- ServerDataContext,
147
- defineRoute,
148
- matchPath,
149
- useNavigate,
150
- useNavigation,
151
- useParams,
152
- useRevalidate,
153
- useServerData
154
- };
@@ -1,8 +0,0 @@
1
- import { type ReactElement, type ComponentType, type ReactNode } from 'react';
2
- import type { RenderOptions } from './types.ts';
3
- export declare function render(element: ReactElement, layouts: ComponentType<{
4
- children: ReactNode;
5
- }>[], options?: RenderOptions): Response;
6
- export declare function renderStream(element: ReactElement, layouts: ComponentType<{
7
- children: ReactNode;
8
- }>[], options?: RenderOptions): Promise<Response>;
@@ -1,31 +0,0 @@
1
- import type { ComponentType } from 'react';
2
- /**
3
- * Type-safe route definition.
4
- *
5
- * Captures the loader return type as a phantom `$data` field.
6
- * Use with `useServerData<typeof route.$data>()` for auto-complete.
7
- *
8
- * @example
9
- * ```ts
10
- * const userRoute = defineRoute({
11
- * path: '/users/:id',
12
- * component: UserPage,
13
- * loader: (params) => fetch(`/users/${params.id}?_data`).then(r => r.json()),
14
- * })
15
- * // userRoute.$data = { user: { id: number; name: string; ... } }
16
- *
17
- * // In the component:
18
- * const { user } = useServerData<typeof userRoute.$data>()
19
- * ```
20
- */
21
- export declare function defineRoute<T extends Record<string, unknown> = Record<string, unknown>>(config: {
22
- path: string;
23
- component: ComponentType;
24
- loader: (params: Record<string, string>) => Promise<T>;
25
- }): {
26
- path: string;
27
- component: ComponentType;
28
- loader: (params: Record<string, string>) => Promise<T>;
29
- /** Phantom type — use with `typeof route.$data` for `useServerData`. */
30
- $data: T;
31
- };