weifuwu 0.8.0 → 0.9.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 CHANGED
@@ -853,7 +853,23 @@ import { tsx } from 'weifuwu/tsx'
853
853
  const app = new Router()
854
854
  app.use('/', await tsx({ dir: './pages/' }))
855
855
 
856
- serve(app.handler(), { port: 3000 })
856
+ serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
857
+ ```
858
+
859
+ ### Development mode
860
+
861
+ `tsx()` automatically runs in development mode (`NODE_ENV !== 'production'`):
862
+
863
+ - **File watching** — editing a `.tsx`/`.ts` file triggers recompilation and the browser auto-refreshes via WebSocket
864
+ - **Tailwind CSS** — if an `app.css` or `globals.css` file is found, Tailwind CSS is processed automatically. Write `className` directly.
865
+ - **`@` aliases** — if `tsconfig.json` or `jsconfig.json` has `compilerOptions.paths`, the `@` alias is passed to esbuild automatically (works with shadcn/ui)
866
+ - **Process state preserved** — DB connections, WebSockets, in-memory caches are not lost
867
+
868
+ Production mode (`NODE_ENV=production`) disables file watching and live reload. All other features work the same.
869
+
870
+ ```bash
871
+ node app.ts # development
872
+ NODE_ENV=production node app.ts # production
857
873
  ```
858
874
 
859
875
  ### File conventions
@@ -947,12 +963,12 @@ app.use('/graphql', graphql(() => ({ schema: `type Query { hello: String }`, res
947
963
  app.use('/agent', workflow(() => ({ tools: myTools, stream: true })))
948
964
  app.ws('/chat', { message(ws, _, data) { ws.send(data) } })
949
965
 
950
- serve(app.handler())
966
+ serve(app.handler(), { websocket: app.websocketHandler() })
951
967
  ```
952
968
 
953
969
  ```bash
954
- node --watch app.ts # development
955
- node app.ts # production
970
+ node app.ts # development (auto-reload on changes)
971
+ NODE_ENV=production node app.ts # production
956
972
  ```
957
973
 
958
974
  No build step, no configuration file — just Node.js.
@@ -1094,10 +1110,25 @@ Returns `MessagerModule` — `{ migrate, router, wsHandler, send, close }`.
1094
1110
 
1095
1111
  | Option | Default | Description |
1096
1112
  |--------|---------|-------------|
1097
- | `dir` | — | Pages directory path |
1113
+ | `dir` | — | Pages directory path |
1098
1114
 
1099
1115
  Returns `Promise<Router>`.
1100
1116
 
1117
+ Development features (auto-detected, no configuration needed):
1118
+
1119
+ | Feature | Behavior |
1120
+ |---------|----------|
1121
+ | **File watching** | Enabled when `NODE_ENV !== 'production'`. Watches pages directory, recompiles on change, sends live-reload signal via WebSocket |
1122
+ | **Tailwind CSS** | Auto-detected when `app.css` / `globals.css` exists. Processed through PostCSS + Tailwind plugin. Served at `/__wfw/style.css` and auto-injected into HTML `<head>` |
1123
+ | **`@` alias** | Read from `tsconfig.json` / `jsconfig.json` `compilerOptions.paths` and passed to esbuild |
1124
+ | **WebSocket live reload** | Endpoint at `/__weifuwu/livereload`. Browser auto-refreshes on file changes or server restart |
1125
+
1126
+ To use WebSocket features, pass `router.websocketHandler()` to `serve()`:
1127
+
1128
+ ```ts
1129
+ serve(app.handler(), { websocket: app.websocketHandler() })
1130
+ ```
1131
+
1101
1132
  ### `Router`
1102
1133
 
1103
1134
  | Method | Description |
package/dist/index.js CHANGED
@@ -10718,9 +10718,24 @@ var require_built3 = __commonJS({
10718
10718
 
10719
10719
  // serve.ts
10720
10720
  import http from "node:http";
10721
- async function readBody(req) {
10721
+ async function readBody(req, maxSize) {
10722
+ if (maxSize) {
10723
+ const cl = parseInt(req.headers["content-length"] ?? "0", 10);
10724
+ if (cl > maxSize) {
10725
+ const err = new Error("Request body too large");
10726
+ err.status = 413;
10727
+ throw err;
10728
+ }
10729
+ }
10722
10730
  const chunks = [];
10731
+ let total = 0;
10723
10732
  for await (const chunk of req) {
10733
+ total += chunk.byteLength;
10734
+ if (maxSize && total > maxSize) {
10735
+ const err = new Error("Request body too large");
10736
+ err.status = 413;
10737
+ throw err;
10738
+ }
10724
10739
  chunks.push(chunk);
10725
10740
  }
10726
10741
  return Buffer.concat(chunks);
@@ -10766,11 +10781,16 @@ function serve(handler, options) {
10766
10781
  const hostname = options?.hostname ?? "0.0.0.0";
10767
10782
  const server = http.createServer(async (req, res) => {
10768
10783
  try {
10769
- const body = await readBody(req);
10784
+ const body = await readBody(req, options?.maxBodySize);
10770
10785
  const [request, query] = createRequest(req, body);
10771
10786
  const response = await handler(request, { params: {}, query });
10772
10787
  await sendResponse(res, response);
10773
- } catch {
10788
+ } catch (err) {
10789
+ if (err?.status === 413) {
10790
+ res.writeHead(413, { "Content-Type": "text/plain" });
10791
+ res.end("Request Body Too Large");
10792
+ return;
10793
+ }
10774
10794
  res.writeHead(500, { "Content-Type": "text/plain" });
10775
10795
  res.end("Internal Server Error");
10776
10796
  }
@@ -10807,7 +10827,10 @@ function serve(handler, options) {
10807
10827
  });
10808
10828
  return {
10809
10829
  stop: () => {
10810
- server.close();
10830
+ return new Promise((resolve3) => {
10831
+ server.closeAllConnections();
10832
+ server.close(() => resolve3());
10833
+ });
10811
10834
  },
10812
10835
  ready,
10813
10836
  get port() {
@@ -10991,39 +11014,51 @@ var Router = class _Router {
10991
11014
  const segments = url.pathname.split("/").filter(Boolean);
10992
11015
  const query = Object.fromEntries(url.searchParams);
10993
11016
  const match = router.matchWsTrie(wsRoot, segments);
10994
- if (!match) {
10995
- socket.destroy();
11017
+ if (match) {
11018
+ const webReq = new Request(url.href, {
11019
+ method: req.method ?? "GET",
11020
+ headers: Object.fromEntries(
11021
+ Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
11022
+ )
11023
+ });
11024
+ const ctx = { params: match.params, query };
11025
+ if (match.middlewares.length === 0) {
11026
+ upgradeSocket(wss, req, socket, head, match.handler, ctx);
11027
+ return;
11028
+ }
11029
+ let index = 0;
11030
+ const dispatch = async (innerReq, ctx2) => {
11031
+ if (index < match.middlewares.length) {
11032
+ const mw = match.middlewares[index++];
11033
+ return mw(innerReq, ctx2, dispatch);
11034
+ }
11035
+ return await new Promise((resolve3) => {
11036
+ try {
11037
+ upgradeSocket(wss, req, socket, head, match.handler, ctx2);
11038
+ resolve3(new Response(null, { status: 101 }));
11039
+ } catch {
11040
+ socket.destroy();
11041
+ resolve3(new Response("WebSocket upgrade failed", { status: 500 }));
11042
+ }
11043
+ });
11044
+ };
11045
+ Promise.resolve(dispatch(webReq, ctx)).then((result) => {
11046
+ if (result.status !== 101) {
11047
+ sendHttpResponseOnSocket(socket, result);
11048
+ }
11049
+ }).catch(() => {
11050
+ socket.destroy();
11051
+ });
10996
11052
  return;
10997
11053
  }
10998
- const webReq = new Request(url.href, {
10999
- method: req.method ?? "GET",
11000
- headers: Object.fromEntries(
11001
- Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v ?? ""])
11002
- )
11003
- });
11004
- const ctx = { params: match.params, query };
11005
- if (match.middlewares.length === 0) {
11006
- upgradeSocket(wss, req, socket, head, match.handler, ctx);
11054
+ const httpMatch = router.matchTrie("GET", segments);
11055
+ if (httpMatch?.subRouter) {
11056
+ const remaining = "/" + segments.slice(httpMatch.subRouter.remainingIdx).join("/");
11057
+ req.url = remaining;
11058
+ httpMatch.subRouter.router.websocketHandler()(req, socket, head);
11007
11059
  return;
11008
11060
  }
11009
- let index = 0;
11010
- const dispatch = async (innerReq, ctx2) => {
11011
- if (index < match.middlewares.length) {
11012
- const mw = match.middlewares[index++];
11013
- return mw(innerReq, ctx2, dispatch);
11014
- }
11015
- return await new Promise((resolve3) => {
11016
- upgradeSocket(wss, req, socket, head, match.handler, ctx2);
11017
- resolve3(new Response(null, { status: 101 }));
11018
- });
11019
- };
11020
- Promise.resolve(dispatch(webReq, ctx)).then((result) => {
11021
- if (result.status !== 101) {
11022
- sendHttpResponseOnSocket(socket, result);
11023
- }
11024
- }).catch(() => {
11025
- socket.destroy();
11026
- });
11061
+ socket.destroy();
11027
11062
  };
11028
11063
  }
11029
11064
  splitPath(path2) {
@@ -11185,14 +11220,64 @@ function sendHttpResponseOnSocket(socket, response) {
11185
11220
  import { createElement, createContext, useContext } from "react";
11186
11221
  import { renderToReadableStream } from "react-dom/server";
11187
11222
  import * as esbuild from "esbuild";
11188
- import { readdirSync, statSync, existsSync, mkdirSync } from "node:fs";
11189
- import { join, relative, resolve, sep, dirname } from "node:path";
11223
+ import { readdirSync, statSync, existsSync, mkdirSync, readFileSync } from "node:fs";
11224
+ import chokidar from "chokidar";
11225
+ import { join, relative, resolve, sep, dirname, basename } from "node:path";
11190
11226
  import { pathToFileURL } from "node:url";
11191
11227
  import { createHash } from "node:crypto";
11192
11228
  var TsxContext = createContext({ params: {}, query: {} });
11193
11229
  function useTsx() {
11194
11230
  return useContext(TsxContext);
11195
11231
  }
11232
+ var pageModules = /* @__PURE__ */ new Map();
11233
+ var layoutModules = /* @__PURE__ */ new Map();
11234
+ var loadModules = /* @__PURE__ */ new Map();
11235
+ var routeModules = /* @__PURE__ */ new Map();
11236
+ var clientBundles = /* @__PURE__ */ new Map();
11237
+ var liveReloadClients = /* @__PURE__ */ new Set();
11238
+ var _watcher = null;
11239
+ var _cssWatcher = null;
11240
+ function broadcastReload() {
11241
+ for (const ws of liveReloadClients) {
11242
+ try {
11243
+ ws.send("reload");
11244
+ } catch {
11245
+ liveReloadClients.delete(ws);
11246
+ }
11247
+ }
11248
+ }
11249
+ var tailwindCssUrl = null;
11250
+ var tailwindCssCode = "";
11251
+ var _projectDir = "";
11252
+ var _watcherStarted = false;
11253
+ var _alias = null;
11254
+ function resolveAliases() {
11255
+ if (_alias) return _alias;
11256
+ const configFiles = ["tsconfig.json", "jsconfig.json"];
11257
+ for (const file of configFiles) {
11258
+ const p = resolve(file);
11259
+ if (existsSync(p)) {
11260
+ try {
11261
+ const config = JSON.parse(readFileSync(p, "utf-8"));
11262
+ const paths = config.compilerOptions?.paths;
11263
+ if (paths) {
11264
+ const alias = {};
11265
+ for (const [key, values] of Object.entries(paths)) {
11266
+ const cleanKey = key.replace("/*", "");
11267
+ const val = values[0]?.replace("/*", "");
11268
+ if (val) alias[cleanKey] = resolve(dirname(p), val);
11269
+ }
11270
+ _alias = alias;
11271
+ return alias;
11272
+ }
11273
+ } catch {
11274
+ }
11275
+ }
11276
+ }
11277
+ _alias = {};
11278
+ return {};
11279
+ }
11280
+ var isDev = process.env.NODE_ENV !== "production";
11196
11281
  function id(s) {
11197
11282
  return createHash("md5").update(s).digest("hex").slice(0, 8);
11198
11283
  }
@@ -11296,7 +11381,7 @@ function resolveLayouts(dir, pagesDir) {
11296
11381
  }
11297
11382
  return layouts.reverse();
11298
11383
  }
11299
- async function compileAll(files, outDir, platform) {
11384
+ async function compileAll(files, outDir, platform, alias) {
11300
11385
  const entryPoints = {};
11301
11386
  for (const f of files) {
11302
11387
  entryPoints[id(f)] = f;
@@ -11320,23 +11405,21 @@ async function compileAll(files, outDir, platform) {
11320
11405
  "@graphql-tools/schema",
11321
11406
  "ai"
11322
11407
  ],
11408
+ alias,
11323
11409
  write: true,
11324
11410
  allowOverwrite: true
11325
11411
  });
11326
11412
  }
11327
11413
  function compiledUrl(filePath, outDir) {
11328
- const hash = id(join(outDir, id(filePath)));
11329
11414
  const p = join(outDir, id(filePath) + ".js");
11330
11415
  return pathToFileURL(p).href;
11331
11416
  }
11332
- var clientBundleCache = /* @__PURE__ */ new Map();
11333
11417
  var clientRouteLog = /* @__PURE__ */ new WeakMap();
11334
11418
  async function getOrBuildClientBundle(entryPath, layoutPaths, pagesDir, router) {
11335
11419
  const key = id(entryPath);
11336
11420
  const url = `/__wfw/client/${key}.js`;
11337
11421
  if (!clientRouteLog.get(router)?.has(url)) {
11338
- let buf = clientBundleCache.get(key);
11339
- if (!buf) {
11422
+ if (!clientBundles.has(key)) {
11340
11423
  try {
11341
11424
  const nested = layoutPaths.slice(1);
11342
11425
  const layoutsImport = nested.map(
@@ -11362,34 +11445,46 @@ async function getOrBuildClientBundle(entryPath, layoutPaths, pagesDir, router)
11362
11445
  format: "esm",
11363
11446
  jsx: "automatic",
11364
11447
  jsxImportSource: "react",
11448
+ alias: resolveAliases(),
11365
11449
  write: false,
11366
11450
  minify: true
11367
11451
  });
11368
- buf = result.outputFiles[0].contents;
11369
- clientBundleCache.set(key, buf);
11452
+ clientBundles.set(key, result.outputFiles[0].contents);
11370
11453
  } catch (err) {
11371
11454
  console.error("hydration bundle failed:", err);
11372
11455
  return null;
11373
11456
  }
11374
11457
  }
11375
- router.get(url, () => new Response(buf, {
11376
- headers: { "content-type": "application/javascript; charset=utf-8" }
11377
- }));
11458
+ router.get(url, () => {
11459
+ const buf = clientBundles.get(key);
11460
+ return buf ? new Response(buf, {
11461
+ headers: { "content-type": "application/javascript; charset=utf-8" }
11462
+ }) : new Response("", { status: 500 });
11463
+ });
11378
11464
  const set = clientRouteLog.get(router) ?? /* @__PURE__ */ new Set();
11379
11465
  set.add(url);
11380
11466
  clientRouteLog.set(router, set);
11381
11467
  }
11382
11468
  return { url };
11383
11469
  }
11384
- function makeSsrHandler(Component, loadFn, layouts, entryPath, layoutPaths, pagesDir, router) {
11470
+ function makeSsrHandler(entryPath, layoutPaths, loadPath, pagesDir, router) {
11385
11471
  return async (req, ctx) => {
11472
+ const pageMod = pageModules.get(entryPath);
11473
+ if (!pageMod) return new Response("", { status: 500 });
11474
+ const Component = pageMod.default;
11475
+ const loadMod = loadPath ? loadModules.get(loadPath) : void 0;
11476
+ const loadFn = loadMod?.default;
11386
11477
  const loadProps = loadFn ? await loadFn({ params: ctx.params, query: ctx.query }) : {};
11387
11478
  const allProps = { ...loadProps, params: ctx.params, query: ctx.query };
11388
11479
  let element = createElement(Component, allProps);
11389
- for (let i = layouts.length - 1; i >= 0; i--) {
11480
+ for (let i = layoutPaths.length - 1; i >= 0; i--) {
11481
+ const lp = layoutPaths[i];
11482
+ const LMod = layoutModules.get(lp);
11483
+ if (!LMod) continue;
11484
+ const Layout = LMod.default;
11390
11485
  const isRoot = i === 0;
11391
11486
  element = createElement(
11392
- layouts[i],
11487
+ Layout,
11393
11488
  isRoot ? { children: element, req, ctx } : { children: element }
11394
11489
  );
11395
11490
  }
@@ -11404,9 +11499,20 @@ function makeSsrHandler(Component, loadFn, layouts, entryPath, layoutPaths, page
11404
11499
  if (bundle) {
11405
11500
  scripts.push(`<script type="module" src="${bundle.url}"></script>`);
11406
11501
  }
11407
- const html = `<!DOCTYPE html>
11502
+ let html = `<!DOCTYPE html>
11408
11503
  ${body}
11409
11504
  ${scripts.join("\n")}`;
11505
+ if (tailwindCssUrl && html.includes("</head>")) {
11506
+ html = html.replace(
11507
+ "</head>",
11508
+ `<link rel="stylesheet" href="${tailwindCssUrl}" />
11509
+ </head>`
11510
+ );
11511
+ }
11512
+ if (isDev) {
11513
+ html += `
11514
+ <script>(function(){var ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/__weifuwu/livereload');ws.onmessage=function(e){if(e.data==='reload')location.reload()};ws.onclose=function(){setTimeout(function(){location.reload()},500)}})()</script>`;
11515
+ }
11410
11516
  return new Response(html, {
11411
11517
  headers: { "content-type": "text/html; charset=utf-8" }
11412
11518
  });
@@ -11414,6 +11520,7 @@ ${scripts.join("\n")}`;
11414
11520
  }
11415
11521
  async function tsx(options) {
11416
11522
  const pagesDir = resolve(options.dir);
11523
+ _projectDir = resolve(pagesDir, "..");
11417
11524
  const outDir = join(pagesDir, "..", ".weifuwu", "ssr");
11418
11525
  const pages = scanPages(pagesDir);
11419
11526
  if (pages.length === 0) return new Router();
@@ -11432,79 +11539,102 @@ async function tsx(options) {
11432
11539
  for (const lp of rootLayouts) allFiles.add(lp);
11433
11540
  }
11434
11541
  mkdirSync(outDir, { recursive: true });
11435
- await compileAll([...allFiles], outDir, "node");
11542
+ const alias = resolveAliases();
11543
+ await compileAll([...allFiles], outDir, "node", alias);
11436
11544
  const router = new Router();
11545
+ const methods = ["POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
11437
11546
  for (const p of pages) {
11438
11547
  if (p.routeOnly && p.routePath) {
11439
11548
  const rUrl = compiledUrl(p.routePath, outDir);
11440
11549
  const modR = await import(rUrl);
11441
- const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
11442
- for (const method of methods) {
11443
- if (modR[method]) {
11444
- router.route(method, p.route, modR[method]);
11445
- }
11550
+ const handlers = /* @__PURE__ */ new Map();
11551
+ for (const m of ["GET", ...methods]) {
11552
+ if (modR[m]) handlers.set(m, modR[m]);
11553
+ }
11554
+ routeModules.set(p.routePath, handlers);
11555
+ router.route(
11556
+ "GET",
11557
+ p.route,
11558
+ (req, ctx) => routeModules.get(p.routePath)?.get("GET")?.(req, ctx) ?? new Response("", { status: 501 })
11559
+ );
11560
+ for (const m of methods) {
11561
+ router.route(
11562
+ m,
11563
+ p.route,
11564
+ (req, ctx) => routeModules.get(p.routePath)?.get(m)?.(req, ctx) ?? new Response("", { status: 501 })
11565
+ );
11446
11566
  }
11447
11567
  continue;
11448
11568
  }
11449
- const url = compiledUrl(p.entryPath, outDir);
11450
- const mod = await import(url);
11451
- const Component = mod.default;
11452
- let loadFn;
11569
+ const pageUrl = compiledUrl(p.entryPath, outDir);
11570
+ pageModules.set(p.entryPath, await import(pageUrl));
11453
11571
  if (p.loadPath) {
11454
11572
  const loadUrl = compiledUrl(p.loadPath, outDir);
11455
- const modLoad = await import(loadUrl);
11456
- loadFn = modLoad.default;
11573
+ loadModules.set(p.loadPath, await import(loadUrl));
11457
11574
  }
11458
- const layoutComponents = [];
11459
11575
  for (const lp of p.layouts) {
11460
11576
  const lUrl = compiledUrl(lp, outDir);
11461
- const modL = await import(lUrl);
11462
- layoutComponents.push(modL.default);
11463
- }
11464
- const handler = makeSsrHandler(
11465
- Component,
11466
- loadFn,
11467
- layoutComponents,
11468
- p.entryPath,
11469
- p.layouts,
11470
- pagesDir,
11471
- router
11472
- );
11473
- router.get(p.route, handler);
11577
+ layoutModules.set(lp, await import(lUrl));
11578
+ }
11474
11579
  if (p.routePath) {
11475
11580
  const rUrl = compiledUrl(p.routePath, outDir);
11476
11581
  const modR = await import(rUrl);
11477
- const methods = ["POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
11478
- for (const method of methods) {
11479
- if (modR[method]) {
11480
- router.route(method, p.route, modR[method]);
11481
- }
11582
+ const handlers = /* @__PURE__ */ new Map();
11583
+ for (const m of methods) {
11584
+ if (modR[m]) handlers.set(m, modR[m]);
11585
+ }
11586
+ routeModules.set(p.routePath, handlers);
11587
+ }
11588
+ const handler = makeSsrHandler(p.entryPath, p.layouts, p.loadPath, pagesDir, router);
11589
+ router.get(p.route, handler);
11590
+ if (p.routePath) {
11591
+ for (const m of methods) {
11592
+ router.route(
11593
+ m,
11594
+ p.route,
11595
+ (req, ctx) => routeModules.get(p.routePath)?.get(m)?.(req, ctx) ?? new Response("", { status: 501 })
11596
+ );
11482
11597
  }
11483
11598
  }
11484
11599
  }
11485
11600
  if (hasNotFound) {
11486
11601
  const nfUrl = compiledUrl(nfPath, outDir);
11487
- const modNf = await import(nfUrl);
11488
- const NfComponent = modNf.default;
11489
- const nfLayouts = [];
11602
+ pageModules.set(nfPath, await import(nfUrl));
11490
11603
  const rootLayouts = resolveLayouts(pagesDir, pagesDir);
11491
11604
  for (const lp of rootLayouts) {
11492
- const lUrl = compiledUrl(lp, outDir);
11493
- const modL = await import(lUrl);
11494
- nfLayouts.push(modL.default);
11605
+ if (!layoutModules.has(lp)) {
11606
+ const lUrl = compiledUrl(lp, outDir);
11607
+ layoutModules.set(lp, await import(lUrl));
11608
+ }
11495
11609
  }
11496
11610
  const handler = async (req, ctx) => {
11611
+ const nfMod = pageModules.get(nfPath);
11612
+ if (!nfMod) return new Response("Not Found", { status: 404 });
11613
+ const NfComponent = nfMod.default;
11497
11614
  let element = createElement(NfComponent, { params: ctx.params, query: ctx.query });
11498
- for (let i = nfLayouts.length - 1; i >= 0; i--) {
11499
- element = createElement(nfLayouts[i], { children: element });
11615
+ for (let i = rootLayouts.length - 1; i >= 0; i--) {
11616
+ const LMod = layoutModules.get(rootLayouts[i]);
11617
+ if (!LMod) continue;
11618
+ element = createElement(LMod.default, { children: element });
11500
11619
  }
11501
11620
  element = createElement(TsxContext.Provider, {
11502
11621
  value: { params: ctx.params, query: ctx.query, user: ctx.user, parsed: ctx.parsed }
11503
11622
  }, element);
11504
11623
  const stream = await renderToReadableStream(element);
11505
11624
  const body = await readStream(stream);
11506
- const html = `<!DOCTYPE html>
11625
+ let html = `<!DOCTYPE html>
11507
11626
  ${body}`;
11627
+ if (tailwindCssUrl && html.includes("</head>")) {
11628
+ html = html.replace(
11629
+ "</head>",
11630
+ `<link rel="stylesheet" href="${tailwindCssUrl}" />
11631
+ </head>`
11632
+ );
11633
+ }
11634
+ if (isDev) {
11635
+ html += `
11636
+ <script>(function(){var ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/__weifuwu/livereload');ws.onmessage=function(e){if(e.data==='reload')location.reload()};ws.onclose=function(){setTimeout(function(){location.reload()},500)}})()</script>`;
11637
+ }
11508
11638
  return new Response(html, {
11509
11639
  status: 404,
11510
11640
  headers: { "content-type": "text/html; charset=utf-8" }
@@ -11512,8 +11642,150 @@ ${body}`;
11512
11642
  };
11513
11643
  router.all("/*", handler);
11514
11644
  }
11645
+ tailwindCssUrl = await setupTailwind(pagesDir, router, alias);
11646
+ if (isDev) {
11647
+ router.ws("/__weifuwu/livereload", {
11648
+ open(ws) {
11649
+ liveReloadClients.add(ws);
11650
+ ws.on("close", () => liveReloadClients.delete(ws));
11651
+ ws.on("error", () => liveReloadClients.delete(ws));
11652
+ }
11653
+ });
11654
+ if (!_watcherStarted) {
11655
+ startFileWatcher(pagesDir, outDir);
11656
+ _watcherStarted = true;
11657
+ }
11658
+ }
11515
11659
  return router;
11516
11660
  }
11661
+ async function setupTailwind(pagesDir, router, alias) {
11662
+ let tailwindPlugin, postcss, autoprefixer;
11663
+ try {
11664
+ tailwindPlugin = (await import("@tailwindcss/postcss")).default;
11665
+ postcss = (await import("postcss")).default;
11666
+ autoprefixer = (await import("autoprefixer")).default;
11667
+ } catch {
11668
+ return null;
11669
+ }
11670
+ const candidates = ["app.css", "globals.css", "src/app.css", "src/globals.css", "style.css"];
11671
+ let inputFile = "";
11672
+ for (const c of candidates) {
11673
+ const p = resolve(pagesDir, "..", c);
11674
+ if (existsSync(p)) {
11675
+ inputFile = p;
11676
+ break;
11677
+ }
11678
+ }
11679
+ if (!inputFile) return null;
11680
+ try {
11681
+ const src = readFileSync(inputFile, "utf-8");
11682
+ const result = await postcss([tailwindPlugin(), autoprefixer]).process(src, { from: inputFile });
11683
+ tailwindCssCode = result.css;
11684
+ } catch (err) {
11685
+ console.warn("Tailwind CSS processing failed:", err.message);
11686
+ return null;
11687
+ }
11688
+ const url = "/__wfw/style.css";
11689
+ router.get(url, () => new Response(tailwindCssCode, {
11690
+ headers: { "content-type": "text/css; charset=utf-8" }
11691
+ }));
11692
+ if (isDev) {
11693
+ _cssWatcher = chokidar.watch(inputFile, { persistent: false });
11694
+ _cssWatcher.on("change", async () => {
11695
+ try {
11696
+ const newSrc = readFileSync(inputFile, "utf-8");
11697
+ const newResult = await postcss([tailwindPlugin(), autoprefixer]).process(newSrc, { from: inputFile });
11698
+ tailwindCssCode = newResult.css;
11699
+ broadcastReload();
11700
+ } catch (err) {
11701
+ console.warn("Tailwind CSS reprocessing failed:", err.message);
11702
+ }
11703
+ });
11704
+ }
11705
+ return url;
11706
+ }
11707
+ function startFileWatcher(pagesDir, outDir) {
11708
+ let timeout = null;
11709
+ const pending = /* @__PURE__ */ new Set();
11710
+ _watcher = chokidar.watch(pagesDir, {
11711
+ ignored: /(^|[/\\])\.(?!\.)|\.weifuwu/,
11712
+ persistent: false,
11713
+ ignoreInitial: true
11714
+ });
11715
+ _watcher.on("all", async (event, filePath) => {
11716
+ if (event !== "change" && event !== "add") return;
11717
+ if (!/\.tsx?$/.test(filePath)) return;
11718
+ pending.add(filePath);
11719
+ if (timeout) clearTimeout(timeout);
11720
+ timeout = setTimeout(async () => {
11721
+ timeout = null;
11722
+ const files = [...pending];
11723
+ pending.clear();
11724
+ for (const f of files) {
11725
+ if (existsSync(f)) await recompileAndSwap(f, outDir);
11726
+ }
11727
+ }, 50);
11728
+ });
11729
+ }
11730
+ async function recompileAndSwap(filePath, outDir) {
11731
+ try {
11732
+ await esbuild.build({
11733
+ entryPoints: { [id(filePath)]: filePath },
11734
+ outdir: outDir,
11735
+ alias: resolveAliases(),
11736
+ format: "esm",
11737
+ platform: "node",
11738
+ jsx: "automatic",
11739
+ jsxImportSource: "react",
11740
+ bundle: true,
11741
+ external: ["react", "react-dom", "esbuild", "graphql", "ws", "zod", "@graphql-tools/schema", "ai"],
11742
+ write: true,
11743
+ allowOverwrite: true
11744
+ });
11745
+ const bustUrl = compiledUrl(filePath, outDir) + "?t=" + Date.now();
11746
+ const freshMod = await import(bustUrl);
11747
+ const name15 = basename(filePath);
11748
+ if (name15 === "layout.tsx") {
11749
+ layoutModules.set(filePath, freshMod);
11750
+ } else if (name15 === "route.ts") {
11751
+ const handlers = /* @__PURE__ */ new Map();
11752
+ for (const m of ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]) {
11753
+ if (freshMod[m]) handlers.set(m, freshMod[m]);
11754
+ }
11755
+ routeModules.set(filePath, handlers);
11756
+ } else if (name15 === "load.ts") {
11757
+ loadModules.set(filePath, freshMod);
11758
+ } else {
11759
+ pageModules.set(filePath, freshMod);
11760
+ clientBundles.delete(id(filePath));
11761
+ }
11762
+ if (tailwindCssUrl) {
11763
+ try {
11764
+ const tailwindPlugin = (await import("@tailwindcss/postcss")).default;
11765
+ const postcss = (await import("postcss")).default;
11766
+ const autoprefixer = (await import("autoprefixer")).default;
11767
+ const candidates = ["app.css", "globals.css", "src/app.css", "src/globals.css", "style.css"];
11768
+ let inputFile = "";
11769
+ for (const c of candidates) {
11770
+ const p = resolve(_projectDir, c);
11771
+ if (existsSync(p)) {
11772
+ inputFile = p;
11773
+ break;
11774
+ }
11775
+ }
11776
+ if (inputFile) {
11777
+ const newSrc = readFileSync(inputFile, "utf-8");
11778
+ const result = await postcss([tailwindPlugin(), autoprefixer]).process(newSrc, { from: inputFile });
11779
+ tailwindCssCode = result.css;
11780
+ }
11781
+ } catch {
11782
+ }
11783
+ }
11784
+ broadcastReload();
11785
+ } catch (err) {
11786
+ console.error("recompile failed:", err.message);
11787
+ }
11788
+ }
11517
11789
 
11518
11790
  // middleware.ts
11519
11791
  function logger(options) {
@@ -11815,7 +12087,7 @@ function validate(schemas) {
11815
12087
  if (issues.length > 0) {
11816
12088
  return Response.json({ error: "Validation failed", issues }, { status: 400 });
11817
12089
  }
11818
- ctx.parsed = parsed;
12090
+ ctx.parsed = { ...ctx.parsed, ...parsed };
11819
12091
  return next(req, ctx);
11820
12092
  };
11821
12093
  }
@@ -11831,7 +12103,11 @@ function getCookies(req) {
11831
12103
  const name15 = pair.slice(0, idx).trim();
11832
12104
  const value = pair.slice(idx + 1).trim();
11833
12105
  if (name15) {
11834
- cookies[name15] = decodeURIComponent(value);
12106
+ try {
12107
+ cookies[name15] = decodeURIComponent(value);
12108
+ } catch {
12109
+ cookies[name15] = value;
12110
+ }
11835
12111
  }
11836
12112
  }
11837
12113
  return cookies;
@@ -11874,67 +12150,45 @@ function upload(options) {
11874
12150
  const saveDir = options?.dir;
11875
12151
  return async (req, ctx, next) => {
11876
12152
  const ct = req.headers.get("content-type") ?? "";
11877
- if (!ct.includes("multipart/form-data")) {
11878
- return next(req, ctx);
11879
- }
11880
- const match = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
11881
- if (!match) {
11882
- return Response.json({ error: "Missing boundary" }, { status: 400 });
12153
+ if (!ct.includes("multipart/form-data")) return next(req, ctx);
12154
+ let formData;
12155
+ try {
12156
+ formData = await req.formData();
12157
+ } catch {
12158
+ return Response.json({ error: "Invalid multipart data" }, { status: 400 });
11883
12159
  }
11884
- const boundary = match[1] ?? match[2];
11885
- const body = await req.text();
11886
- const rawParts = body.split(`--${boundary}`).filter((p) => p && !p.startsWith("--") && !p.startsWith("\r\n--"));
11887
12160
  const files = {};
11888
12161
  const fields = {};
11889
- for (const raw of rawParts) {
11890
- const trimmed = raw.replace(/^\r?\n/, "");
11891
- const lines = trimmed.split(/\r?\n/);
11892
- let i = 0;
11893
- const headers = {};
11894
- while (i < lines.length && lines[i].length > 0) {
11895
- const sep3 = lines[i].indexOf(": ");
11896
- if (sep3 !== -1) headers[lines[i].slice(0, sep3).toLowerCase()] = lines[i].slice(sep3 + 2);
11897
- i++;
11898
- }
11899
- i++;
11900
- const bodyValue = lines.slice(i).join("\r\n");
11901
- const disposition = headers["content-disposition"] ?? "";
11902
- const nameMatch = disposition.match(/name="([^"]*)"/);
11903
- if (!nameMatch) continue;
11904
- const name15 = nameMatch[1];
11905
- const filenameMatch = disposition.match(/filename="([^"]*)"/);
11906
- const filename = filenameMatch?.[1];
11907
- if (filename) {
11908
- const buf = Buffer.from(bodyValue.replace(/\r?\n$/, ""), "binary");
11909
- if (options?.allowedTypes) {
11910
- const mime = headers["content-type"] ?? "application/octet-stream";
11911
- if (!options.allowedTypes.includes(mime)) {
11912
- return Response.json({ error: `File type not allowed: ${mime}` }, { status: 415 });
11913
- }
12162
+ for (const [key, value] of formData) {
12163
+ if (value instanceof File) {
12164
+ if (options?.allowedTypes && !options.allowedTypes.includes(value.type)) {
12165
+ return Response.json({ error: `File type not allowed: ${value.type}` }, { status: 415 });
11914
12166
  }
11915
- if (options?.maxFileSize && buf.byteLength > options.maxFileSize) {
11916
- return Response.json({ error: `File too large: ${filename}` }, { status: 413 });
12167
+ if (options?.maxFileSize && value.size > options.maxFileSize) {
12168
+ return Response.json({ error: `File too large: ${value.name}` }, { status: 413 });
11917
12169
  }
12170
+ const buf = Buffer.from(await value.arrayBuffer());
11918
12171
  const uf = {
11919
- name: filename,
11920
- type: headers["content-type"] ?? "application/octet-stream",
12172
+ name: value.name,
12173
+ type: value.type,
11921
12174
  size: buf.byteLength,
11922
12175
  buffer: saveDir ? void 0 : buf
11923
12176
  };
11924
12177
  if (saveDir) {
11925
- const filePath = join2(saveDir, `${randomUUID()}-${filename}`);
12178
+ const safeName = value.name.replace(/[/\\]/g, "");
12179
+ const filePath = join2(saveDir, `${randomUUID()}-${safeName}`);
11926
12180
  await mkdir(saveDir, { recursive: true });
11927
12181
  await writeFile(filePath, buf);
11928
12182
  uf.path = filePath;
11929
12183
  }
11930
- if (files[name15]) {
11931
- const existing = files[name15];
11932
- files[name15] = Array.isArray(existing) ? [...existing, uf] : [existing, uf];
12184
+ if (files[key]) {
12185
+ const existing = files[key];
12186
+ files[key] = Array.isArray(existing) ? [...existing, uf] : [existing, uf];
11933
12187
  } else {
11934
- files[name15] = uf;
12188
+ files[key] = uf;
11935
12189
  }
11936
12190
  } else {
11937
- fields[name15] = bodyValue.replace(/\r?\n$/, "");
12191
+ fields[key] = value;
11938
12192
  }
11939
12193
  }
11940
12194
  ctx.parsed = { ...ctx.parsed, files, fields };
@@ -13542,12 +13796,21 @@ function queue(opts) {
13542
13796
  if (!running) return;
13543
13797
  try {
13544
13798
  const now = Date.now();
13545
- const jobs = await redis2.zrangebyscore(jobKey, 0, now);
13546
- if (jobs.length > 0) {
13547
- await redis2.zrem(jobKey, ...jobs);
13548
- }
13549
- for (const raw of jobs) {
13550
- const job = JSON.parse(raw);
13799
+ while (true) {
13800
+ const result = await redis2.zpopmin(jobKey);
13801
+ if (result.length < 2) break;
13802
+ const raw = result[0];
13803
+ const score = parseInt(result[1], 10);
13804
+ if (score > now) {
13805
+ await redis2.zadd(jobKey, score, raw);
13806
+ break;
13807
+ }
13808
+ let job;
13809
+ try {
13810
+ job = JSON.parse(raw);
13811
+ } catch {
13812
+ continue;
13813
+ }
13551
13814
  const handler = handlers.get(job.type);
13552
13815
  if (handler) {
13553
13816
  handler(job).then(() => {
@@ -13555,11 +13818,13 @@ function queue(opts) {
13555
13818
  try {
13556
13819
  const nextRun = cronNext(job.schedule);
13557
13820
  const nextJob = { ...job, id: crypto3.randomUUID(), runAt: nextRun, createdAt: Date.now() };
13558
- redis2.zadd(jobKey, nextRun, JSON.stringify(nextJob));
13821
+ redis2.zadd(jobKey, nextRun, JSON.stringify(nextJob)).catch(() => {
13822
+ });
13559
13823
  } catch {
13560
13824
  }
13561
13825
  }
13562
- }).catch(() => {
13826
+ }).catch((e) => {
13827
+ console.error("[queue] handler error:", e);
13563
13828
  });
13564
13829
  }
13565
13830
  }
@@ -13844,6 +14109,10 @@ async function getUserTable(sql, tenantId, slug) {
13844
14109
  `;
13845
14110
  return row ?? null;
13846
14111
  }
14112
+ function requireAdmin(ctx) {
14113
+ if (ctx.tenant?.role !== "admin") return Response.json({ error: "Forbidden" }, { status: 403 });
14114
+ return null;
14115
+ }
13847
14116
  function buildRouter(sql, usersTable) {
13848
14117
  const r = new Router();
13849
14118
  r.post("/sys/tenants", async (req, ctx) => {
@@ -13869,6 +14138,8 @@ function buildRouter(sql, usersTable) {
13869
14138
  return Response.json(rows);
13870
14139
  });
13871
14140
  r.post("/sys/tenants/invite", async (req, ctx) => {
14141
+ const err = requireAdmin(ctx);
14142
+ if (err) return err;
13872
14143
  const { email, role = "member" } = await req.json();
13873
14144
  const [user2] = await sql`
13874
14145
  SELECT id FROM ${sql(usersTable)} WHERE "email" = ${email} LIMIT 1
@@ -13886,6 +14157,8 @@ function buildRouter(sql, usersTable) {
13886
14157
  return Response.json({ ok: true }, { status: 201 });
13887
14158
  });
13888
14159
  r.delete("/sys/tenants/members/:userId", async (req, ctx) => {
14160
+ const err = requireAdmin(ctx);
14161
+ if (err) return err;
13889
14162
  const userId = parseInt(ctx.params.userId, 10);
13890
14163
  await sql`
13891
14164
  DELETE FROM "_tenant_members"
@@ -13894,6 +14167,8 @@ function buildRouter(sql, usersTable) {
13894
14167
  return Response.json({ ok: true });
13895
14168
  });
13896
14169
  r.post("/sys/tables", async (req, ctx) => {
14170
+ const err = requireAdmin(ctx);
14171
+ if (err) return err;
13897
14172
  const body = await req.json();
13898
14173
  const slugErr = validateSlug(body.slug);
13899
14174
  if (slugErr) return Response.json({ error: slugErr }, { status: 400 });
@@ -13932,6 +14207,8 @@ function buildRouter(sql, usersTable) {
13932
14207
  return Response.json(table);
13933
14208
  });
13934
14209
  r.patch("/sys/tables/:slug", async (req, ctx) => {
14210
+ const err = requireAdmin(ctx);
14211
+ if (err) return err;
13935
14212
  const body = await req.json();
13936
14213
  if (!body.fields || !Array.isArray(body.fields)) {
13937
14214
  return Response.json({ error: "fields array required" }, { status: 400 });
@@ -13952,6 +14229,8 @@ function buildRouter(sql, usersTable) {
13952
14229
  return Response.json({ ...table, fields: merged });
13953
14230
  });
13954
14231
  r.delete("/sys/tables/:slug", async (_req, ctx) => {
14232
+ const err = requireAdmin(ctx);
14233
+ if (err) return err;
13955
14234
  await sql.unsafe(dropTableSQL(ctx.tenant.id, ctx.params.slug));
13956
14235
  await sql`
13957
14236
  DELETE FROM "_user_tables"
@@ -24407,15 +24686,19 @@ function createWSHandler(deps) {
24407
24686
  VALUES (${channel_id}, ${am.member_id}, 'agent', ${result.output})
24408
24687
  `.then(([r]) => {
24409
24688
  broadcastToChannel(channel_id, { type: "message", data: r });
24689
+ }).catch((e) => {
24690
+ console.error("[messager] agent reply insert failed:", e);
24410
24691
  });
24411
24692
  }
24412
- }).catch(() => {
24693
+ }).catch((e) => {
24694
+ console.error("[messager] agent run failed:", e);
24413
24695
  });
24414
24696
  }
24415
24697
  }
24416
24698
  break;
24417
24699
  }
24418
24700
  case "typing": {
24701
+ if (channel_id) subscribe(ws, userId, channel_id);
24419
24702
  broadcastToChannel(channel_id, {
24420
24703
  type: "typing",
24421
24704
  channel_id,
@@ -24426,6 +24709,7 @@ function createWSHandler(deps) {
24426
24709
  }
24427
24710
  case "read": {
24428
24711
  if (!channel_id || !last_message_id) return;
24712
+ subscribe(ws, userId, channel_id);
24429
24713
  await sql`
24430
24714
  UPDATE "_channel_members"
24431
24715
  SET last_read_id = ${last_message_id}, last_read_at = NOW()
@@ -24570,9 +24854,12 @@ function buildRouter3(deps) {
24570
24854
  VALUES (${channelId}, ${am.member_id}, 'agent', ${result.output})
24571
24855
  `.then(([r2]) => {
24572
24856
  broadcastToChannel(channelId, { type: "message", data: r2 });
24857
+ }).catch((e) => {
24858
+ console.error("[messager] agent reply insert failed:", e);
24573
24859
  });
24574
24860
  }
24575
- }).catch(() => {
24861
+ }).catch((e) => {
24862
+ console.error("[messager] agent run failed:", e);
24576
24863
  });
24577
24864
  }
24578
24865
  }
package/dist/serve.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface ServeOptions {
6
6
  hostname?: string;
7
7
  signal?: AbortSignal;
8
8
  websocket?: (req: IncomingMessage, socket: Duplex, head: Buffer) => void;
9
+ maxBodySize?: number;
9
10
  }
10
11
  export interface Server {
11
12
  stop: () => void;
@@ -13,7 +14,7 @@ export interface Server {
13
14
  readonly hostname: string;
14
15
  ready: Promise<void>;
15
16
  }
16
- export declare function readBody(req: IncomingMessage): Promise<Buffer>;
17
+ export declare function readBody(req: IncomingMessage, maxSize?: number): Promise<Buffer>;
17
18
  export declare function createRequest(req: IncomingMessage, body: Buffer): [Request, Record<string, string>];
18
19
  export declare function sendResponse(res: ServerResponse, response: Response): Promise<void>;
19
20
  export declare function serve(handler: Handler, options?: ServeOptions): Server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weifuwu",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Web-standard HTTP framework for Node.js — (req, ctx) => Response",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -14,21 +14,26 @@
14
14
  "LICENSE"
15
15
  ],
16
16
  "scripts": {
17
- "build": "esbuild index.ts --bundle --format=esm --platform=node --outfile=dist/index.js --external:react --external:react-dom --external:esbuild --external:graphql --external:ws --external:zod --external:@graphql-tools/schema --external:ai --external:postgres --external:jsonwebtoken",
17
+ "build": "esbuild index.ts --bundle --format=esm --platform=node --outfile=dist/index.js --external:react --external:react-dom --external:esbuild --external:graphql --external:ws --external:zod --external:@graphql-tools/schema --external:ai --external:postgres --external:jsonwebtoken --external:chokidar --external:tailwindcss --external:@tailwindcss/* --external:postcss --external:autoprefixer --external:lightningcss",
18
18
  "prepublishOnly": "npm run build && tsc --emitDeclarationOnly --outdir dist",
19
19
  "test": "node --test 'test/**/*.test.ts'"
20
20
  },
21
21
  "dependencies": {
22
22
  "@ai-sdk/openai": "^3.0.66",
23
23
  "@graphql-tools/schema": "^10",
24
+ "@tailwindcss/postcss": "^4",
24
25
  "ai": "^6",
26
+ "autoprefixer": "^10",
27
+ "chokidar": "^5.0.0",
25
28
  "esbuild": "^0.28.0",
26
29
  "graphql": "^16",
27
30
  "ioredis": "^5.11.0",
28
31
  "jsonwebtoken": "^9.0.3",
32
+ "postcss": "^8",
29
33
  "postgres": "^3.4.9",
30
34
  "react": "^19",
31
35
  "react-dom": "^19",
36
+ "tailwindcss": "^4",
32
37
  "ws": "^8",
33
38
  "zod": "^4.4.3"
34
39
  },