@ubean/pages 0.1.1 → 0.1.3

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.
@@ -0,0 +1,113 @@
1
+ import { PageHead, PageHead as PageHead$1, PageHead as PageHeadMeta } from "@ubean/types";
2
+ //#region src/protocol.d.ts
3
+ interface PageObject<Props = Record<string, unknown>> {
4
+ component: string;
5
+ props: Props;
6
+ params: Record<string, string>;
7
+ url: string;
8
+ layout?: string | false;
9
+ errors?: Record<string, string> | null;
10
+ head?: PageHead$1;
11
+ }
12
+ interface PageAssetTags {
13
+ css?: string;
14
+ preloads?: string;
15
+ body?: string;
16
+ }
17
+ interface LocaleMetaInfo {
18
+ code: string;
19
+ name?: string;
20
+ dir: 'ltr' | 'rtl';
21
+ isDefault?: boolean;
22
+ }
23
+ interface PageRenderContext {
24
+ locale?: string;
25
+ localeDir?: 'ltr' | 'rtl';
26
+ messages?: Record<string, unknown>;
27
+ /** All registered locales (metadata only, no messages) — sent to the
28
+ * client so it can register every available locale during hydration,
29
+ * preventing `availableLocales` hydration mismatches. */
30
+ availableLocales?: LocaleMetaInfo[];
31
+ }
32
+ type PageRenderFn = (pageObj: PageObject, shellHtml: string, assetTags: PageAssetTags, renderContext?: PageRenderContext) => string | Promise<string>;
33
+ interface PageRenderer {
34
+ render: PageRenderFn;
35
+ preambleScript?: string;
36
+ }
37
+ declare const PAGE_DATA_ID = "__UBEAN_PAGE_DATA__";
38
+ declare const LOCALE_DATA_ID = "__UBEAN_LOCALE__";
39
+ declare const PAGE_REQUEST_HEADER = "x-ubeanpages";
40
+ declare const SSR_CONTENT_MARKER = "<!--UBEAN_SSR_CONTENT-->";
41
+ declare function isPagesRequest(c: {
42
+ req: {
43
+ header: (k: string) => string | undefined;
44
+ };
45
+ }): boolean;
46
+ declare function safeJsonStringify(value: unknown): string;
47
+ declare function serializePageData(pageObj: PageObject): string;
48
+ declare function pageJsonResponse(pageObj: PageObject, headers?: Record<string, string>): Response;
49
+ declare function buildPageShell(pageObj: PageObject, assetTags: PageAssetTags, preambleScript?: string, appId?: string, renderContext?: PageRenderContext): string;
50
+ declare function insertSsrContent(shell: string, appHtml: string): string;
51
+ declare function buildClientOnlyShell(pageObj: PageObject, assetTags: PageAssetTags, preambleScript?: string, appId?: string, renderContext?: PageRenderContext): string;
52
+ declare function renderPage(pageObj: PageObject, assetTags: PageAssetTags, renderer: PageRenderer | null, appId?: string, renderContext?: PageRenderContext): Promise<string>;
53
+ //#endregion
54
+ //#region src/data.d.ts
55
+ type DataKey = string | symbol;
56
+ interface DataCacheEntry<T = unknown> {
57
+ key: DataKey;
58
+ data: T;
59
+ timestamp: number;
60
+ ttl?: number;
61
+ tags?: string[];
62
+ }
63
+ type DataFetcher<T> = () => T | Promise<T>;
64
+ declare function defineDataKey(key: string): symbol;
65
+ interface UseDataOptions<T> {
66
+ key?: DataKey;
67
+ tags?: string[];
68
+ ttl?: number;
69
+ staleWhileRevalidate?: boolean;
70
+ fetcher: DataFetcher<T>;
71
+ }
72
+ interface DataResult<T> {
73
+ data: T | undefined;
74
+ error: Error | null;
75
+ loading: boolean;
76
+ timestamp: number | undefined;
77
+ invalidate: () => void;
78
+ }
79
+ declare function useData<T>(options: UseDataOptions<T>, context?: object): Promise<DataResult<T>>;
80
+ declare function invalidateData(keyOrTag: DataKey | string, context?: object): number;
81
+ declare function invalidateAll(context?: object): void;
82
+ declare function clearDataCache(context?: object): void;
83
+ declare function hasData(key: DataKey, context?: object): boolean;
84
+ interface DependencyDeclaration {
85
+ keys: DataKey[];
86
+ tags?: string[];
87
+ }
88
+ declare function declareDependencies(deps: DependencyDeclaration): DependencyDeclaration;
89
+ declare function withDependencies<T>(fn: () => T | Promise<T>, _deps: DependencyDeclaration): () => Promise<T>;
90
+ declare function getInvalidatedKeysForAction(actionName: string, invalidationMap: Record<string, Array<DataKey | string>>, context?: object): number;
91
+ interface InternalFetchOptions {
92
+ baseURL?: string;
93
+ headers?: Record<string, string>;
94
+ credentials?: 'include' | 'omit' | 'same-origin';
95
+ forwardHeaders?: string[];
96
+ }
97
+ declare function createInternalFetch(c: {
98
+ req: {
99
+ raw?: Request;
100
+ header?: (name: string) => string | undefined;
101
+ url?: string;
102
+ };
103
+ }, options?: InternalFetchOptions): typeof fetch;
104
+ interface StreamHelper {
105
+ enqueue: (chunk: unknown) => void;
106
+ close: () => void;
107
+ error: (err: Error) => void;
108
+ response: Response;
109
+ }
110
+ declare function createStreamResponse(init?: ResponseInit, onStart?: (stream: StreamHelper) => void | Promise<void>): Response;
111
+ declare function createSseStream(onStart?: (stream: StreamHelper) => void | Promise<void>): Response;
112
+ //#endregion
113
+ 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 PageRenderer, SSR_CONTENT_MARKER, type StreamHelper, type UseDataOptions, buildClientOnlyShell, buildPageShell, clearDataCache, createInternalFetch, createSseStream, createStreamResponse, declareDependencies, defineDataKey, getInvalidatedKeysForAction, hasData, insertSsrContent, invalidateAll, invalidateData, isPagesRequest, pageJsonResponse, renderPage, safeJsonStringify, serializePageData, useData, withDependencies };
package/dist/index.js ADDED
@@ -0,0 +1,375 @@
1
+ //#region src/protocol.ts
2
+ const PAGE_DATA_ID = "__UBEAN_PAGE_DATA__";
3
+ const LOCALE_DATA_ID = "__UBEAN_LOCALE__";
4
+ const PAGE_REQUEST_HEADER = "x-ubeanpages";
5
+ const SSR_CONTENT_MARKER = "<!--UBEAN_SSR_CONTENT-->";
6
+ function isPagesRequest(c) {
7
+ return c.req.header(PAGE_REQUEST_HEADER) === "true";
8
+ }
9
+ function safeJsonStringify(value) {
10
+ return (JSON.stringify(value) ?? "null").replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
11
+ }
12
+ function serializePageData(pageObj) {
13
+ return safeJsonStringify(pageObj);
14
+ }
15
+ function pageJsonResponse(pageObj, headers = {}) {
16
+ const safeJson = safeJsonStringify(pageObj);
17
+ return new Response(safeJson, { headers: {
18
+ "Content-Type": "application/json",
19
+ "X-UbeanPages": "true",
20
+ Vary: "X-UbeanPages",
21
+ ...headers
22
+ } });
23
+ }
24
+ function escapeAttr(str) {
25
+ return str.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;");
26
+ }
27
+ function renderHeadTags(head) {
28
+ if (!head) return {
29
+ headTags: "",
30
+ htmlAttrsStr: "",
31
+ bodyAttrsStr: ""
32
+ };
33
+ const tags = [];
34
+ if (head.title) tags.push(`<title>${escapeAttr(head.title)}</title>`);
35
+ if (head.meta) for (const meta of head.meta) {
36
+ const attrs = Object.entries(meta).map(([k, v]) => `${k}="${escapeAttr(String(v))}"`).join(" ");
37
+ tags.push(`<meta ${attrs}>`);
38
+ }
39
+ if (head.link) for (const link of head.link) {
40
+ const attrs = Object.entries(link).map(([k, v]) => `${k}="${escapeAttr(String(v))}"`).join(" ");
41
+ tags.push(`<link ${attrs}>`);
42
+ }
43
+ if (head.script) for (const script of head.script) {
44
+ const attrs = Object.entries(script).map(([k, v]) => `${k}="${escapeAttr(String(v))}"`).join(" ");
45
+ tags.push(`<script ${attrs}><\/script>`);
46
+ }
47
+ const htmlAttrsStr = head.htmlAttrs ? Object.entries(head.htmlAttrs).map(([k, v]) => `${k}="${escapeAttr(String(v))}"`).join(" ") : "";
48
+ const bodyAttrsStr = head.bodyAttrs ? Object.entries(head.bodyAttrs).map(([k, v]) => `${k}="${escapeAttr(String(v))}"`).join(" ") : "";
49
+ return {
50
+ headTags: tags.join("\n "),
51
+ htmlAttrsStr,
52
+ bodyAttrsStr
53
+ };
54
+ }
55
+ function buildPageShell(pageObj, assetTags, preambleScript = "", appId = "app", renderContext) {
56
+ const pageData = serializePageData(pageObj);
57
+ const css = assetTags.css ?? "";
58
+ const preloads = assetTags.preloads ?? "";
59
+ const bodyTags = assetTags.body ?? "";
60
+ const locale = renderContext?.locale;
61
+ const localeDir = renderContext?.localeDir || "ltr";
62
+ const messages = renderContext?.messages;
63
+ const availableLocales = renderContext?.availableLocales;
64
+ const localeScript = locale ? `<script id="${LOCALE_DATA_ID}" type="application/json">${safeJsonStringify({
65
+ locale,
66
+ dir: localeDir,
67
+ ...messages ? { messages } : {},
68
+ ...availableLocales?.length ? { availableLocales } : {}
69
+ })}<\/script>` : "";
70
+ const { headTags, htmlAttrsStr, bodyAttrsStr } = renderHeadTags(pageObj.head);
71
+ const localeHtmlAttrs = {};
72
+ if (locale) {
73
+ localeHtmlAttrs.lang = locale;
74
+ localeHtmlAttrs.dir = localeDir;
75
+ }
76
+ const finalHtmlAttrs = [Object.entries(localeHtmlAttrs).map(([k, v]) => `${k}="${escapeAttr(String(v))}"`).join(" "), htmlAttrsStr].filter(Boolean).join(" ");
77
+ const finalBodyAttrs = bodyAttrsStr;
78
+ return `<!doctype html>
79
+ <html${finalHtmlAttrs ? ` ${finalHtmlAttrs}` : ""}>
80
+ <head>
81
+ ${headTags ? `${headTags}\n ` : ""}${css}${preloads}
82
+ </head>
83
+ <body${finalBodyAttrs ? ` ${finalBodyAttrs}` : ""}>
84
+ ${localeScript}
85
+ <script id="${PAGE_DATA_ID}" type="application/json">${pageData}<\/script>
86
+ <div id="${appId}">${SSR_CONTENT_MARKER}</div>
87
+ ${preambleScript}
88
+ ${bodyTags}
89
+ </body>
90
+ </html>`;
91
+ }
92
+ function insertSsrContent(shell, appHtml) {
93
+ return shell.replace(SSR_CONTENT_MARKER, appHtml);
94
+ }
95
+ function buildClientOnlyShell(pageObj, assetTags, preambleScript = "", appId = "app", renderContext) {
96
+ const pageData = serializePageData(pageObj);
97
+ const css = assetTags.css ?? "";
98
+ const preloads = assetTags.preloads ?? "";
99
+ const bodyTags = assetTags.body ?? "";
100
+ const locale = renderContext?.locale;
101
+ const localeDir = renderContext?.localeDir || "ltr";
102
+ const messages = renderContext?.messages;
103
+ const availableLocales = renderContext?.availableLocales;
104
+ const localeScript = locale ? `<script id="${LOCALE_DATA_ID}" type="application/json">${safeJsonStringify({
105
+ locale,
106
+ dir: localeDir,
107
+ ...messages ? { messages } : {},
108
+ ...availableLocales?.length ? { availableLocales } : {}
109
+ })}<\/script>` : "";
110
+ const { headTags, htmlAttrsStr: pageHtmlAttrsStr, bodyAttrsStr } = renderHeadTags(pageObj.head);
111
+ const htmlAttrs = {};
112
+ if (locale) {
113
+ htmlAttrs.lang = locale;
114
+ htmlAttrs.dir = localeDir;
115
+ }
116
+ const finalHtmlAttrs = [Object.entries(htmlAttrs).map(([k, v]) => `${k}="${escapeAttr(String(v))}"`).join(" "), pageHtmlAttrsStr].filter(Boolean).join(" ");
117
+ const finalBodyAttrs = bodyAttrsStr;
118
+ return `<!doctype html>
119
+ <html${finalHtmlAttrs ? ` ${finalHtmlAttrs}` : ""}>
120
+ <head>
121
+ ${headTags ? `${headTags}\n ` : ""}${preambleScript}${css}${preloads}
122
+ </head>
123
+ <body${finalBodyAttrs ? ` ${finalBodyAttrs}` : ""}>
124
+ ${localeScript}
125
+ <script id="${PAGE_DATA_ID}" type="application/json">${pageData}<\/script>
126
+ <div id="${appId}" data-ubean-ssr="false"></div>
127
+ ${bodyTags}
128
+ </body>
129
+ </html>`;
130
+ }
131
+ async function renderPage(pageObj, assetTags, renderer, appId = "app", renderContext) {
132
+ if (!renderer) return buildClientOnlyShell(pageObj, assetTags, "", appId, renderContext);
133
+ const shell = buildPageShell(pageObj, assetTags, renderer.preambleScript ?? "", appId, renderContext);
134
+ const appHtml = await renderer.render(pageObj, shell, assetTags, renderContext);
135
+ if (typeof appHtml === "string" && !shell.includes(appHtml) && !appHtml.includes("<html")) return insertSsrContent(shell, appHtml);
136
+ return appHtml;
137
+ }
138
+ //#endregion
139
+ //#region src/data.ts
140
+ function createRegistry() {
141
+ return {
142
+ entries: /* @__PURE__ */ new Map(),
143
+ tagIndex: /* @__PURE__ */ new Map()
144
+ };
145
+ }
146
+ const globalRegistry = createRegistry();
147
+ const contextRegistry = /* @__PURE__ */ new WeakMap();
148
+ function getRegistry(context) {
149
+ if (context) {
150
+ let reg = contextRegistry.get(context);
151
+ if (!reg) {
152
+ reg = createRegistry();
153
+ contextRegistry.set(context, reg);
154
+ }
155
+ return reg;
156
+ }
157
+ return globalRegistry;
158
+ }
159
+ function defineDataKey(key) {
160
+ return Symbol.for(`ubean:data:${key}`);
161
+ }
162
+ async function useData(options, context) {
163
+ const registry = getRegistry(context);
164
+ const key = options.key || Symbol();
165
+ const invalidate = () => {
166
+ invalidateData(key, context);
167
+ };
168
+ try {
169
+ const cached = registry.entries.get(key);
170
+ if (cached) {
171
+ if (!(cached.ttl !== void 0 && Date.now() - cached.timestamp > cached.ttl)) return {
172
+ data: cached.data,
173
+ error: null,
174
+ loading: false,
175
+ timestamp: cached.timestamp,
176
+ invalidate
177
+ };
178
+ }
179
+ const data = await options.fetcher();
180
+ const entry = {
181
+ key,
182
+ data,
183
+ timestamp: Date.now(),
184
+ ttl: options.ttl,
185
+ tags: options.tags
186
+ };
187
+ registry.entries.set(key, entry);
188
+ if (options.tags) for (const tag of options.tags) {
189
+ let tagSet = registry.tagIndex.get(tag);
190
+ if (!tagSet) {
191
+ tagSet = /* @__PURE__ */ new Set();
192
+ registry.tagIndex.set(tag, tagSet);
193
+ }
194
+ tagSet.add(key);
195
+ }
196
+ return {
197
+ data,
198
+ error: null,
199
+ loading: false,
200
+ timestamp: entry.timestamp,
201
+ invalidate
202
+ };
203
+ } catch (err) {
204
+ return {
205
+ data: void 0,
206
+ error: err instanceof Error ? err : new Error(String(err)),
207
+ loading: false,
208
+ timestamp: void 0,
209
+ invalidate
210
+ };
211
+ }
212
+ }
213
+ function invalidateData(keyOrTag, context) {
214
+ const registry = getRegistry(context);
215
+ let count = 0;
216
+ if (typeof keyOrTag === "string") {
217
+ const tagSet = registry.tagIndex.get(keyOrTag);
218
+ if (tagSet) {
219
+ for (const key of tagSet) {
220
+ const entry = registry.entries.get(key);
221
+ if (entry) {
222
+ if (entry.tags) for (const t of entry.tags) {
223
+ const tSet = registry.tagIndex.get(t);
224
+ if (tSet) tSet.delete(key);
225
+ }
226
+ registry.entries.delete(key);
227
+ count++;
228
+ }
229
+ }
230
+ registry.tagIndex.delete(keyOrTag);
231
+ }
232
+ } else {
233
+ const entry = registry.entries.get(keyOrTag);
234
+ if (entry) {
235
+ if (entry.tags) for (const t of entry.tags) {
236
+ const tSet = registry.tagIndex.get(t);
237
+ if (tSet) tSet.delete(keyOrTag);
238
+ }
239
+ registry.entries.delete(keyOrTag);
240
+ count = 1;
241
+ }
242
+ }
243
+ return count;
244
+ }
245
+ function invalidateAll(context) {
246
+ const registry = getRegistry(context);
247
+ registry.entries.clear();
248
+ registry.tagIndex.clear();
249
+ }
250
+ function clearDataCache(context) {
251
+ invalidateAll(context);
252
+ }
253
+ function hasData(key, context) {
254
+ return getRegistry(context).entries.has(key);
255
+ }
256
+ function declareDependencies(deps) {
257
+ return deps;
258
+ }
259
+ function withDependencies(fn, _deps) {
260
+ return async () => {
261
+ return fn();
262
+ };
263
+ }
264
+ function getInvalidatedKeysForAction(actionName, invalidationMap, context) {
265
+ const toInvalidate = invalidationMap[actionName];
266
+ if (!toInvalidate) return 0;
267
+ let count = 0;
268
+ for (const key of toInvalidate) count += invalidateData(key, context);
269
+ return count;
270
+ }
271
+ const FORWARD_HEADER_DEFAULTS = [
272
+ "cookie",
273
+ "authorization",
274
+ "x-request-id",
275
+ "x-forwarded-for",
276
+ "x-forwarded-proto",
277
+ "x-forwarded-host",
278
+ "accept-language",
279
+ "user-agent",
280
+ "referer"
281
+ ];
282
+ function createInternalFetch(c, options = {}) {
283
+ const forwardHeaders = options.forwardHeaders || FORWARD_HEADER_DEFAULTS;
284
+ const incomingHeaders = new Headers();
285
+ for (const headerName of forwardHeaders) {
286
+ const value = c.req.header?.(headerName);
287
+ if (value) incomingHeaders.set(headerName, value);
288
+ }
289
+ if (options.headers) for (const [k, v] of Object.entries(options.headers)) incomingHeaders.set(k, v);
290
+ const baseURL = options.baseURL || "";
291
+ return async function internalFetch(input, init) {
292
+ let url;
293
+ if (typeof input === "string") url = input.startsWith("http") ? input : `${baseURL}${input.startsWith("/") ? "" : "/"}${input}`;
294
+ else if (input instanceof URL) url = input.toString();
295
+ else url = input.url;
296
+ const mergedInit = {
297
+ ...init,
298
+ headers: mergeHeaders(incomingHeaders, init?.headers)
299
+ };
300
+ return fetch(url, mergedInit);
301
+ };
302
+ }
303
+ function mergeHeaders(base, extra) {
304
+ const result = new Headers(base);
305
+ if (!extra) return result;
306
+ if (extra instanceof Headers) extra.forEach((value, key) => result.set(key, value));
307
+ else if (Array.isArray(extra)) for (const [key, value] of extra) result.set(key, value);
308
+ else for (const [key, value] of Object.entries(extra)) if (value !== void 0) result.set(key, String(value));
309
+ return result;
310
+ }
311
+ function createStreamResponse(init, onStart) {
312
+ const encoder = new TextEncoder();
313
+ const stream = new ReadableStream({ async start(controller) {
314
+ const helper = {
315
+ enqueue(chunk) {
316
+ const data = typeof chunk === "string" ? chunk : JSON.stringify(chunk);
317
+ controller.enqueue(encoder.encode(data));
318
+ },
319
+ close() {
320
+ controller.close();
321
+ },
322
+ error(err) {
323
+ controller.error(err);
324
+ },
325
+ response: null
326
+ };
327
+ try {
328
+ await onStart?.(helper);
329
+ } catch (err) {
330
+ controller.error(err);
331
+ }
332
+ } });
333
+ return new Response(stream, {
334
+ ...init,
335
+ headers: {
336
+ "Content-Type": init?.headers ? init.headers["Content-Type"] || "text/event-stream" : "text/event-stream",
337
+ "Cache-Control": "no-cache",
338
+ Connection: "keep-alive",
339
+ ...init?.headers
340
+ }
341
+ });
342
+ }
343
+ function createSseStream(onStart) {
344
+ const encoder = new TextEncoder();
345
+ const stream = new ReadableStream({ async start(controller) {
346
+ const helper = {
347
+ enqueue(chunk) {
348
+ let text;
349
+ if (typeof chunk === "string") text = `data: ${chunk}\n\n`;
350
+ else text = `data: ${JSON.stringify(chunk)}\n\n`;
351
+ controller.enqueue(encoder.encode(text));
352
+ },
353
+ close() {
354
+ controller.close();
355
+ },
356
+ error(err) {
357
+ controller.error(err);
358
+ },
359
+ response: null
360
+ };
361
+ controller.enqueue(encoder.encode(": connected\n\n"));
362
+ try {
363
+ await onStart?.(helper);
364
+ } catch (err) {
365
+ controller.error(err);
366
+ }
367
+ } });
368
+ return new Response(stream, { headers: {
369
+ "Content-Type": "text/event-stream",
370
+ "Cache-Control": "no-cache",
371
+ Connection: "keep-alive"
372
+ } });
373
+ }
374
+ //#endregion
375
+ export { LOCALE_DATA_ID, PAGE_DATA_ID, PAGE_REQUEST_HEADER, SSR_CONTENT_MARKER, buildClientOnlyShell, buildPageShell, clearDataCache, createInternalFetch, createSseStream, createStreamResponse, declareDependencies, defineDataKey, getInvalidatedKeysForAction, hasData, insertSsrContent, invalidateAll, invalidateData, isPagesRequest, pageJsonResponse, renderPage, safeJsonStringify, serializePageData, useData, withDependencies };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ubean/pages",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Page data protocol and isomorphic data layer for ubean (PageObject, useData, invalidateData)",
5
5
  "files": [
6
6
  "dist"
@@ -16,7 +16,7 @@
16
16
  }
17
17
  },
18
18
  "dependencies": {
19
- "@ubean/types": "0.1.1"
19
+ "@ubean/types": "0.1.3"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^26.1.1",