@nmvuong92/fluxe 0.1.1 → 0.3.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/lib/core/client.d.ts +9 -0
- package/lib/core/client.js +14 -0
- package/lib/core/engine.d.ts +3 -0
- package/lib/core/i18n.d.ts +17 -0
- package/lib/core/i18n.js +40 -0
- package/lib/core/multipart.d.ts +8 -0
- package/lib/core/multipart.js +49 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -0
- package/lib/react/LocaleSwitch.d.ts +6 -0
- package/lib/react/LocaleSwitch.js +8 -0
- package/lib/react/index.d.ts +1 -0
- package/lib/react/index.js +1 -0
- package/lib/react/shell.d.ts +1 -1
- package/lib/react/shell.js +1 -1
- package/lib/server_factory.d.ts +7 -1
- package/lib/server_factory.js +82 -9
- package/lib/storage/local.d.ts +5 -0
- package/lib/storage/local.js +37 -0
- package/lib/storage/memory.d.ts +2 -0
- package/lib/storage/memory.js +19 -0
- package/lib/storage/s3.d.ts +6 -0
- package/lib/storage/s3.js +28 -0
- package/lib/storage/types.d.ts +21 -0
- package/lib/storage/types.js +14 -0
- package/package.json +1 -1
package/lib/core/client.d.ts
CHANGED
|
@@ -16,6 +16,15 @@ export interface RpcMeta {
|
|
|
16
16
|
}
|
|
17
17
|
export declare const lastRpcMeta: () => RpcMeta;
|
|
18
18
|
export declare function rpc<T = any>(cell: string, action: string, input: unknown): Promise<T>;
|
|
19
|
+
export declare function upload(field: string, files: File | File[]): Promise<{
|
|
20
|
+
key: string;
|
|
21
|
+
url: string;
|
|
22
|
+
size: number;
|
|
23
|
+
} | Array<{
|
|
24
|
+
key: string;
|
|
25
|
+
url: string;
|
|
26
|
+
size: number;
|
|
27
|
+
}>>;
|
|
19
28
|
export declare function mutate<T>(opts: {
|
|
20
29
|
optimistic?: () => void;
|
|
21
30
|
run: () => Promise<T>;
|
package/lib/core/client.js
CHANGED
|
@@ -70,6 +70,20 @@ export async function rpc(cell, action, input) {
|
|
|
70
70
|
throw parseRpcError(res.status, await res.text());
|
|
71
71
|
return res.json();
|
|
72
72
|
}
|
|
73
|
+
/* Upload file qua POST /__upload/<field> (multipart). Trả { key, url, size } (hoặc mảng nếu nhiều). */
|
|
74
|
+
export async function upload(field, files) {
|
|
75
|
+
const fd = new FormData();
|
|
76
|
+
for (const f of Array.isArray(files) ? files : [files])
|
|
77
|
+
fd.append(field, f);
|
|
78
|
+
const res = await fetch(`/__upload/${field}`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "x-csrf-token": cookie("csrf") }, // KHÔNG set content-type — browser tự thêm boundary
|
|
81
|
+
body: fd,
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok)
|
|
84
|
+
throw parseRpcError(res.status, await res.text());
|
|
85
|
+
return res.json();
|
|
86
|
+
}
|
|
73
87
|
// Optimistic update: chạy optimistic() ngay, run() ngầm; lỗi → rollback() + ném lại.
|
|
74
88
|
export async function mutate(opts) {
|
|
75
89
|
opts.optimistic?.();
|
package/lib/core/engine.d.ts
CHANGED
|
@@ -2,10 +2,13 @@ import type { ComponentType } from "react";
|
|
|
2
2
|
import type { Backend } from "../backends/types";
|
|
3
3
|
import type { HeadMeta } from "./seo";
|
|
4
4
|
import type { Session } from "./auth";
|
|
5
|
+
import type { TFn } from "./i18n";
|
|
5
6
|
export interface Ctx<I> {
|
|
6
7
|
input: I;
|
|
7
8
|
backend: Backend;
|
|
8
9
|
session?: Session | null;
|
|
10
|
+
locale?: string;
|
|
11
|
+
t?: TFn;
|
|
9
12
|
}
|
|
10
13
|
export type Loader<I, O> = (ctx: Ctx<I>) => Promise<O>;
|
|
11
14
|
export type Action<I, O> = (ctx: Ctx<I>) => Promise<O>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type Messages = Record<string, string>;
|
|
2
|
+
export type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
|
3
|
+
export interface I18n {
|
|
4
|
+
locales: string[];
|
|
5
|
+
defaultLocale: string;
|
|
6
|
+
catalogs: Record<string, Messages>;
|
|
7
|
+
}
|
|
8
|
+
export declare function createI18n(opts: {
|
|
9
|
+
defaultLocale: string;
|
|
10
|
+
catalogs: Record<string, Messages>;
|
|
11
|
+
}): I18n;
|
|
12
|
+
export declare function translate(i18n: I18n, locale: string, key: string, vars?: Record<string, string | number>): string;
|
|
13
|
+
export declare function makeT(i18n: I18n, locale: string): TFn;
|
|
14
|
+
export declare function resolveLocale(i18n: I18n, opts: {
|
|
15
|
+
cookie?: string;
|
|
16
|
+
acceptLanguage?: string;
|
|
17
|
+
}): string;
|
package/lib/core/i18n.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Copyright (c) 2026 nmvuong92
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/* i18n — THUẦN, testable. Catalog message theo locale + t(key, vars) interpolate {var}.
|
|
4
|
+
* resolveLocale: chọn locale từ cookie / Accept-Language, giới hạn trong locale có thật. */
|
|
5
|
+
export function createI18n(opts) {
|
|
6
|
+
const locales = Object.keys(opts.catalogs);
|
|
7
|
+
if (!locales.includes(opts.defaultLocale)) {
|
|
8
|
+
throw new Error(`i18n: defaultLocale '${opts.defaultLocale}' không có trong catalogs`);
|
|
9
|
+
}
|
|
10
|
+
return { locales, defaultLocale: opts.defaultLocale, catalogs: opts.catalogs };
|
|
11
|
+
}
|
|
12
|
+
/* Tra key trong catalog locale → fallback defaultLocale → fallback chính key. Interpolate {var}. */
|
|
13
|
+
export function translate(i18n, locale, key, vars) {
|
|
14
|
+
const msg = i18n.catalogs[locale]?.[key] ?? i18n.catalogs[i18n.defaultLocale]?.[key] ?? key;
|
|
15
|
+
if (!vars)
|
|
16
|
+
return msg;
|
|
17
|
+
return msg.replace(/\{(\w+)\}/g, (_, k) => (k in vars ? String(vars[k]) : `{${k}}`));
|
|
18
|
+
}
|
|
19
|
+
/* t() đã bind locale — dùng trong loader/view. */
|
|
20
|
+
export function makeT(i18n, locale) {
|
|
21
|
+
return (key, vars) => translate(i18n, locale, key, vars);
|
|
22
|
+
}
|
|
23
|
+
/* Chọn locale: cookie hợp lệ > Accept-Language (khớp tag hoặc base) > defaultLocale. */
|
|
24
|
+
export function resolveLocale(i18n, opts) {
|
|
25
|
+
if (opts.cookie && i18n.locales.includes(opts.cookie))
|
|
26
|
+
return opts.cookie;
|
|
27
|
+
if (opts.acceptLanguage) {
|
|
28
|
+
for (const part of opts.acceptLanguage.split(",")) {
|
|
29
|
+
const tag = part.split(";")[0].trim(); // "en-US"
|
|
30
|
+
if (!tag)
|
|
31
|
+
continue;
|
|
32
|
+
if (i18n.locales.includes(tag))
|
|
33
|
+
return tag;
|
|
34
|
+
const base = tag.split("-")[0]; // "en"
|
|
35
|
+
if (i18n.locales.includes(base))
|
|
36
|
+
return base;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return i18n.defaultLocale;
|
|
40
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface Part {
|
|
2
|
+
name: string;
|
|
3
|
+
filename?: string;
|
|
4
|
+
contentType?: string;
|
|
5
|
+
data: Buffer;
|
|
6
|
+
}
|
|
7
|
+
export declare function boundaryFromContentType(contentType: string | undefined): string | null;
|
|
8
|
+
export declare function parseMultipart(body: Buffer, boundary: string): Part[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Copyright (c) 2026 nmvuong92
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/* Parser multipart/form-data — THUẦN, chỉ Buffer (không lib). Tách body theo boundary thành
|
|
4
|
+
* các part { name, filename?, contentType?, data }. Dùng cho upload. */
|
|
5
|
+
const CRLFCRLF = Buffer.from("\r\n\r\n");
|
|
6
|
+
/* boundary lấy từ header Content-Type: multipart/form-data; boundary=XXXX */
|
|
7
|
+
export function boundaryFromContentType(contentType) {
|
|
8
|
+
if (!contentType)
|
|
9
|
+
return null;
|
|
10
|
+
const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
|
|
11
|
+
return m ? (m[1] ?? m[2]).trim() : null;
|
|
12
|
+
}
|
|
13
|
+
export function parseMultipart(body, boundary) {
|
|
14
|
+
const parts = [];
|
|
15
|
+
const delim = Buffer.from(`--${boundary}`);
|
|
16
|
+
const positions = [];
|
|
17
|
+
let idx = body.indexOf(delim, 0);
|
|
18
|
+
while (idx !== -1) {
|
|
19
|
+
positions.push(idx);
|
|
20
|
+
idx = body.indexOf(delim, idx + delim.length);
|
|
21
|
+
}
|
|
22
|
+
for (let i = 0; i < positions.length - 1; i++) {
|
|
23
|
+
let start = positions[i] + delim.length;
|
|
24
|
+
if (body[start] === 0x2d && body[start + 1] === 0x2d)
|
|
25
|
+
continue; // "--" → delim đóng, bỏ
|
|
26
|
+
if (body[start] === 0x0d && body[start + 1] === 0x0a)
|
|
27
|
+
start += 2; // bỏ \r\n sau delim
|
|
28
|
+
let end = positions[i + 1];
|
|
29
|
+
if (body[end - 2] === 0x0d && body[end - 1] === 0x0a)
|
|
30
|
+
end -= 2; // bỏ \r\n trước delim kế
|
|
31
|
+
const seg = body.subarray(start, end);
|
|
32
|
+
const sep = seg.indexOf(CRLFCRLF);
|
|
33
|
+
if (sep === -1)
|
|
34
|
+
continue;
|
|
35
|
+
const headerStr = seg.subarray(0, sep).toString("utf8");
|
|
36
|
+
const data = seg.subarray(sep + CRLFCRLF.length);
|
|
37
|
+
const cd = /content-disposition:[^\r\n]*?name="([^"]*)"(?:[^\r\n]*?filename="([^"]*)")?/i.exec(headerStr);
|
|
38
|
+
if (!cd)
|
|
39
|
+
continue;
|
|
40
|
+
const ct = /content-type:\s*([^\r\n]+)/i.exec(headerStr);
|
|
41
|
+
parts.push({
|
|
42
|
+
name: cd[1],
|
|
43
|
+
filename: cd[2] || undefined,
|
|
44
|
+
contentType: ct?.[1]?.trim(),
|
|
45
|
+
data,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return parts;
|
|
49
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from "./core/resolver.ts";
|
|
|
5
5
|
export * from "./core/wiring.ts";
|
|
6
6
|
export * from "./core/auth.ts";
|
|
7
7
|
export * from "./core/env.ts";
|
|
8
|
+
export * from "./core/i18n.ts";
|
|
8
9
|
export * from "./core/seo.ts";
|
|
9
10
|
export * from "./core/broker.ts";
|
|
10
11
|
export * from "./core/presence.ts";
|
|
@@ -14,6 +15,10 @@ export * from "./core/layouts.ts";
|
|
|
14
15
|
export * from "./core/router.ts";
|
|
15
16
|
export * from "./core/testing.ts";
|
|
16
17
|
export * from "./backends/types.ts";
|
|
18
|
+
export * from "./storage/types.ts";
|
|
19
|
+
export { createMemoryStorage } from "./storage/memory.ts";
|
|
20
|
+
export { createLocalStorage } from "./storage/local.ts";
|
|
21
|
+
export { createS3Storage } from "./storage/s3.ts";
|
|
17
22
|
export { createMemoryBackend } from "./backends/memory.ts";
|
|
18
23
|
export { createHttpBackend } from "./backends/http.ts";
|
|
19
24
|
export { createPostgresBackend } from "./backends/postgres.ts";
|
package/lib/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export * from "./core/resolver.js"; // resolve, ResolutionProfile/Manifest, Cell
|
|
|
9
9
|
export * from "./core/wiring.js"; // backendFromManifest, backendsFromManifest
|
|
10
10
|
export * from "./core/auth.js"; // session HMAC, scrypt password, CSRF, RBAC
|
|
11
11
|
export * from "./core/env.js"; // loadEnv
|
|
12
|
+
export * from "./core/i18n.js"; // createI18n, resolveLocale, translate, makeT, t(key, vars)
|
|
12
13
|
export * from "./core/seo.js"; // renderHead, renderSitemap, renderRobots, HeadMeta
|
|
13
14
|
export * from "./core/broker.js"; // pub/sub
|
|
14
15
|
export * from "./core/presence.js"; // ai online theo topic
|
|
@@ -18,6 +19,10 @@ export * from "./core/layouts.js"; // layoutChain, LayoutMeta
|
|
|
18
19
|
export * from "./core/router.js"; // makeRouter
|
|
19
20
|
export * from "./core/testing.js"; // createTestBackend
|
|
20
21
|
export * from "./backends/types.js"; // Backend, Todo
|
|
22
|
+
export * from "./storage/types.js"; // Storage, PutResult, GetResult, safeKey, makeKey
|
|
23
|
+
export { createMemoryStorage } from "./storage/memory.js";
|
|
24
|
+
export { createLocalStorage } from "./storage/local.js";
|
|
25
|
+
export { createS3Storage } from "./storage/s3.js"; // adapter tham chiếu (cần @aws-sdk/client-s3)
|
|
21
26
|
export { createMemoryBackend } from "./backends/memory.js";
|
|
22
27
|
export { createHttpBackend } from "./backends/http.js";
|
|
23
28
|
export { createPostgresBackend } from "./backends/postgres.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Copyright (c) 2026 nmvuong92
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
/* <LocaleSwitch> — đổi ngôn ngữ qua link ?locale=xx (server set cookie + redirect).
|
|
5
|
+
* Render HTML thuần → chạy cả trên cell static (0 JS). Active theo `current` (ctx.locale). */
|
|
6
|
+
export function LocaleSwitch({ locales, current, labels, className, }) {
|
|
7
|
+
return (_jsx("span", { className: className ?? "locale-switch", children: locales.map((l) => (_jsx("a", { href: `?locale=${l}`, className: l === current ? "locale active" : "locale", "aria-current": l === current ? "true" : undefined, children: labels?.[l] ?? l.toUpperCase() }, l))) }));
|
|
8
|
+
}
|
package/lib/react/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { Link } from "./Link";
|
|
|
4
4
|
export { Nav, type NavItem } from "./Nav";
|
|
5
5
|
export { useTheme, type Theme } from "./theme";
|
|
6
6
|
export { ThemeToggle } from "./ThemeToggle";
|
|
7
|
+
export { LocaleSwitch } from "./LocaleSwitch";
|
|
7
8
|
export { shellScript } from "./shell";
|
|
8
9
|
export { DebugBar } from "./DebugBar";
|
|
9
10
|
export { debug, DebugStore } from "./store";
|
package/lib/react/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { Link } from "./Link";
|
|
|
8
8
|
export { Nav } from "./Nav";
|
|
9
9
|
export { useTheme } from "./theme";
|
|
10
10
|
export { ThemeToggle } from "./ThemeToggle";
|
|
11
|
+
export { LocaleSwitch } from "./LocaleSwitch";
|
|
11
12
|
export { shellScript } from "./shell";
|
|
12
13
|
export { DebugBar } from "./DebugBar";
|
|
13
14
|
export { debug, DebugStore } from "./store";
|
package/lib/react/shell.d.ts
CHANGED
|
@@ -1 +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})();";
|
|
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){}document.cookie='theme='+n+';Path=/;Max-Age=31536000;SameSite=Lax';});}active();}\nif(document.readyState!=='loading')wire();else document.addEventListener('DOMContentLoaded',wire);\naddEventListener('fluxe:nav',active);addEventListener('popstate',active);\n})();";
|
package/lib/react/shell.js
CHANGED
|
@@ -9,7 +9,7 @@ export const shellScript = `(function(){
|
|
|
9
9
|
var d=document.documentElement;
|
|
10
10
|
try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')d.dataset.theme=t;}catch(e){}
|
|
11
11
|
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){}});}
|
|
12
|
-
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();}
|
|
12
|
+
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){}document.cookie='theme='+n+';Path=/;Max-Age=31536000;SameSite=Lax';});}active();}
|
|
13
13
|
if(document.readyState!=='loading')wire();else document.addEventListener('DOMContentLoaded',wire);
|
|
14
14
|
addEventListener('fluxe:nav',active);addEventListener('popstate',active);
|
|
15
15
|
})();`;
|
package/lib/server_factory.d.ts
CHANGED
|
@@ -8,5 +8,11 @@ type LayoutEntry = LayoutMeta & {
|
|
|
8
8
|
}) => any;
|
|
9
9
|
};
|
|
10
10
|
type LayoutMap = Record<string, LayoutEntry>;
|
|
11
|
-
|
|
11
|
+
import { type I18n } from "./core/i18n.ts";
|
|
12
|
+
import { type Storage } from "./storage/types.ts";
|
|
13
|
+
export declare function makeServer(manifest: ResolutionManifest, cells: CellDef<any, any>[], layouts?: LayoutMap, opts?: {
|
|
14
|
+
i18n?: I18n;
|
|
15
|
+
storage?: Storage;
|
|
16
|
+
maxUpload?: number;
|
|
17
|
+
}): http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
|
|
12
18
|
export {};
|
package/lib/server_factory.js
CHANGED
|
@@ -20,6 +20,9 @@ import { createPresence } from "./core/presence.js";
|
|
|
20
20
|
import { etagOf, etagMatches } from "./core/etag.js";
|
|
21
21
|
import { createRenderCache } from "./core/rendercache.js";
|
|
22
22
|
import { parseChaos } from "./core/chaos.js";
|
|
23
|
+
import { resolveLocale, makeT } from "./core/i18n.js";
|
|
24
|
+
import { parseMultipart, boundaryFromContentType } from "./core/multipart.js";
|
|
25
|
+
import { makeKey } from "./storage/types.js";
|
|
23
26
|
import { createMemoryBackend } from "./backends/memory.js";
|
|
24
27
|
import { createHttpBackend } from "./backends/http.js";
|
|
25
28
|
// Build backend theo ngôn ngữ (cho live swap trong devtools).
|
|
@@ -36,7 +39,7 @@ function devBackend(lang) {
|
|
|
36
39
|
const url = process.env[`${lang.toUpperCase()}_URL`] ?? DEV_BACKENDS[lang];
|
|
37
40
|
return url ? createHttpBackend(lang, url) : createMemoryBackend();
|
|
38
41
|
}
|
|
39
|
-
import { randomUUID } from "node:crypto";
|
|
42
|
+
import { randomUUID, randomBytes } from "node:crypto";
|
|
40
43
|
const DEV = process.env.NODE_ENV !== "production";
|
|
41
44
|
const SECRET = process.env.FLUXE_SECRET ?? "dev-secret-change-me";
|
|
42
45
|
// Demo user store (password hash scrypt tạo lúc boot). App thật: lấy từ DB.
|
|
@@ -59,9 +62,10 @@ function sendError(res, wantsJson, err) {
|
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
// Phần head (trước body) và tail (sau body) — body được STREAM ở giữa.
|
|
62
|
-
function shellHead(cell, data) {
|
|
65
|
+
function shellHead(cell, data, lang = "vi", theme = "") {
|
|
63
66
|
const headHtml = renderHead(cell.head ? cell.head(data) : {});
|
|
64
|
-
|
|
67
|
+
const themeAttr = theme ? ` data-theme="${theme}"` : ""; // theme-SSR: no-flash ngay lần đầu
|
|
68
|
+
return `<!doctype html><html lang="${lang}"${themeAttr}><head><meta charset="utf-8">${headHtml}</head><body><div id="root">`;
|
|
65
69
|
}
|
|
66
70
|
function shellTail(cell, data, shipClientJs) {
|
|
67
71
|
const island = shipClientJs
|
|
@@ -85,7 +89,25 @@ function renderBodyToString(node) {
|
|
|
85
89
|
});
|
|
86
90
|
});
|
|
87
91
|
}
|
|
88
|
-
export function makeServer(manifest, cells, layouts = {}) {
|
|
92
|
+
export function makeServer(manifest, cells, layouts = {}, opts = {}) {
|
|
93
|
+
const i18n = opts.i18n;
|
|
94
|
+
const storage = opts.storage;
|
|
95
|
+
const MAX_UPLOAD = opts.maxUpload ?? 10 * 1024 * 1024; // 10MB mặc định
|
|
96
|
+
const readBodyBuffer = (req) => new Promise((resolve, reject) => {
|
|
97
|
+
const chunks = [];
|
|
98
|
+
let size = 0;
|
|
99
|
+
req.on("data", (c) => {
|
|
100
|
+
size += c.length;
|
|
101
|
+
if (size > MAX_UPLOAD) {
|
|
102
|
+
req.destroy();
|
|
103
|
+
reject(new FluxeError("upload", "File quá lớn", 413));
|
|
104
|
+
}
|
|
105
|
+
else
|
|
106
|
+
chunks.push(c);
|
|
107
|
+
});
|
|
108
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
109
|
+
req.on("error", reject);
|
|
110
|
+
});
|
|
89
111
|
// Cells được TIÊM từ app (DI) — engine không import ngược vào app/. Thêm trang = sửa app/app.ts.
|
|
90
112
|
const matchRoute = makeRouter(cells);
|
|
91
113
|
const byId = new Map(cells.map((c) => [c.id, c]));
|
|
@@ -108,7 +130,20 @@ export function makeServer(manifest, cells, layouts = {}) {
|
|
|
108
130
|
// CSRF double-submit: đảm bảo có cookie csrf (đặt nếu chưa) — client gửi lại qua header.
|
|
109
131
|
let csrf = cookies.csrf;
|
|
110
132
|
const csrfCookie = csrf ? "" : (csrf = newCsrfToken(), `csrf=${csrf}; Path=/; SameSite=Lax`);
|
|
133
|
+
// Resolved Shell: locale (i18n) + theme — giải từ cookie/header, đưa vào loader + <html>.
|
|
134
|
+
const locale = i18n ? resolveLocale(i18n, { cookie: cookies.locale, acceptLanguage: req.headers["accept-language"] }) : "vi";
|
|
135
|
+
const t = i18n ? makeT(i18n, locale) : (k) => k;
|
|
136
|
+
const theme = cookies.theme === "dark" || cookies.theme === "light" ? cookies.theme : "";
|
|
111
137
|
try {
|
|
138
|
+
// Đổi ngôn ngữ qua ?locale=xx → set cookie + redirect (chạy cả trên cell static, 0 JS).
|
|
139
|
+
{
|
|
140
|
+
const ql = url.searchParams.get("locale");
|
|
141
|
+
if (i18n && ql && i18n.locales.includes(ql)) {
|
|
142
|
+
url.searchParams.delete("locale");
|
|
143
|
+
res.writeHead(303, { "set-cookie": `locale=${ql}; Path=/; SameSite=Lax`, location: url.pathname + (url.search || "") });
|
|
144
|
+
return res.end();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
112
147
|
if (url.pathname === "/client.js") {
|
|
113
148
|
if (clientJs === undefined && existsSync("./dist/client.js"))
|
|
114
149
|
clientJs = readFileSync("./dist/client.js");
|
|
@@ -166,6 +201,43 @@ export function makeServer(manifest, cells, layouts = {}) {
|
|
|
166
201
|
});
|
|
167
202
|
return res.end(`<p>Đã đăng xuất. <a href="/">trang chủ</a></p>`);
|
|
168
203
|
}
|
|
204
|
+
// File upload: POST /__upload/<field> → parse multipart → storage.put. CSRF + giới hạn size.
|
|
205
|
+
if (url.pathname.startsWith("/__upload/") && req.method === "POST") {
|
|
206
|
+
if (!storage) {
|
|
207
|
+
res.writeHead(501);
|
|
208
|
+
return res.end(JSON.stringify({ error: { code: "no_storage", message: "Chưa cấu hình storage", status: 501 } }));
|
|
209
|
+
}
|
|
210
|
+
if (!cookies.csrf || req.headers["x-csrf-token"] !== cookies.csrf)
|
|
211
|
+
throw new FluxeError("csrf", "CSRF không hợp lệ", 403);
|
|
212
|
+
const boundary = boundaryFromContentType(req.headers["content-type"]);
|
|
213
|
+
if (!boundary)
|
|
214
|
+
throw new FluxeError("upload", "Cần multipart/form-data", 400);
|
|
215
|
+
const files = parseMultipart(await readBodyBuffer(req), boundary).filter((p) => p.filename);
|
|
216
|
+
if (!files.length)
|
|
217
|
+
throw new FluxeError("upload", "Không có file", 400);
|
|
218
|
+
const out = [];
|
|
219
|
+
for (const f of files) {
|
|
220
|
+
const key = makeKey(f.filename, randomBytes(8).toString("hex"));
|
|
221
|
+
out.push(await storage.put(key, f.data, { contentType: f.contentType }));
|
|
222
|
+
}
|
|
223
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
224
|
+
return res.end(JSON.stringify(out.length === 1 ? out[0] : out));
|
|
225
|
+
}
|
|
226
|
+
// Serve file: GET /__file/<key> → storage.get → stream về.
|
|
227
|
+
if (url.pathname.startsWith("/__file/") && req.method === "GET") {
|
|
228
|
+
if (!storage) {
|
|
229
|
+
res.writeHead(404);
|
|
230
|
+
return res.end();
|
|
231
|
+
}
|
|
232
|
+
const key = decodeURIComponent(url.pathname.slice("/__file/".length));
|
|
233
|
+
const file = await storage.get(key);
|
|
234
|
+
if (!file) {
|
|
235
|
+
res.writeHead(404);
|
|
236
|
+
return res.end();
|
|
237
|
+
}
|
|
238
|
+
res.writeHead(200, { "content-type": file.contentType ?? "application/octet-stream", "content-length": String(file.size) });
|
|
239
|
+
return res.end(file.data);
|
|
240
|
+
}
|
|
169
241
|
if (url.pathname.startsWith("/__sse/")) {
|
|
170
242
|
// Realtime channel (SSE): giữ kết nối, đẩy event khi publish trên topic. ?id= → presence.
|
|
171
243
|
const topic = decodeURIComponent(url.pathname.slice("/__sse/".length));
|
|
@@ -243,7 +315,7 @@ export function makeServer(manifest, cells, layouts = {}) {
|
|
|
243
315
|
if (cell.requireRole && !hasRole(session, cell.requireRole)) {
|
|
244
316
|
throw new FluxeError("forbidden", `Cần quyền '${cell.requireRole}'`, 403);
|
|
245
317
|
}
|
|
246
|
-
const data = await cell.loader({ input: match.params, backend: backendFor(cell.id), session });
|
|
318
|
+
const data = await cell.loader({ input: match.params, backend: backendFor(cell.id), session, locale, t });
|
|
247
319
|
if (wantsJson) {
|
|
248
320
|
const body = JSON.stringify({ cell: cell.id, data, layout: cell.layout });
|
|
249
321
|
const etag = etagOf(body); // render cache: 304 nếu props không đổi
|
|
@@ -255,8 +327,9 @@ export function makeServer(manifest, cells, layouts = {}) {
|
|
|
255
327
|
return res.end(body);
|
|
256
328
|
}
|
|
257
329
|
let node = h(cell.view, { data });
|
|
330
|
+
const shellCtx = { locale, t, theme, path: url.pathname }; // Resolved Shell ctx cho layout
|
|
258
331
|
for (const id of layoutChain(cell.layout, layouts)) { // inner→outer: bọc dần
|
|
259
|
-
node = h(layouts[id].component, { children: node });
|
|
332
|
+
node = h(layouts[id].component, { children: node, ctx: shellCtx });
|
|
260
333
|
}
|
|
261
334
|
const shipClientJs = manifest.cells[cell.id]?.render.shipClientJs ?? false;
|
|
262
335
|
const pageHeaders = { "content-type": "text/html; charset=utf-8" };
|
|
@@ -265,10 +338,10 @@ export function makeServer(manifest, cells, layouts = {}) {
|
|
|
265
338
|
// Ý B — render cache cell static: render 1 lần → giữ Buffer → ghi lại (zero-copy).
|
|
266
339
|
// Gate etag(data): data đổi ⇒ miss ⇒ render lại (không trả HTML cũ). Chỉ cell static & public.
|
|
267
340
|
if (manifest.cells[cell.id]?.render.mode === "static" && cell.cache !== false && !cell.requireAuth && !cell.requireRole) {
|
|
268
|
-
const etag = etagOf(JSON.stringify(data));
|
|
341
|
+
const etag = etagOf(JSON.stringify(data) + "|" + locale + "|" + theme); // lang/theme đổi → bust cache
|
|
269
342
|
let hit = renderCache.get(url.pathname);
|
|
270
343
|
if (!hit || hit.etag !== etag) {
|
|
271
|
-
const full = shellHead(cell, data) + await renderBodyToString(node) + shellTail(cell, data, shipClientJs);
|
|
344
|
+
const full = shellHead(cell, data, locale, theme) + await renderBodyToString(node) + shellTail(cell, data, shipClientJs);
|
|
272
345
|
hit = { etag, buf: Buffer.from(full, "utf8") };
|
|
273
346
|
renderCache.set(url.pathname, hit);
|
|
274
347
|
}
|
|
@@ -277,7 +350,7 @@ export function makeServer(manifest, cells, layouts = {}) {
|
|
|
277
350
|
}
|
|
278
351
|
// Streaming SSR: gửi head ngay → stream body (Suspense chảy dần) → tail khi xong.
|
|
279
352
|
res.writeHead(200, pageHeaders);
|
|
280
|
-
res.write(shellHead(cell, data));
|
|
353
|
+
res.write(shellHead(cell, data, locale, theme));
|
|
281
354
|
const through = new PassThrough();
|
|
282
355
|
through.on("data", (c) => res.write(c));
|
|
283
356
|
through.on("end", () => { res.write(shellTail(cell, data, shipClientJs)); res.end(); });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Copyright (c) 2026 nmvuong92
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { mkdir, writeFile, readFile, unlink } from "node:fs/promises";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { safeKey } from "./types.js";
|
|
6
|
+
/* Driver đĩa local — ghi dưới `dir`, serve qua baseUrl (/__file/<key>). Key được làm sạch
|
|
7
|
+
* (safeKey) nên không thoát thư mục (path traversal). */
|
|
8
|
+
export function createLocalStorage(opts) {
|
|
9
|
+
const baseUrl = opts.baseUrl ?? "/__file";
|
|
10
|
+
const pathOf = (key) => join(opts.dir, safeKey(key)); // chặn ../
|
|
11
|
+
const u = (key) => `${baseUrl}/${encodeURIComponent(safeKey(key))}`;
|
|
12
|
+
return {
|
|
13
|
+
name: "local",
|
|
14
|
+
async put(key, data, _opts) {
|
|
15
|
+
const p = pathOf(key);
|
|
16
|
+
await mkdir(dirname(p), { recursive: true });
|
|
17
|
+
await writeFile(p, data);
|
|
18
|
+
return { key: safeKey(key), url: u(key), size: data.length };
|
|
19
|
+
},
|
|
20
|
+
async get(key) {
|
|
21
|
+
try {
|
|
22
|
+
const data = await readFile(pathOf(key));
|
|
23
|
+
return { data, size: data.length };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
async delete(key) {
|
|
30
|
+
try {
|
|
31
|
+
await unlink(pathOf(key));
|
|
32
|
+
}
|
|
33
|
+
catch { /* không có thì thôi */ }
|
|
34
|
+
},
|
|
35
|
+
url: u,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* Driver in-RAM (dev/test). 0 đĩa, mất khi restart. */
|
|
2
|
+
export function createMemoryStorage(baseUrl = "/__file") {
|
|
3
|
+
const m = new Map();
|
|
4
|
+
const u = (key) => `${baseUrl}/${encodeURIComponent(key)}`;
|
|
5
|
+
return {
|
|
6
|
+
name: "memory",
|
|
7
|
+
async put(key, data, opts) {
|
|
8
|
+
m.set(key, { data, contentType: opts?.contentType, size: data.length });
|
|
9
|
+
return { key, url: u(key), size: data.length };
|
|
10
|
+
},
|
|
11
|
+
async get(key) {
|
|
12
|
+
return m.get(key) ?? null;
|
|
13
|
+
},
|
|
14
|
+
async delete(key) {
|
|
15
|
+
m.delete(key);
|
|
16
|
+
},
|
|
17
|
+
url: u,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { safeKey } from "./types.js";
|
|
2
|
+
export function createS3Storage(opts) {
|
|
3
|
+
const base = opts.publicBaseUrl ?? `https://${opts.bucket}.s3.${opts.region}.amazonaws.com`;
|
|
4
|
+
const u = (key) => `${base}/${safeKey(key)}`;
|
|
5
|
+
// Khi đã `npm i @aws-sdk/client-s3`, thay phần ném lỗi bằng lệnh thật (mẫu trong comment).
|
|
6
|
+
const notReady = () => {
|
|
7
|
+
throw new Error("createS3Storage: cài '@aws-sdk/client-s3' rồi bỏ comment phần triển khai trong src/storage/s3.ts");
|
|
8
|
+
};
|
|
9
|
+
return {
|
|
10
|
+
name: "s3",
|
|
11
|
+
async put(key, _data, _o) {
|
|
12
|
+
notReady();
|
|
13
|
+
// await client.send(new PutObjectCommand({ Bucket: opts.bucket, Key: safeKey(key), Body: _data, ContentType: _o?.contentType }));
|
|
14
|
+
return { key: safeKey(key), url: u(key), size: _data.length };
|
|
15
|
+
},
|
|
16
|
+
async get(_key) {
|
|
17
|
+
notReady();
|
|
18
|
+
// const r = await client.send(new GetObjectCommand({ Bucket: opts.bucket, Key: safeKey(_key) }));
|
|
19
|
+
// return { data: Buffer.from(await r.Body!.transformToByteArray()), contentType: r.ContentType, size: r.ContentLength ?? 0 };
|
|
20
|
+
return null;
|
|
21
|
+
},
|
|
22
|
+
async delete(_key) {
|
|
23
|
+
notReady();
|
|
24
|
+
// await client.send(new DeleteObjectCommand({ Bucket: opts.bucket, Key: safeKey(_key) }));
|
|
25
|
+
},
|
|
26
|
+
url: u,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface PutResult {
|
|
2
|
+
key: string;
|
|
3
|
+
url: string;
|
|
4
|
+
size: number;
|
|
5
|
+
}
|
|
6
|
+
export interface GetResult {
|
|
7
|
+
data: Buffer;
|
|
8
|
+
contentType?: string;
|
|
9
|
+
size: number;
|
|
10
|
+
}
|
|
11
|
+
export interface Storage {
|
|
12
|
+
name: string;
|
|
13
|
+
put(key: string, data: Buffer, opts?: {
|
|
14
|
+
contentType?: string;
|
|
15
|
+
}): Promise<PutResult>;
|
|
16
|
+
get(key: string): Promise<GetResult | null>;
|
|
17
|
+
delete(key: string): Promise<void>;
|
|
18
|
+
url(key: string): string;
|
|
19
|
+
}
|
|
20
|
+
export declare function safeKey(name: string): string;
|
|
21
|
+
export declare function makeKey(filename: string, randomHex: string): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) 2026 nmvuong92
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/* Storage Adapter — interface chuẩn để SWITCH driver lưu file (như Backend).
|
|
4
|
+
* Cell/endpoint chỉ biết interface này; đổi local ↔ S3 ↔ … = thay implementation. */
|
|
5
|
+
/* Làm sạch tên file → key an toàn (không slash, không '..', chỉ [A-Za-z0-9._-]). */
|
|
6
|
+
export function safeKey(name) {
|
|
7
|
+
const base = name.split(/[\\/]/).pop() ?? name; // bỏ path
|
|
8
|
+
const clean = base.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_");
|
|
9
|
+
return clean.slice(0, 120) || "file";
|
|
10
|
+
}
|
|
11
|
+
/* Sinh key duy nhất: <randomHex>-<safeName>. randomHex truyền vào để thuần (test dễ). */
|
|
12
|
+
export function makeKey(filename, randomHex) {
|
|
13
|
+
return `${randomHex}-${safeKey(filename)}`;
|
|
14
|
+
}
|