weifuwu 0.15.0 → 0.16.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
@@ -28,6 +28,8 @@ Everything follows the same `(req, ctx) => Response` contract. The Router handle
28
28
  - **Data** — Redis client, job queue with cron scheduling
29
29
  - **Multi-tenant BaaS** — dynamic tables, auto REST + GraphQL, row-level isolation
30
30
  - **Deploy** — self-hosted PaaS: multi-app proxy, zero-downtime updates, auto SSL
31
+ - **Security** — `helmet()` security headers, request ID tracing, rate limiting, CORS, auth
32
+ - **SEO** — `robots.txt`, `sitemap.xml`, `X-Robots-Tag` middleware, `seoTags()` for meta / OG / Twitter Card
31
33
  - **i18n** — locale detection, JSON translations, `ctx.t()`
32
34
  - **Email** — SMTP or custom transport
33
35
  - **Health check** — configurable `/health` endpoint
@@ -106,6 +108,7 @@ All use the same pattern — `const m = module(options)` → `app.use('/path', m
106
108
  | `graphql(handler)` | GraphQL endpoint | — |
107
109
  | `logdb(options)` | Structured event logging | `log()`, `migrate()`, `clean()`, `close()` |
108
110
  | `health(options?)` | Health check | — |
111
+ | `seo(options?)` | `robots.txt`, `sitemap.xml`, indexing control | `seoMiddleware()`, `seoTags()` |
109
112
  | `iii(options?)` | Worker/Function/Trigger service paradigm | `migrate()`, `trigger()`, `addWorker()`, `listWorkers()`, `listFunctions()`, `listTriggers()`, `shutdown()` |
110
113
  | `registerWorker(url)` | Pure WebSocket SDK (browser/Node) | `registerFunction()`, `registerTrigger()`, `trigger()`, `shutdown()` |
111
114
 
@@ -121,6 +124,9 @@ All use the same pattern — `const m = module(options)` → `app.use('/path', m
121
124
  | `validate(schemas)` | Zod validation (body, query, params) |
122
125
  | `upload(options?)` | Multipart file upload |
123
126
  | `i18n(options)` | Internationalization — `ctx.t()`, locale detection |
127
+ | `seoMiddleware(options?)` | `X-Robots-Tag` header — string or path-based function |
128
+ | `helmet(options?)` | Security headers — CSP, HSTS, X-Frame-Options, etc. |
129
+ | `requestId(options?)` | `X-Request-ID` header + `ctx.requestId` |
124
130
 
125
131
  ## Utility functions
126
132
 
@@ -131,6 +137,10 @@ All use the same pattern — `const m = module(options)` → `app.use('/path', m
131
137
  | `getCookies(req)` / `setCookie(res, ...)` / `deleteCookie(res, ...)` | Cookie helpers |
132
138
  | `mailer(options)` | Email sender (SMTP or custom) |
133
139
  | `createTestServer(handler)` | Start test server → `{ server, url }` |
140
+ | `seoTags(config)` | Generate `<title>`, `<meta>`, Open Graph, Twitter Card, canonical tags |
141
+ | `createSSEStream(iterable, opts?)` | SSE response from `AsyncIterable` |
142
+ | `formatSSE(event, data)` | Format SSE event string |
143
+ | `formatSSEData(data)` | Format SSE data string |
134
144
  | `runWorkflow(options)` | DAG execution engine as AI SDK `Tool` |
135
145
  | `pgTable(name, columns)` | Type-safe table schema builder |
136
146
  | `pg.table(name, columns)` | Pre-bound table (no `sql` param needed) |
@@ -1485,6 +1495,176 @@ const oc = await opencode({
1485
1495
 
1486
1496
  ---
1487
1497
 
1498
+ # SEO
1499
+
1500
+ Built-in SEO module — `robots.txt`, `sitemap.xml`, indexing headers, and meta tag utilities.
1501
+
1502
+ ```ts
1503
+ import { seo, seoMiddleware, seoTags } from 'weifuwu'
1504
+
1505
+ const app = new Router()
1506
+
1507
+ // robots.txt + sitemap.xml
1508
+ app.use(seo({
1509
+ baseUrl: 'https://example.com',
1510
+ robots: [
1511
+ { userAgent: '*', allow: '/', disallow: ['/admin', '/api'] },
1512
+ ],
1513
+ sitemap: {
1514
+ urls: [
1515
+ { loc: '/', changefreq: 'daily', priority: 1.0 },
1516
+ { loc: '/about', changefreq: 'monthly', priority: 0.8 },
1517
+ ],
1518
+ // Dynamic URLs from database
1519
+ async resolve() {
1520
+ const articles = await db.query('SELECT slug, updated_at FROM articles')
1521
+ return articles.map(a => ({
1522
+ loc: `/blog/${a.slug}`,
1523
+ lastmod: a.updated_at,
1524
+ }))
1525
+ },
1526
+ cacheTTL: 3_600_000, // re-generate every hour (default)
1527
+ },
1528
+ }))
1529
+ ```
1530
+
1531
+ ### Endpoints
1532
+
1533
+ | Path | Description |
1534
+ |------|-------------|
1535
+ | `GET /robots.txt` | Generated robots.txt with optional Sitemap reference |
1536
+ | `GET /sitemap.xml` | Generated XML sitemap with caching |
1537
+
1538
+ ### seoMiddleware — Indexing control
1539
+
1540
+ ```ts
1541
+ // Global — block all paths
1542
+ app.use(seoMiddleware({ headers: { 'X-Robots-Tag': 'noindex' } }))
1543
+
1544
+ // Per-path via function
1545
+ app.use(seoMiddleware({
1546
+ headers: {
1547
+ 'X-Robots-Tag': (path) => path.startsWith('/admin') ? 'noindex' : undefined,
1548
+ },
1549
+ }))
1550
+ ```
1551
+
1552
+ ### seoTags — Meta / OG / Twitter Card
1553
+
1554
+ Generate SEO meta tags for SSR pages:
1555
+
1556
+ ```ts
1557
+ const tags = seoTags({
1558
+ title: 'My Page',
1559
+ description: 'A great page about things',
1560
+ ogImage: 'https://example.com/og.png',
1561
+ twitterCard: 'summary_large_image',
1562
+ canonical: 'https://example.com/page',
1563
+ })
1564
+ // → <title>My Page</title>
1565
+ // → <meta property="og:title" content="My Page">
1566
+ // → <meta name="twitter:card" content="summary_large_image">
1567
+ // → <link rel="canonical" href="https://example.com/page">
1568
+ // ...
1569
+ ```
1570
+
1571
+ Use in `layout.tsx` or `page.tsx` with `tsx()`:
1572
+
1573
+ ```tsx
1574
+ export default function RootLayout({ children }) {
1575
+ return (
1576
+ <html>
1577
+ <head>{seoTags({ title: 'My App' })}</head>
1578
+ <body>{children}</body>
1579
+ </html>
1580
+ )
1581
+ }
1582
+ ```
1583
+
1584
+ # Security
1585
+
1586
+ ## Helmet — Security headers
1587
+
1588
+ ```ts
1589
+ import { helmet } from 'weifuwu'
1590
+
1591
+ // Apply all security headers with safe defaults
1592
+ app.use(helmet())
1593
+
1594
+ // Customize individual headers (any can be set to false to remove)
1595
+ app.use(helmet({
1596
+ contentSecurityPolicy: "default-src 'self'",
1597
+ xFrameOptions: 'DENY',
1598
+ strictTransportSecurity: 'max-age=63072000; includeSubDomains; preload',
1599
+ }))
1600
+
1601
+ // Middleware-order: set after helmet to override
1602
+ app.use(helmet({ xFrameOptions: false })) // remove a header
1603
+ ```
1604
+
1605
+ 13 security headers set by default:
1606
+
1607
+ | Header | Default |
1608
+ |--------|---------|
1609
+ | `Content-Security-Policy` | `default-src 'self'; ...` |
1610
+ | `X-Content-Type-Options` | `nosniff` |
1611
+ | `X-Frame-Options` | `SAMEORIGIN` |
1612
+ | `Strict-Transport-Security` | `max-age=15552000; includeSubDomains` |
1613
+ | `X-XSS-Protection` | `0` |
1614
+ | `Referrer-Policy` | `no-referrer` |
1615
+ | `Permissions-Policy` | `camera=(),geolocation=(),...` |
1616
+ | `Cross-Origin-Embedder-Policy` | `require-corp` |
1617
+ | `Cross-Origin-Opener-Policy` | `same-origin` |
1618
+ | `Cross-Origin-Resource-Policy` | `same-origin` |
1619
+ | `Origin-Agent-Cluster` | `?1` |
1620
+ | `X-DNS-Prefetch-Control` | `off` |
1621
+ | `X-Download-Options` | `noopen` |
1622
+ | `X-Permitted-Cross-Domain-Policies` | `none` |
1623
+
1624
+ Does not override response headers already set by the application — your explicit headers take precedence.
1625
+
1626
+ ## Request ID
1627
+
1628
+ ```ts
1629
+ import { requestId } from 'weifuwu'
1630
+
1631
+ // Every response gets X-Request-ID
1632
+ app.use(requestId())
1633
+
1634
+ // Custom header name
1635
+ app.use(requestId({ header: 'X-Trace-Id' }))
1636
+
1637
+ // Custom ID generator
1638
+ app.use(requestId({ generator: () => crypto.randomUUID() }))
1639
+
1640
+ // Access the ID in handlers via ctx.requestId
1641
+ app.get('/log', (req, ctx) => {
1642
+ console.log(`Handling request ${ctx.requestId}`)
1643
+ return Response.json({ id: ctx.requestId })
1644
+ })
1645
+ ```
1646
+
1647
+ Preserves incoming `X-Request-ID` for distributed tracing — if the upstream service already set it, the value is reused and propagated.
1648
+
1649
+ ## Server-Sent Events
1650
+
1651
+ ```ts
1652
+ import { createSSEStream, formatSSE } from 'weifuwu'
1653
+
1654
+ async function* eventStream() {
1655
+ yield { type: 'ping', data: { time: Date.now() } }
1656
+ yield { type: 'message', data: { text: 'hello' } }
1657
+ }
1658
+
1659
+ app.get('/events', () => createSSEStream(eventStream()))
1660
+ ```
1661
+
1662
+ | Function | Description |
1663
+ |----------|-------------|
1664
+ | `createSSEStream(iterable, opts?)` | Returns a `Response` with `Content-Type: text/event-stream` |
1665
+ | `formatSSE(event, data)` | Formats an SSE event string (`event: ...\ndata: ...\n\n`) |
1666
+ | `formatSSEData(data)` | Formats SSE data-only string (`data: ...\n\n`) |
1667
+
1488
1668
  # Health, i18n, Email & Testing
1489
1669
 
1490
1670
  ## Health check
@@ -0,0 +1,18 @@
1
+ import type { Middleware } from './types.ts';
2
+ export interface HelmetOptions {
3
+ contentSecurityPolicy?: string | false;
4
+ crossOriginEmbedderPolicy?: string | false;
5
+ crossOriginOpenerPolicy?: string | false;
6
+ crossOriginResourcePolicy?: string | false;
7
+ originAgentCluster?: string | false;
8
+ referrerPolicy?: string | false;
9
+ strictTransportSecurity?: string | false;
10
+ xContentTypeOptions?: string | false;
11
+ xDnsPrefetchControl?: string | false;
12
+ xDownloadOptions?: string | false;
13
+ xFrameOptions?: string | false;
14
+ xPermittedCrossDomainPolicies?: string | false;
15
+ xXssProtection?: string | false;
16
+ permissionsPolicy?: string | false;
17
+ }
18
+ export declare function helmet(options?: HelmetOptions): Middleware;
@@ -4,6 +4,7 @@ import type { StreamUpdateOp, StreamSubscription } from './types.ts';
4
4
  export declare function createStream(opts?: {
5
5
  pg?: PostgresClient;
6
6
  redis?: Redis;
7
+ streamTTL?: number;
7
8
  }): {
8
9
  subscribe(ws: WebSocket, sub: StreamSubscription): void;
9
10
  unsubscribe(ws: WebSocket): void;
@@ -17,6 +17,8 @@ export interface TriggerInput {
17
17
  export interface IIIOptions {
18
18
  pg?: PostgresClient;
19
19
  redis?: Redis;
20
+ /** TTL in seconds for Redis stream keys. Default: 3600 (1 hour). Set to 0 for no expiration. */
21
+ streamTTL?: number;
20
22
  }
21
23
  export interface TriggerRequest {
22
24
  function_id: string;
package/dist/index.d.ts CHANGED
@@ -20,6 +20,12 @@ export { rateLimit } from './rate-limit.ts';
20
20
  export type { RateLimitOptions } from './rate-limit.ts';
21
21
  export { compress } from './compress.ts';
22
22
  export type { CompressOptions } from './compress.ts';
23
+ export { helmet } from './helmet.ts';
24
+ export type { HelmetOptions } from './helmet.ts';
25
+ export { requestId } from './request-id.ts';
26
+ export type { RequestIdOptions } from './request-id.ts';
27
+ export { createSSEStream, formatSSE, formatSSEData } from './sse.ts';
28
+ export type { SSEEvent } from './sse.ts';
23
29
  export { graphql } from './graphql.ts';
24
30
  export type { GraphQLOptions, GraphQLHandler } from './graphql.ts';
25
31
  export { aiStream } from './ai.ts';
@@ -47,6 +53,8 @@ export { health } from './health.ts';
47
53
  export type { HealthOptions } from './health.ts';
48
54
  export { i18n } from './i18n.ts';
49
55
  export type { I18nOptions } from './i18n.ts';
56
+ export { seo, seoMiddleware, seoTags } from './seo.ts';
57
+ export type { SeoOptions, RobotsRule, SitemapUrl, SitemapConfig, SeoHeadersConfig, SeoTagsConfig } from './seo.ts';
50
58
  export { mailer } from './mailer.ts';
51
59
  export type { MailerOptions, MailOptions, Mailer } from './mailer.ts';
52
60
  export { logdb } from './logdb/index.ts';
package/dist/index.js CHANGED
@@ -1859,6 +1859,118 @@ function compress(options) {
1859
1859
  };
1860
1860
  }
1861
1861
 
1862
+ // helmet.ts
1863
+ var DEFAULTS = {
1864
+ contentSecurityPolicy: "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests",
1865
+ crossOriginEmbedderPolicy: "require-corp",
1866
+ crossOriginOpenerPolicy: "same-origin",
1867
+ crossOriginResourcePolicy: "same-origin",
1868
+ originAgentCluster: "?1",
1869
+ referrerPolicy: "no-referrer",
1870
+ strictTransportSecurity: "max-age=15552000; includeSubDomains",
1871
+ xContentTypeOptions: "nosniff",
1872
+ xDnsPrefetchControl: "off",
1873
+ xDownloadOptions: "noopen",
1874
+ xFrameOptions: "SAMEORIGIN",
1875
+ xPermittedCrossDomainPolicies: "none",
1876
+ xXssProtection: "0",
1877
+ permissionsPolicy: "camera=(),display-capture=(),fullscreen=(),geolocation=(),microphone=()"
1878
+ };
1879
+ var HEADER_MAP = {
1880
+ "Content-Security-Policy": "contentSecurityPolicy",
1881
+ "Cross-Origin-Embedder-Policy": "crossOriginEmbedderPolicy",
1882
+ "Cross-Origin-Opener-Policy": "crossOriginOpenerPolicy",
1883
+ "Cross-Origin-Resource-Policy": "crossOriginResourcePolicy",
1884
+ "Origin-Agent-Cluster": "originAgentCluster",
1885
+ "Referrer-Policy": "referrerPolicy",
1886
+ "Strict-Transport-Security": "strictTransportSecurity",
1887
+ "X-Content-Type-Options": "xContentTypeOptions",
1888
+ "X-DNS-Prefetch-Control": "xDnsPrefetchControl",
1889
+ "X-Download-Options": "xDownloadOptions",
1890
+ "X-Frame-Options": "xFrameOptions",
1891
+ "X-Permitted-Cross-Domain-Policies": "xPermittedCrossDomainPolicies",
1892
+ "X-XSS-Protection": "xXssProtection",
1893
+ "Permissions-Policy": "permissionsPolicy"
1894
+ };
1895
+ function helmet(options) {
1896
+ const opts = { ...DEFAULTS, ...options };
1897
+ const headers = new Headers();
1898
+ for (const [header, key] of Object.entries(HEADER_MAP)) {
1899
+ const val = opts[key];
1900
+ if (val !== false && val !== void 0) headers.set(header, val);
1901
+ }
1902
+ return async (req, ctx, next) => {
1903
+ const res = await next(req, ctx);
1904
+ const h = new Headers(res.headers);
1905
+ for (const [k, v] of headers) {
1906
+ if (!h.has(k)) h.set(k, v);
1907
+ }
1908
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
1909
+ };
1910
+ }
1911
+
1912
+ // request-id.ts
1913
+ import crypto from "node:crypto";
1914
+ function requestId(options) {
1915
+ const header = options?.header ?? "X-Request-ID";
1916
+ const gen = options?.generator ?? (() => crypto.randomUUID());
1917
+ return async (req, ctx, next) => {
1918
+ const existing = req.headers.get(header);
1919
+ const id2 = existing ?? gen();
1920
+ ctx.requestId = id2;
1921
+ const res = await next(req, ctx);
1922
+ if (res.headers.has(header)) return res;
1923
+ const h = new Headers(res.headers);
1924
+ h.set(header, id2);
1925
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
1926
+ };
1927
+ }
1928
+
1929
+ // sse.ts
1930
+ var encoder = new TextEncoder();
1931
+ function formatSSE(event, data) {
1932
+ return `event: ${event}
1933
+ data: ${JSON.stringify(data)}
1934
+
1935
+ `;
1936
+ }
1937
+ function formatSSEData(data) {
1938
+ return `data: ${JSON.stringify(data)}
1939
+
1940
+ `;
1941
+ }
1942
+ function createSSEStream(iterable, opts) {
1943
+ return new Response(
1944
+ new ReadableStream({
1945
+ async start(controller) {
1946
+ try {
1947
+ for await (const event of iterable) {
1948
+ const text2 = event.type ? formatSSE(event.type, event) : formatSSEData(event);
1949
+ controller.enqueue(encoder.encode(text2));
1950
+ }
1951
+ } catch (e) {
1952
+ if (e.name !== "AbortError") {
1953
+ controller.enqueue(
1954
+ encoder.encode(formatSSE("error", { error: e.message }))
1955
+ );
1956
+ }
1957
+ } finally {
1958
+ controller.close();
1959
+ }
1960
+ }
1961
+ }),
1962
+ {
1963
+ status: opts?.status ?? 200,
1964
+ headers: {
1965
+ "Content-Type": "text/event-stream",
1966
+ "Cache-Control": "no-cache",
1967
+ Connection: "keep-alive",
1968
+ ...opts?.headers
1969
+ }
1970
+ }
1971
+ );
1972
+ }
1973
+
1862
1974
  // graphql.ts
1863
1975
  import { buildSchema, graphql as executeGraphQL } from "graphql";
1864
1976
  import { makeExecutableSchema } from "@graphql-tools/schema";
@@ -2821,7 +2933,7 @@ import jwt2 from "jsonwebtoken";
2821
2933
  import { z as z2 } from "zod";
2822
2934
 
2823
2935
  // user/oauth2.ts
2824
- import crypto from "node:crypto";
2936
+ import crypto2 from "node:crypto";
2825
2937
  import jwt from "jsonwebtoken";
2826
2938
  function createOAuth2Server(deps) {
2827
2939
  const { pg, users, jwtSecret, expiresIn } = deps;
@@ -2840,8 +2952,8 @@ function createOAuth2Server(deps) {
2840
2952
  };
2841
2953
  }
2842
2954
  async function registerClient(data) {
2843
- const clientId = crypto.randomUUID();
2844
- const clientSecret = crypto.randomBytes(32).toString("hex");
2955
+ const clientId = crypto2.randomUUID();
2956
+ const clientSecret = crypto2.randomBytes(32).toString("hex");
2845
2957
  const [row] = await pg.sql`
2846
2958
  INSERT INTO "_oauth2_clients" ("name", "client_id", "client_secret", "redirect_uris")
2847
2959
  VALUES (${data.name}, ${clientId}, ${clientSecret}, ${pg.sql.array(data.redirectUris)})
@@ -2989,7 +3101,7 @@ h2{color:#dc2626}.desc{color:#555}</style>
2989
3101
  const loc2 = `${redirectUri}?error=access_denied${state ? `&state=${state}` : ""}`;
2990
3102
  return Response.redirect(loc2, 302);
2991
3103
  }
2992
- const code = crypto.randomUUID();
3104
+ const code = crypto2.randomUUID();
2993
3105
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
2994
3106
  await pg.sql`
2995
3107
  INSERT INTO "_oauth2_codes" ("code", "client_id", "user_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "expires_at")
@@ -3054,7 +3166,7 @@ h2{color:#dc2626}.desc{color:#555}</style>
3054
3166
  if (stored.code_challenge_method === "plain") {
3055
3167
  expected = codeVerifier;
3056
3168
  } else {
3057
- expected = crypto.createHash("sha256").update(codeVerifier).digest().toString("base64url");
3169
+ expected = crypto2.createHash("sha256").update(codeVerifier).digest().toString("base64url");
3058
3170
  }
3059
3171
  if (expected !== stored.code_challenge) {
3060
3172
  return Response.json({ error: "invalid_grant", error_description: "code_verifier mismatch" }, { status: 400 });
@@ -3071,7 +3183,7 @@ h2{color:#dc2626}.desc{color:#555}</style>
3071
3183
  jwtSecret,
3072
3184
  { expiresIn }
3073
3185
  );
3074
- const refreshToken = crypto.randomUUID();
3186
+ const refreshToken = crypto2.randomUUID();
3075
3187
  const refreshExpires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3);
3076
3188
  await pg.sql`
3077
3189
  INSERT INTO "_oauth2_tokens" ("token", "client_id", "user_id", "scope", "expires_at")
@@ -3339,7 +3451,7 @@ function redis(opts) {
3339
3451
 
3340
3452
  // queue/index.ts
3341
3453
  import { Redis as IORedis2 } from "ioredis";
3342
- import crypto2 from "node:crypto";
3454
+ import crypto3 from "node:crypto";
3343
3455
  function cronNext(expr, from = /* @__PURE__ */ new Date()) {
3344
3456
  const parts = expr.trim().split(/\s+/);
3345
3457
  if (parts.length !== 5) throw new Error(`Invalid cron expression "${expr}": expected 5 fields`);
@@ -3430,7 +3542,7 @@ function queue(opts) {
3430
3542
  if (job.schedule) {
3431
3543
  try {
3432
3544
  const nextRun = cronNext(job.schedule);
3433
- const nextJob = { ...job, id: crypto2.randomUUID(), runAt: nextRun, createdAt: Date.now() };
3545
+ const nextJob = { ...job, id: crypto3.randomUUID(), runAt: nextRun, createdAt: Date.now() };
3434
3546
  redis2.zadd(jobKey, nextRun, JSON.stringify(nextJob)).catch(() => {
3435
3547
  });
3436
3548
  } catch {
@@ -3448,7 +3560,7 @@ function queue(opts) {
3448
3560
  }
3449
3561
  }
3450
3562
  mw.add = function add(type, payload, opts2) {
3451
- const id2 = crypto2.randomUUID();
3563
+ const id2 = crypto3.randomUUID();
3452
3564
  let runAt;
3453
3565
  if (opts2?.schedule) {
3454
3566
  runAt = cronNext(opts2.schedule);
@@ -4495,53 +4607,6 @@ function buildRouter2(deps) {
4495
4607
  // agent/run.ts
4496
4608
  import { streamText, generateText as generateText2, embed } from "ai";
4497
4609
  import { z as z4 } from "zod";
4498
-
4499
- // sse.ts
4500
- var encoder = new TextEncoder();
4501
- function formatSSE(event, data) {
4502
- return `event: ${event}
4503
- data: ${JSON.stringify(data)}
4504
-
4505
- `;
4506
- }
4507
- function formatSSEData(data) {
4508
- return `data: ${JSON.stringify(data)}
4509
-
4510
- `;
4511
- }
4512
- function createSSEStream(iterable, opts) {
4513
- return new Response(
4514
- new ReadableStream({
4515
- async start(controller) {
4516
- try {
4517
- for await (const event of iterable) {
4518
- const text2 = event.type ? formatSSE(event.type, event) : formatSSEData(event);
4519
- controller.enqueue(encoder.encode(text2));
4520
- }
4521
- } catch (e) {
4522
- if (e.name !== "AbortError") {
4523
- controller.enqueue(
4524
- encoder.encode(formatSSE("error", { error: e.message }))
4525
- );
4526
- }
4527
- } finally {
4528
- controller.close();
4529
- }
4530
- }
4531
- }),
4532
- {
4533
- status: opts?.status ?? 200,
4534
- headers: {
4535
- "Content-Type": "text/event-stream",
4536
- "Cache-Control": "no-cache",
4537
- Connection: "keep-alive",
4538
- ...opts?.headers
4539
- }
4540
- }
4541
- );
4542
- }
4543
-
4544
- // agent/run.ts
4545
4610
  function hasKnowledgeDocs(sql2, agentId) {
4546
4611
  return sql2`SELECT 1 FROM "_knowledge_documents" WHERE agent_id = ${agentId} LIMIT 1`.then((r) => r.length > 0);
4547
4612
  }
@@ -5161,7 +5226,7 @@ function createGateway(config, getPort) {
5161
5226
  }
5162
5227
 
5163
5228
  // deploy/manager.ts
5164
- import crypto3 from "node:crypto";
5229
+ import crypto4 from "node:crypto";
5165
5230
  function createManager(config, apps, manager) {
5166
5231
  const router = new Router();
5167
5232
  const auth2 = (req, ctx, next) => {
@@ -5170,7 +5235,7 @@ function createManager(config, apps, manager) {
5170
5235
  const token = header.replace("Bearer ", "");
5171
5236
  const tokenBuf = Buffer.from(token);
5172
5237
  const secretBuf = Buffer.from(config.deployToken);
5173
- if (tokenBuf.length !== secretBuf.length || !crypto3.timingSafeEqual(tokenBuf, secretBuf)) {
5238
+ if (tokenBuf.length !== secretBuf.length || !crypto4.timingSafeEqual(tokenBuf, secretBuf)) {
5174
5239
  return Response.json({ error: "Unauthorized" }, { status: 401 });
5175
5240
  }
5176
5241
  return next(req, ctx);
@@ -5269,10 +5334,10 @@ function createManager(config, apps, manager) {
5269
5334
  const rawBody = await req.text();
5270
5335
  if (config.webhookSecret) {
5271
5336
  const sig = req.headers.get("x-hub-signature-256") ?? "";
5272
- const expected = `sha256=${crypto3.createHmac("sha256", config.webhookSecret).update(rawBody).digest("hex")}`;
5337
+ const expected = `sha256=${crypto4.createHmac("sha256", config.webhookSecret).update(rawBody).digest("hex")}`;
5273
5338
  const sigBuf = Buffer.from(sig);
5274
5339
  const expectedBuf = Buffer.from(expected);
5275
- if (sigBuf.length !== expectedBuf.length || !crypto3.timingSafeEqual(sigBuf, expectedBuf)) {
5340
+ if (sigBuf.length !== expectedBuf.length || !crypto4.timingSafeEqual(sigBuf, expectedBuf)) {
5276
5341
  return Response.json({ error: "invalid signature" }, { status: 401 });
5277
5342
  }
5278
5343
  }
@@ -6588,6 +6653,155 @@ function extractCookie(req, name) {
6588
6653
  return null;
6589
6654
  }
6590
6655
 
6656
+ // seo.ts
6657
+ function escapeXml(s) {
6658
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
6659
+ }
6660
+ function buildRobotsTxt(rules, sitemapUrl) {
6661
+ const lines = [];
6662
+ for (const rule of rules) {
6663
+ lines.push(`User-agent: ${rule.userAgent ?? "*"}`);
6664
+ if (rule.allow) {
6665
+ for (const a of Array.isArray(rule.allow) ? rule.allow : [rule.allow]) {
6666
+ lines.push(`Allow: ${a}`);
6667
+ }
6668
+ }
6669
+ if (rule.disallow) {
6670
+ for (const d of Array.isArray(rule.disallow) ? rule.disallow : [rule.disallow]) {
6671
+ lines.push(`Disallow: ${d}`);
6672
+ }
6673
+ }
6674
+ }
6675
+ if (sitemapUrl) {
6676
+ lines.push(`Sitemap: ${sitemapUrl}`);
6677
+ }
6678
+ lines.push("");
6679
+ return lines.join("\n");
6680
+ }
6681
+ function buildSitemapXml(urls, baseUrl) {
6682
+ if (urls.length === 0) {
6683
+ return `<?xml version="1.0" encoding="UTF-8"?>
6684
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
6685
+ </urlset>
6686
+ `;
6687
+ }
6688
+ let xml = `<?xml version="1.0" encoding="UTF-8"?>
6689
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
6690
+ `;
6691
+ for (const url of urls) {
6692
+ let loc = url.loc;
6693
+ if (baseUrl && loc.startsWith("/")) {
6694
+ loc = baseUrl.replace(/\/+$/, "") + loc;
6695
+ }
6696
+ xml += ` <url>
6697
+ <loc>${escapeXml(loc)}</loc>`;
6698
+ if (url.lastmod) {
6699
+ xml += `
6700
+ <lastmod>${escapeXml(url.lastmod)}</lastmod>`;
6701
+ }
6702
+ if (url.changefreq) {
6703
+ xml += `
6704
+ <changefreq>${escapeXml(url.changefreq)}</changefreq>`;
6705
+ }
6706
+ if (url.priority !== void 0) {
6707
+ xml += `
6708
+ <priority>${url.priority.toFixed(1)}</priority>`;
6709
+ }
6710
+ xml += `
6711
+ </url>
6712
+ `;
6713
+ }
6714
+ xml += `</urlset>
6715
+ `;
6716
+ return xml;
6717
+ }
6718
+ function getRobotsHeader(headers, path2) {
6719
+ if (!headers?.["X-Robots-Tag"]) return void 0;
6720
+ const val = headers["X-Robots-Tag"];
6721
+ if (typeof val === "function") return val(path2);
6722
+ return val;
6723
+ }
6724
+ function seoMiddleware(options) {
6725
+ const headers = options?.headers;
6726
+ return async (req, ctx, next) => {
6727
+ const res = await next(req, ctx);
6728
+ if (!headers) return res;
6729
+ const url = new URL(req.url);
6730
+ const robotTag = getRobotsHeader(headers, url.pathname);
6731
+ if (robotTag) {
6732
+ const h = new Headers(res.headers);
6733
+ h.set("X-Robots-Tag", robotTag);
6734
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
6735
+ }
6736
+ return res;
6737
+ };
6738
+ }
6739
+ function seo(options) {
6740
+ const { robots, sitemap: sitemapConfig, baseUrl } = options ?? {};
6741
+ const r = new Router();
6742
+ const robotsHandler = () => {
6743
+ const sitemapUrl = sitemapConfig ? `${baseUrl ?? ""}/sitemap.xml` : void 0;
6744
+ const body = buildRobotsTxt(robots ?? [{ userAgent: "*", allow: "/" }], sitemapUrl);
6745
+ return new Response(body, {
6746
+ headers: { "Content-Type": "text/plain; charset=utf-8" }
6747
+ });
6748
+ };
6749
+ let cached = null;
6750
+ let cacheTime = 0;
6751
+ const cacheTTL = sitemapConfig?.cacheTTL ?? 36e5;
6752
+ const sitemapHandler = async () => {
6753
+ if (cached && Date.now() - cacheTime < cacheTTL) {
6754
+ return new Response(cached, {
6755
+ headers: { "Content-Type": "application/xml; charset=utf-8" }
6756
+ });
6757
+ }
6758
+ const urls = [...sitemapConfig?.urls ?? []];
6759
+ if (sitemapConfig?.resolve) {
6760
+ const dynamic = await sitemapConfig.resolve();
6761
+ urls.push(...dynamic);
6762
+ }
6763
+ const xml = buildSitemapXml(urls, baseUrl);
6764
+ cached = xml;
6765
+ cacheTime = Date.now();
6766
+ return new Response(xml, {
6767
+ headers: { "Content-Type": "application/xml; charset=utf-8" }
6768
+ });
6769
+ };
6770
+ r.get("/robots.txt", robotsHandler);
6771
+ r.get("/sitemap.xml", sitemapHandler);
6772
+ return r;
6773
+ }
6774
+ function seoTags(config) {
6775
+ const tags = [];
6776
+ if (config.title) {
6777
+ tags.push(`<title>${escapeXml(config.title)}</title>`);
6778
+ tags.push(`<meta property="og:title" content="${escapeXml(config.title)}">`);
6779
+ tags.push(`<meta name="twitter:title" content="${escapeXml(config.title)}">`);
6780
+ }
6781
+ if (config.description) {
6782
+ tags.push(`<meta name="description" content="${escapeXml(config.description)}">`);
6783
+ tags.push(`<meta property="og:description" content="${escapeXml(config.description)}">`);
6784
+ tags.push(`<meta name="twitter:description" content="${escapeXml(config.description)}">`);
6785
+ }
6786
+ if (config.ogTitle) {
6787
+ tags.push(`<meta property="og:title" content="${escapeXml(config.ogTitle)}">`);
6788
+ }
6789
+ if (config.ogDescription) {
6790
+ tags.push(`<meta property="og:description" content="${escapeXml(config.ogDescription)}">`);
6791
+ }
6792
+ if (config.ogImage) {
6793
+ tags.push(`<meta property="og:image" content="${escapeXml(config.ogImage)}">`);
6794
+ tags.push(`<meta name="twitter:image" content="${escapeXml(config.ogImage)}">`);
6795
+ }
6796
+ if (config.twitterCard) {
6797
+ tags.push(`<meta name="twitter:card" content="${escapeXml(config.twitterCard)}">`);
6798
+ }
6799
+ if (config.canonical) {
6800
+ tags.push(`<link rel="canonical" href="${escapeXml(config.canonical)}">`);
6801
+ }
6802
+ return tags.join("\n");
6803
+ }
6804
+
6591
6805
  // mailer.ts
6592
6806
  import { createTransport } from "nodemailer";
6593
6807
  function mailer(options) {
@@ -6780,7 +6994,7 @@ function logdb(options) {
6780
6994
  }
6781
6995
 
6782
6996
  // iii/client.ts
6783
- import crypto4 from "node:crypto";
6997
+ import crypto5 from "node:crypto";
6784
6998
 
6785
6999
  // iii/stream.ts
6786
7000
  var channels2 = /* @__PURE__ */ new Map();
@@ -6998,16 +7212,20 @@ function createPgStore(pg) {
6998
7212
  }
6999
7213
  };
7000
7214
  }
7001
- function createRedisStore(redis2) {
7215
+ function createRedisStore(redis2, ttl) {
7002
7216
  function hashKey(stream, group) {
7003
7217
  return `iii:stream:${stream}:${group}`;
7004
7218
  }
7219
+ function setTTL(hk) {
7220
+ if (ttl) redis2.expire(hk, ttl);
7221
+ }
7005
7222
  return {
7006
7223
  async set(stream, group, item, data) {
7007
7224
  const hk = hashKey(stream, group);
7008
7225
  const oldRaw = await redis2.hget(hk, item);
7009
7226
  let old = oldRaw ? JSON.parse(oldRaw) : null;
7010
7227
  await redis2.hset(hk, item, JSON.stringify(data));
7228
+ setTTL(hk);
7011
7229
  await redis2.publish(`iii:stream:${stream}`, JSON.stringify({ event: "set", group, item, data }));
7012
7230
  notify(stream, group, item, "set", data);
7013
7231
  return { old_value: old, new_value: deepClone(data) };
@@ -7021,6 +7239,8 @@ function createRedisStore(redis2) {
7021
7239
  const oldRaw = await redis2.hget(hk, item);
7022
7240
  const old = oldRaw ? JSON.parse(oldRaw) : null;
7023
7241
  await redis2.hdel(hk, item);
7242
+ const remaining = await redis2.hlen(hk);
7243
+ if (remaining === 0) await redis2.del(hk);
7024
7244
  await redis2.publish(`iii:stream:${stream}`, JSON.stringify({ event: "delete", group, item }));
7025
7245
  notify(stream, group, item, "delete", null);
7026
7246
  return { old_value: old };
@@ -7081,6 +7301,7 @@ function createRedisStore(redis2) {
7081
7301
  const old = oldRaw ? JSON.parse(oldRaw) : null;
7082
7302
  const newVal = applyOps(old, ops);
7083
7303
  await redis2.hset(hk, item, JSON.stringify(newVal));
7304
+ setTTL(hk);
7084
7305
  await redis2.publish(`iii:stream:${stream}`, JSON.stringify({ event: "update", group, item, data: newVal }));
7085
7306
  notify(stream, group, item, "update", newVal);
7086
7307
  return { old_value: old, new_value: deepClone(newVal) };
@@ -7088,7 +7309,7 @@ function createRedisStore(redis2) {
7088
7309
  };
7089
7310
  }
7090
7311
  function createStream(opts) {
7091
- const store = opts?.pg ? createPgStore(opts.pg) : opts?.redis ? createRedisStore(opts.redis) : createMemoryStore();
7312
+ const store = opts?.pg ? createPgStore(opts.pg) : opts?.redis ? createRedisStore(opts.redis, opts.streamTTL ?? 3600) : createMemoryStore();
7092
7313
  let redisSub = null;
7093
7314
  if (opts?.redis) {
7094
7315
  redisSub = opts.redis.duplicate();
@@ -7272,7 +7493,7 @@ function buildRouter5(engine, wsHandler) {
7272
7493
 
7273
7494
  // iii/client.ts
7274
7495
  function iii(opts = {}) {
7275
- const stream = createStream({ pg: opts.pg, redis: opts.redis });
7496
+ const stream = createStream({ pg: opts.pg, redis: opts.redis, streamTTL: opts.streamTTL });
7276
7497
  const workers = /* @__PURE__ */ new Map();
7277
7498
  const functions = /* @__PURE__ */ new Map();
7278
7499
  const triggers = /* @__PURE__ */ new Map();
@@ -7295,7 +7516,7 @@ function iii(opts = {}) {
7295
7516
  registerBuiltin("stream::send", (p) => stream.send(p.stream_name, p.group_id, p.type, p.data, p.id));
7296
7517
  registerBuiltin("stream::update", (p) => stream.update(p.stream_name, p.group_id, p.item_id, p.ops));
7297
7518
  function addLocalWorker(worker) {
7298
- const workerId = crypto4.randomUUID();
7519
+ const workerId = crypto5.randomUUID();
7299
7520
  const reg = {
7300
7521
  id: workerId,
7301
7522
  name: worker.name,
@@ -7310,7 +7531,7 @@ function iii(opts = {}) {
7310
7531
  const triggerIds = [];
7311
7532
  for (const t of worker.getTriggers()) {
7312
7533
  if (t.input.function_id === fn.id) {
7313
- const tid = crypto4.randomUUID();
7534
+ const tid = crypto5.randomUUID();
7314
7535
  triggers.set(tid, {
7315
7536
  id: tid,
7316
7537
  type: t.input.type,
@@ -7339,7 +7560,7 @@ function iii(opts = {}) {
7339
7560
  if (!worker) return;
7340
7561
  const handler = async (payload) => {
7341
7562
  if (!worker.ws) throw new Error(`Worker "${worker.name}" disconnected`);
7342
- const invocationId = crypto4.randomUUID();
7563
+ const invocationId = crypto5.randomUUID();
7343
7564
  return new Promise((resolve11, reject) => {
7344
7565
  const timer = setTimeout(() => {
7345
7566
  pending.delete(invocationId);
@@ -7373,7 +7594,7 @@ function iii(opts = {}) {
7373
7594
  }
7374
7595
  const wsHandler = createWsHandler({
7375
7596
  registerRemoteWorker(ws, name) {
7376
- const id2 = crypto4.randomUUID();
7597
+ const id2 = crypto5.randomUUID();
7377
7598
  workers.set(id2, { id: id2, name, ws, functions: [], triggers: [] });
7378
7599
  return id2;
7379
7600
  },
@@ -7384,7 +7605,7 @@ function iii(opts = {}) {
7384
7605
  addRemoteFunction(workerId, id2);
7385
7606
  },
7386
7607
  registerRemoteTrigger(workerId, input) {
7387
- const tid = crypto4.randomUUID();
7608
+ const tid = crypto5.randomUUID();
7388
7609
  const reg = { id: tid, ...input, workerId };
7389
7610
  triggers.set(tid, reg);
7390
7611
  const worker = workers.get(workerId);
@@ -7740,13 +7961,17 @@ export {
7740
7961
  auth,
7741
7962
  compress,
7742
7963
  cors,
7964
+ createSSEStream,
7743
7965
  createTestServer,
7744
7966
  defineConfig,
7745
7967
  deleteCookie,
7746
7968
  deploy,
7969
+ formatSSE,
7970
+ formatSSEData,
7747
7971
  getCookies,
7748
7972
  graphql,
7749
7973
  health,
7974
+ helmet,
7750
7975
  i18n,
7751
7976
  iii,
7752
7977
  loadEnv,
@@ -7760,7 +7985,11 @@ export {
7760
7985
  rateLimit,
7761
7986
  redis,
7762
7987
  registerWorker,
7988
+ requestId,
7763
7989
  runWorkflow,
7990
+ seo,
7991
+ seoMiddleware,
7992
+ seoTags,
7764
7993
  serve,
7765
7994
  serveStatic,
7766
7995
  setCookie,
@@ -0,0 +1,6 @@
1
+ import type { Middleware } from './types.ts';
2
+ export interface RequestIdOptions {
3
+ header?: string;
4
+ generator?: () => string;
5
+ }
6
+ export declare function requestId(options?: RequestIdOptions): Middleware;
package/dist/seo.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { Middleware } from './types.ts';
2
+ import { Router } from './router.ts';
3
+ export interface RobotsRule {
4
+ userAgent?: string;
5
+ allow?: string | string[];
6
+ disallow?: string | string[];
7
+ }
8
+ export interface SitemapUrl {
9
+ loc: string;
10
+ lastmod?: string;
11
+ changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
12
+ priority?: number;
13
+ }
14
+ export interface SitemapConfig {
15
+ urls?: SitemapUrl[];
16
+ resolve?: () => SitemapUrl[] | Promise<SitemapUrl[]>;
17
+ cacheTTL?: number;
18
+ }
19
+ export interface SeoHeadersConfig {
20
+ 'X-Robots-Tag'?: string | ((path: string) => string | undefined);
21
+ }
22
+ export interface SeoOptions {
23
+ robots?: RobotsRule[];
24
+ sitemap?: SitemapConfig;
25
+ headers?: SeoHeadersConfig;
26
+ baseUrl?: string;
27
+ }
28
+ export declare function seoMiddleware(options?: SeoOptions): Middleware;
29
+ export declare function seo(options?: SeoOptions): Router;
30
+ export interface SeoTagsConfig {
31
+ title?: string;
32
+ description?: string;
33
+ ogImage?: string;
34
+ ogTitle?: string;
35
+ ogDescription?: string;
36
+ twitterCard?: 'summary' | 'summary_large_image';
37
+ canonical?: string;
38
+ }
39
+ export declare function seoTags(config: SeoTagsConfig): string;
package/dist/types.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface Context {
6
6
  mountPath?: string;
7
7
  locale?: string;
8
8
  t?: (key: string, params?: Record<string, string>) => string;
9
+ requestId?: string;
9
10
  }
10
11
  export type Handler = (req: Request, ctx: Context) => Response | Promise<Response>;
11
12
  export type Middleware = (req: Request, ctx: Context, next: Handler) => Response | Promise<Response>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weifuwu",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",