@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
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# fluxe
|
|
2
|
+
|
|
3
|
+
Khung fullstack **polyglot** dựa trên triết lý **RCA — Resolved Cell Architecture**:
|
|
4
|
+
*logic chỉ phụ thuộc HỢP ĐỒNG; mọi quyết định vận hành (ngôn ngữ, render, transport,
|
|
5
|
+
backend, scale) là **kết quả được GIẢI** bởi engine, không viết tay.*
|
|
6
|
+
|
|
7
|
+
> Đổi backend từ TS → Go → Rust, đổi render static ↔ island, gộp nhiều backend trong một
|
|
8
|
+
> app per-cell — tất cả chỉ sửa `app/profiles.ts`, **cell & frontend không đổi một dòng**.
|
|
9
|
+
|
|
10
|
+
## Cài & dùng (npm)
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm i @nmvuong92/fluxe react react-dom zod
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { defineCell, withInput, makeServer, createMemoryBackend } from "@nmvuong92/fluxe";
|
|
18
|
+
import { useQuery, useMutation, Link, Nav, ThemeToggle } from "@nmvuong92/fluxe/react";
|
|
19
|
+
import { rpc } from "@nmvuong92/fluxe/client";
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Import | Nội dung |
|
|
23
|
+
|--------|----------|
|
|
24
|
+
| `@nmvuong92/fluxe` | engine: defineCell, makeServer, resolver, auth, validate, backends, seo, broker, ratelimit, codegen… |
|
|
25
|
+
| `@nmvuong92/fluxe/react` | useQuery, useMutation, Link, Nav, ThemeToggle, useTheme, DebugBar |
|
|
26
|
+
| `@nmvuong92/fluxe/client` | rpc, RpcError, mutate, revalidate, subscribe |
|
|
27
|
+
| `@nmvuong92/fluxe/jobs` · `/sqlite` | queue/dead-letter · SQLite backend (cần `--experimental-sqlite`) |
|
|
28
|
+
|
|
29
|
+
## Chạy nhanh
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install
|
|
33
|
+
npm run fx -- build # resolve + prerender + bundle (1 schema → TS/Go/Rust qua fx gen)
|
|
34
|
+
npm run fx -- dev # http://localhost:5180
|
|
35
|
+
npm run test:all # typecheck + 107 unit + 28 integration — TẤT CẢ XANH
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`fx`: `gen · resolve · prerender · build · dev · test · jobs`.
|
|
39
|
+
|
|
40
|
+
## Cấu trúc — ranh giới DEV vs ENGINE
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
app/ ← DEV sở hữu (sửa thoải mái) — Contract Plane
|
|
44
|
+
cells/ trang/feature (route + loader + view + action/head/layout/guard)
|
|
45
|
+
layouts/ layout dùng chung (nested)
|
|
46
|
+
profiles.ts CHỌN backend per môi trường + per-cell (memory/go/rust)
|
|
47
|
+
contract.ts schema → codegen TS/Go/Rust
|
|
48
|
+
env.ts env có kiểu, validate fail-fast lúc boot
|
|
49
|
+
native/ service Go/Rust CỦA DEV (backend/host/hot/actor)
|
|
50
|
+
|
|
51
|
+
src/ ← ENGINE (không đụng) — Resolution Plane
|
|
52
|
+
core/ resolver · router · errors · auth · validate · codegen · layouts ·
|
|
53
|
+
broker · presence · jobs · ratelimit · observe · panel · seo · cli · ...
|
|
54
|
+
server_factory.ts runtime ráp cell + giải manifest
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Quy tắc:** "backend nào đang chạy" do `app/profiles.ts` (config) quyết, **không** do vị trí
|
|
58
|
+
folder. Engine không bao giờ import ngược vào `app/`.
|
|
59
|
+
|
|
60
|
+
## Tính năng (tất cả TDD + chạy thật)
|
|
61
|
+
|
|
62
|
+
- **Render** — static (0 JS) · island hydrate · SPA nav (Inertia) · static-prerender (Go phục vụ thẳng) · API mode `?json=1`
|
|
63
|
+
- **Routing** — động `[param]` → `ctx.input` · **nested layouts** · SEO (head/canonical/OG/JSON-LD per cell, `/sitemap.xml`, `/robots.txt`)
|
|
64
|
+
- **Bảo mật (đầy đủ)** — input validation (Zod) · auth password **scrypt** · **RBAC** · **CSRF** double-submit · **rate-limit** token-bucket · error handling không-leak + structured
|
|
65
|
+
- **Data** — backend polyglot per-cell · DB **SQLite thật** + adapter **Postgres** · **codegen contract** 1 schema → TS/Go/Rust
|
|
66
|
+
- **Mutations DX** — `RpcError` có cấu trúc · `mutate()` optimistic + rollback · lỗi validation field-level
|
|
67
|
+
- **Realtime (Trục 4g)** — **SSE channel** + pub/sub broker · live-update on action · **presence** (multi-tab) · **actor-Go** (BEAM-style: room=goroutine, supervisor restart)
|
|
68
|
+
- **Async** — **job queue bền** (SQLite, retry → dead-letter)
|
|
69
|
+
- **Observability** — request log + dashboard `/_fluxe` (RCA Resolution + Recent requests)
|
|
70
|
+
- **Config** — env có kiểu, fail-fast lúc boot
|
|
71
|
+
- **DX** — `fx` CLI · mock `Backend` test cực dễ · typecheck gate
|
|
72
|
+
|
|
73
|
+
## Polyglot 4 tầng (Go/Rust thật, đã chạy)
|
|
74
|
+
|
|
75
|
+
| Tầng | Ngôn ngữ | Demo |
|
|
76
|
+
|------|----------|------|
|
|
77
|
+
| **Backend** (Todo CRUD) | Go · Rust | `./run-native.sh` |
|
|
78
|
+
| **Host/edge** (proxy + static) | Go | `./run-host.sh` |
|
|
79
|
+
| **Hot compute** (search) | Rust | `./run-hot.sh` |
|
|
80
|
+
| **Actor/realtime** (room+supervisor) | Go | `./run-actor.sh` |
|
|
81
|
+
|
|
82
|
+
Engine viết bằng: **TS** (Resolver + SSR/cell) · **Go** (host/actor) · **Rust** (hot compute) —
|
|
83
|
+
mỗi tầng đúng ngôn ngữ tối ưu, đúng §6d của [idea.md](idea.md).
|
|
84
|
+
|
|
85
|
+
## Triết lý
|
|
86
|
+
|
|
87
|
+
Toàn bộ định hướng, các trục chiến lược, tenets và nguyên lý RCA: xem **[idea.md](idea.md)**.
|
|
88
|
+
Spec + plan: `docs/superpowers/`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* Backend #3/#4: gọi một service THẬT qua HTTP (Go hoặc Rust).
|
|
2
|
+
* Cùng interface Backend → cell + frontend KHÔNG đổi một dòng.
|
|
3
|
+
* Service chỉ cần tôn trọng "hợp đồng" 3 endpoint:
|
|
4
|
+
* GET /todos → Todo[]
|
|
5
|
+
* POST /todos {title} → Todo
|
|
6
|
+
* POST /todos/{id}/toggle → Todo[]
|
|
7
|
+
*/
|
|
8
|
+
export function createHttpBackend(name, baseUrl) {
|
|
9
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
10
|
+
async function j(path, init) {
|
|
11
|
+
const r = await fetch(base + path, init);
|
|
12
|
+
if (!r.ok)
|
|
13
|
+
throw new Error(`${name} backend ${path} → HTTP ${r.status}`);
|
|
14
|
+
return (await r.json());
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
name,
|
|
18
|
+
listTodos() {
|
|
19
|
+
return j("/todos");
|
|
20
|
+
},
|
|
21
|
+
addTodo(title) {
|
|
22
|
+
return j("/todos", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
body: JSON.stringify({ title }),
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
toggleTodo(id) {
|
|
29
|
+
return j(`/todos/${encodeURIComponent(id)}/toggle`, { method: "POST" });
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/* Backend #1: in-memory. Đại diện cho "TS thuần". */
|
|
2
|
+
export function createMemoryBackend() {
|
|
3
|
+
let todos = [
|
|
4
|
+
{ id: "1", title: "Học kiến trúc fullstack", done: true },
|
|
5
|
+
{ id: "2", title: "Dựng PoC switch backend", done: false },
|
|
6
|
+
];
|
|
7
|
+
let seq = 3;
|
|
8
|
+
return {
|
|
9
|
+
name: "memory",
|
|
10
|
+
async listTodos() { return todos; },
|
|
11
|
+
async addTodo(title) {
|
|
12
|
+
const t = { id: String(seq++), title, done: false };
|
|
13
|
+
todos = [...todos, t];
|
|
14
|
+
return t;
|
|
15
|
+
},
|
|
16
|
+
async toggleTodo(id) {
|
|
17
|
+
todos = todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
|
|
18
|
+
return todos;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Nhận sẵn client (đã connect) để testable + không buộc import pg ở repo này.
|
|
2
|
+
export function createPostgresBackend(client) {
|
|
3
|
+
const toTodo = (r) => ({ id: String(r.id), title: r.title, done: !!r.done });
|
|
4
|
+
return {
|
|
5
|
+
name: "postgres",
|
|
6
|
+
async listTodos() {
|
|
7
|
+
const { rows } = await client.query("SELECT * FROM todos ORDER BY id");
|
|
8
|
+
return rows.map(toTodo);
|
|
9
|
+
},
|
|
10
|
+
async addTodo(title) {
|
|
11
|
+
const { rows } = await client.query("INSERT INTO todos (title, done) VALUES ($1, false) RETURNING *", [title]);
|
|
12
|
+
return toTodo(rows[0]);
|
|
13
|
+
},
|
|
14
|
+
async toggleTodo(id) {
|
|
15
|
+
await client.query("UPDATE todos SET done = NOT done WHERE id = $1", [id]);
|
|
16
|
+
const { rows } = await client.query("SELECT * FROM todos ORDER BY id");
|
|
17
|
+
return rows.map(toTodo);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/* Cách dùng thật (khi có pg + server):
|
|
22
|
+
* import { Client } from "pg";
|
|
23
|
+
* const client = new Client(process.env.DATABASE_URL);
|
|
24
|
+
* await client.connect();
|
|
25
|
+
* await client.query(`CREATE TABLE IF NOT EXISTS todos (
|
|
26
|
+
* id SERIAL PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT false)`);
|
|
27
|
+
* const backend = createPostgresBackend(client);
|
|
28
|
+
*/
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
/* Backend DB THẬT — SQLite qua node:sqlite (built-in, 0 dep, persist ra file).
|
|
3
|
+
* Chạy cần cờ: node --experimental-sqlite … Cùng interface Backend → switch như mọi backend. */
|
|
4
|
+
export function createSqliteBackend(path = ":memory:") {
|
|
5
|
+
const db = new DatabaseSync(path);
|
|
6
|
+
db.exec(`CREATE TABLE IF NOT EXISTS todos (
|
|
7
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
8
|
+
title TEXT NOT NULL,
|
|
9
|
+
done INTEGER NOT NULL DEFAULT 0
|
|
10
|
+
)`);
|
|
11
|
+
const toTodo = (r) => ({ id: String(r.id), title: r.title, done: !!r.done });
|
|
12
|
+
const all = () => db.prepare("SELECT * FROM todos ORDER BY id").all().map(toTodo);
|
|
13
|
+
return {
|
|
14
|
+
name: "sqlite",
|
|
15
|
+
async listTodos() {
|
|
16
|
+
return all();
|
|
17
|
+
},
|
|
18
|
+
async addTodo(title) {
|
|
19
|
+
const info = db.prepare("INSERT INTO todos (title) VALUES (?)").run(title);
|
|
20
|
+
return toTodo(db.prepare("SELECT * FROM todos WHERE id = ?").get(info.lastInsertRowid));
|
|
21
|
+
},
|
|
22
|
+
async toggleTodo(id) {
|
|
23
|
+
db.prepare("UPDATE todos SET done = 1 - done WHERE id = ?").run(Number(id));
|
|
24
|
+
return all();
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* Backend Adapter — interface chuẩn để SWITCH backend.
|
|
3
|
+
* loader/action chỉ biết tới interface này, không biết
|
|
4
|
+
* dữ liệu đến từ memory, Postgres, hay một service Go từ xa.
|
|
5
|
+
* Đổi backend = thay một implementation, frontend & cell không đổi.
|
|
6
|
+
* ============================================================ */
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface Session {
|
|
2
|
+
user: string;
|
|
3
|
+
roles?: string[];
|
|
4
|
+
[k: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
export declare function hasRole(session: Session | null, role: string): boolean;
|
|
7
|
+
export declare function signSession(payload: Session, secret: string): string;
|
|
8
|
+
export declare function verifySession(token: string | undefined, secret: string): Session | null;
|
|
9
|
+
export declare function hashPassword(password: string): string;
|
|
10
|
+
export declare function verifyPassword(password: string, stored: string): boolean;
|
|
11
|
+
export declare function newCsrfToken(): string;
|
|
12
|
+
export declare function parseCookie(header: string | undefined): Record<string, string>;
|
package/lib/core/auth.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual, scryptSync, randomBytes } from "node:crypto";
|
|
2
|
+
export function hasRole(session, role) {
|
|
3
|
+
return !!session?.roles?.includes(role);
|
|
4
|
+
}
|
|
5
|
+
export function signSession(payload, secret) {
|
|
6
|
+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
7
|
+
const sig = createHmac("sha256", secret).update(body).digest("base64url");
|
|
8
|
+
return `${body}.${sig}`;
|
|
9
|
+
}
|
|
10
|
+
export function verifySession(token, secret) {
|
|
11
|
+
if (!token)
|
|
12
|
+
return null;
|
|
13
|
+
const [body, sig] = token.split(".");
|
|
14
|
+
if (!body || !sig)
|
|
15
|
+
return null;
|
|
16
|
+
const expected = createHmac("sha256", secret).update(body).digest("base64url");
|
|
17
|
+
const a = Buffer.from(sig), b = Buffer.from(expected);
|
|
18
|
+
if (a.length !== b.length || !timingSafeEqual(a, b))
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/* Password hashing — scrypt (built-in, memory-hard). Format: scrypt$salt$hash.
|
|
28
|
+
* (Production: Argon2id qua `npm i argon2`; scrypt là chuẩn built-in, an toàn.) */
|
|
29
|
+
export function hashPassword(password) {
|
|
30
|
+
const salt = randomBytes(16);
|
|
31
|
+
const hash = scryptSync(password, salt, 32);
|
|
32
|
+
return `scrypt$${salt.toString("hex")}$${hash.toString("hex")}`;
|
|
33
|
+
}
|
|
34
|
+
export function verifyPassword(password, stored) {
|
|
35
|
+
const [algo, saltHex, hashHex] = stored.split("$");
|
|
36
|
+
if (algo !== "scrypt" || !saltHex || !hashHex)
|
|
37
|
+
return false;
|
|
38
|
+
const hash = scryptSync(password, Buffer.from(saltHex, "hex"), 32);
|
|
39
|
+
const expected = Buffer.from(hashHex, "hex");
|
|
40
|
+
return hash.length === expected.length && timingSafeEqual(hash, expected);
|
|
41
|
+
}
|
|
42
|
+
/* CSRF token (double-submit cookie): cookie csrf + header x-csrf-token phải khớp. */
|
|
43
|
+
export function newCsrfToken() {
|
|
44
|
+
return randomBytes(24).toString("hex");
|
|
45
|
+
}
|
|
46
|
+
export function parseCookie(header) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const part of (header ?? "").split(";")) {
|
|
49
|
+
const i = part.indexOf("=");
|
|
50
|
+
if (i > 0)
|
|
51
|
+
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* Broker pub/sub in-memory — nền của realtime channel (Trục 4g), bản 1-node.
|
|
2
|
+
* Bản distributed: thay bằng NATS/Redis fan-out (cùng interface). Bản stateful:
|
|
3
|
+
* actor-Go (app/native/actor-go). Đây là lõi đơn giản nhất, dependency-free. */
|
|
4
|
+
export function createBroker() {
|
|
5
|
+
const subs = new Map();
|
|
6
|
+
return {
|
|
7
|
+
subscribe(topic, fn) {
|
|
8
|
+
let set = subs.get(topic);
|
|
9
|
+
if (!set) {
|
|
10
|
+
set = new Set();
|
|
11
|
+
subs.set(topic, set);
|
|
12
|
+
}
|
|
13
|
+
set.add(fn);
|
|
14
|
+
return () => {
|
|
15
|
+
set.delete(fn);
|
|
16
|
+
if (set.size === 0)
|
|
17
|
+
subs.delete(topic);
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
publish(topic, data) {
|
|
21
|
+
subs.get(topic)?.forEach((fn) => fn(data));
|
|
22
|
+
},
|
|
23
|
+
count(topic) {
|
|
24
|
+
return subs.get(topic)?.size ?? 0;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function parseChaos(header) {
|
|
2
|
+
const out = { delayMs: 0, failRate: 0 };
|
|
3
|
+
for (const part of (header ?? "").split(";")) {
|
|
4
|
+
const [k, v] = part.split("=");
|
|
5
|
+
if (k?.trim() === "delay")
|
|
6
|
+
out.delayMs = Math.max(0, Number(v) || 0);
|
|
7
|
+
if (k?.trim() === "fail")
|
|
8
|
+
out.failRate = Math.min(1, Math.max(0, Number(v) || 0));
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
package/lib/core/cli.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/* fx CLI — registry lệnh thuần (testable). bin/fx.ts dispatch + spawn. */
|
|
2
|
+
const SYNC = "tsx scripts/sync.ts"; // auto-discovery: quét app/cells/* → app/app.ts
|
|
3
|
+
const ESBUILD = "esbuild src/client.tsx --bundle --format=esm --outfile=dist/client.js --jsx=automatic --loader:.tsx=tsx";
|
|
4
|
+
const p = (a) => a[0] ?? "dev"; // profile mặc định
|
|
5
|
+
export const COMMANDS = {
|
|
6
|
+
init: {
|
|
7
|
+
desc: "Scaffold app/ mới (env, profiles, layout, cell home) — chỉ tạo file còn thiếu",
|
|
8
|
+
shell: () => `tsx scripts/init.ts`,
|
|
9
|
+
},
|
|
10
|
+
new: {
|
|
11
|
+
desc: "Tạo cell mới: fx new <id> [--island] (auto-discovery tự đăng ký)",
|
|
12
|
+
shell: (a) => `tsx scripts/new-cell.ts ${a.join(" ")}`,
|
|
13
|
+
},
|
|
14
|
+
sync: {
|
|
15
|
+
desc: "Auto-discovery: quét app/cells/* → app/app.ts (dev/resolve tự gọi)",
|
|
16
|
+
shell: () => SYNC,
|
|
17
|
+
},
|
|
18
|
+
gen: {
|
|
19
|
+
desc: "Codegen contract → types TS/Go/Rust (.fluxe/gen)",
|
|
20
|
+
shell: () => `tsx scripts/codegen.ts`,
|
|
21
|
+
},
|
|
22
|
+
resolve: {
|
|
23
|
+
desc: "Sinh .fluxe/resolution.json từ profile",
|
|
24
|
+
shell: (a) => `${SYNC} && tsx scripts/resolve.ts ${p(a)}`,
|
|
25
|
+
},
|
|
26
|
+
jobs: {
|
|
27
|
+
desc: "Demo job queue (enqueue + worker drain + dead-letter)",
|
|
28
|
+
shell: () => `node --experimental-sqlite --import tsx scripts/jobs-demo.ts`,
|
|
29
|
+
},
|
|
30
|
+
bench: {
|
|
31
|
+
desc: "Benchmark RPS/QPS + latency p50/p99 + RAM/CPU",
|
|
32
|
+
shell: (a) => `${SYNC} && tsx scripts/resolve.ts dev && npm run --silent build:client && tsx scripts/bench.ts ${a.join(" ")}`,
|
|
33
|
+
},
|
|
34
|
+
prerender: {
|
|
35
|
+
desc: "Prerender cell static → .fluxe/static.json",
|
|
36
|
+
shell: (a) => `${SYNC} && tsx scripts/prerender.ts ${p(a)}`,
|
|
37
|
+
},
|
|
38
|
+
build: {
|
|
39
|
+
desc: "Build đầy đủ: sync + resolve + prerender + client bundle",
|
|
40
|
+
shell: (a) => `${SYNC} && tsx scripts/resolve.ts ${p(a)} && tsx scripts/prerender.ts ${p(a)} && ${ESBUILD}`,
|
|
41
|
+
},
|
|
42
|
+
dev: {
|
|
43
|
+
desc: "Sync + resolve + build client + chạy server",
|
|
44
|
+
shell: (a) => `${SYNC} && tsx scripts/resolve.ts ${p(a)} && ${ESBUILD} && tsx src/server.tsx`,
|
|
45
|
+
},
|
|
46
|
+
test: {
|
|
47
|
+
desc: "Sync + typecheck + unit + integration",
|
|
48
|
+
shell: () => `${SYNC} && tsc --noEmit && node --experimental-sqlite --import tsx --test '{src,app}/**/*.test.ts' && tsx src/selftest2.ts`,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
export function renderUsage() {
|
|
52
|
+
const lines = Object.entries(COMMANDS).map(([n, c]) => ` fx ${n.padEnd(10)} ${c.desc}`);
|
|
53
|
+
return `fluxe CLI\n\nLệnh:\n${lines.join("\n")}\n`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export declare class RpcError extends Error {
|
|
2
|
+
code: string;
|
|
3
|
+
status: number;
|
|
4
|
+
details?: unknown;
|
|
5
|
+
constructor(code: string, message: string, status: number, details?: unknown);
|
|
6
|
+
}
|
|
7
|
+
export declare function parseRpcError(status: number, body: string): RpcError;
|
|
8
|
+
export declare const setChaos: (v: string) => void;
|
|
9
|
+
export declare const getChaos: () => string;
|
|
10
|
+
export declare const setDevBackend: (v: string) => void;
|
|
11
|
+
export declare const getDevBackend: () => string;
|
|
12
|
+
export interface RpcMeta {
|
|
13
|
+
resolution?: string;
|
|
14
|
+
serverMs?: number;
|
|
15
|
+
clientMs?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare const lastRpcMeta: () => RpcMeta;
|
|
18
|
+
export declare function rpc<T = any>(cell: string, action: string, input: unknown): Promise<T>;
|
|
19
|
+
export declare function mutate<T>(opts: {
|
|
20
|
+
optimistic?: () => void;
|
|
21
|
+
run: () => Promise<T>;
|
|
22
|
+
rollback?: () => void;
|
|
23
|
+
}): Promise<T>;
|
|
24
|
+
export declare function fetchPageProps(url: string): Promise<{
|
|
25
|
+
cell: string;
|
|
26
|
+
data: unknown;
|
|
27
|
+
layout?: string;
|
|
28
|
+
}>;
|
|
29
|
+
export declare function revalidate(): Promise<{
|
|
30
|
+
cell: string;
|
|
31
|
+
data: unknown;
|
|
32
|
+
}>;
|
|
33
|
+
export declare function subscribe(topic: string, onData: (data: any) => void): () => void;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* Client runtime — Inertia-style + mutations DX.
|
|
3
|
+
* - rpc(): gọi action, ném RpcError CÓ CẤU TRÚC (code/message/details) khi lỗi.
|
|
4
|
+
* - mutate(): optimistic + rollback khi lỗi.
|
|
5
|
+
* - revalidate(): refetch props trang hiện tại sau khi mutate.
|
|
6
|
+
* ============================================================ */
|
|
7
|
+
export class RpcError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
status;
|
|
10
|
+
details;
|
|
11
|
+
constructor(code, message, status, details) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "RpcError";
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.details = details;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Pure: dựng RpcError từ status + body trả về (server trả {error:{code,message,details}}).
|
|
20
|
+
export function parseRpcError(status, body) {
|
|
21
|
+
try {
|
|
22
|
+
const e = JSON.parse(body)?.error;
|
|
23
|
+
if (e?.code)
|
|
24
|
+
return new RpcError(e.code, e.message ?? "Lỗi", status, e.details);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* không phải JSON */
|
|
28
|
+
}
|
|
29
|
+
return new RpcError("http", `HTTP ${status}`, status);
|
|
30
|
+
}
|
|
31
|
+
function cookie(name) {
|
|
32
|
+
if (typeof document === "undefined")
|
|
33
|
+
return "";
|
|
34
|
+
const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
|
35
|
+
return m ? decodeURIComponent(m[1]) : "";
|
|
36
|
+
}
|
|
37
|
+
/* DevTools config (chỉ ảnh hưởng dev): chaos injection + live backend swap. DebugBar set. */
|
|
38
|
+
let _chaos = ""; // vd "delay=600;fail=0.3"
|
|
39
|
+
let _devBackend = ""; // vd "go" | "rust" | "memory"
|
|
40
|
+
export const setChaos = (v) => { _chaos = v; };
|
|
41
|
+
export const getChaos = () => _chaos;
|
|
42
|
+
export const setDevBackend = (v) => { _devBackend = v; };
|
|
43
|
+
export const getDevBackend = () => _devBackend;
|
|
44
|
+
let _lastMeta = {};
|
|
45
|
+
export const lastRpcMeta = () => _lastMeta;
|
|
46
|
+
export async function rpc(cell, action, input) {
|
|
47
|
+
const t0 = typeof performance !== "undefined" ? performance.now() : 0;
|
|
48
|
+
const headers = { "content-type": "application/json", "x-csrf-token": cookie("csrf") };
|
|
49
|
+
if (_chaos)
|
|
50
|
+
headers["x-fluxe-chaos"] = _chaos; // #1 chaos
|
|
51
|
+
if (_devBackend)
|
|
52
|
+
headers["x-fluxe-backend"] = _devBackend; // #5 live swap
|
|
53
|
+
let res;
|
|
54
|
+
try {
|
|
55
|
+
res = await fetch(`/__action/${cell}/${action}`, { method: "POST", headers, body: JSON.stringify(input) });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
_lastMeta = {};
|
|
59
|
+
throw new RpcError("network", "Mất kết nối máy chủ", 0);
|
|
60
|
+
}
|
|
61
|
+
const hget = (k) => res.headers?.get?.(k) ?? null;
|
|
62
|
+
_lastMeta = {
|
|
63
|
+
resolution: hget("x-fluxe-resolution") ?? undefined,
|
|
64
|
+
serverMs: Number(hget("x-fluxe-server-ms")) || undefined,
|
|
65
|
+
clientMs: Math.round((typeof performance !== "undefined" ? performance.now() : 0) - t0),
|
|
66
|
+
};
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
throw parseRpcError(res.status, await res.text());
|
|
69
|
+
return res.json();
|
|
70
|
+
}
|
|
71
|
+
// Optimistic update: chạy optimistic() ngay, run() ngầm; lỗi → rollback() + ném lại.
|
|
72
|
+
export async function mutate(opts) {
|
|
73
|
+
opts.optimistic?.();
|
|
74
|
+
try {
|
|
75
|
+
return await opts.run();
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
opts.rollback?.();
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/* SPA navigation kiểu Inertia: lấy props JSON rồi để runtime render lại */
|
|
83
|
+
export async function fetchPageProps(url) {
|
|
84
|
+
const res = await fetch(url, { headers: { "x-fluxe": "1" } });
|
|
85
|
+
if (!res.ok)
|
|
86
|
+
throw new Error(`fetchPageProps ${url} → ${res.status}`);
|
|
87
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
88
|
+
if (!ct.includes("application/json"))
|
|
89
|
+
throw new Error("không phải trang cell"); // file tĩnh → hard nav
|
|
90
|
+
return res.json();
|
|
91
|
+
}
|
|
92
|
+
/* Revalidate: refetch props trang hiện tại (gọi sau mutate để đồng bộ data). */
|
|
93
|
+
export async function revalidate() {
|
|
94
|
+
return fetchPageProps(location.pathname + location.search);
|
|
95
|
+
}
|
|
96
|
+
/* Realtime: subscribe topic qua SSE. Trả hàm hủy. (Trục 4g) */
|
|
97
|
+
export function subscribe(topic, onData) {
|
|
98
|
+
if (typeof EventSource === "undefined")
|
|
99
|
+
return () => { };
|
|
100
|
+
const es = new EventSource(`/__sse/${encodeURIComponent(topic)}`);
|
|
101
|
+
es.onmessage = (e) => {
|
|
102
|
+
try {
|
|
103
|
+
onData(JSON.parse(e.data));
|
|
104
|
+
}
|
|
105
|
+
catch { /* ignore */ }
|
|
106
|
+
};
|
|
107
|
+
return () => es.close();
|
|
108
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type FieldType = "string" | "bool" | "int";
|
|
2
|
+
export interface Schema {
|
|
3
|
+
types: Record<string, Record<string, FieldType>>;
|
|
4
|
+
}
|
|
5
|
+
export declare function genTS(s: Schema): string;
|
|
6
|
+
export declare function genGo(s: Schema, pkg?: string): string;
|
|
7
|
+
export declare function genRust(s: Schema): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/* Codegen contract polyglot — MỘT schema → types TS + Go + Rust (chữ ký RCA:
|
|
2
|
+
* type-safe xuyên ngôn ngữ). Thuần (string-in/string-out), dễ test. */
|
|
3
|
+
const pascal = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
4
|
+
const TS = { string: "string", bool: "boolean", int: "number" };
|
|
5
|
+
const GO = { string: "string", bool: "bool", int: "int" };
|
|
6
|
+
const RS = { string: "String", bool: "bool", int: "i64" };
|
|
7
|
+
export function genTS(s) {
|
|
8
|
+
return Object.entries(s.types)
|
|
9
|
+
.map(([name, fields]) => `export interface ${name} {\n` +
|
|
10
|
+
Object.entries(fields).map(([f, t]) => ` ${f}: ${TS[t]};`).join("\n") +
|
|
11
|
+
`\n}`)
|
|
12
|
+
.join("\n\n") + "\n";
|
|
13
|
+
}
|
|
14
|
+
export function genGo(s, pkg = "contract") {
|
|
15
|
+
return `package ${pkg}\n\n` +
|
|
16
|
+
Object.entries(s.types)
|
|
17
|
+
.map(([name, fields]) => `type ${name} struct {\n` +
|
|
18
|
+
Object.entries(fields).map(([f, t]) => `\t${pascal(f)} ${GO[t]} \`json:"${f}"\``).join("\n") +
|
|
19
|
+
`\n}`)
|
|
20
|
+
.join("\n\n") + "\n";
|
|
21
|
+
}
|
|
22
|
+
export function genRust(s) {
|
|
23
|
+
return Object.entries(s.types)
|
|
24
|
+
.map(([name, fields]) => `#[derive(Clone, Debug)]\npub struct ${name} {\n` +
|
|
25
|
+
Object.entries(fields).map(([f, t]) => ` pub ${f}: ${RS[t]},`).join("\n") +
|
|
26
|
+
`\n}`)
|
|
27
|
+
.join("\n\n") + "\n";
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import type { Backend } from "../backends/types";
|
|
3
|
+
import type { HeadMeta } from "./seo";
|
|
4
|
+
import type { Session } from "./auth";
|
|
5
|
+
export interface Ctx<I> {
|
|
6
|
+
input: I;
|
|
7
|
+
backend: Backend;
|
|
8
|
+
session?: Session | null;
|
|
9
|
+
}
|
|
10
|
+
export type Loader<I, O> = (ctx: Ctx<I>) => Promise<O>;
|
|
11
|
+
export type Action<I, O> = (ctx: Ctx<I>) => Promise<O>;
|
|
12
|
+
export type Hydration = "static" | "island";
|
|
13
|
+
export interface CellDef<I, O> {
|
|
14
|
+
id: string;
|
|
15
|
+
route: string;
|
|
16
|
+
hydration?: Hydration;
|
|
17
|
+
loader: Loader<I, O>;
|
|
18
|
+
view: ComponentType<{
|
|
19
|
+
data: O;
|
|
20
|
+
}>;
|
|
21
|
+
actions?: Record<string, Action<any, any>>;
|
|
22
|
+
head?: (data: O) => HeadMeta;
|
|
23
|
+
layout?: string;
|
|
24
|
+
requireAuth?: boolean;
|
|
25
|
+
requireRole?: string;
|
|
26
|
+
cache?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare function defineCell<I, O>(c: CellDef<I, O>): CellDef<I, O>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function defineCell(c) { return c; }
|
package/lib/core/env.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* Config/env có kiểu (4k) — validate process.env theo schema, FAIL-FAST lúc boot
|
|
2
|
+
* (thiếu/sai biến → chặn ngay, không chết giữa prod). Coerce string→number/bool qua Zod. */
|
|
3
|
+
export function loadEnv(schema, source = process.env) {
|
|
4
|
+
const r = schema.safeParse(source);
|
|
5
|
+
if (!r.success) {
|
|
6
|
+
const issues = r.error.issues.map((i) => ` ${i.path.join(".") || "(env)"}: ${i.message}`).join("\n");
|
|
7
|
+
throw new Error(`Cấu hình env không hợp lệ (fail-fast lúc boot):\n${issues}`);
|
|
8
|
+
}
|
|
9
|
+
return r.data;
|
|
10
|
+
}
|