@ubean/pages 0.1.12 → 0.2.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/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { PageHead, PageHead as PageHead$1, PageHead as PageHeadMeta } from "@ubean/types";
1
+ import { Ref, ShallowRef } from "vue";
2
+ import { PageHead, PageHead as PageHead$1, PageHead as PageHeadMeta } from "@ubean/shared";
2
3
  //#region src/protocol.d.ts
3
4
  interface PageObject<Props = Record<string, unknown>> {
4
5
  component: string;
@@ -13,6 +14,8 @@ interface PageAssetTags {
13
14
  css?: string;
14
15
  preloads?: string;
15
16
  body?: string;
17
+ /** Favicon HREF (如 `/favicon.ico`),自动注入 `<link rel="icon">` 到 `<head>` */
18
+ favicon?: string;
16
19
  }
17
20
  interface LocaleMetaInfo {
18
21
  code: string;
@@ -43,8 +46,19 @@ type PageRenderResult = string | {
43
46
  state?: Record<string, unknown>;
44
47
  };
45
48
  type PageRenderFn = (pageObj: PageObject, shellHtml: string, assetTags: PageAssetTags, renderContext?: PageRenderContext) => PageRenderResult | Promise<PageRenderResult>;
49
+ /**
50
+ * 流式渲染函数:返回一个 `ReadableStream<Uint8Array>`,将完整 HTML 文档
51
+ * 分块流式输出(头部先发送,app HTML 边渲染边输出,state 在尾部注入)。
52
+ *
53
+ * 由 `@ubean/ssr` 的 `createVueRenderer` 实现。当 renderer 提供此方法且
54
+ * 应用配置启用了 `streaming` 时,页面处理器会优先使用流式渲染,显著改善
55
+ * TTFB/LCP(浏览器可在 app 渲染期间提前加载 CSS/JS)。
56
+ */
57
+ type PageStreamRenderFn = (pageObj: PageObject, shellHtml: string, assetTags: PageAssetTags, renderContext?: PageRenderContext) => ReadableStream<Uint8Array>;
46
58
  interface PageRenderer {
47
59
  render: PageRenderFn;
60
+ /** 流式渲染(可选)。提供时优先于 `render` 用于流式 SSR 响应。 */
61
+ renderToStream?: PageStreamRenderFn;
48
62
  preambleScript?: string;
49
63
  }
50
64
  declare const PAGE_DATA_ID = "__UBEAN_PAGE_DATA__";
@@ -62,6 +76,11 @@ declare function isPagesRequest(c: {
62
76
  declare function safeJsonStringify(value: unknown): string;
63
77
  declare function serializePageData(pageObj: PageObject): string;
64
78
  declare function pageJsonResponse(pageObj: PageObject, headers?: Record<string, string>): Response;
79
+ /**
80
+ * 生成 favicon `<link>` 标签字符串。
81
+ * 当 `favicon` 为空/未设置时返回空字符串。
82
+ */
83
+ declare function renderFaviconLink(favicon?: string): string;
65
84
  declare function buildPageShell(pageObj: PageObject, assetTags: PageAssetTags, preambleScript?: string, appId?: string, renderContext?: PageRenderContext): string;
66
85
  declare function insertSsrContent(shell: string, appHtml: string): string;
67
86
  /**
@@ -76,6 +95,17 @@ declare function insertSsrContent(shell: string, appHtml: string): string;
76
95
  declare function insertStateContent(shell: string, stateJson: string | null): string;
77
96
  declare function buildClientOnlyShell(pageObj: PageObject, assetTags: PageAssetTags, preambleScript?: string, appId?: string, renderContext?: PageRenderContext): string;
78
97
  declare function renderPage(pageObj: PageObject, assetTags: PageAssetTags, renderer: PageRenderer | null, appId?: string, renderContext?: PageRenderContext): Promise<string>;
98
+ /**
99
+ * 流式渲染页面:当 renderer 提供 `renderToStream` 时,返回一个
100
+ * `ReadableStream<Uint8Array>`,将完整 HTML 文档分块流式输出。
101
+ *
102
+ * 当 renderer 不支持流式(无 `renderToStream`)时,回退到缓冲式 `renderPage`,
103
+ * 返回一个包含完整 HTML 的单块流(保持调用方接口一致)。
104
+ *
105
+ * 调用方应使用 `c.body(stream, { headers: { 'Content-Type': 'text/html; charset=utf-8' } })`
106
+ * 将流作为 HTTP 响应返回。
107
+ */
108
+ declare function renderPageToStream(pageObj: PageObject, assetTags: PageAssetTags, renderer: PageRenderer | null, appId?: string, renderContext?: PageRenderContext): ReadableStream<Uint8Array>;
79
109
  //#endregion
80
110
  //#region src/data.d.ts
81
111
  type DataKey = string | symbol;
@@ -88,24 +118,110 @@ interface DataCacheEntry<T = unknown> {
88
118
  }
89
119
  type DataFetcher<T> = () => T | Promise<T>;
90
120
  declare function defineDataKey(key: string): symbol;
121
+ /**
122
+ * `__UBEAN_DATA__` script 标签 ID — SSR 渲染时,useData 解析的数据以 JSON 形式
123
+ * 注入到此标签中,客户端水合时读取,避免二次请求。
124
+ *
125
+ * 与 `__UBEAN_DEFERRED__`(非关键延迟数据)和 `__UBEAN_STATE__`(用户状态如 Pinia)
126
+ * 分离,职责单一,便于 SSG payload 提取(roadmap Task 3)。
127
+ */
128
+ declare const DATA_PAYLOAD_ID = "__UBEAN_DATA__";
129
+ /**
130
+ * SSR payload 条目:记录 useData 解析结果,序列化到 `__UBEAN_DATA__`。
131
+ * `error` 以字符串形式存储(可序列化),客户端水合时重建为 Error。
132
+ */
133
+ interface DataPayloadEntry {
134
+ data: unknown;
135
+ error: string | null;
136
+ timestamp: number;
137
+ }
138
+ /**
139
+ * SSR 内部:注册一个 useData 解析结果到 payload。
140
+ *
141
+ * 在 `useData` 的 SSR 分支中调用。每次渲染前应调用 `__clearDataPayload()`。
142
+ * 仅 string key 可注册(symbol 无法序列化)。
143
+ */
144
+ declare function __registerDataPayload(key: string, entry: DataPayloadEntry): void;
145
+ /**
146
+ * SSR 内部:返回所有已注册的 payload 条目,用于序列化到 `__UBEAN_DATA__`。
147
+ */
148
+ declare function __resolveDataPayload(): Record<string, DataPayloadEntry>;
149
+ /**
150
+ * SSR 内部:清空 payload 注册表 + 全局 inflight。每次渲染前调用,
151
+ * 避免上一个请求的残留。
152
+ */
153
+ declare function __clearDataPayload(): void;
154
+ /**
155
+ * SSR 内部:将 data payload 序列化为 `<script>` 标签字符串。
156
+ * 无数据时返回空字符串(不注入标签)。
157
+ */
158
+ declare function __serializeDataPayload(data: Record<string, DataPayloadEntry>): string;
159
+ /**
160
+ * 重置客户端 payload 缓存(测试用)。
161
+ */
162
+ declare function __resetDataPayloadCache(): void;
91
163
  interface UseDataOptions<T> {
92
164
  key?: DataKey;
93
165
  tags?: string[];
94
166
  ttl?: number;
95
167
  staleWhileRevalidate?: boolean;
96
168
  fetcher: DataFetcher<T>;
169
+ /**
170
+ * Dedupe in-flight requests with the same key (default: `true`).
171
+ * When enabled, concurrent calls within the same request cycle share a
172
+ * single Promise, preventing duplicate fetcher invocations.
173
+ */
174
+ dedupe?: boolean;
97
175
  }
176
+ type DataStatus = 'idle' | 'pending' | 'success' | 'error';
98
177
  interface DataResult<T> {
99
178
  data: T | undefined;
100
179
  error: Error | null;
101
180
  loading: boolean;
181
+ /** Alias for `!loading` (Nuxt `useAsyncData` compat). */
182
+ pending: boolean;
183
+ /** Current fetch status. */
184
+ status: DataStatus;
102
185
  timestamp: number | undefined;
103
186
  invalidate: () => void;
187
+ /**
188
+ * Force re-fetch, bypassing cache and dedupe.
189
+ * Mutates this result object in place and resolves when done.
190
+ */
191
+ refresh: () => Promise<void>;
104
192
  }
105
193
  declare function useData<T>(options: UseDataOptions<T>, context?: object): Promise<DataResult<T>>;
194
+ /**
195
+ * `useAsyncData` 选项:Nuxt 风格的 `useAsyncData(key, fn, options)` 第三参数。
196
+ *
197
+ * 是 `UseDataOptions` 的子集(去掉 `key`/`fetcher`,改用位置参数)。
198
+ */
199
+ interface UseAsyncDataOptions {
200
+ tags?: string[];
201
+ ttl?: number;
202
+ staleWhileRevalidate?: boolean;
203
+ /** Dedupe in-flight requests (default: `true`). */
204
+ dedupe?: boolean;
205
+ }
206
+ /**
207
+ * Nuxt 风格的 `useAsyncData(key, fn, options)`,作为 `useData` 的超集/别名。
208
+ *
209
+ * 与 `useData` 的区别仅在于调用签名:位置参数 `(key, fn, options)` vs
210
+ * 选项对象 `{ key, fetcher, ... }`。返回值与 `useData` 完全一致。
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * const { data, pending, error, refresh } = await useAsyncData(
215
+ * 'posts',
216
+ * () => fetch('/api/posts').then(r => r.json()),
217
+ * { ttl: 60_000 }
218
+ * );
219
+ * ```
220
+ */
221
+ declare function useAsyncData<T>(key: string, fn: DataFetcher<T>, options?: UseAsyncDataOptions, context?: object): Promise<DataResult<T>>;
106
222
  declare function invalidateData(keyOrTag: DataKey | string, context?: object): number;
107
223
  declare function invalidateAll(context?: object): void;
108
- declare function clearDataCache(context?: object): void;
224
+ declare function clearPageData(context?: object): void;
109
225
  declare function hasData(key: DataKey, context?: object): boolean;
110
226
  interface DependencyDeclaration {
111
227
  keys: DataKey[];
@@ -136,4 +252,102 @@ interface StreamHelper {
136
252
  declare function createStreamResponse(init?: ResponseInit, onStart?: (stream: StreamHelper) => void | Promise<void>): Response;
137
253
  declare function createSseStream(onStart?: (stream: StreamHelper) => void | Promise<void>): Response;
138
254
  //#endregion
139
- export { type DataCacheEntry, type DataKey, type DataResult, type DependencyDeclaration, type InternalFetchOptions, LOCALE_DATA_ID, type LocaleMetaInfo, PAGE_DATA_ID, PAGE_REQUEST_HEADER, type PageAssetTags, type PageHead, type PageHeadMeta, type PageObject, type PageRenderContext, type PageRenderFn, type PageRenderResult, type PageRenderer, SSR_CONTENT_MARKER, STATE_DATA_ID, STATE_MARKER, type StreamHelper, type UseDataOptions, buildClientOnlyShell, buildPageShell, clearDataCache, createInternalFetch, createSseStream, createStreamResponse, declareDependencies, defineDataKey, getInvalidatedKeysForAction, hasData, insertSsrContent, insertStateContent, invalidateAll, invalidateData, isPagesRequest, pageJsonResponse, renderPage, safeJsonStringify, serializePageData, useData, withDependencies };
255
+ //#region src/defer.d.ts
256
+ /**
257
+ * `__UBEAN_DEFERRED__` script 标签 ID — SSR 流式渲染完成后,延迟数据
258
+ * 以 JSON 形式注入到此标签中,客户端水合时读取。
259
+ */
260
+ declare const DEFERRED_DATA_ID = "__UBEAN_DEFERRED__";
261
+ /**
262
+ * 延迟值:包装一个 factory 函数,标记为"非关键数据"。
263
+ *
264
+ * SSR 时不阻塞初始渲染,数据在主内容之后流式注入。
265
+ */
266
+ interface DeferredValue<T> {
267
+ readonly __isDeferred: true;
268
+ readonly factory: () => Promise<T>;
269
+ }
270
+ /**
271
+ * 标记一个 Promise 为可延迟的(非关键数据)。
272
+ *
273
+ * 在 SSR 流式渲染中,`defer()` 包装的 Promise 不会阻塞初始 HTML 输出。
274
+ * 主内容渲染完成后,延迟数据解析结果作为 `<script>` 标签流式注入,
275
+ * 客户端水合后立即可用。
276
+ *
277
+ * @example
278
+ * ```ts
279
+ * // 阻塞初始渲染(关键数据)
280
+ * const critical = await fetchCritical();
281
+ * // 不阻塞,流式输出(非关键数据)
282
+ * const nonCritical = defer(() => fetchNonCritical());
283
+ * ```
284
+ */
285
+ declare function defer<T>(factory: (() => Promise<T>) | Promise<T>): DeferredValue<T>;
286
+ /**
287
+ * 判断值是否为 DeferredValue(类型守卫)。
288
+ */
289
+ declare function isDeferredValue<T>(value: unknown): value is DeferredValue<T>;
290
+ /**
291
+ * SSR 内部:注册一个 deferred promise,在流式渲染后统一解析。
292
+ *
293
+ * 在 `useDeferredData` 的 SSR 分支中调用。每次渲染前应调用 `__clearDeferred()`。
294
+ */
295
+ declare function __registerDeferred(key: string, promise: Promise<unknown>): void;
296
+ /**
297
+ * SSR 内部:解析所有已注册的 deferred promise,返回 key → data 映射。
298
+ *
299
+ * 失败的 promise 以 `{ __deferredError: message }` 形式包含在结果中,
300
+ * 不影响其他 promise 的解析。
301
+ */
302
+ declare function __resolveDeferred(): Promise<Record<string, unknown>>;
303
+ /**
304
+ * SSR 内部:清空注册表。每次渲染前调用,避免上一个请求的残留。
305
+ */
306
+ declare function __clearDeferred(): void;
307
+ /**
308
+ * SSR 内部:将 deferred 数据序列化为 `<script>` 标签字符串。
309
+ *
310
+ * 无数据时返回空字符串(不注入标签)。
311
+ */
312
+ declare function __serializeDeferred(data: Record<string, unknown>): string;
313
+ /**
314
+ * 重置客户端缓存(测试用)。
315
+ */
316
+ declare function __resetDeferredCache(): void;
317
+ interface UseDeferredDataResult<T> {
318
+ /** 延迟数据(初始为 undefined,解析后更新) */
319
+ data: ShallowRef<T | undefined>;
320
+ /** 是否正在获取 */
321
+ pending: Ref<boolean>;
322
+ /** 错误信息(获取失败时设置) */
323
+ error: ShallowRef<Error | null>;
324
+ }
325
+ /**
326
+ * 在组件中使用延迟数据。
327
+ *
328
+ * - **SSR**: 注册 promise 但不阻塞渲染,组件渲染 fallback;渲染完成后数据流式注入
329
+ * - **客户端水合**: 从 `__UBEAN_DEFERRED__` 读取已解析数据,立即显示(无闪烁)
330
+ * - **客户端导航**: 调用 factory 获取数据,显示 pending → resolved
331
+ *
332
+ * @example
333
+ * ```vue
334
+ * <script setup>
335
+ * import { defer, useDeferredData } from 'ubean';
336
+ *
337
+ * const { data: comments, pending } = useDeferredData(
338
+ * 'comments',
339
+ * defer(() => fetch('/api/comments').then(r => r.json()))
340
+ * );
341
+ * </script>
342
+ *
343
+ * <template>
344
+ * <div v-if="pending">Loading comments...</div>
345
+ * <ul v-else>
346
+ * <li v-for="c in comments" :key="c.id">{{ c.text }}</li>
347
+ * </ul>
348
+ * </template>
349
+ * ```
350
+ */
351
+ declare function useDeferredData<T>(key: string, deferred: DeferredValue<T>): UseDeferredDataResult<T>;
352
+ //#endregion
353
+ export { DATA_PAYLOAD_ID, DEFERRED_DATA_ID, type DataCacheEntry, type DataKey, type DataPayloadEntry, type DataResult, type DataStatus, type DeferredValue, type DependencyDeclaration, type InternalFetchOptions, LOCALE_DATA_ID, type LocaleMetaInfo, PAGE_DATA_ID, PAGE_REQUEST_HEADER, type PageAssetTags, type PageHead, type PageHeadMeta, type PageObject, type PageRenderContext, type PageRenderFn, type PageRenderResult, type PageRenderer, type PageStreamRenderFn, SSR_CONTENT_MARKER, STATE_DATA_ID, STATE_MARKER, type StreamHelper, type UseAsyncDataOptions, type UseDataOptions, type UseDeferredDataResult, __clearDataPayload, __clearDeferred, __registerDataPayload, __registerDeferred, __resetDataPayloadCache, __resetDeferredCache, __resolveDataPayload, __resolveDeferred, __serializeDataPayload, __serializeDeferred, buildClientOnlyShell, buildPageShell, clearPageData, createInternalFetch, createSseStream, createStreamResponse, declareDependencies, defer, defineDataKey, getInvalidatedKeysForAction, hasData, insertSsrContent, insertStateContent, invalidateAll, invalidateData, isDeferredValue, isPagesRequest, pageJsonResponse, renderFaviconLink, renderPage, renderPageToStream, safeJsonStringify, serializePageData, useAsyncData, useData, useDeferredData, withDependencies };
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ref, shallowRef } from "vue";
1
2
  //#region src/protocol.ts
2
3
  const PAGE_DATA_ID = "__UBEAN_PAGE_DATA__";
3
4
  const LOCALE_DATA_ID = "__UBEAN_LOCALE__";
@@ -27,6 +28,33 @@ function pageJsonResponse(pageObj, headers = {}) {
27
28
  function escapeAttr(str) {
28
29
  return str.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;");
29
30
  }
31
+ /**
32
+ * 根据文件扩展名推断 favicon 的 MIME type。
33
+ * SVG 必须显式声明 `type="image/svg+xml"`,否则部分浏览器不会渲染;
34
+ * 其他类型浏览器可从 URL 自动推断,但显式声明更规范。
35
+ */
36
+ function faviconMimeType(href) {
37
+ switch (href.split(".").pop()?.toLowerCase()) {
38
+ case "svg": return "image/svg+xml";
39
+ case "ico": return "image/x-icon";
40
+ case "png": return "image/png";
41
+ case "jpg":
42
+ case "jpeg": return "image/jpeg";
43
+ case "gif": return "image/gif";
44
+ case "webp": return "image/webp";
45
+ default: return null;
46
+ }
47
+ }
48
+ /**
49
+ * 生成 favicon `<link>` 标签字符串。
50
+ * 当 `favicon` 为空/未设置时返回空字符串。
51
+ */
52
+ function renderFaviconLink(favicon) {
53
+ if (!favicon) return "";
54
+ const type = faviconMimeType(favicon);
55
+ const typeAttr = type ? ` type="${escapeAttr(type)}"` : "";
56
+ return `<link rel="icon" href="${escapeAttr(favicon)}"${typeAttr}>`;
57
+ }
30
58
  function renderHeadTags(head) {
31
59
  if (!head) return {
32
60
  headTags: "",
@@ -60,6 +88,7 @@ function buildPageShell(pageObj, assetTags, preambleScript = "", appId = "app",
60
88
  const css = assetTags.css ?? "";
61
89
  const preloads = assetTags.preloads ?? "";
62
90
  const bodyTags = assetTags.body ?? "";
91
+ const faviconLink = renderFaviconLink(assetTags.favicon);
63
92
  const locale = renderContext?.locale;
64
93
  const localeDir = renderContext?.localeDir || "ltr";
65
94
  const messages = renderContext?.messages;
@@ -81,7 +110,7 @@ function buildPageShell(pageObj, assetTags, preambleScript = "", appId = "app",
81
110
  return `<!doctype html>
82
111
  <html${finalHtmlAttrs ? ` ${finalHtmlAttrs}` : ""}>
83
112
  <head>
84
- ${headTags ? `${headTags}\n ` : ""}${css}${preloads}
113
+ ${faviconLink ? `${faviconLink}\n ` : ""}${headTags ? `${headTags}\n ` : ""}${css}${preloads}
85
114
  </head>
86
115
  <body${finalBodyAttrs ? ` ${finalBodyAttrs}` : ""}>
87
116
  ${localeScript}
@@ -113,6 +142,7 @@ function buildClientOnlyShell(pageObj, assetTags, preambleScript = "", appId = "
113
142
  const css = assetTags.css ?? "";
114
143
  const preloads = assetTags.preloads ?? "";
115
144
  const bodyTags = assetTags.body ?? "";
145
+ const faviconLink = renderFaviconLink(assetTags.favicon);
116
146
  const locale = renderContext?.locale;
117
147
  const localeDir = renderContext?.localeDir || "ltr";
118
148
  const messages = renderContext?.messages;
@@ -134,7 +164,7 @@ function buildClientOnlyShell(pageObj, assetTags, preambleScript = "", appId = "
134
164
  return `<!doctype html>
135
165
  <html${finalHtmlAttrs ? ` ${finalHtmlAttrs}` : ""}>
136
166
  <head>
137
- ${headTags ? `${headTags}\n ` : ""}${preambleScript}${css}${preloads}
167
+ ${faviconLink ? `${faviconLink}\n ` : ""}${headTags ? `${headTags}\n ` : ""}${preambleScript}${css}${preloads}
138
168
  </head>
139
169
  <body${finalBodyAttrs ? ` ${finalBodyAttrs}` : ""}>
140
170
  ${localeScript}
@@ -160,12 +190,35 @@ async function renderPage(pageObj, assetTags, renderer, appId = "app", renderCon
160
190
  else finalHtml = insertStateContent(finalHtml, "");
161
191
  return finalHtml;
162
192
  }
193
+ /**
194
+ * 流式渲染页面:当 renderer 提供 `renderToStream` 时,返回一个
195
+ * `ReadableStream<Uint8Array>`,将完整 HTML 文档分块流式输出。
196
+ *
197
+ * 当 renderer 不支持流式(无 `renderToStream`)时,回退到缓冲式 `renderPage`,
198
+ * 返回一个包含完整 HTML 的单块流(保持调用方接口一致)。
199
+ *
200
+ * 调用方应使用 `c.body(stream, { headers: { 'Content-Type': 'text/html; charset=utf-8' } })`
201
+ * 将流作为 HTTP 响应返回。
202
+ */
203
+ function renderPageToStream(pageObj, assetTags, renderer, appId = "app", renderContext) {
204
+ if (!renderer || !renderer.renderToStream) {
205
+ const encoder = new TextEncoder();
206
+ return new ReadableStream({ async start(controller) {
207
+ const html = await renderPage(pageObj, assetTags, renderer, appId, renderContext);
208
+ controller.enqueue(encoder.encode(html));
209
+ controller.close();
210
+ } });
211
+ }
212
+ const shell = buildPageShell(pageObj, assetTags, renderer.preambleScript ?? "", appId, renderContext);
213
+ return renderer.renderToStream(pageObj, shell, assetTags, renderContext);
214
+ }
163
215
  //#endregion
164
216
  //#region src/data.ts
165
217
  function createRegistry() {
166
218
  return {
167
219
  entries: /* @__PURE__ */ new Map(),
168
- tagIndex: /* @__PURE__ */ new Map()
220
+ tagIndex: /* @__PURE__ */ new Map(),
221
+ inflight: /* @__PURE__ */ new Map()
169
222
  };
170
223
  }
171
224
  const globalRegistry = createRegistry();
@@ -184,24 +237,171 @@ function getRegistry(context) {
184
237
  function defineDataKey(key) {
185
238
  return Symbol.for(`ubean:data:${key}`);
186
239
  }
187
- async function useData(options, context) {
188
- const registry = getRegistry(context);
189
- const key = options.key || Symbol();
190
- const invalidate = () => {
191
- invalidateData(key, context);
192
- };
240
+ /**
241
+ * `__UBEAN_DATA__` script 标签 ID — SSR 渲染时,useData 解析的数据以 JSON 形式
242
+ * 注入到此标签中,客户端水合时读取,避免二次请求。
243
+ *
244
+ * 与 `__UBEAN_DEFERRED__`(非关键延迟数据)和 `__UBEAN_STATE__`(用户状态如 Pinia)
245
+ * 分离,职责单一,便于 SSG payload 提取(roadmap Task 3)。
246
+ */
247
+ const DATA_PAYLOAD_ID = "__UBEAN_DATA__";
248
+ const ssrPayload = /* @__PURE__ */ new Map();
249
+ /**
250
+ * SSR 内部:注册一个 useData 解析结果到 payload。
251
+ *
252
+ * 在 `useData` 的 SSR 分支中调用。每次渲染前应调用 `__clearDataPayload()`。
253
+ * 仅 string key 可注册(symbol 无法序列化)。
254
+ */
255
+ function __registerDataPayload(key, entry) {
256
+ ssrPayload.set(key, entry);
257
+ }
258
+ /**
259
+ * SSR 内部:返回所有已注册的 payload 条目,用于序列化到 `__UBEAN_DATA__`。
260
+ */
261
+ function __resolveDataPayload() {
262
+ return Object.fromEntries(ssrPayload);
263
+ }
264
+ /**
265
+ * SSR 内部:清空 payload 注册表 + 全局 inflight。每次渲染前调用,
266
+ * 避免上一个请求的残留。
267
+ */
268
+ function __clearDataPayload() {
269
+ ssrPayload.clear();
270
+ globalRegistry.inflight.clear();
271
+ }
272
+ /**
273
+ * SSR 内部:将 data payload 序列化为 `<script>` 标签字符串。
274
+ * 无数据时返回空字符串(不注入标签)。
275
+ */
276
+ function __serializeDataPayload(data) {
277
+ if (Object.keys(data).length === 0) return "";
278
+ return `<script id="${DATA_PAYLOAD_ID}" type="application/json">${safeJsonStringify(data)}<\/script>`;
279
+ }
280
+ let clientPayloadCache;
281
+ /**
282
+ * 客户端:实际执行 payload 解析。
283
+ *
284
+ * 优先级:
285
+ * 1. `globalThis.__UBEAN_DATA_PAYLOAD__`(SSG 模式注入,可能是 Promise / 对象 / null)
286
+ * - Promise → await,解析失败返回 null
287
+ * - 对象 → 直接使用
288
+ * - null/undefined → 降级到 DOM 路径
289
+ * 2. DOM 读取 `__UBEAN_DATA__` script 标签(SSR/无 SSG 提取场景)
290
+ *
291
+ * SSR 环境(无 document)返回 `null`。
292
+ */
293
+ async function computeClientPayload() {
294
+ const injected = globalThis.__UBEAN_DATA_PAYLOAD__;
295
+ if (injected !== void 0) try {
296
+ const value = injected instanceof Promise ? await injected : injected;
297
+ if (value && typeof value === "object") return value;
298
+ return null;
299
+ } catch {
300
+ return null;
301
+ }
302
+ if (typeof document === "undefined") return null;
303
+ const el = document.getElementById(DATA_PAYLOAD_ID);
304
+ if (!el?.textContent) return null;
193
305
  try {
194
- const cached = registry.entries.get(key);
195
- if (cached) {
196
- if (!(cached.ttl !== void 0 && Date.now() - cached.timestamp > cached.ttl)) return {
197
- data: cached.data,
198
- error: null,
199
- loading: false,
200
- timestamp: cached.timestamp,
201
- invalidate
306
+ return JSON.parse(el.textContent);
307
+ } catch {
308
+ return null;
309
+ }
310
+ }
311
+ /**
312
+ * 客户端:读取 payload,带 in-flight 去重缓存。
313
+ *
314
+ * 首次调用启动计算并存 Promise 到缓存(去重并发调用);解析后用结果替换 Promise,
315
+ * 后续调用直接命中同步缓存。
316
+ * SSR 环境返回 `null`。
317
+ */
318
+ function readClientPayload() {
319
+ const cached = clientPayloadCache;
320
+ if (cached !== void 0) return Promise.resolve(cached);
321
+ const promise = computeClientPayload();
322
+ clientPayloadCache = promise;
323
+ return promise.then((value) => {
324
+ clientPayloadCache = value;
325
+ return value;
326
+ }, () => {
327
+ clientPayloadCache = null;
328
+ return null;
329
+ });
330
+ }
331
+ /**
332
+ * 重置客户端 payload 缓存(测试用)。
333
+ */
334
+ function __resetDataPayloadCache() {
335
+ clientPayloadCache = void 0;
336
+ }
337
+ /**
338
+ * 客户端水合:从 payload 缓存中读取指定 key 的初始数据,注入到 data registry。
339
+ *
340
+ * 在 `useData` 的客户端分支中调用:若 payload 命中,将数据写入 registry,
341
+ * 后续逻辑直接走缓存命中路径,避免二次请求。
342
+ * 仅处理 string key,且仅当 registry 中尚无该 key 时注入(不覆盖已有缓存)。
343
+ *
344
+ * @returns true 表示 payload 命中并已注入
345
+ */
346
+ async function hydrateFromPayload(key, registry) {
347
+ if (typeof key !== "string") return false;
348
+ if (registry.entries.has(key)) return false;
349
+ const payload = await readClientPayload();
350
+ if (!payload) return false;
351
+ const entry = payload[key];
352
+ if (!entry) return false;
353
+ if (entry.error !== null) return false;
354
+ registry.entries.set(key, {
355
+ key,
356
+ data: entry.data,
357
+ timestamp: entry.timestamp,
358
+ tags: void 0
359
+ });
360
+ return true;
361
+ }
362
+ /**
363
+ * 判断当前是否为 SSR 环境(无 `window`)。
364
+ * 与 `defer.ts` 保持一致的环境检测方式。
365
+ */
366
+ function isSSR() {
367
+ return typeof window === "undefined";
368
+ }
369
+ /**
370
+ * 将解析结果注册到 SSR payload(仅 string key + SSR 环境)。
371
+ */
372
+ function registerSSRPayload(key, data, error) {
373
+ if (!isSSR()) return;
374
+ if (typeof key !== "string") return;
375
+ __registerDataPayload(key, {
376
+ data: error ? void 0 : data,
377
+ error: error ? error instanceof Error ? error.message : String(error) : null,
378
+ timestamp: Date.now()
379
+ });
380
+ }
381
+ /**
382
+ * 执行 fetcher,管理 inflight 注册 + cache 写入 + tag 索引 + SSR payload 注册。
383
+ * dedupe 决定是否复用同 key 的 in-flight Promise。
384
+ */
385
+ async function executeFetch(options, key, registry, _context) {
386
+ const dedupe = options.dedupe !== false;
387
+ if (dedupe) {
388
+ const existing = registry.inflight.get(key);
389
+ if (existing) try {
390
+ return {
391
+ data: await existing,
392
+ error: null
393
+ };
394
+ } catch (err) {
395
+ return {
396
+ data: void 0,
397
+ error: err instanceof Error ? err : new Error(String(err))
202
398
  };
203
399
  }
204
- const data = await options.fetcher();
400
+ }
401
+ const promise = Promise.resolve(options.fetcher());
402
+ if (dedupe) registry.inflight.set(key, promise);
403
+ try {
404
+ const data = await promise;
205
405
  const entry = {
206
406
  key,
207
407
  data,
@@ -218,23 +418,100 @@ async function useData(options, context) {
218
418
  }
219
419
  tagSet.add(key);
220
420
  }
421
+ registerSSRPayload(key, data, null);
221
422
  return {
222
423
  data,
223
- error: null,
224
- loading: false,
225
- timestamp: entry.timestamp,
226
- invalidate
424
+ error: null
227
425
  };
228
426
  } catch (err) {
427
+ const error = err instanceof Error ? err : new Error(String(err));
428
+ registerSSRPayload(key, void 0, error);
229
429
  return {
230
430
  data: void 0,
231
- error: err instanceof Error ? err : new Error(String(err)),
232
- loading: false,
233
- timestamp: void 0,
234
- invalidate
431
+ error
235
432
  };
433
+ } finally {
434
+ if (dedupe) registry.inflight.delete(key);
236
435
  }
237
436
  }
437
+ async function useData(options, context) {
438
+ const registry = getRegistry(context);
439
+ const key = options.key || Symbol();
440
+ const invalidate = () => {
441
+ invalidateData(key, context);
442
+ };
443
+ if (!isSSR()) await hydrateFromPayload(key, registry);
444
+ const result = {
445
+ data: void 0,
446
+ error: null,
447
+ loading: false,
448
+ pending: false,
449
+ status: "idle",
450
+ timestamp: void 0,
451
+ invalidate,
452
+ refresh: async () => {
453
+ invalidateData(key, context);
454
+ result.loading = true;
455
+ result.pending = true;
456
+ result.status = "pending";
457
+ const { data, error } = await executeFetch(options, key, registry, context);
458
+ result.data = data;
459
+ result.error = error;
460
+ result.loading = false;
461
+ result.pending = false;
462
+ result.status = error ? "error" : "success";
463
+ result.timestamp = registry.entries.get(key)?.timestamp;
464
+ }
465
+ };
466
+ const cached = registry.entries.get(key);
467
+ if (cached) {
468
+ if (!(cached.ttl !== void 0 && Date.now() - cached.timestamp > cached.ttl)) {
469
+ result.data = cached.data;
470
+ result.error = null;
471
+ result.loading = false;
472
+ result.pending = false;
473
+ result.status = "success";
474
+ result.timestamp = cached.timestamp;
475
+ return result;
476
+ }
477
+ }
478
+ result.loading = true;
479
+ result.pending = true;
480
+ result.status = "pending";
481
+ const { data, error } = await executeFetch(options, key, registry, context);
482
+ result.data = data;
483
+ result.error = error;
484
+ result.loading = false;
485
+ result.pending = false;
486
+ result.status = error ? "error" : "success";
487
+ result.timestamp = registry.entries.get(key)?.timestamp;
488
+ return result;
489
+ }
490
+ /**
491
+ * Nuxt 风格的 `useAsyncData(key, fn, options)`,作为 `useData` 的超集/别名。
492
+ *
493
+ * 与 `useData` 的区别仅在于调用签名:位置参数 `(key, fn, options)` vs
494
+ * 选项对象 `{ key, fetcher, ... }`。返回值与 `useData` 完全一致。
495
+ *
496
+ * @example
497
+ * ```ts
498
+ * const { data, pending, error, refresh } = await useAsyncData(
499
+ * 'posts',
500
+ * () => fetch('/api/posts').then(r => r.json()),
501
+ * { ttl: 60_000 }
502
+ * );
503
+ * ```
504
+ */
505
+ async function useAsyncData(key, fn, options, context) {
506
+ return useData({
507
+ key,
508
+ fetcher: fn,
509
+ tags: options?.tags,
510
+ ttl: options?.ttl,
511
+ staleWhileRevalidate: options?.staleWhileRevalidate,
512
+ dedupe: options?.dedupe
513
+ }, context);
514
+ }
238
515
  function invalidateData(keyOrTag, context) {
239
516
  const registry = getRegistry(context);
240
517
  let count = 0;
@@ -271,8 +548,9 @@ function invalidateAll(context) {
271
548
  const registry = getRegistry(context);
272
549
  registry.entries.clear();
273
550
  registry.tagIndex.clear();
551
+ registry.inflight.clear();
274
552
  }
275
- function clearDataCache(context) {
553
+ function clearPageData(context) {
276
554
  invalidateAll(context);
277
555
  }
278
556
  function hasData(key, context) {
@@ -397,4 +675,169 @@ function createSseStream(onStart) {
397
675
  } });
398
676
  }
399
677
  //#endregion
400
- export { LOCALE_DATA_ID, PAGE_DATA_ID, PAGE_REQUEST_HEADER, SSR_CONTENT_MARKER, STATE_DATA_ID, STATE_MARKER, buildClientOnlyShell, buildPageShell, clearDataCache, createInternalFetch, createSseStream, createStreamResponse, declareDependencies, defineDataKey, getInvalidatedKeysForAction, hasData, insertSsrContent, insertStateContent, invalidateAll, invalidateData, isPagesRequest, pageJsonResponse, renderPage, safeJsonStringify, serializePageData, useData, withDependencies };
678
+ //#region src/defer.ts
679
+ /**
680
+ * `__UBEAN_DEFERRED__` script 标签 ID — SSR 流式渲染完成后,延迟数据
681
+ * 以 JSON 形式注入到此标签中,客户端水合时读取。
682
+ */
683
+ const DEFERRED_DATA_ID = "__UBEAN_DEFERRED__";
684
+ /**
685
+ * 标记一个 Promise 为可延迟的(非关键数据)。
686
+ *
687
+ * 在 SSR 流式渲染中,`defer()` 包装的 Promise 不会阻塞初始 HTML 输出。
688
+ * 主内容渲染完成后,延迟数据解析结果作为 `<script>` 标签流式注入,
689
+ * 客户端水合后立即可用。
690
+ *
691
+ * @example
692
+ * ```ts
693
+ * // 阻塞初始渲染(关键数据)
694
+ * const critical = await fetchCritical();
695
+ * // 不阻塞,流式输出(非关键数据)
696
+ * const nonCritical = defer(() => fetchNonCritical());
697
+ * ```
698
+ */
699
+ function defer(factory) {
700
+ return {
701
+ __isDeferred: true,
702
+ factory: typeof factory === "function" ? factory : () => factory
703
+ };
704
+ }
705
+ /**
706
+ * 判断值是否为 DeferredValue(类型守卫)。
707
+ */
708
+ function isDeferredValue(value) {
709
+ return value !== null && typeof value === "object" && value.__isDeferred === true;
710
+ }
711
+ const entries = [];
712
+ /**
713
+ * SSR 内部:注册一个 deferred promise,在流式渲染后统一解析。
714
+ *
715
+ * 在 `useDeferredData` 的 SSR 分支中调用。每次渲染前应调用 `__clearDeferred()`。
716
+ */
717
+ function __registerDeferred(key, promise) {
718
+ entries.push({
719
+ key,
720
+ promise
721
+ });
722
+ }
723
+ /**
724
+ * SSR 内部:解析所有已注册的 deferred promise,返回 key → data 映射。
725
+ *
726
+ * 失败的 promise 以 `{ __deferredError: message }` 形式包含在结果中,
727
+ * 不影响其他 promise 的解析。
728
+ */
729
+ async function __resolveDeferred() {
730
+ if (entries.length === 0) return {};
731
+ const results = {};
732
+ await Promise.allSettled(entries.map(async (entry) => {
733
+ try {
734
+ results[entry.key] = await entry.promise;
735
+ } catch (err) {
736
+ results[entry.key] = { __deferredError: err instanceof Error ? err.message : String(err) };
737
+ }
738
+ }));
739
+ return results;
740
+ }
741
+ /**
742
+ * SSR 内部:清空注册表。每次渲染前调用,避免上一个请求的残留。
743
+ */
744
+ function __clearDeferred() {
745
+ entries.length = 0;
746
+ }
747
+ /**
748
+ * SSR 内部:将 deferred 数据序列化为 `<script>` 标签字符串。
749
+ *
750
+ * 无数据时返回空字符串(不注入标签)。
751
+ */
752
+ function __serializeDeferred(data) {
753
+ if (Object.keys(data).length === 0) return "";
754
+ return `<script id="${DEFERRED_DATA_ID}" type="application/json">${safeJsonStringify(data)}<\/script>`;
755
+ }
756
+ let clientCache;
757
+ /**
758
+ * 客户端:从 DOM 读取 `__UBEAN_DEFERRED__` script 标签内容。
759
+ *
760
+ * 首次调用后缓存结果,后续调用直接返回缓存。
761
+ * SSR 环境返回 `null`。
762
+ */
763
+ function readClientCache() {
764
+ const cached = clientCache;
765
+ if (cached !== void 0) return cached;
766
+ let result;
767
+ if (typeof document === "undefined") result = null;
768
+ else {
769
+ const el = document.getElementById(DEFERRED_DATA_ID);
770
+ if (!el?.textContent) result = {};
771
+ else try {
772
+ result = JSON.parse(el.textContent);
773
+ } catch {
774
+ result = {};
775
+ }
776
+ }
777
+ clientCache = result;
778
+ return result;
779
+ }
780
+ /**
781
+ * 重置客户端缓存(测试用)。
782
+ */
783
+ function __resetDeferredCache() {
784
+ clientCache = void 0;
785
+ }
786
+ /**
787
+ * 在组件中使用延迟数据。
788
+ *
789
+ * - **SSR**: 注册 promise 但不阻塞渲染,组件渲染 fallback;渲染完成后数据流式注入
790
+ * - **客户端水合**: 从 `__UBEAN_DEFERRED__` 读取已解析数据,立即显示(无闪烁)
791
+ * - **客户端导航**: 调用 factory 获取数据,显示 pending → resolved
792
+ *
793
+ * @example
794
+ * ```vue
795
+ * <script setup>
796
+ * import { defer, useDeferredData } from 'ubean';
797
+ *
798
+ * const { data: comments, pending } = useDeferredData(
799
+ * 'comments',
800
+ * defer(() => fetch('/api/comments').then(r => r.json()))
801
+ * );
802
+ * <\/script>
803
+ *
804
+ * <template>
805
+ * <div v-if="pending">Loading comments...</div>
806
+ * <ul v-else>
807
+ * <li v-for="c in comments" :key="c.id">{{ c.text }}</li>
808
+ * </ul>
809
+ * </template>
810
+ * ```
811
+ */
812
+ function useDeferredData(key, deferred) {
813
+ const data = shallowRef(void 0);
814
+ const pending = ref(true);
815
+ const error = shallowRef(null);
816
+ if (typeof window === "undefined") {
817
+ __registerDeferred(key, deferred.factory());
818
+ pending.value = true;
819
+ } else {
820
+ const cached = readClientCache()?.[key];
821
+ if (cached !== void 0) {
822
+ if (cached !== null && typeof cached === "object" && "__deferredError" in cached) error.value = new Error(String(cached.__deferredError));
823
+ else data.value = cached;
824
+ pending.value = false;
825
+ } else {
826
+ pending.value = true;
827
+ deferred.factory().then((result) => {
828
+ data.value = result;
829
+ pending.value = false;
830
+ }).catch((err) => {
831
+ error.value = err instanceof Error ? err : new Error(String(err));
832
+ pending.value = false;
833
+ });
834
+ }
835
+ }
836
+ return {
837
+ data,
838
+ pending,
839
+ error
840
+ };
841
+ }
842
+ //#endregion
843
+ export { DATA_PAYLOAD_ID, DEFERRED_DATA_ID, LOCALE_DATA_ID, PAGE_DATA_ID, PAGE_REQUEST_HEADER, SSR_CONTENT_MARKER, STATE_DATA_ID, STATE_MARKER, __clearDataPayload, __clearDeferred, __registerDataPayload, __registerDeferred, __resetDataPayloadCache, __resetDeferredCache, __resolveDataPayload, __resolveDeferred, __serializeDataPayload, __serializeDeferred, buildClientOnlyShell, buildPageShell, clearPageData, createInternalFetch, createSseStream, createStreamResponse, declareDependencies, defer, defineDataKey, getInvalidatedKeysForAction, hasData, insertSsrContent, insertStateContent, invalidateAll, invalidateData, isDeferredValue, isPagesRequest, pageJsonResponse, renderFaviconLink, renderPage, renderPageToStream, safeJsonStringify, serializePageData, useAsyncData, useData, useDeferredData, withDependencies };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ubean/pages",
3
- "version": "0.1.12",
3
+ "version": "0.2.0",
4
4
  "description": "Page data protocol and isomorphic data layer for ubean (PageObject, useData, invalidateData)",
5
5
  "files": [
6
6
  "dist"
@@ -16,12 +16,15 @@
16
16
  }
17
17
  },
18
18
  "dependencies": {
19
- "@ubean/types": "0.1.12"
19
+ "@ubean/shared": "0.2.0"
20
20
  },
21
21
  "devDependencies": {
22
- "@types/node": "^26.1.2",
22
+ "@types/node": "^26.2.0",
23
23
  "typescript": "7.0.2",
24
- "vite-plus": "0.2.6"
24
+ "vite-plus": "0.2.9"
25
+ },
26
+ "peerDependencies": {
27
+ "vue": ">=3.4.0"
25
28
  },
26
29
  "scripts": {
27
30
  "build": "vp pack",