@nmvuong92/fluxe 0.1.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.
Files changed (90) hide show
  1. package/README.md +88 -0
  2. package/lib/backends/http.d.ts +2 -0
  3. package/lib/backends/http.js +32 -0
  4. package/lib/backends/memory.d.ts +2 -0
  5. package/lib/backends/memory.js +21 -0
  6. package/lib/backends/postgres.d.ts +7 -0
  7. package/lib/backends/postgres.js +28 -0
  8. package/lib/backends/sqlite.d.ts +2 -0
  9. package/lib/backends/sqlite.js +27 -0
  10. package/lib/backends/types.d.ts +11 -0
  11. package/lib/backends/types.js +7 -0
  12. package/lib/core/auth.d.ts +12 -0
  13. package/lib/core/auth.js +54 -0
  14. package/lib/core/broker.d.ts +7 -0
  15. package/lib/core/broker.js +27 -0
  16. package/lib/core/chaos.d.ts +5 -0
  17. package/lib/core/chaos.js +11 -0
  18. package/lib/core/cli.d.ts +6 -0
  19. package/lib/core/cli.js +54 -0
  20. package/lib/core/client.d.ts +33 -0
  21. package/lib/core/client.js +108 -0
  22. package/lib/core/codegen.d.ts +7 -0
  23. package/lib/core/codegen.js +28 -0
  24. package/lib/core/engine.d.ts +28 -0
  25. package/lib/core/engine.js +1 -0
  26. package/lib/core/env.d.ts +2 -0
  27. package/lib/core/env.js +10 -0
  28. package/lib/core/errors.d.ts +19 -0
  29. package/lib/core/errors.js +39 -0
  30. package/lib/core/etag.d.ts +2 -0
  31. package/lib/core/etag.js +11 -0
  32. package/lib/core/jobs.d.ts +27 -0
  33. package/lib/core/jobs.js +54 -0
  34. package/lib/core/layouts.d.ts +5 -0
  35. package/lib/core/layouts.js +17 -0
  36. package/lib/core/nav.d.ts +29 -0
  37. package/lib/core/nav.js +53 -0
  38. package/lib/core/observe.d.ts +12 -0
  39. package/lib/core/observe.js +24 -0
  40. package/lib/core/panel.d.ts +3 -0
  41. package/lib/core/panel.js +46 -0
  42. package/lib/core/presence.d.ts +6 -0
  43. package/lib/core/presence.js +33 -0
  44. package/lib/core/ratelimit.d.ts +13 -0
  45. package/lib/core/ratelimit.js +32 -0
  46. package/lib/core/rendercache.d.ts +15 -0
  47. package/lib/core/rendercache.js +39 -0
  48. package/lib/core/resolver.d.ts +37 -0
  49. package/lib/core/resolver.js +42 -0
  50. package/lib/core/router.d.ts +6 -0
  51. package/lib/core/router.js +41 -0
  52. package/lib/core/seo.d.ts +10 -0
  53. package/lib/core/seo.js +27 -0
  54. package/lib/core/testing.d.ts +9 -0
  55. package/lib/core/testing.js +38 -0
  56. package/lib/core/validate.d.ts +4 -0
  57. package/lib/core/validate.js +17 -0
  58. package/lib/core/wiring.d.ts +8 -0
  59. package/lib/core/wiring.js +33 -0
  60. package/lib/hot/search.d.ts +9 -0
  61. package/lib/hot/search.js +16 -0
  62. package/lib/index.d.ts +20 -0
  63. package/lib/index.js +22 -0
  64. package/lib/react/DebugBar.d.ts +3 -0
  65. package/lib/react/DebugBar.js +57 -0
  66. package/lib/react/Link.d.ts +12 -0
  67. package/lib/react/Link.js +11 -0
  68. package/lib/react/Nav.d.ts +8 -0
  69. package/lib/react/Nav.js +7 -0
  70. package/lib/react/ThemeToggle.d.ts +1 -0
  71. package/lib/react/ThemeToggle.js +6 -0
  72. package/lib/react/index.d.ts +10 -0
  73. package/lib/react/index.js +11 -0
  74. package/lib/react/mutation.d.ts +5 -0
  75. package/lib/react/mutation.js +30 -0
  76. package/lib/react/nav-client.d.ts +10 -0
  77. package/lib/react/nav-client.js +76 -0
  78. package/lib/react/query.d.ts +9 -0
  79. package/lib/react/query.js +62 -0
  80. package/lib/react/repro.d.ts +4 -0
  81. package/lib/react/repro.js +19 -0
  82. package/lib/react/shell.d.ts +1 -0
  83. package/lib/react/shell.js +13 -0
  84. package/lib/react/store.d.ts +28 -0
  85. package/lib/react/store.js +31 -0
  86. package/lib/react/theme.d.ts +6 -0
  87. package/lib/react/theme.js +29 -0
  88. package/lib/server_factory.d.ts +12 -0
  89. package/lib/server_factory.js +293 -0
  90. package/package.json +33 -0
@@ -0,0 +1,27 @@
1
+ /* SEO / head management — builder thuần (testable). Cell khai báo head(data) →
2
+ * framework bơm vào <head>; sitemap/robots tự sinh từ route table. */
3
+ function esc(s) {
4
+ return String(s)
5
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6
+ }
7
+ export function renderHead(meta) {
8
+ const tags = [`<title>${esc(meta.title ?? "fluxe")}</title>`];
9
+ if (meta.description)
10
+ tags.push(`<meta name="description" content="${esc(meta.description)}">`);
11
+ if (meta.canonical)
12
+ tags.push(`<link rel="canonical" href="${esc(meta.canonical)}">`);
13
+ for (const [k, v] of Object.entries(meta.og ?? {})) {
14
+ tags.push(`<meta property="og:${esc(k)}" content="${esc(v)}">`);
15
+ }
16
+ if (meta.jsonLd)
17
+ tags.push(`<script type="application/ld+json">${JSON.stringify(meta.jsonLd)}</script>`);
18
+ return tags.join("");
19
+ }
20
+ // Chỉ route tĩnh (bỏ [param] và route nội bộ) — caller lọc trước hoặc truyền sẵn.
21
+ export function renderSitemap(routes, baseUrl) {
22
+ const urls = routes.map((r) => ` <url><loc>${esc(baseUrl + r)}</loc></url>`).join("\n");
23
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
24
+ }
25
+ export function renderRobots(baseUrl) {
26
+ return `User-agent: *\nAllow: /\nSitemap: ${baseUrl}/sitemap.xml\n`;
27
+ }
@@ -0,0 +1,9 @@
1
+ import type { Backend, Todo } from "../backends/types";
2
+ export interface TestBackend extends Backend {
3
+ calls: {
4
+ method: string;
5
+ args: unknown[];
6
+ }[];
7
+ failNext(method: "listTodos" | "addTodo" | "toggleTodo", error?: Error): void;
8
+ }
9
+ export declare function createTestBackend(initial?: Todo[]): TestBackend;
@@ -0,0 +1,38 @@
1
+ export function createTestBackend(initial = []) {
2
+ let todos = initial.map((t) => ({ ...t }));
3
+ let seq = initial.length + 1;
4
+ const calls = [];
5
+ const failures = {};
6
+ const guard = (method) => {
7
+ const e = failures[method];
8
+ if (e) {
9
+ failures[method] = undefined;
10
+ throw e;
11
+ }
12
+ };
13
+ return {
14
+ name: "test",
15
+ calls,
16
+ failNext(method, error = new Error(`test fail: ${method}`)) {
17
+ failures[method] = error;
18
+ },
19
+ async listTodos() {
20
+ calls.push({ method: "listTodos", args: [] });
21
+ guard("listTodos");
22
+ return todos.map((t) => ({ ...t }));
23
+ },
24
+ async addTodo(title) {
25
+ calls.push({ method: "addTodo", args: [title] });
26
+ guard("addTodo");
27
+ const t = { id: String(seq++), title, done: false };
28
+ todos = [...todos, t];
29
+ return { ...t };
30
+ },
31
+ async toggleTodo(id) {
32
+ calls.push({ method: "toggleTodo", args: [id] });
33
+ guard("toggleTodo");
34
+ todos = todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
35
+ return todos.map((t) => ({ ...t }));
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,4 @@
1
+ import type { ZodType } from "zod";
2
+ import type { Action } from "./engine";
3
+ export declare function validateInput<T>(schema: ZodType<T>, raw: unknown): T;
4
+ export declare function withInput<I, O>(schema: ZodType<I>, handler: Action<I, O>): Action<I, O>;
@@ -0,0 +1,17 @@
1
+ import { FluxeError } from "./errors.js";
2
+ /* Input validation từ schema (Zod). Sai → FluxeError 400 code=validation + details
3
+ * field-level (gắn error handling T5). Dùng cho action input (body không tin được). */
4
+ export function validateInput(schema, raw) {
5
+ const r = schema.safeParse(raw);
6
+ if (!r.success) {
7
+ const details = r.error.issues.map((i) => ({ path: i.path.join(".") || "(root)", message: i.message }));
8
+ throw new FluxeError("validation", "Dữ liệu không hợp lệ", 400, details);
9
+ }
10
+ return r.data;
11
+ }
12
+ /* Bọc một action với schema input → runtime tự validate trước khi gọi handler. */
13
+ export function withInput(schema, handler) {
14
+ const fn = ((ctx) => handler(ctx));
15
+ fn.inputSchema = schema;
16
+ return fn;
17
+ }
@@ -0,0 +1,8 @@
1
+ import type { Backend } from "../backends/types";
2
+ import type { ResolutionManifest } from "./resolver.ts";
3
+ export declare function backendFromManifest(m: ResolutionManifest): Backend;
4
+ export interface ManifestBackends {
5
+ byCell: Map<string, Backend>;
6
+ default: Backend;
7
+ }
8
+ export declare function backendsFromManifest(m: ResolutionManifest): ManifestBackends;
@@ -0,0 +1,33 @@
1
+ import { createMemoryBackend } from "../backends/memory.js";
2
+ import { createHttpBackend } from "../backends/http.js";
3
+ function buildBackend(b) {
4
+ if (b.language === "memory")
5
+ return createMemoryBackend();
6
+ if (!b.endpoint)
7
+ throw new Error(`manifest backend "${b.language}" thiếu endpoint`);
8
+ return createHttpBackend(b.language, b.endpoint);
9
+ }
10
+ // Backend app-level (default) — giữ cho code cũ/đơn giản.
11
+ export function backendFromManifest(m) {
12
+ return buildBackend(m.backend);
13
+ }
14
+ // Dựng backend per-cell, DEDUP theo key `language:endpoint` → cells cùng resolution
15
+ // chia sẻ MỘT instance (vd memory dùng chung một store).
16
+ export function backendsFromManifest(m) {
17
+ const cache = new Map();
18
+ const make = (b) => {
19
+ const key = `${b.language}:${b.endpoint ?? ""}`;
20
+ let inst = cache.get(key);
21
+ if (!inst) {
22
+ inst = buildBackend(b);
23
+ cache.set(key, inst);
24
+ }
25
+ return inst;
26
+ };
27
+ const def = make(m.backend);
28
+ const byCell = new Map();
29
+ for (const id of Object.keys(m.cells)) {
30
+ byCell.set(id, make(m.cells[id].backend));
31
+ }
32
+ return { byCell, default: def };
33
+ }
@@ -0,0 +1,9 @@
1
+ export interface SearchHit {
2
+ item: string;
3
+ score: number;
4
+ }
5
+ export interface SearchService {
6
+ name: string;
7
+ search(items: string[], query: string): Promise<SearchHit[]>;
8
+ }
9
+ export declare function createRustSearch(baseUrl: string): SearchService;
@@ -0,0 +1,16 @@
1
+ export function createRustSearch(baseUrl) {
2
+ const base = baseUrl.replace(/\/$/, "");
3
+ return {
4
+ name: "rust-hot",
5
+ async search(items, query) {
6
+ const r = await fetch(`${base}/search?q=${encodeURIComponent(query)}`, {
7
+ method: "POST",
8
+ headers: { "content-type": "text/plain" },
9
+ body: items.join("\n"),
10
+ });
11
+ if (!r.ok)
12
+ throw new Error(`hot search → HTTP ${r.status}`);
13
+ return (await r.json());
14
+ },
15
+ };
16
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ export * from "./core/engine.ts";
2
+ export * from "./core/validate.ts";
3
+ export * from "./core/errors.ts";
4
+ export * from "./core/resolver.ts";
5
+ export * from "./core/wiring.ts";
6
+ export * from "./core/auth.ts";
7
+ export * from "./core/env.ts";
8
+ export * from "./core/seo.ts";
9
+ export * from "./core/broker.ts";
10
+ export * from "./core/presence.ts";
11
+ export * from "./core/ratelimit.ts";
12
+ export * from "./core/codegen.ts";
13
+ export * from "./core/layouts.ts";
14
+ export * from "./core/router.ts";
15
+ export * from "./core/testing.ts";
16
+ export * from "./backends/types.ts";
17
+ export { createMemoryBackend } from "./backends/memory.ts";
18
+ export { createHttpBackend } from "./backends/http.ts";
19
+ export { createPostgresBackend } from "./backends/postgres.ts";
20
+ export { makeServer } from "./server_factory.ts";
package/lib/index.js ADDED
@@ -0,0 +1,22 @@
1
+ /* @nmvuong92/fluxe — entry ENGINE (server-side, không kéo node:sqlite).
2
+ * Subpath: /react (DX React), /client (rpc runtime), /jobs, /sqlite (cần node:sqlite). */
3
+ export * from "./core/engine.js"; // defineCell, Ctx, CellDef, Loader, Action, Hydration
4
+ export * from "./core/validate.js"; // validateInput, withInput
5
+ export * from "./core/errors.js"; // FluxeError, ErrorPayload, toErrorPayload, renderErrorPage
6
+ export * from "./core/resolver.js"; // resolve, ResolutionProfile/Manifest, CellDecl, RenderMode…
7
+ export * from "./core/wiring.js"; // backendFromManifest, backendsFromManifest
8
+ export * from "./core/auth.js"; // session HMAC, scrypt password, CSRF, RBAC
9
+ export * from "./core/env.js"; // loadEnv
10
+ export * from "./core/seo.js"; // renderHead, renderSitemap, renderRobots, HeadMeta
11
+ export * from "./core/broker.js"; // pub/sub
12
+ export * from "./core/presence.js"; // ai online theo topic
13
+ export * from "./core/ratelimit.js"; // token-bucket + LRU
14
+ export * from "./core/codegen.js"; // genTS/genGo/genRust
15
+ export * from "./core/layouts.js"; // layoutChain, LayoutMeta
16
+ export * from "./core/router.js"; // makeRouter
17
+ export * from "./core/testing.js"; // createTestBackend
18
+ export * from "./backends/types.js"; // Backend, Todo
19
+ export { createMemoryBackend } from "./backends/memory.js";
20
+ export { createHttpBackend } from "./backends/http.js";
21
+ export { createPostgresBackend } from "./backends/postgres.js";
22
+ export { makeServer } from "./server_factory.js";
@@ -0,0 +1,3 @@
1
+ export declare function DebugBar(): import("react").DetailedReactHTMLElement<{
2
+ style: any;
3
+ }, HTMLElement>;
@@ -0,0 +1,57 @@
1
+ import { createElement as h, useState, useSyncExternalStore } from "react";
2
+ import { debug } from "./store";
3
+ import { setChaos, getChaos, setDevBackend, getDevBackend } from "../core/client";
4
+ import { reproTest } from "./repro";
5
+ const EMPTY = [];
6
+ const DOT = { pending: "#e3b341", ok: "#3fb950", error: "#f85149" };
7
+ const CHAOS = "delay=600;fail=0.4";
8
+ function useEvents() {
9
+ return useSyncExternalStore(debug.subscribe, debug.getSnapshot, () => EMPTY);
10
+ }
11
+ const ctrlBtn = (active) => ({
12
+ background: active ? "#1f6feb" : "#21262d", color: "#e6edf3", border: "1px solid #30363d",
13
+ borderRadius: 6, padding: "2px 8px", cursor: "pointer", fontSize: 11,
14
+ });
15
+ export function DebugBar() {
16
+ const events = useEvents();
17
+ const [open, setOpen] = useState(false);
18
+ const [sel, setSel] = useState(null);
19
+ const [, force] = useState(0); // re-render khi đổi chaos/backend
20
+ const [copied, setCopied] = useState(false);
21
+ const errs = events.filter((e) => e.status === "error").length;
22
+ const wrap = { position: "fixed", right: 12, bottom: 12, zIndex: 99999, font: "12px ui-monospace,monospace" };
23
+ if (!open) {
24
+ return h("div", { style: wrap }, h("button", {
25
+ onClick: () => setOpen(true),
26
+ style: { background: errs ? "#3d1417" : "#161b22", color: "#e6edf3", border: "1px solid #30363d", borderRadius: 999, padding: "6px 12px", cursor: "pointer", boxShadow: "0 4px 16px #0008" },
27
+ }, `⚡ fluxe · ${events.length}${errs ? ` · ${errs}✗` : ""}`));
28
+ }
29
+ const chaosOn = getChaos() !== "";
30
+ const be = getDevBackend();
31
+ // #1 Chaos toggle + #5 backend swap
32
+ const controls = h("div", { style: { display: "flex", gap: 6, alignItems: "center", padding: "6px 10px", background: "#0d1117", borderBottom: "1px solid #21262d", flexWrap: "wrap" } }, h("button", { onClick: () => { setChaos(chaosOn ? "" : CHAOS); force((x) => x + 1); }, style: ctrlBtn(chaosOn), title: CHAOS }, chaosOn ? "🔥 Chaos ON" : "Chaos"), h("span", { style: { color: "#7d8590" } }, "backend:"), ...["", "memory", "go", "rust"].map((v) => h("button", { key: v || "auto", onClick: () => { setDevBackend(v); force((x) => x + 1); }, style: ctrlBtn(be === v) }, v || "auto")));
33
+ const rows = events.length === 0
34
+ ? [h("div", { key: "e", style: { color: "#7d8590", padding: 8 } }, "Tương tác đi — query/mutation sẽ hiện ở đây.")]
35
+ : events.map((e) => h("div", { key: e.id, onClick: () => { setSel(sel === e.id ? null : e.id); setCopied(false); },
36
+ style: { display: "flex", gap: 8, alignItems: "center", padding: "4px 8px", cursor: "pointer", borderBottom: "1px solid #21262d", background: sel === e.id ? "#161b22" : "transparent" } }, h("span", { style: { width: 8, height: 8, borderRadius: 8, background: DOT[e.status], flexShrink: 0 } }), h("span", { style: { color: "#7d8590", textTransform: "uppercase", fontSize: 10, width: 50 } }, e.kind), h("span", { style: { color: "#e6edf3", flex: 1 } }, e.label), e.resolution ? h("span", { style: { color: "#5fd3f0", fontSize: 10 } }, e.resolution) : null, // #3 RCA
37
+ h("span", { style: { color: "#7d8590", width: 44, textAlign: "right" } }, e.ms != null ? `${e.ms}ms` : "…")));
38
+ // #4 trace (timing bars) + repro→test (#2)
39
+ let detail = null;
40
+ if (sel != null) {
41
+ const e = events.find((x) => x.id === sel);
42
+ if (e) {
43
+ const scale = Math.max(1, e.ms ?? 1);
44
+ const bar = (label, ms, color) => ms == null ? null :
45
+ h("div", { style: { display: "flex", gap: 6, alignItems: "center", margin: "2px 0" } }, h("span", { style: { color: "#7d8590", width: 60 } }, label), h("div", { style: { height: 8, width: `${Math.max(4, (ms / scale) * 200)}px`, background: color, borderRadius: 3 } }), h("span", { style: { color: "#7d8590" } }, `${ms}ms`));
46
+ detail = h("div", { style: { padding: 8, borderTop: "1px solid #30363d" } }, e.resolution ? h("div", { style: { color: "#5fd3f0", marginBottom: 4 } }, `resolution: ${e.resolution}`) : null, bar("server", e.serverMs, "#3fb950"), bar("client", e.ms, "#1f6feb"), h("pre", { style: { margin: "6px 0 0", maxHeight: 120, overflow: "auto", color: "#a5d6ff", whiteSpace: "pre-wrap" } }, e.error ? "ERROR: " + e.error : JSON.stringify(e.data, null, 2)), e.kind === "mutation" ? h("button", {
47
+ onClick: () => { try {
48
+ navigator.clipboard?.writeText(reproTest(e));
49
+ setCopied(true);
50
+ }
51
+ catch { } },
52
+ style: { ...ctrlBtn(false), marginTop: 6 },
53
+ }, copied ? "✓ đã copy test" : "📋 Copy as test") : null);
54
+ }
55
+ }
56
+ return h("div", { style: wrap }, h("div", { style: { width: 380, background: "#0d1117", border: "1px solid #30363d", borderRadius: 10, overflow: "hidden", boxShadow: "0 8px 32px #000a" } }, h("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 10px", background: "#161b22", color: "#e6edf3" } }, h("b", null, "⚡ fluxe devtools"), h("button", { onClick: () => setOpen(false), style: { background: "none", border: "none", color: "#7d8590", cursor: "pointer", fontSize: 14 } }, "✕")), controls, h("div", { style: { maxHeight: 220, overflow: "auto" } }, ...rows), detail));
57
+ }
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from "react";
2
+ interface LinkProps {
3
+ href: string;
4
+ children?: ReactNode;
5
+ prefetch?: boolean;
6
+ preserveScroll?: boolean;
7
+ className?: string;
8
+ target?: string;
9
+ download?: boolean;
10
+ }
11
+ export declare function Link({ href, children, prefetch: pf, preserveScroll, ...rest }: LinkProps): import("react").JSX.Element;
12
+ export {};
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { go, prefetch, shouldIntercept } from "./nav-client";
3
+ export function Link({ href, children, prefetch: pf = true, preserveScroll, ...rest }) {
4
+ return (_jsx("a", { href: href, onMouseEnter: pf ? () => prefetch(href) : undefined, onClick: (e) => {
5
+ const origin = typeof location !== "undefined" ? location.origin : "";
6
+ if (shouldIntercept(e.nativeEvent, { href, origin, target: rest.target, download: rest.download })) {
7
+ e.preventDefault();
8
+ void go(href, true, { preserveScroll });
9
+ }
10
+ }, ...rest, children: children }));
11
+ }
@@ -0,0 +1,8 @@
1
+ export interface NavItem {
2
+ label: string;
3
+ href: string;
4
+ }
5
+ export declare function Nav({ items, className }: {
6
+ items: NavItem[];
7
+ className?: string;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /* <Nav/> — render menu khai báo (app/nav.ts) bằng <Link>. Active set bởi shellScript (vanilla)
3
+ * theo path → chạy cả trên trang static. <Link> cho SPA nav trên trang island. */
4
+ import { Link } from "./Link";
5
+ export function Nav({ items, className }) {
6
+ return (_jsx("nav", { className: className ?? "nav", children: items.map((it) => (_jsx(Link, { href: it.href, className: "nav-link", children: it.label }, it.href))) }));
7
+ }
@@ -0,0 +1 @@
1
+ export declare function ThemeToggle(): import("react").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /* <ThemeToggle/> — nút đổi light/dark. Render HTML thuần + marker; logic do shellScript (vanilla)
3
+ * xử lý → chạy cả trên trang static (0 React JS). Không cần hydrate. */
4
+ export function ThemeToggle() {
5
+ return (_jsx("button", { "data-fluxe-theme-toggle": true, className: "theme-toggle", "aria-label": "\u0110\u1ED5i giao di\u1EC7n s\u00E1ng/t\u1ED1i", title: "S\u00E1ng/T\u1ED1i", children: "\u25D0" }));
6
+ }
@@ -0,0 +1,10 @@
1
+ export { useQuery } from "./query";
2
+ export { useMutation } from "./mutation";
3
+ export { Link } from "./Link";
4
+ export { Nav, type NavItem } from "./Nav";
5
+ export { useTheme, type Theme } from "./theme";
6
+ export { ThemeToggle } from "./ThemeToggle";
7
+ export { shellScript } from "./shell";
8
+ export { DebugBar } from "./DebugBar";
9
+ export { debug, DebugStore } from "./store";
10
+ export type { DebugEvent } from "./store";
@@ -0,0 +1,11 @@
1
+ /* @fluxe/react — devtools lite: data fetching + tracing + debug bar.
2
+ * Dùng: useQuery / useMutation trong cell; mount <DebugBar/> 1 lần. */
3
+ export { useQuery } from "./query";
4
+ export { useMutation } from "./mutation";
5
+ export { Link } from "./Link";
6
+ export { Nav } from "./Nav";
7
+ export { useTheme } from "./theme";
8
+ export { ThemeToggle } from "./ThemeToggle";
9
+ export { shellScript } from "./shell";
10
+ export { DebugBar } from "./DebugBar";
11
+ export { debug, DebugStore } from "./store";
@@ -0,0 +1,5 @@
1
+ export declare function useMutation<I, O>(label: string, fn: (input: I) => Promise<O>): {
2
+ mutate: (input: I) => Promise<O | undefined>;
3
+ loading: boolean;
4
+ error: string;
5
+ };
@@ -0,0 +1,30 @@
1
+ import { useState } from "react";
2
+ import { debug } from "./store";
3
+ import { lastRpcMeta } from "../core/client";
4
+ /* useMutation — gọi action server, log tracing (resolution/timing) + lỗi có cấu trúc. */
5
+ export function useMutation(label, fn) {
6
+ const [loading, setLoading] = useState(false);
7
+ const [error, setError] = useState("");
8
+ async function mutate(input) {
9
+ setLoading(true);
10
+ setError("");
11
+ const id = debug.start("mutation", "rpc:" + label);
12
+ try {
13
+ const out = await fn(input);
14
+ const m = lastRpcMeta();
15
+ debug.finish(id, { status: "ok", data: out, input, resolution: m.resolution, serverMs: m.serverMs, ms: m.clientMs });
16
+ return out;
17
+ }
18
+ catch (e) {
19
+ const m = lastRpcMeta();
20
+ const msg = e?.details?.[0]?.message ?? e?.message ?? String(e);
21
+ setError(msg);
22
+ debug.finish(id, { status: "error", error: msg, input, resolution: m.resolution, serverMs: m.serverMs });
23
+ throw e;
24
+ }
25
+ finally {
26
+ setLoading(false);
27
+ }
28
+ }
29
+ return { mutate, loading, error };
30
+ }
@@ -0,0 +1,10 @@
1
+ import { shouldIntercept } from "../core/nav";
2
+ type Swap = (cell: string, data: unknown, layout?: string) => void;
3
+ export declare function initNav(swap: Swap): void;
4
+ export declare function prefetch(href: string): void;
5
+ export interface NavOptions {
6
+ preserveScroll?: boolean;
7
+ }
8
+ export declare function go(href: string, push?: boolean, opts?: NavOptions): Promise<void>;
9
+ export declare function navigate(href: string, opts?: NavOptions): Promise<void>;
10
+ export { shouldIntercept };
@@ -0,0 +1,76 @@
1
+ /* Client nav controller — wire phần thuần (nav.ts) với DOM/history/fetch.
2
+ * KHÔNG import cells/router (giữ client bundle sạch server code) → "internal?" = cùng origin +
3
+ * server trả về cell hợp lệ. window/history/location chỉ đụng trong hàm (an toàn SSR import). */
4
+ import { fetchPageProps } from "../core/client";
5
+ import { createPrefetchCache, shouldIntercept } from "../core/nav";
6
+ let onSwap = null;
7
+ const cache = createPrefetchCache();
8
+ const pathOf = (href) => {
9
+ try {
10
+ const u = new URL(href, location.origin);
11
+ return u.pathname + u.search;
12
+ }
13
+ catch {
14
+ return href;
15
+ }
16
+ };
17
+ const sameOrigin = (href) => {
18
+ try {
19
+ return new URL(href, location.origin).origin === location.origin;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ };
25
+ export function initNav(swap) {
26
+ onSwap = swap;
27
+ if ("scrollRestoration" in history)
28
+ history.scrollRestoration = "manual";
29
+ window.addEventListener("popstate", () => { void go(location.pathname + location.search, false); });
30
+ window.fluxe = { navigate, prefetch }; // API điều hướng programmatic
31
+ }
32
+ function saveScroll() {
33
+ history.replaceState({ ...(history.state ?? {}), scrollY: window.scrollY }, "");
34
+ }
35
+ function restoreScroll(y) {
36
+ requestAnimationFrame(() => requestAnimationFrame(() => window.scrollTo(0, y)));
37
+ }
38
+ /* Prefetch (hover): nạp trước props link cùng origin vào cache → click sau cảm giác tức thì. */
39
+ export function prefetch(href) {
40
+ if (!sameOrigin(href))
41
+ return;
42
+ void cache.load(pathOf(href), fetchPageProps).catch(() => { });
43
+ }
44
+ /* Điều hướng: cùng origin → thử SPA swap; server trả non-cell / lỗi → để browser (hard nav). */
45
+ export async function go(href, push = true, opts = {}) {
46
+ if (!sameOrigin(href)) {
47
+ location.href = href;
48
+ return;
49
+ }
50
+ const path = pathOf(href);
51
+ let props;
52
+ try {
53
+ props = await cache.load(path, fetchPageProps);
54
+ }
55
+ catch {
56
+ location.href = href;
57
+ return;
58
+ }
59
+ if (!props || !props.cell) {
60
+ location.href = href;
61
+ return;
62
+ } // không phải cell (file tĩnh…) → hard nav
63
+ if (push) {
64
+ saveScroll();
65
+ history.pushState({ fluxe: 1, scrollY: opts.preserveScroll ? window.scrollY : 0 }, "", href);
66
+ }
67
+ onSwap?.(props.cell, props.data, props.layout);
68
+ window.dispatchEvent(new Event("fluxe:nav")); // cho <Nav/> cập nhật active
69
+ if (opts.preserveScroll)
70
+ return;
71
+ restoreScroll(push ? 0 : (history.state?.scrollY ?? 0));
72
+ }
73
+ export function navigate(href, opts = {}) {
74
+ return go(href, true, opts);
75
+ }
76
+ export { shouldIntercept };
@@ -0,0 +1,9 @@
1
+ export declare function useQuery<T>(key: string, fetcher: () => Promise<T>, opts?: {
2
+ initial?: T;
3
+ enabled?: boolean;
4
+ }): {
5
+ data: T | undefined;
6
+ error: string;
7
+ loading: boolean;
8
+ refetch: () => Promise<void>;
9
+ };
@@ -0,0 +1,62 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { debug } from "./store";
3
+ import { lastRpcMeta } from "../core/client";
4
+ /* useQuery — react-query-lite: cache theo key, dedup in-flight (chống refetch storm),
5
+ * loading/error/data, refetch, log tracing (resolution/timing). */
6
+ const cache = new Map();
7
+ const inflight = new Map();
8
+ export function useQuery(key, fetcher, opts = {}) {
9
+ const [data, setData] = useState(opts.initial !== undefined ? opts.initial : cache.get(key));
10
+ const [error, setError] = useState("");
11
+ const [loading, setLoading] = useState(false);
12
+ const fetcherRef = useRef(fetcher);
13
+ fetcherRef.current = fetcher;
14
+ async function run() {
15
+ // Dedup: nếu đã có fetch đang bay cho key → dùng chung, không bắn request mới.
16
+ let p = inflight.get(key);
17
+ const fresh = !p;
18
+ if (!p) {
19
+ const id = debug.start("query", "query:" + key);
20
+ p = (async () => {
21
+ try {
22
+ const d = await fetcherRef.current();
23
+ const m = lastRpcMeta();
24
+ cache.set(key, d);
25
+ debug.finish(id, { status: "ok", data: d, resolution: m.resolution, serverMs: m.serverMs, ms: m.clientMs });
26
+ return d;
27
+ }
28
+ catch (e) {
29
+ const m = lastRpcMeta();
30
+ debug.finish(id, { status: "error", error: e?.message ?? String(e), resolution: m.resolution });
31
+ throw e;
32
+ }
33
+ finally {
34
+ inflight.delete(key);
35
+ }
36
+ })();
37
+ inflight.set(key, p);
38
+ }
39
+ if (fresh) {
40
+ setLoading(true);
41
+ setError("");
42
+ }
43
+ try {
44
+ setData(await p);
45
+ }
46
+ catch (e) {
47
+ setError(e?.message ?? String(e));
48
+ }
49
+ finally {
50
+ setLoading(false);
51
+ }
52
+ }
53
+ useEffect(() => {
54
+ if (opts.enabled === false)
55
+ return;
56
+ if (opts.initial !== undefined && cache.get(key) === undefined)
57
+ cache.set(key, opts.initial);
58
+ run();
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ }, [key]);
61
+ return { data, error, loading, refetch: run };
62
+ }
@@ -0,0 +1,4 @@
1
+ import type { DebugEvent } from "./store";
2
+ export declare function reproTest(ev: DebugEvent & {
3
+ input?: unknown;
4
+ }): string;
@@ -0,0 +1,19 @@
1
+ /* Repro → Test: từ một event (label "rpc:cell.action", input, data) sinh sẵn một test
2
+ * dùng createTestBackend — biến bug thành test deterministic, dán vào là chạy. Thuần. */
3
+ export function reproTest(ev) {
4
+ const m = ev.label.match(/^rpc:([^.]+)\.(.+)$/);
5
+ const cell = m?.[1] ?? "cell";
6
+ const action = m?.[2] ?? "action";
7
+ const input = JSON.stringify(ev.input ?? {});
8
+ const expected = JSON.stringify(ev.data ?? null);
9
+ return `import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import ${cell} from "../app/cells/${cell}/index";
12
+ import { createTestBackend } from "../src/core/testing";
13
+
14
+ test("repro: ${cell}.${action}", async () => {
15
+ const backend = createTestBackend();
16
+ const out = await ${cell}.actions!.${action}({ input: ${input}, backend });
17
+ assert.deepEqual(out, ${expected});
18
+ });`;
19
+ }
@@ -0,0 +1 @@
1
+ export declare const shellScript = "(function(){\nvar d=document.documentElement;\ntry{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')d.dataset.theme=t;}catch(e){}\nfunction active(){var p=location.pathname;document.querySelectorAll('.nav-link').forEach(function(a){try{a.classList.toggle('active',new URL(a.href).pathname===p);}catch(e){}});}\nfunction wire(){var b=document.querySelector('[data-fluxe-theme-toggle]');if(b&&!b._w){b._w=1;b.addEventListener('click',function(){var n=d.dataset.theme==='dark'?'light':'dark';d.dataset.theme=n;try{localStorage.setItem('theme',n);}catch(e){}});}active();}\nif(document.readyState!=='loading')wire();else document.addEventListener('DOMContentLoaded',wire);\naddEventListener('fluxe:nav',active);addEventListener('popstate',active);\n})();";
@@ -0,0 +1,13 @@
1
+ /* shellScript — JS vanilla TÍ HON chạy trên MỌI trang (kể cả static 0-React-JS) để:
2
+ * - no-flash theme: đọc localStorage.theme → set data-theme ngay khi parse (trước content).
3
+ * - nút .theme-toggle: đổi light/dark + lưu localStorage (không cần React hydrate).
4
+ * - .nav-link active theo path; cập nhật khi SPA nav ("fluxe:nav") + back/forward.
5
+ * Layout nhúng <script>{shellScript}</script>. ~0.5KB, độc lập React → static cell vẫn tương tác. */
6
+ export const shellScript = `(function(){
7
+ var d=document.documentElement;
8
+ try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')d.dataset.theme=t;}catch(e){}
9
+ function active(){var p=location.pathname;document.querySelectorAll('.nav-link').forEach(function(a){try{a.classList.toggle('active',new URL(a.href).pathname===p);}catch(e){}});}
10
+ function wire(){var b=document.querySelector('[data-fluxe-theme-toggle]');if(b&&!b._w){b._w=1;b.addEventListener('click',function(){var n=d.dataset.theme==='dark'?'light':'dark';d.dataset.theme=n;try{localStorage.setItem('theme',n);}catch(e){}});}active();}
11
+ if(document.readyState!=='loading')wire();else document.addEventListener('DOMContentLoaded',wire);
12
+ addEventListener('fluxe:nav',active);addEventListener('popstate',active);
13
+ })();`;
@@ -0,0 +1,28 @@
1
+ export type EventKind = "query" | "mutation" | "error";
2
+ export type EventStatus = "pending" | "ok" | "error";
3
+ export interface DebugEvent {
4
+ id: number;
5
+ kind: EventKind;
6
+ label: string;
7
+ status: EventStatus;
8
+ startedAt: number;
9
+ ms?: number;
10
+ data?: unknown;
11
+ error?: string;
12
+ input?: unknown;
13
+ resolution?: string;
14
+ serverMs?: number;
15
+ }
16
+ type Listener = () => void;
17
+ export declare class DebugStore {
18
+ events: DebugEvent[];
19
+ private listeners;
20
+ private seq;
21
+ start(kind: EventKind, label: string): number;
22
+ finish(id: number, patch: Partial<DebugEvent>): void;
23
+ subscribe: (fn: Listener) => (() => void);
24
+ getSnapshot: () => DebugEvent[];
25
+ private emit;
26
+ }
27
+ export declare const debug: DebugStore;
28
+ export {};