@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.
- package/README.md +88 -0
- package/lib/backends/http.d.ts +2 -0
- package/lib/backends/http.js +32 -0
- package/lib/backends/memory.d.ts +2 -0
- package/lib/backends/memory.js +21 -0
- package/lib/backends/postgres.d.ts +7 -0
- package/lib/backends/postgres.js +28 -0
- package/lib/backends/sqlite.d.ts +2 -0
- package/lib/backends/sqlite.js +27 -0
- package/lib/backends/types.d.ts +11 -0
- package/lib/backends/types.js +7 -0
- package/lib/core/auth.d.ts +12 -0
- package/lib/core/auth.js +54 -0
- package/lib/core/broker.d.ts +7 -0
- package/lib/core/broker.js +27 -0
- package/lib/core/chaos.d.ts +5 -0
- package/lib/core/chaos.js +11 -0
- package/lib/core/cli.d.ts +6 -0
- package/lib/core/cli.js +54 -0
- package/lib/core/client.d.ts +33 -0
- package/lib/core/client.js +108 -0
- package/lib/core/codegen.d.ts +7 -0
- package/lib/core/codegen.js +28 -0
- package/lib/core/engine.d.ts +28 -0
- package/lib/core/engine.js +1 -0
- package/lib/core/env.d.ts +2 -0
- package/lib/core/env.js +10 -0
- package/lib/core/errors.d.ts +19 -0
- package/lib/core/errors.js +39 -0
- package/lib/core/etag.d.ts +2 -0
- package/lib/core/etag.js +11 -0
- package/lib/core/jobs.d.ts +27 -0
- package/lib/core/jobs.js +54 -0
- package/lib/core/layouts.d.ts +5 -0
- package/lib/core/layouts.js +17 -0
- package/lib/core/nav.d.ts +29 -0
- package/lib/core/nav.js +53 -0
- package/lib/core/observe.d.ts +12 -0
- package/lib/core/observe.js +24 -0
- package/lib/core/panel.d.ts +3 -0
- package/lib/core/panel.js +46 -0
- package/lib/core/presence.d.ts +6 -0
- package/lib/core/presence.js +33 -0
- package/lib/core/ratelimit.d.ts +13 -0
- package/lib/core/ratelimit.js +32 -0
- package/lib/core/rendercache.d.ts +15 -0
- package/lib/core/rendercache.js +39 -0
- package/lib/core/resolver.d.ts +37 -0
- package/lib/core/resolver.js +42 -0
- package/lib/core/router.d.ts +6 -0
- package/lib/core/router.js +41 -0
- package/lib/core/seo.d.ts +10 -0
- package/lib/core/seo.js +27 -0
- package/lib/core/testing.d.ts +9 -0
- package/lib/core/testing.js +38 -0
- package/lib/core/validate.d.ts +4 -0
- package/lib/core/validate.js +17 -0
- package/lib/core/wiring.d.ts +8 -0
- package/lib/core/wiring.js +33 -0
- package/lib/hot/search.d.ts +9 -0
- package/lib/hot/search.js +16 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +22 -0
- package/lib/react/DebugBar.d.ts +3 -0
- package/lib/react/DebugBar.js +57 -0
- package/lib/react/Link.d.ts +12 -0
- package/lib/react/Link.js +11 -0
- package/lib/react/Nav.d.ts +8 -0
- package/lib/react/Nav.js +7 -0
- package/lib/react/ThemeToggle.d.ts +1 -0
- package/lib/react/ThemeToggle.js +6 -0
- package/lib/react/index.d.ts +10 -0
- package/lib/react/index.js +11 -0
- package/lib/react/mutation.d.ts +5 -0
- package/lib/react/mutation.js +30 -0
- package/lib/react/nav-client.d.ts +10 -0
- package/lib/react/nav-client.js +76 -0
- package/lib/react/query.d.ts +9 -0
- package/lib/react/query.js +62 -0
- package/lib/react/repro.d.ts +4 -0
- package/lib/react/repro.js +19 -0
- package/lib/react/shell.d.ts +1 -0
- package/lib/react/shell.js +13 -0
- package/lib/react/store.d.ts +28 -0
- package/lib/react/store.js +31 -0
- package/lib/react/theme.d.ts +6 -0
- package/lib/react/theme.js +29 -0
- package/lib/server_factory.d.ts +12 -0
- package/lib/server_factory.js +293 -0
- package/package.json +33 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* @fluxe/react — debug store + tracing (lite). Mọi query/mutation/error ghi vào đây;
|
|
2
|
+
* DebugBar subscribe để hiển thị "full flow". Immutable update → useSyncExternalStore re-render. */
|
|
3
|
+
const MAX = 50;
|
|
4
|
+
export class DebugStore {
|
|
5
|
+
events = [];
|
|
6
|
+
listeners = new Set();
|
|
7
|
+
seq = 0;
|
|
8
|
+
start(kind, label) {
|
|
9
|
+
const id = ++this.seq;
|
|
10
|
+
const ev = { id, kind, label, status: "pending", startedAt: now() };
|
|
11
|
+
this.events = [ev, ...this.events].slice(0, MAX); // immutable + cap
|
|
12
|
+
this.emit();
|
|
13
|
+
return id;
|
|
14
|
+
}
|
|
15
|
+
finish(id, patch) {
|
|
16
|
+
this.events = this.events.map((e) => e.id === id ? { ...e, ...patch, ms: now() - e.startedAt } : e);
|
|
17
|
+
this.emit();
|
|
18
|
+
}
|
|
19
|
+
subscribe = (fn) => {
|
|
20
|
+
this.listeners.add(fn);
|
|
21
|
+
return () => this.listeners.delete(fn);
|
|
22
|
+
};
|
|
23
|
+
getSnapshot = () => this.events; // ref ổn định giữa các lần mutate
|
|
24
|
+
emit() {
|
|
25
|
+
this.listeners.forEach((l) => l());
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function now() {
|
|
29
|
+
return typeof performance !== "undefined" ? Math.round(performance.now()) : 0;
|
|
30
|
+
}
|
|
31
|
+
export const debug = new DebugStore(); // singleton dùng chung
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* Theme — light/dark/auto qua data-theme trên <html> + localStorage. SSR-safe (window chỉ
|
|
2
|
+
* đụng trong effect/handler). "auto" = theo OS (prefers-color-scheme) → static cell 0 JS vẫn
|
|
3
|
+
* dark được nhờ CSS @media, không cần script. */
|
|
4
|
+
import { useState, useEffect, useCallback } from "react";
|
|
5
|
+
function readTheme() {
|
|
6
|
+
if (typeof localStorage === "undefined")
|
|
7
|
+
return "auto";
|
|
8
|
+
const t = localStorage.getItem("theme");
|
|
9
|
+
return t === "light" || t === "dark" ? t : "auto";
|
|
10
|
+
}
|
|
11
|
+
function applyTheme(t) {
|
|
12
|
+
const el = document.documentElement;
|
|
13
|
+
if (t === "auto")
|
|
14
|
+
el.removeAttribute("data-theme");
|
|
15
|
+
else
|
|
16
|
+
el.dataset.theme = t;
|
|
17
|
+
}
|
|
18
|
+
export function useTheme() {
|
|
19
|
+
const [theme, set] = useState("auto");
|
|
20
|
+
useEffect(() => { const t = readTheme(); set(t); applyTheme(t); }, []);
|
|
21
|
+
const setTheme = useCallback((t) => {
|
|
22
|
+
set(t);
|
|
23
|
+
if (typeof localStorage !== "undefined")
|
|
24
|
+
localStorage.setItem("theme", t);
|
|
25
|
+
applyTheme(t);
|
|
26
|
+
}, []);
|
|
27
|
+
const toggle = useCallback(() => setTheme(theme === "dark" ? "light" : "dark"), [theme, setTheme]);
|
|
28
|
+
return { theme, setTheme, toggle };
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import type { CellDef } from "./core/engine";
|
|
3
|
+
import type { ResolutionManifest } from "./core/resolver";
|
|
4
|
+
import { type LayoutMeta } from "./core/layouts.ts";
|
|
5
|
+
type LayoutEntry = LayoutMeta & {
|
|
6
|
+
component: (props: {
|
|
7
|
+
children: any;
|
|
8
|
+
}) => any;
|
|
9
|
+
};
|
|
10
|
+
type LayoutMap = Record<string, LayoutEntry>;
|
|
11
|
+
export declare function makeServer(manifest: ResolutionManifest, cells: CellDef<any, any>[], layouts?: LayoutMap): http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { PassThrough } from "node:stream";
|
|
4
|
+
import { createElement as h } from "react";
|
|
5
|
+
import { renderToPipeableStream } from "react-dom/server";
|
|
6
|
+
import { backendsFromManifest } from "./core/wiring.js";
|
|
7
|
+
import { renderResolutionPanel } from "./core/panel.js";
|
|
8
|
+
import { makeRouter } from "./core/router.js";
|
|
9
|
+
import { layoutChain } from "./core/layouts.js";
|
|
10
|
+
import { renderHead, renderSitemap, renderRobots } from "./core/seo.js";
|
|
11
|
+
import { FluxeError, toErrorPayload, renderErrorPage } from "./core/errors.js";
|
|
12
|
+
import { signSession, verifySession, parseCookie, hasRole, hashPassword, verifyPassword, newCsrfToken } from "./core/auth.js";
|
|
13
|
+
import { validateInput } from "./core/validate.js";
|
|
14
|
+
import { createBroker } from "./core/broker.js";
|
|
15
|
+
import { createRateLimiter } from "./core/ratelimit.js";
|
|
16
|
+
import { createRecorder } from "./core/observe.js";
|
|
17
|
+
import { createPresence } from "./core/presence.js";
|
|
18
|
+
import { etagOf, etagMatches } from "./core/etag.js";
|
|
19
|
+
import { createRenderCache } from "./core/rendercache.js";
|
|
20
|
+
import { parseChaos } from "./core/chaos.js";
|
|
21
|
+
import { createMemoryBackend } from "./backends/memory.js";
|
|
22
|
+
import { createHttpBackend } from "./backends/http.js";
|
|
23
|
+
// Build backend theo ngôn ngữ (cho live swap trong devtools).
|
|
24
|
+
// Map ngôn ngữ → service HTTP demo (cùng hợp đồng list/add/toggle). Override bằng <LANG>_URL.
|
|
25
|
+
const DEV_BACKENDS = {
|
|
26
|
+
go: "http://127.0.0.1:8081",
|
|
27
|
+
rust: "http://127.0.0.1:8082",
|
|
28
|
+
python: "http://127.0.0.1:8083",
|
|
29
|
+
hono: "http://127.0.0.1:8084",
|
|
30
|
+
dotnet: "http://127.0.0.1:8085",
|
|
31
|
+
java: "http://127.0.0.1:8086",
|
|
32
|
+
};
|
|
33
|
+
function devBackend(lang) {
|
|
34
|
+
const url = process.env[`${lang.toUpperCase()}_URL`] ?? DEV_BACKENDS[lang];
|
|
35
|
+
return url ? createHttpBackend(lang, url) : createMemoryBackend();
|
|
36
|
+
}
|
|
37
|
+
import { randomUUID } from "node:crypto";
|
|
38
|
+
const DEV = process.env.NODE_ENV !== "production";
|
|
39
|
+
const SECRET = process.env.FLUXE_SECRET ?? "dev-secret-change-me";
|
|
40
|
+
// Demo user store (password hash scrypt tạo lúc boot). App thật: lấy từ DB.
|
|
41
|
+
const USERS = {
|
|
42
|
+
alice: { hash: hashPassword("secret"), roles: ["admin", "user"] },
|
|
43
|
+
bob: { hash: hashPassword("secret"), roles: ["user"] },
|
|
44
|
+
};
|
|
45
|
+
function sendError(res, wantsJson, err) {
|
|
46
|
+
const errorId = randomUUID();
|
|
47
|
+
const p = toErrorPayload(err, { dev: DEV, errorId });
|
|
48
|
+
if (!(err instanceof FluxeError))
|
|
49
|
+
console.error(`[fluxe] unexpected ${errorId}:`, err);
|
|
50
|
+
if (wantsJson) {
|
|
51
|
+
res.writeHead(p.status, { "content-type": "application/json" });
|
|
52
|
+
res.end(JSON.stringify({ error: p }));
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
res.writeHead(p.status, { "content-type": "text/html; charset=utf-8" });
|
|
56
|
+
res.end(renderErrorPage(p));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Phần head (trước body) và tail (sau body) — body được STREAM ở giữa.
|
|
60
|
+
function shellHead(cell, data) {
|
|
61
|
+
const headHtml = renderHead(cell.head ? cell.head(data) : {});
|
|
62
|
+
return `<!doctype html><html lang="vi"><head><meta charset="utf-8">${headHtml}</head><body><div id="root">`;
|
|
63
|
+
}
|
|
64
|
+
function shellTail(cell, data, shipClientJs) {
|
|
65
|
+
const island = shipClientJs
|
|
66
|
+
? `<script>window.__FLUXE__=${JSON.stringify({ cell: cell.id, data, layout: cell.layout })};</script><script type="module" src="/client.js"></script>`
|
|
67
|
+
: `<!-- static: 0 JS -->`;
|
|
68
|
+
return `</div>${island}</body></html>`;
|
|
69
|
+
}
|
|
70
|
+
const readBody = (req) => new Promise(res => { let b = ""; req.on("data", c => b += c); req.on("end", () => res(b)); });
|
|
71
|
+
/* Render React node THÀNH chuỗi HTML đầy đủ (gom hết stream) — dùng cho cache cell static.
|
|
72
|
+
* Byte giống hệt đường stream: cùng renderToPipeableStream, chỉ gom lại 1 lần thay vì chảy. */
|
|
73
|
+
function renderBodyToString(node) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const chunks = [];
|
|
76
|
+
const sink = new PassThrough();
|
|
77
|
+
sink.on("data", (c) => chunks.push(Buffer.from(c)));
|
|
78
|
+
sink.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
79
|
+
sink.on("error", reject);
|
|
80
|
+
const { pipe } = renderToPipeableStream(node, {
|
|
81
|
+
onShellReady() { pipe(sink); },
|
|
82
|
+
onError(e) { reject(e); },
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
export function makeServer(manifest, cells, layouts = {}) {
|
|
87
|
+
// Cells được TIÊM từ app (DI) — engine không import ngược vào app/. Thêm trang = sửa app/app.ts.
|
|
88
|
+
const matchRoute = makeRouter(cells);
|
|
89
|
+
const byId = new Map(cells.map((c) => [c.id, c]));
|
|
90
|
+
// Backend GIẢI per-cell từ manifest (Resolution Plane) — cell/frontend giữ nguyên.
|
|
91
|
+
const backends = backendsFromManifest(manifest);
|
|
92
|
+
const backendFor = (id) => backends.byCell.get(id) ?? backends.default;
|
|
93
|
+
const broker = createBroker(); // realtime pub/sub (Trục 4g, bản 1-node)
|
|
94
|
+
const actionLimit = createRateLimiter({ capacity: 30, refillPerSec: 10 }); // per-IP cho action
|
|
95
|
+
const recorder = createRecorder(); // request log (observability)
|
|
96
|
+
const presence = createPresence(); // ai đang online per topic (Trục 4g)
|
|
97
|
+
const renderCache = createRenderCache({ maxKeys: 256 }); // memoize HTML cell static (key route, gate etag)
|
|
98
|
+
let clientJs; // ý A: đọc dist/client.js 1 lần (zero-copy: tái dùng buffer)
|
|
99
|
+
return http.createServer(async (req, res) => {
|
|
100
|
+
const url = new URL(req.url, "http://localhost");
|
|
101
|
+
const start = Date.now();
|
|
102
|
+
res.on("finish", () => recorder.record({ method: req.method ?? "?", path: url.pathname, status: res.statusCode, ms: Date.now() - start, ts: start }));
|
|
103
|
+
const wantsJson = req.headers["x-fluxe"] === "1" || url.searchParams.get("json") === "1";
|
|
104
|
+
const cookies = parseCookie(req.headers.cookie);
|
|
105
|
+
const session = verifySession(cookies.session, SECRET);
|
|
106
|
+
// CSRF double-submit: đảm bảo có cookie csrf (đặt nếu chưa) — client gửi lại qua header.
|
|
107
|
+
let csrf = cookies.csrf;
|
|
108
|
+
const csrfCookie = csrf ? "" : (csrf = newCsrfToken(), `csrf=${csrf}; Path=/; SameSite=Lax`);
|
|
109
|
+
try {
|
|
110
|
+
if (url.pathname === "/client.js") {
|
|
111
|
+
if (clientJs === undefined && existsSync("./dist/client.js"))
|
|
112
|
+
clientJs = readFileSync("./dist/client.js");
|
|
113
|
+
if (clientJs !== undefined) {
|
|
114
|
+
res.writeHead(200, { "content-type": "text/javascript" });
|
|
115
|
+
return res.end(clientJs);
|
|
116
|
+
}
|
|
117
|
+
res.writeHead(404);
|
|
118
|
+
return res.end("// no client");
|
|
119
|
+
}
|
|
120
|
+
if (url.pathname === "/_fluxe/stats") {
|
|
121
|
+
const m = process.memoryUsage();
|
|
122
|
+
const c = process.cpuUsage();
|
|
123
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
124
|
+
return res.end(JSON.stringify({ rss: m.rss, heapUsed: m.heapUsed, cpuUser: c.user, cpuSystem: c.system, uptimeMs: Math.round(process.uptime() * 1000) }));
|
|
125
|
+
}
|
|
126
|
+
if (url.pathname === "/_fluxe/requests") {
|
|
127
|
+
// Observability: log request gần đây (timing/status). Prod: gate sau auth.
|
|
128
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
129
|
+
return res.end(JSON.stringify(recorder.recent()));
|
|
130
|
+
}
|
|
131
|
+
if (url.pathname === "/_fluxe") {
|
|
132
|
+
// Panel RCA — đọc manifest, hiển thị mỗi cell giải trục nào. (Prod: gate sau auth.)
|
|
133
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
134
|
+
return res.end(renderResolutionPanel(manifest, recorder.recent(20)));
|
|
135
|
+
}
|
|
136
|
+
const baseUrl = "http://" + (req.headers.host ?? "localhost");
|
|
137
|
+
if (url.pathname === "/sitemap.xml") {
|
|
138
|
+
const staticRoutes = cells.map(c => c.route).filter(r => !r.includes("["));
|
|
139
|
+
res.writeHead(200, { "content-type": "application/xml; charset=utf-8" });
|
|
140
|
+
return res.end(renderSitemap(staticRoutes, baseUrl));
|
|
141
|
+
}
|
|
142
|
+
if (url.pathname === "/robots.txt") {
|
|
143
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
144
|
+
return res.end(renderRobots(baseUrl));
|
|
145
|
+
}
|
|
146
|
+
if (url.pathname === "/login" && req.method === "POST") {
|
|
147
|
+
// POST {user, password} → verify password hash → set session + csrf cookie.
|
|
148
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
149
|
+
const u = USERS[body.user];
|
|
150
|
+
if (!u || !verifyPassword(String(body.password ?? ""), u.hash)) {
|
|
151
|
+
throw new FluxeError("unauthorized", "Sai tài khoản hoặc mật khẩu", 401);
|
|
152
|
+
}
|
|
153
|
+
const token = signSession({ user: body.user, roles: u.roles }, SECRET);
|
|
154
|
+
res.writeHead(200, {
|
|
155
|
+
"content-type": "application/json",
|
|
156
|
+
"set-cookie": [`session=${token}; HttpOnly; Path=/; SameSite=Lax`, `csrf=${csrf}; Path=/; SameSite=Lax`],
|
|
157
|
+
});
|
|
158
|
+
return res.end(JSON.stringify({ user: body.user, roles: u.roles }));
|
|
159
|
+
}
|
|
160
|
+
if (url.pathname === "/logout") {
|
|
161
|
+
res.writeHead(200, {
|
|
162
|
+
"content-type": "text/html; charset=utf-8",
|
|
163
|
+
"set-cookie": "session=; HttpOnly; Path=/; Max-Age=0",
|
|
164
|
+
});
|
|
165
|
+
return res.end(`<p>Đã đăng xuất. <a href="/">trang chủ</a></p>`);
|
|
166
|
+
}
|
|
167
|
+
if (url.pathname.startsWith("/__sse/")) {
|
|
168
|
+
// Realtime channel (SSE): giữ kết nối, đẩy event khi publish trên topic. ?id= → presence.
|
|
169
|
+
const topic = decodeURIComponent(url.pathname.slice("/__sse/".length));
|
|
170
|
+
const id = url.searchParams.get("id");
|
|
171
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
172
|
+
res.write(`event: ready\ndata: {"topic":"${topic}"}\n\n`);
|
|
173
|
+
const offBroker = broker.subscribe(topic, (data) => res.write(`data: ${JSON.stringify(data)}\n\n`));
|
|
174
|
+
let offPresence = () => { };
|
|
175
|
+
if (id) {
|
|
176
|
+
offPresence = presence.join(topic, id);
|
|
177
|
+
broker.publish(topic, { presence: presence.members(topic) }); // báo mọi người trong topic
|
|
178
|
+
}
|
|
179
|
+
req.on("close", () => {
|
|
180
|
+
offBroker();
|
|
181
|
+
offPresence();
|
|
182
|
+
if (id)
|
|
183
|
+
broker.publish(topic, { presence: presence.members(topic) });
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (url.pathname.startsWith("/__action/") && req.method === "POST") {
|
|
188
|
+
// Rate limit per-IP → 429 + Retry-After (bảo vệ trước CSRF/handler).
|
|
189
|
+
const ip = req.socket.remoteAddress ?? "?";
|
|
190
|
+
const rl = actionLimit.take("act:" + ip);
|
|
191
|
+
if (!rl.ok) {
|
|
192
|
+
res.writeHead(429, { "content-type": "application/json", "retry-after": String(rl.retryAfter) });
|
|
193
|
+
return res.end(JSON.stringify({ error: { status: 429, code: "rate_limited", message: "Quá nhiều request" } }));
|
|
194
|
+
}
|
|
195
|
+
// CSRF: header x-csrf-token phải khớp cookie csrf (double-submit).
|
|
196
|
+
if (!cookies.csrf || req.headers["x-csrf-token"] !== cookies.csrf) {
|
|
197
|
+
throw new FluxeError("csrf", "CSRF token không hợp lệ", 403);
|
|
198
|
+
}
|
|
199
|
+
const [, , cellId, name] = url.pathname.split("/");
|
|
200
|
+
const fn = byId.get(cellId)?.actions?.[name];
|
|
201
|
+
if (!fn) {
|
|
202
|
+
res.writeHead(404);
|
|
203
|
+
return res.end("no action");
|
|
204
|
+
}
|
|
205
|
+
const t0 = Date.now();
|
|
206
|
+
// DevTools (DEV): #5 live backend swap + #3 resolution.
|
|
207
|
+
let backend = backendFor(cellId);
|
|
208
|
+
const r = manifest.cells[cellId]?.backend;
|
|
209
|
+
let resolution = r ? `${r.language}/${r.transport}` : "memory/in-process";
|
|
210
|
+
if (DEV && req.headers["x-fluxe-backend"]) {
|
|
211
|
+
const lang = String(req.headers["x-fluxe-backend"]);
|
|
212
|
+
backend = devBackend(lang);
|
|
213
|
+
resolution = `${lang}/${lang === "memory" ? "in-process" : "http"} (swap)`;
|
|
214
|
+
}
|
|
215
|
+
// #1 Chaos (DEV): inject delay + lỗi giả lập để test UX.
|
|
216
|
+
if (DEV && req.headers["x-fluxe-chaos"]) {
|
|
217
|
+
const c = parseChaos(String(req.headers["x-fluxe-chaos"]));
|
|
218
|
+
if (c.delayMs)
|
|
219
|
+
await new Promise((rr) => setTimeout(rr, c.delayMs));
|
|
220
|
+
if (c.failRate && Math.random() < c.failRate)
|
|
221
|
+
throw new FluxeError("chaos", "Chaos: lỗi giả lập", 500);
|
|
222
|
+
}
|
|
223
|
+
let input = JSON.parse((await readBody(req)) || "{}");
|
|
224
|
+
const schema = fn.inputSchema;
|
|
225
|
+
if (schema)
|
|
226
|
+
input = validateInput(schema, input); // sai → FluxeError 400 (caught)
|
|
227
|
+
const out = await fn({ input, backend, session });
|
|
228
|
+
broker.publish(cellId, { action: name, out }); // realtime: báo client khác
|
|
229
|
+
res.writeHead(200, { "content-type": "application/json", "x-fluxe-resolution": resolution, "x-fluxe-server-ms": String(Date.now() - t0) });
|
|
230
|
+
return res.end(JSON.stringify(out));
|
|
231
|
+
}
|
|
232
|
+
const match = matchRoute(url.pathname);
|
|
233
|
+
if (!match) {
|
|
234
|
+
res.writeHead(404);
|
|
235
|
+
return res.end("404");
|
|
236
|
+
}
|
|
237
|
+
const cell = match.cell;
|
|
238
|
+
if ((cell.requireAuth || cell.requireRole) && !session) {
|
|
239
|
+
throw new FluxeError("unauthorized", "Cần đăng nhập (/login)", 401);
|
|
240
|
+
}
|
|
241
|
+
if (cell.requireRole && !hasRole(session, cell.requireRole)) {
|
|
242
|
+
throw new FluxeError("forbidden", `Cần quyền '${cell.requireRole}'`, 403);
|
|
243
|
+
}
|
|
244
|
+
const data = await cell.loader({ input: match.params, backend: backendFor(cell.id), session });
|
|
245
|
+
if (wantsJson) {
|
|
246
|
+
const body = JSON.stringify({ cell: cell.id, data, layout: cell.layout });
|
|
247
|
+
const etag = etagOf(body); // render cache: 304 nếu props không đổi
|
|
248
|
+
if (etagMatches(req.headers["if-none-match"], etag)) {
|
|
249
|
+
res.writeHead(304, { etag });
|
|
250
|
+
return res.end();
|
|
251
|
+
}
|
|
252
|
+
res.writeHead(200, { "content-type": "application/json", etag });
|
|
253
|
+
return res.end(body);
|
|
254
|
+
}
|
|
255
|
+
let node = h(cell.view, { data });
|
|
256
|
+
for (const id of layoutChain(cell.layout, layouts)) { // inner→outer: bọc dần
|
|
257
|
+
node = h(layouts[id].component, { children: node });
|
|
258
|
+
}
|
|
259
|
+
const shipClientJs = manifest.cells[cell.id]?.render.shipClientJs ?? false;
|
|
260
|
+
const pageHeaders = { "content-type": "text/html; charset=utf-8" };
|
|
261
|
+
if (csrfCookie)
|
|
262
|
+
pageHeaders["set-cookie"] = csrfCookie; // gửi csrf token cho client
|
|
263
|
+
// Ý B — render cache cell static: render 1 lần → giữ Buffer → ghi lại (zero-copy).
|
|
264
|
+
// Gate etag(data): data đổi ⇒ miss ⇒ render lại (không trả HTML cũ). Chỉ cell static & public.
|
|
265
|
+
if (manifest.cells[cell.id]?.render.mode === "static" && cell.cache !== false && !cell.requireAuth && !cell.requireRole) {
|
|
266
|
+
const etag = etagOf(JSON.stringify(data));
|
|
267
|
+
let hit = renderCache.get(url.pathname);
|
|
268
|
+
if (!hit || hit.etag !== etag) {
|
|
269
|
+
const full = shellHead(cell, data) + await renderBodyToString(node) + shellTail(cell, data, shipClientJs);
|
|
270
|
+
hit = { etag, buf: Buffer.from(full, "utf8") };
|
|
271
|
+
renderCache.set(url.pathname, hit);
|
|
272
|
+
}
|
|
273
|
+
res.writeHead(200, pageHeaders);
|
|
274
|
+
return res.end(hit.buf);
|
|
275
|
+
}
|
|
276
|
+
// Streaming SSR: gửi head ngay → stream body (Suspense chảy dần) → tail khi xong.
|
|
277
|
+
res.writeHead(200, pageHeaders);
|
|
278
|
+
res.write(shellHead(cell, data));
|
|
279
|
+
const through = new PassThrough();
|
|
280
|
+
through.on("data", (c) => res.write(c));
|
|
281
|
+
through.on("end", () => { res.write(shellTail(cell, data, shipClientJs)); res.end(); });
|
|
282
|
+
const { pipe } = renderToPipeableStream(node, {
|
|
283
|
+
onShellReady() { pipe(through); }, // shell sẵn → bắt đầu stream
|
|
284
|
+
onError(e) { console.error("[fluxe] ssr stream error:", e); },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
// Error boundary ở biên request: domain → status/code; unexpected → 500 + errorId (không leak prod).
|
|
289
|
+
// Action (rpc) luôn nhận lỗi dạng JSON.
|
|
290
|
+
sendError(res, wantsJson || url.pathname.startsWith("/__action/"), err);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nmvuong92/fluxe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "fluxe — khung fullstack tối giản polyglot (RCA: Resolved Cell Architecture).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" },
|
|
8
|
+
"./react": { "types": "./lib/react/index.d.ts", "default": "./lib/react/index.js" },
|
|
9
|
+
"./client": { "types": "./lib/core/client.d.ts", "default": "./lib/core/client.js" },
|
|
10
|
+
"./jobs": { "types": "./lib/core/jobs.d.ts", "default": "./lib/core/jobs.js" },
|
|
11
|
+
"./sqlite": { "types": "./lib/backends/sqlite.d.ts", "default": "./lib/backends/sqlite.js" }
|
|
12
|
+
},
|
|
13
|
+
"files": ["lib", "README.md"],
|
|
14
|
+
"publishConfig": { "access": "public" },
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"sync": "tsx scripts/sync.ts",
|
|
18
|
+
"build:client": "npm run sync && esbuild src/client.tsx --bundle --format=esm --outfile=dist/client.js --jsx=automatic --loader:.tsx=tsx",
|
|
19
|
+
"dev": "npm run build:client && tsx src/server.tsx",
|
|
20
|
+
"dev:remote": "npm run build:client && FLUXE_BACKEND=remote tsx src/server.tsx",
|
|
21
|
+
"test": "tsx src/selftest2.ts",
|
|
22
|
+
"test:unit": "node --experimental-sqlite --import tsx --test '{src,app}/**/*.test.ts'",
|
|
23
|
+
"test:cells": "node --experimental-sqlite --import tsx --test 'app/cells/**/*.test.ts'",
|
|
24
|
+
"test:all": "npm run sync && npm run typecheck && npm run test:unit && npm run test",
|
|
25
|
+
"resolve": "npm run sync && tsx scripts/resolve.ts",
|
|
26
|
+
"fx": "tsx bin/fx.ts",
|
|
27
|
+
"build": "rm -rf lib && tsc -p tsconfig.build.json",
|
|
28
|
+
"prepublishOnly": "npm run build"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": { "zod": "^3" },
|
|
31
|
+
"peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" },
|
|
32
|
+
"devDependencies": { "typescript": "^5", "tsx": "^4", "esbuild": "^0.23", "react": "^18", "react-dom": "^18", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18" }
|
|
33
|
+
}
|