weifuwu 0.33.5 → 0.33.7

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 CHANGED
@@ -66,7 +66,8 @@ app.mount('#root', AppShell)
66
66
  ```
67
67
 
68
68
  ```js
69
- // build.mjs — 可选,也可用 ctx.ui.js() 服务端动态编译
69
+ // build.mjs — 传统构建方式(可选)
70
+ // 推荐:使用 ctx.ui.js() 服务端动态编译,无需独立构建脚本
70
71
  esbuild.build({
71
72
  entryPoints: ['src/main.tsx'],
72
73
  jsx: 'automatic',
@@ -75,9 +76,10 @@ esbuild.build({
75
76
  })
76
77
  ```
77
78
 
78
- 或用服务端动态编译(无需独立构建脚本):
79
+ 或用服务端动态编译——一行代码,无需构建步骤、无需 watch 模式:
79
80
  ```ts
80
81
  app.get('/static/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
82
+ app.get('/static/style.css', async (req, ctx) => ctx.ui.css('./src/style.css'))
81
83
  ```
82
84
 
83
85
  ### Environment Variables
@@ -151,6 +153,10 @@ app.get('/static/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
151
153
  | `LoginForm` | Login/register form component |
152
154
  | `Chat` | Real-time messaging component |
153
155
  | `domMount()` | Direct DOM mounting |
156
+ | `wrap()` | Third-party library integration |
157
+ | `useForm()` | Form state management (validation, submit, reset) |
158
+ | `createPortal()` | Render outside parent DOM hierarchy |
159
+ | `<ErrorBoundary>` | Catch render errors, show fallback |
154
160
 
155
161
  ### Utilities
156
162
 
@@ -229,6 +235,29 @@ ctx.route.path // "/chat/123"
229
235
  ctx.route.params // { id: "123" }
230
236
  ctx.route.query // { tab: "settings" }
231
237
 
238
+ #### Route Loader — 数据预取
239
+
240
+ ```tsx
241
+ const routes: RouteDef[] = [
242
+ {
243
+ path: '/post/:id',
244
+ component: PostPage,
245
+ loader: async (ctx) => ({
246
+ post: await ctx.api.get(`/api/posts/${ctx.route.params.id}`),
247
+ }),
248
+ },
249
+ ]
250
+
251
+ // In component:
252
+ function PostPage(_, ctx) {
253
+ const post = ctx.route.data.post
254
+ if (!post) return <p class="text-gray-400">加载中...</p>
255
+ return <h1 class="text-2xl font-bold">{post.title}</h1>
256
+ }
257
+ ```
258
+
259
+ 组件先渲染(显示 loading),loader 完成后自动重渲染。
260
+
232
261
  ### Middleware: api / auth / ws
233
262
 
234
263
  ```tsx
@@ -330,6 +359,79 @@ const PieChart = wrap('div', (el, props: { data: any[] }, ctx) => {
330
359
  </Dashboard>
331
360
  ```
332
361
 
362
+ ### useForm() — 表单状态管理
363
+
364
+ ```tsx
365
+ import { useForm } from 'weifuwu/client'
366
+
367
+ const form = useForm({
368
+ initial: { email: '', password: '' },
369
+ validate: {
370
+ email: (v) => !v.includes('@') && '请输入有效邮箱',
371
+ password: (v) => v.length < 6 && '至少 6 位',
372
+ },
373
+ })
374
+
375
+ // 绑定到 input:{...form.field('name')} 自动设置 value + onInput
376
+ <input {...form.field('email')} placeholder="邮箱" />
377
+ {form.errors.email && <span class="text-red-500">{form.errors.email}</span>}
378
+
379
+ // 提交时自动验证所有字段
380
+ <button onClick={() => form.submit((data) => ctx.login(data.email, data.password))}>
381
+ 登录
382
+ </button>
383
+
384
+ // 重置
385
+ <button onClick={form.reset}>重置</button>
386
+
387
+ // 编程设置
388
+ form.setValue('email', 'a@b.com')
389
+ form.setValues({ email: 'a@b.com', password: '123' })
390
+ ```
391
+
392
+ ### createPortal() — 渲染到父容器外
393
+
394
+ 适用于 Modal、Dropdown、Tooltip 等需突破 `overflow: hidden` 或 z-index 层级的情况。
395
+
396
+ ```tsx
397
+ import { createPortal, Show } from 'weifuwu/client'
398
+
399
+ function Modal({ show, title, children }) {
400
+ return <Show when={show}>
401
+ {createPortal(
402
+ <div class="fixed inset-0 bg-black/50 flex items-center justify-center">
403
+ <div class="bg-white rounded-xl p-6 min-w-[400px]">
404
+ <h2 class="text-lg font-bold mb-4">{title}</h2>
405
+ {children}
406
+ </div>
407
+ </div>,
408
+ document.body
409
+ )}
410
+ </Show>
411
+ }
412
+ ```
413
+
414
+ ### ErrorBoundary — 错误边界
415
+
416
+ 子组件渲染异常时捕获,显示 fallback 而非白屏。children 必须是 thunk(延迟执行)。
417
+
418
+ ```tsx
419
+ import { ErrorBoundary } from 'weifuwu/client'
420
+
421
+ function AppShell(_, ctx) {
422
+ return (
423
+ <div>
424
+ <nav>...</nav>
425
+ <main>
426
+ <ErrorBoundary fallback={(e) => <p>出错了: {e.message}</p>}>
427
+ {() => <RouteView />}
428
+ </ErrorBoundary>
429
+ </main>
430
+ </div>
431
+ )
432
+ }
433
+ ```
434
+
333
435
  ### Show / For
334
436
 
335
437
  ```tsx
@@ -858,12 +960,14 @@ src/
858
960
  ├── hub.ts ← WebSocket hub
859
961
  ├── ui/ ← ctx.ui.html / ctx.ui.js / ctx.ui.css
860
962
  ├── client/ ← Frontend framework
861
- │ ├── index.ts ← Entry (exports signal, wrap, createApp, ...)
963
+ │ ├── index.ts ← Entry (exports signal, useForm, wrap, createApp, ...)
862
964
  │ ├── signal.ts ← Signal / effect / computed
863
- │ ├── jsx-runtime.ts ← JSX → DOM / Show / For / wrap / onMount
965
+ │ ├── jsx-runtime.ts ← JSX → DOM / Show / For / wrap / ErrorBoundary / createPortal
864
966
  │ ├── app.ts ← createApp / hydrate / middleware chain
865
- │ ├── router.ts ← Route matching / RouteView
967
+ │ ├── router.ts ← Route matching / RouteView / loader
866
968
  │ ├── types.ts ← WfuiContext / RouteDef
969
+ │ ├── lib/
970
+ │ │ └── form.ts ← useForm
867
971
  │ ├── middleware/
868
972
  │ │ ├── api.ts ← HTTP client
869
973
  │ │ ├── auth.ts ← Login / logout / token
@@ -15,7 +15,7 @@
15
15
  */
16
16
  export { signal, computed, effect, isSignal } from './signal.ts';
17
17
  export type { Signal } from './signal.ts';
18
- export { jsx, jsxs, jsxDEV, Fragment, Show, For, domMount, wrap } from './jsx-runtime.ts';
18
+ export { jsx, jsxs, jsxDEV, Fragment, Show, For, domMount, wrap, createPortal, ErrorBoundary } from './jsx-runtime.ts';
19
19
  export type { Component } from './jsx-runtime.ts';
20
20
  export type { WfuiContext, AppMiddleware, RouteDef } from './types.ts';
21
21
  export { createApp } from './app.ts';
@@ -24,5 +24,6 @@ export { api, ApiClient, ApiError } from './middleware/api.ts';
24
24
  export { auth } from './middleware/auth.ts';
25
25
  export type { UserRecord } from './middleware/auth.ts';
26
26
  export { ws } from './middleware/ws.ts';
27
+ export { useForm } from './lib/form.ts';
27
28
  export { LoginForm } from './components/LoginForm.ts';
28
29
  export { Chat } from './components/Chat.ts';
@@ -184,6 +184,17 @@ function domMount(root, app) {
184
184
  container.innerHTML = "";
185
185
  container.appendChild(app);
186
186
  }
187
+ function ErrorBoundary({ fallback, children }, _ctx) {
188
+ try {
189
+ return children();
190
+ } catch (e) {
191
+ return fallback(e);
192
+ }
193
+ }
194
+ function createPortal(node, target) {
195
+ target.appendChild(node);
196
+ return document.createDocumentFragment();
197
+ }
187
198
  function toNode(v) {
188
199
  if (v instanceof Node) return v;
189
200
  if (typeof v === "function") return toNode(v());
@@ -232,7 +243,8 @@ function createApp() {
232
243
  params: {},
233
244
  query: Object.fromEntries(new URLSearchParams(window.location.search)),
234
245
  hash: window.location.hash,
235
- component: null
246
+ component: null,
247
+ data: {}
236
248
  },
237
249
  app: {
238
250
  navigate(path) {
@@ -308,56 +320,79 @@ function router(opts) {
308
320
  }).join("/") + "$";
309
321
  return { re: new RegExp(reStr), keys, route };
310
322
  });
311
- function matchPath(path) {
312
- for (const { re, keys, route } of matchers) {
313
- const m = path.match(re);
314
- if (m) {
315
- const params = {};
316
- keys.forEach((k, i) => {
317
- params[k] = decodeURIComponent(m[i + 1]);
318
- });
319
- return { component: route.component, params, title: route.title, auth: route.auth };
320
- }
323
+ function matchRoute(path) {
324
+ for (const m of matchers) {
325
+ const result = path.match(m.re);
326
+ if (!result) continue;
327
+ const params = {};
328
+ m.keys.forEach((k, i) => {
329
+ params[k] = decodeURIComponent(result[i + 1]);
330
+ });
331
+ return {
332
+ matched: { component: m.route.component, params, title: m.route.title, auth: m.route.auth },
333
+ routeDef: m.route
334
+ };
321
335
  }
322
336
  return null;
323
337
  }
324
338
  return (ctx) => {
325
- function handleRoute() {
326
- const raw = mode === "hash" ? window.location.hash.slice(1) || "/" : window.location.pathname + window.location.search;
327
- const [path, qs] = raw.split("?");
339
+ function emit(path) {
340
+ window.dispatchEvent(new CustomEvent("wefu:route", { detail: { path } }));
341
+ }
342
+ function resolve(path) {
343
+ const raw = mode === "hash" ? path || "/" : path;
344
+ const [cleanPath, qs] = raw.split("?");
328
345
  ctx.route.query = Object.fromEntries(new URLSearchParams(qs ?? ""));
329
- const matched = matchPath(path);
330
- if (matched) {
331
- ctx.route.path = path;
332
- ctx.route.params = matched.params;
333
- ctx.route.component = matched.component;
334
- ctx.route.title = matched.title;
335
- ctx.route.auth = matched.auth;
336
- if (matched.title) document.title = matched.title;
337
- if (matched.auth && !ctx.user) {
338
- setTimeout(() => ctx.app.navigate("/login"), 0);
339
- return;
340
- }
341
- } else {
346
+ ctx.route.path = cleanPath;
347
+ const r = matchRoute(cleanPath);
348
+ if (!r) {
342
349
  ctx.route.component = opts.notFound ?? null;
350
+ ctx.route.data = {};
351
+ return { component: null };
352
+ }
353
+ ctx.route.params = r.matched.params;
354
+ ctx.route.component = r.matched.component;
355
+ ctx.route.title = r.matched.title;
356
+ ctx.route.auth = r.matched.auth;
357
+ if (r.matched.title) document.title = r.matched.title;
358
+ return { component: r.matched.component, routeDef: r.routeDef };
359
+ }
360
+ function navigateAndLoad(path) {
361
+ const { routeDef } = resolve(path);
362
+ if (ctx.route.auth && !ctx.user) {
363
+ setTimeout(() => ctx.app.navigate("/login"), 0);
364
+ return;
365
+ }
366
+ emit(ctx.route.path);
367
+ if (routeDef?.loader) {
368
+ routeDef.loader(ctx).then((data) => {
369
+ ctx.route.data = data;
370
+ emit(ctx.route.path);
371
+ }).catch(() => {
372
+ ctx.route.data = {};
373
+ emit(ctx.route.path);
374
+ });
343
375
  }
344
- window.dispatchEvent(new CustomEvent("wefu:route", { detail: { path } }));
345
376
  }
346
- const origNavigate = ctx.app.navigate;
347
377
  ctx.app.navigate = (path) => {
348
378
  if (mode === "hash") {
349
379
  window.location.hash = "#" + path;
350
380
  } else {
351
381
  window.history.pushState({}, "", path);
352
- handleRoute();
382
+ navigateAndLoad(path);
353
383
  }
354
384
  };
355
385
  if (mode === "hash") {
356
- window.addEventListener("hashchange", handleRoute);
386
+ window.addEventListener("hashchange", () => {
387
+ navigateAndLoad(window.location.hash.slice(1) || "/");
388
+ });
357
389
  } else {
358
- window.addEventListener("popstate", handleRoute);
390
+ window.addEventListener("popstate", () => {
391
+ navigateAndLoad(window.location.pathname + window.location.search);
392
+ });
359
393
  }
360
- handleRoute();
394
+ const initialPath = mode === "hash" ? window.location.hash.slice(1) || "/" : window.location.pathname + window.location.search;
395
+ navigateAndLoad(initialPath);
361
396
  return ctx;
362
397
  };
363
398
  }
@@ -583,6 +618,112 @@ function ws(opts = {}) {
583
618
  };
584
619
  }
585
620
 
621
+ // src/client/lib/form.ts
622
+ function useForm(opts) {
623
+ const fields = /* @__PURE__ */ new Map();
624
+ const errors = {};
625
+ const touched = {};
626
+ for (const key of Object.keys(opts.initial)) {
627
+ fields.set(key, signal(opts.initial[key]));
628
+ errors[key] = null;
629
+ touched[key] = false;
630
+ }
631
+ const valid = signal(true);
632
+ const values = signal({ ...opts.initial });
633
+ function snapshot() {
634
+ const data = {};
635
+ for (const [key, sig] of fields) {
636
+ data[key] = sig.value;
637
+ }
638
+ values.value = data;
639
+ }
640
+ function validateField(name) {
641
+ if (!opts.validate) return null;
642
+ const rule = opts.validate[name];
643
+ if (!rule) return null;
644
+ const sig = fields.get(name);
645
+ const result = rule(sig.value, values.value);
646
+ const error = result && typeof result === "string" ? result : null;
647
+ errors[name] = error;
648
+ return error;
649
+ }
650
+ function validateAll() {
651
+ if (!opts.validate) return true;
652
+ let allValid = true;
653
+ for (const key of Object.keys(opts.initial)) {
654
+ const err = validateField(key);
655
+ if (err) allValid = false;
656
+ }
657
+ valid.value = allValid;
658
+ return allValid;
659
+ }
660
+ return {
661
+ field(name) {
662
+ let sig = fields.get(name);
663
+ if (!sig) {
664
+ sig = signal(opts.initial[name]);
665
+ fields.set(name, sig);
666
+ }
667
+ return {
668
+ value: sig,
669
+ onInput(e) {
670
+ const target = e.target;
671
+ touched[name] = true;
672
+ if (target.type === "checkbox") {
673
+ sig.value = target.checked;
674
+ } else {
675
+ sig.value = target.value;
676
+ }
677
+ snapshot();
678
+ validateField(name);
679
+ }
680
+ };
681
+ },
682
+ get errors() {
683
+ return errors;
684
+ },
685
+ get touched() {
686
+ return touched;
687
+ },
688
+ valid,
689
+ get values() {
690
+ return values;
691
+ },
692
+ async submit(handler) {
693
+ if (!validateAll()) return;
694
+ const data = {};
695
+ for (const [key, sig] of fields) {
696
+ data[key] = sig.value;
697
+ }
698
+ await handler(data);
699
+ },
700
+ reset() {
701
+ for (const key of Object.keys(opts.initial)) {
702
+ const sig = fields.get(key);
703
+ if (sig) sig.value = opts.initial[key];
704
+ errors[key] = null;
705
+ touched[key] = false;
706
+ }
707
+ valid.value = true;
708
+ snapshot();
709
+ },
710
+ setValue(name, value) {
711
+ const sig = fields.get(name);
712
+ if (sig) sig.value = value;
713
+ snapshot();
714
+ validateField(name);
715
+ },
716
+ setValues(partial) {
717
+ for (const [key, value] of Object.entries(partial)) {
718
+ const sig = fields.get(key);
719
+ if (sig) sig.value = value;
720
+ }
721
+ snapshot();
722
+ validateAll();
723
+ }
724
+ };
725
+ }
726
+
586
727
  // src/client/components/LoginForm.ts
587
728
  var h = (tag, props, ...children) => jsx(tag, props ?? {}, ...children);
588
729
  function LoginForm(_props, ctx) {
@@ -739,6 +880,7 @@ export {
739
880
  ApiClient,
740
881
  ApiError,
741
882
  Chat,
883
+ ErrorBoundary,
742
884
  For,
743
885
  Fragment,
744
886
  LoginForm,
@@ -748,6 +890,7 @@ export {
748
890
  auth,
749
891
  computed,
750
892
  createApp,
893
+ createPortal,
751
894
  domMount,
752
895
  effect,
753
896
  isSignal,
@@ -756,6 +899,7 @@ export {
756
899
  jsxs,
757
900
  router,
758
901
  signal,
902
+ useForm,
759
903
  wrap,
760
904
  ws
761
905
  };
@@ -13,8 +13,306 @@ import type { WfuiContext } from './types.ts';
13
13
  declare global {
14
14
  namespace JSX {
15
15
  interface IntrinsicElements {
16
- [tag: string]: any;
16
+ div: HtmlDiv;
17
+ span: HtmlSpan;
18
+ p: HtmlP;
19
+ h1: HtmlH1;
20
+ h2: HtmlH2;
21
+ h3: HtmlH3;
22
+ h4: HtmlH4;
23
+ h5: HtmlH5;
24
+ h6: HtmlH6;
25
+ header: HtmlHeader;
26
+ footer: HtmlFooter;
27
+ nav: HtmlNav;
28
+ main: HtmlMain;
29
+ section: HtmlSection;
30
+ article: HtmlArticle;
31
+ aside: HtmlAside;
32
+ pre: HtmlPre;
33
+ blockquote: HtmlBlockquote;
34
+ figure: HtmlFigure;
35
+ address: HtmlAddress;
36
+ ul: HtmlUl;
37
+ ol: HtmlOl;
38
+ li: HtmlLi;
39
+ dl: HtmlDl;
40
+ dt: HtmlDt;
41
+ dd: HtmlDd;
42
+ table: HtmlTable;
43
+ thead: HtmlThead;
44
+ tbody: HtmlTbody;
45
+ tr: HtmlTr;
46
+ th: HtmlTh;
47
+ td: HtmlTd;
48
+ form: HtmlForm;
49
+ input: HtmlInput;
50
+ button: HtmlButton;
51
+ select: HtmlSelect;
52
+ option: HtmlOption;
53
+ textarea: HtmlTextarea;
54
+ label: HtmlLabel;
55
+ fieldset: HtmlFieldset;
56
+ legend: HtmlLegend;
57
+ img: HtmlImg;
58
+ video: HtmlVideo;
59
+ audio: HtmlAudio;
60
+ canvas: HtmlCanvas;
61
+ svg: HtmlSvg;
62
+ a: HtmlA;
63
+ link: HtmlLink;
64
+ strong: HtmlStrong;
65
+ em: HtmlEm;
66
+ b: HtmlB;
67
+ i: HtmlI;
68
+ u: HtmlU;
69
+ s: HtmlS;
70
+ mark: HtmlMark;
71
+ code: HtmlCode;
72
+ small: HtmlSmall;
73
+ sub: HtmlSub;
74
+ sup: HtmlSup;
75
+ abbr: HtmlAbbr;
76
+ time: HtmlTime;
77
+ br: HtmlBr;
78
+ hr: HtmlHr;
79
+ wbr: HtmlWbr;
80
+ style: HtmlStyle;
81
+ script: HtmlScript;
82
+ template: HtmlTemplate;
83
+ slot: HtmlSlot;
84
+ details: HtmlDetails;
85
+ summary: HtmlSummary;
86
+ dialog: HtmlDialog;
87
+ iframe: HtmlIframe;
17
88
  }
89
+ interface WfuiAttributes<T> {
90
+ class?: string | Signal<string>;
91
+ className?: string | Signal<string>;
92
+ id?: string;
93
+ style?: Record<string, string | undefined>;
94
+ title?: string;
95
+ lang?: string;
96
+ dir?: string;
97
+ hidden?: boolean | Signal<boolean>;
98
+ tabindex?: number;
99
+ accesskey?: string;
100
+ draggable?: boolean;
101
+ contenteditable?: boolean;
102
+ slot?: string;
103
+ spellcheck?: boolean;
104
+ ref?: (el: T) => void;
105
+ onClick?: (e: MouseEvent) => void;
106
+ onDblClick?: (e: MouseEvent) => void;
107
+ onMouseDown?: (e: MouseEvent) => void;
108
+ onMouseUp?: (e: MouseEvent) => void;
109
+ onMouseMove?: (e: MouseEvent) => void;
110
+ onMouseEnter?: (e: MouseEvent) => void;
111
+ onMouseLeave?: (e: MouseEvent) => void;
112
+ onKeyDown?: (e: KeyboardEvent) => void;
113
+ onKeyUp?: (e: KeyboardEvent) => void;
114
+ onKeyPress?: (e: KeyboardEvent) => void;
115
+ onFocus?: (e: FocusEvent) => void;
116
+ onBlur?: (e: FocusEvent) => void;
117
+ onInput?: (e: Event) => void;
118
+ onChange?: (e: Event) => void;
119
+ onSubmit?: (e: Event) => void;
120
+ onScroll?: (e: Event) => void;
121
+ onWheel?: (e: WheelEvent) => void;
122
+ onTouchStart?: (e: TouchEvent) => void;
123
+ onTouchEnd?: (e: TouchEvent) => void;
124
+ onTouchMove?: (e: TouchEvent) => void;
125
+ onLoad?: (e: Event) => void;
126
+ onError?: (e: Event) => void;
127
+ onAnimationEnd?: (e: AnimationEvent) => void;
128
+ onTransitionEnd?: (e: TransitionEvent) => void;
129
+ [data: string]: unknown;
130
+ }
131
+ type HtmlDiv = WfuiAttributes<HTMLDivElement>;
132
+ type HtmlSpan = WfuiAttributes<HTMLSpanElement>;
133
+ type HtmlP = WfuiAttributes<HTMLParagraphElement>;
134
+ type HtmlH1 = WfuiAttributes<HTMLHeadingElement>;
135
+ type HtmlH2 = WfuiAttributes<HTMLHeadingElement>;
136
+ type HtmlH3 = WfuiAttributes<HTMLHeadingElement>;
137
+ type HtmlH4 = WfuiAttributes<HTMLHeadingElement>;
138
+ type HtmlH5 = WfuiAttributes<HTMLHeadingElement>;
139
+ type HtmlH6 = WfuiAttributes<HTMLHeadingElement>;
140
+ type HtmlHeader = WfuiAttributes<HTMLElement>;
141
+ type HtmlFooter = WfuiAttributes<HTMLElement>;
142
+ type HtmlNav = WfuiAttributes<HTMLElement>;
143
+ type HtmlMain = WfuiAttributes<HTMLElement>;
144
+ type HtmlSection = WfuiAttributes<HTMLElement>;
145
+ type HtmlArticle = WfuiAttributes<HTMLElement>;
146
+ type HtmlAside = WfuiAttributes<HTMLElement>;
147
+ type HtmlPre = WfuiAttributes<HTMLPreElement>;
148
+ type HtmlBlockquote = WfuiAttributes<HTMLQuoteElement>;
149
+ type HtmlFigure = WfuiAttributes<HTMLElement>;
150
+ type HtmlAddress = WfuiAttributes<HTMLElement>;
151
+ type HtmlUl = WfuiAttributes<HTMLUListElement>;
152
+ type HtmlOl = WfuiAttributes<HTMLOListElement>;
153
+ type HtmlLi = WfuiAttributes<HTMLLIElement>;
154
+ type HtmlDl = WfuiAttributes<HTMLDListElement>;
155
+ type HtmlDt = WfuiAttributes<HTMLElement>;
156
+ type HtmlDd = WfuiAttributes<HTMLElement>;
157
+ type HtmlTable = WfuiAttributes<HTMLTableElement>;
158
+ type HtmlThead = WfuiAttributes<HTMLTableSectionElement>;
159
+ type HtmlTbody = WfuiAttributes<HTMLTableSectionElement>;
160
+ type HtmlTr = WfuiAttributes<HTMLTableRowElement>;
161
+ type HtmlTh = WfuiAttributes<HTMLTableCellElement>;
162
+ type HtmlTd = WfuiAttributes<HTMLTableCellElement>;
163
+ interface HtmlForm extends WfuiAttributes<HTMLFormElement> {
164
+ action?: string;
165
+ method?: string;
166
+ enctype?: string;
167
+ novalidate?: boolean;
168
+ target?: string;
169
+ }
170
+ interface HtmlInput extends WfuiAttributes<HTMLInputElement> {
171
+ type?: string;
172
+ value?: string | Signal<string>;
173
+ placeholder?: string;
174
+ checked?: boolean | Signal<boolean>;
175
+ disabled?: boolean | Signal<boolean>;
176
+ readonly?: boolean;
177
+ required?: boolean;
178
+ autofocus?: boolean;
179
+ autocomplete?: string;
180
+ name?: string;
181
+ min?: string | number;
182
+ max?: string | number;
183
+ step?: number;
184
+ pattern?: string;
185
+ accept?: string;
186
+ multiple?: boolean;
187
+ src?: string;
188
+ alt?: string;
189
+ }
190
+ interface HtmlButton extends WfuiAttributes<HTMLButtonElement> {
191
+ type?: 'button' | 'submit' | 'reset';
192
+ disabled?: boolean | Signal<boolean>;
193
+ name?: string;
194
+ value?: string;
195
+ }
196
+ interface HtmlSelect extends WfuiAttributes<HTMLSelectElement> {
197
+ value?: string | Signal<string>;
198
+ disabled?: boolean;
199
+ name?: string;
200
+ required?: boolean;
201
+ multiple?: boolean;
202
+ }
203
+ interface HtmlOption extends WfuiAttributes<HTMLOptionElement> {
204
+ value?: string;
205
+ selected?: boolean;
206
+ disabled?: boolean;
207
+ label?: string;
208
+ }
209
+ interface HtmlTextarea extends WfuiAttributes<HTMLTextAreaElement> {
210
+ value?: string | Signal<string>;
211
+ placeholder?: string;
212
+ disabled?: boolean;
213
+ readonly?: boolean;
214
+ required?: boolean;
215
+ rows?: number;
216
+ cols?: number;
217
+ autofocus?: boolean;
218
+ name?: string;
219
+ }
220
+ interface HtmlLabel extends WfuiAttributes<HTMLLabelElement> {
221
+ htmlFor?: string;
222
+ }
223
+ interface HtmlFieldset extends WfuiAttributes<HTMLFieldSetElement> {
224
+ disabled?: boolean;
225
+ name?: string;
226
+ }
227
+ type HtmlLegend = WfuiAttributes<HTMLLegendElement>;
228
+ interface HtmlA extends WfuiAttributes<HTMLAnchorElement> {
229
+ href?: string;
230
+ target?: string;
231
+ rel?: string;
232
+ download?: string;
233
+ }
234
+ interface HtmlImg extends WfuiAttributes<HTMLImageElement> {
235
+ src?: string | Signal<string>;
236
+ alt?: string;
237
+ width?: number | string;
238
+ height?: number | string;
239
+ loading?: 'lazy' | 'eager';
240
+ srcset?: string;
241
+ sizes?: string;
242
+ }
243
+ interface HtmlVideo extends WfuiAttributes<HTMLVideoElement> {
244
+ src?: string;
245
+ controls?: boolean;
246
+ autoplay?: boolean;
247
+ loop?: boolean;
248
+ muted?: boolean;
249
+ poster?: string;
250
+ width?: number | string;
251
+ height?: number | string;
252
+ }
253
+ interface HtmlAudio extends WfuiAttributes<HTMLAudioElement> {
254
+ src?: string;
255
+ controls?: boolean;
256
+ autoplay?: boolean;
257
+ loop?: boolean;
258
+ muted?: boolean;
259
+ }
260
+ type HtmlCanvas = WfuiAttributes<HTMLCanvasElement> & {
261
+ width?: number;
262
+ height?: number;
263
+ };
264
+ type HtmlSvg = WfuiAttributes<SVGSVGElement> & {
265
+ viewBox?: string;
266
+ xmlns?: string;
267
+ fill?: string;
268
+ width?: string | number;
269
+ height?: string | number;
270
+ };
271
+ interface HtmlLink extends WfuiAttributes<HTMLLinkElement> {
272
+ rel?: string;
273
+ href?: string;
274
+ type?: string;
275
+ media?: string;
276
+ }
277
+ interface HtmlStyle extends WfuiAttributes<HTMLStyleElement> {
278
+ scoped?: boolean;
279
+ media?: string;
280
+ }
281
+ interface HtmlScript extends WfuiAttributes<HTMLScriptElement> {
282
+ src?: string;
283
+ type?: string;
284
+ async?: boolean;
285
+ defer?: boolean;
286
+ }
287
+ type HtmlStrong = WfuiAttributes<HTMLElement>;
288
+ type HtmlEm = WfuiAttributes<HTMLElement>;
289
+ type HtmlB = WfuiAttributes<HTMLElement>;
290
+ type HtmlI = WfuiAttributes<HTMLElement>;
291
+ type HtmlU = WfuiAttributes<HTMLElement>;
292
+ type HtmlS = WfuiAttributes<HTMLElement>;
293
+ type HtmlMark = WfuiAttributes<HTMLElement>;
294
+ type HtmlCode = WfuiAttributes<HTMLElement>;
295
+ type HtmlSmall = WfuiAttributes<HTMLElement>;
296
+ type HtmlSub = WfuiAttributes<HTMLElement>;
297
+ type HtmlSup = WfuiAttributes<HTMLElement>;
298
+ type HtmlAbbr = WfuiAttributes<HTMLElement>;
299
+ type HtmlTime = WfuiAttributes<HTMLElement> & {
300
+ datetime?: string;
301
+ };
302
+ type HtmlBr = WfuiAttributes<HTMLBRElement>;
303
+ type HtmlHr = WfuiAttributes<HTMLHRElement>;
304
+ type HtmlWbr = WfuiAttributes<HTMLElement>;
305
+ type HtmlTemplate = WfuiAttributes<HTMLTemplateElement>;
306
+ type HtmlSlot = WfuiAttributes<HTMLSlotElement>;
307
+ type HtmlDetails = WfuiAttributes<HTMLDetailsElement>;
308
+ type HtmlSummary = WfuiAttributes<HTMLElement>;
309
+ type HtmlDialog = WfuiAttributes<HTMLDialogElement>;
310
+ type HtmlIframe = WfuiAttributes<HTMLIFrameElement> & {
311
+ src?: string;
312
+ name?: string;
313
+ width?: string;
314
+ height?: string;
315
+ };
18
316
  }
19
317
  }
20
318
  export declare function setCtx(ctx: WfuiContext | null): void;
@@ -58,6 +356,42 @@ export declare function Fragment(props: Record<string, unknown> | null, ...child
58
356
  * 直接挂载 DOM(低层 API,一般用 createApp().mount())
59
357
  */
60
358
  export declare function domMount(root: string | Element, app: Node): void;
359
+ /**
360
+ * ErrorBoundary — 捕获子组件渲染时的异常。
361
+ *
362
+ * children 必须是 thunk(函数),延迟执行以便捕获错误。
363
+ *
364
+ * ```tsx
365
+ * <ErrorBoundary fallback={(e) => <p>出错了: {e.message}</p>}>
366
+ * {() => <Dashboard />}
367
+ * </ErrorBoundary>
368
+ * ```
369
+ */
370
+ export declare function ErrorBoundary({ fallback, children }: {
371
+ fallback: (error: Error) => Node;
372
+ children: () => Node;
373
+ }, _ctx: WfuiContext): Node;
374
+ /**
375
+ * Portal — 将组件渲染到父容器之外的 DOM 位置。
376
+ *
377
+ * 适用于 Modal、Dropdown、Tooltip 等需要突破 overflow / z-index 的场景。
378
+ *
379
+ * ```tsx
380
+ * import { createPortal } from 'weifuwu/client'
381
+ *
382
+ * function Modal({ show, children }) {
383
+ * return <Show when={show}>
384
+ * {createPortal(
385
+ * <div class="fixed inset-0 bg-black/50 flex items-center justify-center">
386
+ * {children}
387
+ * </div>,
388
+ * document.body
389
+ * )}
390
+ * </Show>
391
+ * }
392
+ * ```
393
+ */
394
+ export declare function createPortal(node: Node, target: Element): Node;
61
395
  /**
62
396
  * 条件渲染 — when 为 Signal 时响应式切换
63
397
  *
@@ -184,6 +184,17 @@ function domMount(root, app) {
184
184
  container.innerHTML = "";
185
185
  container.appendChild(app);
186
186
  }
187
+ function ErrorBoundary({ fallback, children }, _ctx) {
188
+ try {
189
+ return children();
190
+ } catch (e) {
191
+ return fallback(e);
192
+ }
193
+ }
194
+ function createPortal(node, target) {
195
+ target.appendChild(node);
196
+ return document.createDocumentFragment();
197
+ }
187
198
  function toNode(v) {
188
199
  if (v instanceof Node) return v;
189
200
  if (typeof v === "function") return toNode(v());
@@ -232,7 +243,8 @@ function createApp() {
232
243
  params: {},
233
244
  query: Object.fromEntries(new URLSearchParams(window.location.search)),
234
245
  hash: window.location.hash,
235
- component: null
246
+ component: null,
247
+ data: {}
236
248
  },
237
249
  app: {
238
250
  navigate(path) {
@@ -308,56 +320,79 @@ function router(opts) {
308
320
  }).join("/") + "$";
309
321
  return { re: new RegExp(reStr), keys, route };
310
322
  });
311
- function matchPath(path) {
312
- for (const { re, keys, route } of matchers) {
313
- const m = path.match(re);
314
- if (m) {
315
- const params = {};
316
- keys.forEach((k, i) => {
317
- params[k] = decodeURIComponent(m[i + 1]);
318
- });
319
- return { component: route.component, params, title: route.title, auth: route.auth };
320
- }
323
+ function matchRoute(path) {
324
+ for (const m of matchers) {
325
+ const result = path.match(m.re);
326
+ if (!result) continue;
327
+ const params = {};
328
+ m.keys.forEach((k, i) => {
329
+ params[k] = decodeURIComponent(result[i + 1]);
330
+ });
331
+ return {
332
+ matched: { component: m.route.component, params, title: m.route.title, auth: m.route.auth },
333
+ routeDef: m.route
334
+ };
321
335
  }
322
336
  return null;
323
337
  }
324
338
  return (ctx) => {
325
- function handleRoute() {
326
- const raw = mode === "hash" ? window.location.hash.slice(1) || "/" : window.location.pathname + window.location.search;
327
- const [path, qs] = raw.split("?");
339
+ function emit(path) {
340
+ window.dispatchEvent(new CustomEvent("wefu:route", { detail: { path } }));
341
+ }
342
+ function resolve(path) {
343
+ const raw = mode === "hash" ? path || "/" : path;
344
+ const [cleanPath, qs] = raw.split("?");
328
345
  ctx.route.query = Object.fromEntries(new URLSearchParams(qs ?? ""));
329
- const matched = matchPath(path);
330
- if (matched) {
331
- ctx.route.path = path;
332
- ctx.route.params = matched.params;
333
- ctx.route.component = matched.component;
334
- ctx.route.title = matched.title;
335
- ctx.route.auth = matched.auth;
336
- if (matched.title) document.title = matched.title;
337
- if (matched.auth && !ctx.user) {
338
- setTimeout(() => ctx.app.navigate("/login"), 0);
339
- return;
340
- }
341
- } else {
346
+ ctx.route.path = cleanPath;
347
+ const r = matchRoute(cleanPath);
348
+ if (!r) {
342
349
  ctx.route.component = opts.notFound ?? null;
350
+ ctx.route.data = {};
351
+ return { component: null };
352
+ }
353
+ ctx.route.params = r.matched.params;
354
+ ctx.route.component = r.matched.component;
355
+ ctx.route.title = r.matched.title;
356
+ ctx.route.auth = r.matched.auth;
357
+ if (r.matched.title) document.title = r.matched.title;
358
+ return { component: r.matched.component, routeDef: r.routeDef };
359
+ }
360
+ function navigateAndLoad(path) {
361
+ const { routeDef } = resolve(path);
362
+ if (ctx.route.auth && !ctx.user) {
363
+ setTimeout(() => ctx.app.navigate("/login"), 0);
364
+ return;
365
+ }
366
+ emit(ctx.route.path);
367
+ if (routeDef?.loader) {
368
+ routeDef.loader(ctx).then((data) => {
369
+ ctx.route.data = data;
370
+ emit(ctx.route.path);
371
+ }).catch(() => {
372
+ ctx.route.data = {};
373
+ emit(ctx.route.path);
374
+ });
343
375
  }
344
- window.dispatchEvent(new CustomEvent("wefu:route", { detail: { path } }));
345
376
  }
346
- const origNavigate = ctx.app.navigate;
347
377
  ctx.app.navigate = (path) => {
348
378
  if (mode === "hash") {
349
379
  window.location.hash = "#" + path;
350
380
  } else {
351
381
  window.history.pushState({}, "", path);
352
- handleRoute();
382
+ navigateAndLoad(path);
353
383
  }
354
384
  };
355
385
  if (mode === "hash") {
356
- window.addEventListener("hashchange", handleRoute);
386
+ window.addEventListener("hashchange", () => {
387
+ navigateAndLoad(window.location.hash.slice(1) || "/");
388
+ });
357
389
  } else {
358
- window.addEventListener("popstate", handleRoute);
390
+ window.addEventListener("popstate", () => {
391
+ navigateAndLoad(window.location.pathname + window.location.search);
392
+ });
359
393
  }
360
- handleRoute();
394
+ const initialPath = mode === "hash" ? window.location.hash.slice(1) || "/" : window.location.pathname + window.location.search;
395
+ navigateAndLoad(initialPath);
361
396
  return ctx;
362
397
  };
363
398
  }
@@ -583,6 +618,112 @@ function ws(opts = {}) {
583
618
  };
584
619
  }
585
620
 
621
+ // src/client/lib/form.ts
622
+ function useForm(opts) {
623
+ const fields = /* @__PURE__ */ new Map();
624
+ const errors = {};
625
+ const touched = {};
626
+ for (const key of Object.keys(opts.initial)) {
627
+ fields.set(key, signal(opts.initial[key]));
628
+ errors[key] = null;
629
+ touched[key] = false;
630
+ }
631
+ const valid = signal(true);
632
+ const values = signal({ ...opts.initial });
633
+ function snapshot() {
634
+ const data = {};
635
+ for (const [key, sig] of fields) {
636
+ data[key] = sig.value;
637
+ }
638
+ values.value = data;
639
+ }
640
+ function validateField(name) {
641
+ if (!opts.validate) return null;
642
+ const rule = opts.validate[name];
643
+ if (!rule) return null;
644
+ const sig = fields.get(name);
645
+ const result = rule(sig.value, values.value);
646
+ const error = result && typeof result === "string" ? result : null;
647
+ errors[name] = error;
648
+ return error;
649
+ }
650
+ function validateAll() {
651
+ if (!opts.validate) return true;
652
+ let allValid = true;
653
+ for (const key of Object.keys(opts.initial)) {
654
+ const err = validateField(key);
655
+ if (err) allValid = false;
656
+ }
657
+ valid.value = allValid;
658
+ return allValid;
659
+ }
660
+ return {
661
+ field(name) {
662
+ let sig = fields.get(name);
663
+ if (!sig) {
664
+ sig = signal(opts.initial[name]);
665
+ fields.set(name, sig);
666
+ }
667
+ return {
668
+ value: sig,
669
+ onInput(e) {
670
+ const target = e.target;
671
+ touched[name] = true;
672
+ if (target.type === "checkbox") {
673
+ sig.value = target.checked;
674
+ } else {
675
+ sig.value = target.value;
676
+ }
677
+ snapshot();
678
+ validateField(name);
679
+ }
680
+ };
681
+ },
682
+ get errors() {
683
+ return errors;
684
+ },
685
+ get touched() {
686
+ return touched;
687
+ },
688
+ valid,
689
+ get values() {
690
+ return values;
691
+ },
692
+ async submit(handler) {
693
+ if (!validateAll()) return;
694
+ const data = {};
695
+ for (const [key, sig] of fields) {
696
+ data[key] = sig.value;
697
+ }
698
+ await handler(data);
699
+ },
700
+ reset() {
701
+ for (const key of Object.keys(opts.initial)) {
702
+ const sig = fields.get(key);
703
+ if (sig) sig.value = opts.initial[key];
704
+ errors[key] = null;
705
+ touched[key] = false;
706
+ }
707
+ valid.value = true;
708
+ snapshot();
709
+ },
710
+ setValue(name, value) {
711
+ const sig = fields.get(name);
712
+ if (sig) sig.value = value;
713
+ snapshot();
714
+ validateField(name);
715
+ },
716
+ setValues(partial) {
717
+ for (const [key, value] of Object.entries(partial)) {
718
+ const sig = fields.get(key);
719
+ if (sig) sig.value = value;
720
+ }
721
+ snapshot();
722
+ validateAll();
723
+ }
724
+ };
725
+ }
726
+
586
727
  // src/client/components/LoginForm.ts
587
728
  var h = (tag, props, ...children) => jsx(tag, props ?? {}, ...children);
588
729
  function LoginForm(_props, ctx) {
@@ -739,6 +880,7 @@ export {
739
880
  ApiClient,
740
881
  ApiError,
741
882
  Chat,
883
+ ErrorBoundary,
742
884
  For,
743
885
  Fragment,
744
886
  LoginForm,
@@ -748,6 +890,7 @@ export {
748
890
  auth,
749
891
  computed,
750
892
  createApp,
893
+ createPortal,
751
894
  domMount,
752
895
  effect,
753
896
  isSignal,
@@ -756,6 +899,7 @@ export {
756
899
  jsxs,
757
900
  router,
758
901
  signal,
902
+ useForm,
759
903
  wrap,
760
904
  ws
761
905
  };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * useForm — 表单状态管理
3
+ *
4
+ * 自动绑定字段信号、验证、提交。
5
+ *
6
+ * ```tsx
7
+ * const form = useForm({
8
+ * initial: { email: '', password: '' },
9
+ * validate: {
10
+ * email: (v) => !v.includes('@') && '请输入有效邮箱',
11
+ * },
12
+ * })
13
+ *
14
+ * // 在 JSX 中
15
+ * <input {...form.field('email')} placeholder="邮箱" />
16
+ * {form.errors.email && <span class="text-red-500">{form.errors.email}</span>}
17
+ *
18
+ * <button onClick={() => form.submit((data) => ctx.login(data.email, data.password))}>
19
+ * 登录
20
+ * </button>
21
+ * ```
22
+ */
23
+ import { type Signal } from '../signal.ts';
24
+ export type ValidationRule<T> = {
25
+ [K in keyof T]?: (value: T[K], values: T) => string | false | undefined | null;
26
+ };
27
+ export interface UseFormOptions<T extends Record<string, unknown>> {
28
+ initial: T;
29
+ validate?: ValidationRule<T>;
30
+ }
31
+ export interface UseFormResult<T extends Record<string, unknown>> {
32
+ /** 绑定到 input 的属性:value + onInput */
33
+ field: (name: keyof T) => {
34
+ value: Signal<T[keyof T]>;
35
+ onInput: (e: Event) => void;
36
+ };
37
+ /** 字段错误信息(空字符串表示无错误) */
38
+ errors: Record<keyof T, string | null>;
39
+ /** 字段是否已 touched(用户操作过) */
40
+ touched: Record<keyof T, boolean>;
41
+ /** 整个表单是否有效 */
42
+ valid: Signal<boolean>;
43
+ /** 表单数据快照(只读) */
44
+ values: Signal<T>;
45
+ /** 提交 — 先验证,验证通过后调用 handler */
46
+ submit: (handler: (data: T) => void | Promise<void>) => Promise<void>;
47
+ /** 重置到初始值 */
48
+ reset: () => void;
49
+ /** 设置单个字段值 */
50
+ setValue: <K extends keyof T>(name: K, value: T[K]) => void;
51
+ /** 批量设置字段值 */
52
+ setValues: (partial: Partial<T>) => void;
53
+ }
54
+ export declare function useForm<T extends Record<string, unknown>>(opts: UseFormOptions<T>): UseFormResult<T>;
@@ -1,51 +1,18 @@
1
1
  /**
2
2
  * weifuwu/client router — 路由中间件 + RouteView 组件
3
- *
4
- * ```tsx
5
- * import { createApp, router, RouteView } from 'weifuwu/client'
6
- *
7
- * const app = createApp()
8
- * app.use(router({
9
- * routes: [
10
- * { path: '/', component: HomePage },
11
- * { path: '/chat/:id', component: ChatPage },
12
- * ],
13
- * }))
14
- *
15
- * function AppShell(_, ctx) {
16
- * return <div><Header /><RouteView /></div>
17
- * }
18
- * app.mount('#root', AppShell)
19
- * ```
20
3
  */
21
4
  import type { WfuiContext, AppMiddleware, RouteDef } from './types.ts';
22
5
  import type { Component } from './jsx-runtime.ts';
23
6
  export interface RouterOptions {
24
- /** 路由模式,默认 'hash' */
25
7
  mode?: 'hash' | 'history';
26
- /** 路由表 */
27
8
  routes: RouteDef[];
28
- /** 404 组件 */
29
9
  notFound?: Component;
30
10
  }
31
11
  /**
32
- * 路由中间件 — 注入 ctx.route.component + 改写 ctx.app.navigate
12
+ * 路由中间件 — 注入 ctx.route + 改写 ctx.app.navigate
33
13
  */
34
14
  export declare function router(opts: RouterOptions): AppMiddleware;
35
15
  /**
36
16
  * RouteView — 渲染当前路由匹配的组件
37
- *
38
- * 放在布局组件中,URL 变化时自动切换内容。
39
- *
40
- * ```tsx
41
- * function AppShell(_, ctx) {
42
- * return (
43
- * <div class="app">
44
- * <Header user={ctx.user} />
45
- * <main><RouteView /></main>
46
- * </div>
47
- * )
48
- * }
49
- * ```
50
17
  */
51
18
  export declare function RouteView(_props: {}, ctx: WfuiContext): Node;
@@ -24,6 +24,8 @@ export interface WfuiContext {
24
24
  component: Component | null;
25
25
  title?: string;
26
26
  auth?: boolean;
27
+ /** 路由 loader 返回的数据 */
28
+ data: Record<string, unknown>;
27
29
  };
28
30
  app: {
29
31
  navigate: (path: string) => void;
@@ -72,4 +74,6 @@ export interface RouteDef {
72
74
  component: Component;
73
75
  auth?: boolean;
74
76
  title?: string;
77
+ /** 页面加载器 — 切换路由时自动调用,结果注入 ctx.route.data */
78
+ loader?: (ctx: WfuiContext) => Promise<Record<string, unknown>>;
75
79
  }
package/dist/index.js CHANGED
@@ -1457,7 +1457,16 @@ function ui() {
1457
1457
  headers: { "Content-Type": "text/css; charset=utf-8" }
1458
1458
  });
1459
1459
  }
1460
- const code = await readFile2(absPath, "utf-8");
1460
+ let code = await readFile2(absPath, "utf-8");
1461
+ try {
1462
+ const postcss = await import("postcss");
1463
+ const tw = await import("@tailwindcss/postcss");
1464
+ const plugin = tw.default || tw;
1465
+ const instance = typeof plugin === "function" ? plugin() : plugin;
1466
+ const result = await postcss.default([instance]).process(code, { from: absPath });
1467
+ code = result.css;
1468
+ } catch {
1469
+ }
1461
1470
  cssCache.set(absPath, { code, mtime: stat.mtimeMs });
1462
1471
  return new Response(code, {
1463
1472
  headers: { "Content-Type": "text/css; charset=utf-8" }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "weifuwu",
3
3
  "type": "module",
4
- "version": "0.33.5",
4
+ "version": "0.33.7",
5
5
  "description": "AI SaaS framework — (req, ctx) => Response",
6
6
  "exports": {
7
7
  ".": {
@@ -32,10 +32,13 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@graphql-tools/schema": "^10",
35
+ "@tailwindcss/postcss": "^4.3.3",
35
36
  "ai": "^7.0.14",
36
37
  "graphql": "^16",
37
38
  "ioredis": "^5.11.0",
39
+ "postcss": "^8.5.19",
38
40
  "postgres": "^3.4.9",
41
+ "tailwindcss": "^4.3.3",
39
42
  "ws": "^8"
40
43
  },
41
44
  "devDependencies": {