@nmvuong92/fluxe 0.2.0 → 0.4.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/cli.js +4 -0
- package/lib/core/client.d.ts +9 -0
- package/lib/core/client.js +14 -0
- package/lib/core/config.d.ts +98 -0
- package/lib/core/config.js +66 -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/server_factory.d.ts +4 -0
- package/lib/server_factory.js +61 -3
- 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/cli.js
CHANGED
|
@@ -17,6 +17,10 @@ export const COMMANDS = {
|
|
|
17
17
|
desc: "Auto-discovery: quét app/cells/* → app/app.ts (dev/resolve tự gọi)",
|
|
18
18
|
shell: () => SYNC,
|
|
19
19
|
},
|
|
20
|
+
config: {
|
|
21
|
+
desc: "In config đã giải (default ← ENV FLUXE_* ← override)",
|
|
22
|
+
shell: () => `tsx scripts/config.ts`,
|
|
23
|
+
},
|
|
20
24
|
gen: {
|
|
21
25
|
desc: "Codegen contract → types TS/Go/Rust (.fluxe/gen)",
|
|
22
26
|
shell: () => `tsx scripts/codegen.ts`,
|
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?.();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const Schema: z.ZodObject<{
|
|
3
|
+
env: z.ZodEnum<["development", "production", "test"]>;
|
|
4
|
+
secret: z.ZodString;
|
|
5
|
+
port: z.ZodNumber;
|
|
6
|
+
defaultBackend: z.ZodEnum<["memory", "go", "rust"]>;
|
|
7
|
+
rateLimit: z.ZodObject<{
|
|
8
|
+
capacity: z.ZodNumber;
|
|
9
|
+
refillPerSec: z.ZodNumber;
|
|
10
|
+
maxKeys: z.ZodNumber;
|
|
11
|
+
}, "strip", z.ZodTypeAny, {
|
|
12
|
+
capacity: number;
|
|
13
|
+
refillPerSec: number;
|
|
14
|
+
maxKeys: number;
|
|
15
|
+
}, {
|
|
16
|
+
capacity: number;
|
|
17
|
+
refillPerSec: number;
|
|
18
|
+
maxKeys: number;
|
|
19
|
+
}>;
|
|
20
|
+
renderCache: z.ZodObject<{
|
|
21
|
+
maxKeys: z.ZodNumber;
|
|
22
|
+
}, "strip", z.ZodTypeAny, {
|
|
23
|
+
maxKeys: number;
|
|
24
|
+
}, {
|
|
25
|
+
maxKeys: number;
|
|
26
|
+
}>;
|
|
27
|
+
upload: z.ZodObject<{
|
|
28
|
+
maxBytes: z.ZodNumber;
|
|
29
|
+
}, "strip", z.ZodTypeAny, {
|
|
30
|
+
maxBytes: number;
|
|
31
|
+
}, {
|
|
32
|
+
maxBytes: number;
|
|
33
|
+
}>;
|
|
34
|
+
i18n: z.ZodObject<{
|
|
35
|
+
defaultLocale: z.ZodString;
|
|
36
|
+
}, "strip", z.ZodTypeAny, {
|
|
37
|
+
defaultLocale: string;
|
|
38
|
+
}, {
|
|
39
|
+
defaultLocale: string;
|
|
40
|
+
}>;
|
|
41
|
+
}, "strip", z.ZodTypeAny, {
|
|
42
|
+
env: "development" | "production" | "test";
|
|
43
|
+
secret: string;
|
|
44
|
+
port: number;
|
|
45
|
+
defaultBackend: "memory" | "go" | "rust";
|
|
46
|
+
rateLimit: {
|
|
47
|
+
capacity: number;
|
|
48
|
+
refillPerSec: number;
|
|
49
|
+
maxKeys: number;
|
|
50
|
+
};
|
|
51
|
+
renderCache: {
|
|
52
|
+
maxKeys: number;
|
|
53
|
+
};
|
|
54
|
+
upload: {
|
|
55
|
+
maxBytes: number;
|
|
56
|
+
};
|
|
57
|
+
i18n: {
|
|
58
|
+
defaultLocale: string;
|
|
59
|
+
};
|
|
60
|
+
}, {
|
|
61
|
+
env: "development" | "production" | "test";
|
|
62
|
+
secret: string;
|
|
63
|
+
port: number;
|
|
64
|
+
defaultBackend: "memory" | "go" | "rust";
|
|
65
|
+
rateLimit: {
|
|
66
|
+
capacity: number;
|
|
67
|
+
refillPerSec: number;
|
|
68
|
+
maxKeys: number;
|
|
69
|
+
};
|
|
70
|
+
renderCache: {
|
|
71
|
+
maxKeys: number;
|
|
72
|
+
};
|
|
73
|
+
upload: {
|
|
74
|
+
maxBytes: number;
|
|
75
|
+
};
|
|
76
|
+
i18n: {
|
|
77
|
+
defaultLocale: string;
|
|
78
|
+
};
|
|
79
|
+
}>;
|
|
80
|
+
export type FluxeConfig = z.infer<typeof Schema>;
|
|
81
|
+
type Src = Record<string, string | undefined>;
|
|
82
|
+
export declare const ENV_KEYS: {
|
|
83
|
+
readonly NODE_ENV: "env";
|
|
84
|
+
readonly FLUXE_SECRET: "secret";
|
|
85
|
+
readonly "PORT (ho\u1EB7c FLUXE_PORT)": "port";
|
|
86
|
+
readonly FLUXE_BACKEND: "defaultBackend";
|
|
87
|
+
readonly FLUXE_RATELIMIT_CAPACITY: "rateLimit.capacity";
|
|
88
|
+
readonly FLUXE_RATELIMIT_REFILL: "rateLimit.refillPerSec";
|
|
89
|
+
readonly FLUXE_RATELIMIT_MAX_KEYS: "rateLimit.maxKeys";
|
|
90
|
+
readonly FLUXE_RENDERCACHE_MAX_KEYS: "renderCache.maxKeys";
|
|
91
|
+
readonly FLUXE_UPLOAD_MAX_BYTES: "upload.maxBytes";
|
|
92
|
+
readonly FLUXE_LOCALE_DEFAULT: "i18n.defaultLocale";
|
|
93
|
+
};
|
|
94
|
+
type DeepPartial<T> = {
|
|
95
|
+
[K in keyof T]?: T[K] extends object ? Partial<T[K]> : T[K];
|
|
96
|
+
};
|
|
97
|
+
export declare function loadConfig(source?: Src, overrides?: DeepPartial<FluxeConfig>): FluxeConfig;
|
|
98
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Copyright (c) 2026 nmvuong92
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/* Config core — DEFAULT + override qua ENV (quy ước FLUXE_*), kiểu Laravel config().
|
|
4
|
+
* Một nguồn sự thật cho mọi tham số tinh chỉnh của engine. Thứ tự ưu tiên:
|
|
5
|
+
* default < ENV (FLUXE_*) < override truyền tay.
|
|
6
|
+
* Mọi giá trị quan trọng ĐỀU expose ra ENV + tài liệu (xem docs/reference/configuration). */
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
const Schema = z.object({
|
|
9
|
+
env: z.enum(["development", "production", "test"]),
|
|
10
|
+
secret: z.string().min(8),
|
|
11
|
+
port: z.coerce.number().int().positive(),
|
|
12
|
+
defaultBackend: z.enum(["memory", "go", "rust"]),
|
|
13
|
+
rateLimit: z.object({
|
|
14
|
+
capacity: z.coerce.number().int().positive(),
|
|
15
|
+
refillPerSec: z.coerce.number().positive(),
|
|
16
|
+
maxKeys: z.coerce.number().int().positive(),
|
|
17
|
+
}),
|
|
18
|
+
renderCache: z.object({ maxKeys: z.coerce.number().int().positive() }),
|
|
19
|
+
upload: z.object({ maxBytes: z.coerce.number().int().positive() }),
|
|
20
|
+
i18n: z.object({ defaultLocale: z.string().min(2) }),
|
|
21
|
+
});
|
|
22
|
+
/* Bảng ENV — TÊN biến ↔ field. Dùng cho loadConfig + sinh tài liệu. */
|
|
23
|
+
export const ENV_KEYS = {
|
|
24
|
+
"NODE_ENV": "env",
|
|
25
|
+
"FLUXE_SECRET": "secret",
|
|
26
|
+
"PORT (hoặc FLUXE_PORT)": "port",
|
|
27
|
+
"FLUXE_BACKEND": "defaultBackend",
|
|
28
|
+
"FLUXE_RATELIMIT_CAPACITY": "rateLimit.capacity",
|
|
29
|
+
"FLUXE_RATELIMIT_REFILL": "rateLimit.refillPerSec",
|
|
30
|
+
"FLUXE_RATELIMIT_MAX_KEYS": "rateLimit.maxKeys",
|
|
31
|
+
"FLUXE_RENDERCACHE_MAX_KEYS": "renderCache.maxKeys",
|
|
32
|
+
"FLUXE_UPLOAD_MAX_BYTES": "upload.maxBytes",
|
|
33
|
+
"FLUXE_LOCALE_DEFAULT": "i18n.defaultLocale",
|
|
34
|
+
};
|
|
35
|
+
const num = (s, k, d) => {
|
|
36
|
+
const v = s[k];
|
|
37
|
+
return v == null || v === "" ? d : Number(v);
|
|
38
|
+
};
|
|
39
|
+
function merge(base, o) {
|
|
40
|
+
return {
|
|
41
|
+
...base,
|
|
42
|
+
...o,
|
|
43
|
+
rateLimit: { ...base.rateLimit, ...o.rateLimit },
|
|
44
|
+
renderCache: { ...base.renderCache, ...o.renderCache },
|
|
45
|
+
upload: { ...base.upload, ...o.upload },
|
|
46
|
+
i18n: { ...base.i18n, ...o.i18n },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/* Giải config: default ← ENV (FLUXE_*) ← override. Validate Zod (sai → ném ngay, fail-fast). */
|
|
50
|
+
export function loadConfig(source = process.env, overrides = {}) {
|
|
51
|
+
const fromEnv = {
|
|
52
|
+
env: source.NODE_ENV || "development",
|
|
53
|
+
secret: source.FLUXE_SECRET || "dev-secret-change-me",
|
|
54
|
+
port: num(source, "PORT", num(source, "FLUXE_PORT", 5180)),
|
|
55
|
+
defaultBackend: source.FLUXE_BACKEND || "memory",
|
|
56
|
+
rateLimit: {
|
|
57
|
+
capacity: num(source, "FLUXE_RATELIMIT_CAPACITY", 30),
|
|
58
|
+
refillPerSec: num(source, "FLUXE_RATELIMIT_REFILL", 10),
|
|
59
|
+
maxKeys: num(source, "FLUXE_RATELIMIT_MAX_KEYS", 5000),
|
|
60
|
+
},
|
|
61
|
+
renderCache: { maxKeys: num(source, "FLUXE_RENDERCACHE_MAX_KEYS", 256) },
|
|
62
|
+
upload: { maxBytes: num(source, "FLUXE_UPLOAD_MAX_BYTES", 10 * 1024 * 1024) },
|
|
63
|
+
i18n: { defaultLocale: source.FLUXE_LOCALE_DEFAULT || "en" },
|
|
64
|
+
};
|
|
65
|
+
return Schema.parse(merge(fromEnv, overrides));
|
|
66
|
+
}
|
|
@@ -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/config.ts";
|
|
8
9
|
export * from "./core/i18n.ts";
|
|
9
10
|
export * from "./core/seo.ts";
|
|
10
11
|
export * from "./core/broker.ts";
|
|
@@ -15,6 +16,10 @@ export * from "./core/layouts.ts";
|
|
|
15
16
|
export * from "./core/router.ts";
|
|
16
17
|
export * from "./core/testing.ts";
|
|
17
18
|
export * from "./backends/types.ts";
|
|
19
|
+
export * from "./storage/types.ts";
|
|
20
|
+
export { createMemoryStorage } from "./storage/memory.ts";
|
|
21
|
+
export { createLocalStorage } from "./storage/local.ts";
|
|
22
|
+
export { createS3Storage } from "./storage/s3.ts";
|
|
18
23
|
export { createMemoryBackend } from "./backends/memory.ts";
|
|
19
24
|
export { createHttpBackend } from "./backends/http.ts";
|
|
20
25
|
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/config.js"; // FluxeConfig, loadConfig (default ← ENV FLUXE_* ← override)
|
|
12
13
|
export * from "./core/i18n.js"; // createI18n, resolveLocale, translate, makeT, t(key, vars)
|
|
13
14
|
export * from "./core/seo.js"; // renderHead, renderSitemap, renderRobots, HeadMeta
|
|
14
15
|
export * from "./core/broker.js"; // pub/sub
|
|
@@ -19,6 +20,10 @@ export * from "./core/layouts.js"; // layoutChain, LayoutMeta
|
|
|
19
20
|
export * from "./core/router.js"; // makeRouter
|
|
20
21
|
export * from "./core/testing.js"; // createTestBackend
|
|
21
22
|
export * from "./backends/types.js"; // Backend, Todo
|
|
23
|
+
export * from "./storage/types.js"; // Storage, PutResult, GetResult, safeKey, makeKey
|
|
24
|
+
export { createMemoryStorage } from "./storage/memory.js";
|
|
25
|
+
export { createLocalStorage } from "./storage/local.js";
|
|
26
|
+
export { createS3Storage } from "./storage/s3.js"; // adapter tham chiếu (cần @aws-sdk/client-s3)
|
|
22
27
|
export { createMemoryBackend } from "./backends/memory.js";
|
|
23
28
|
export { createHttpBackend } from "./backends/http.js";
|
|
24
29
|
export { createPostgresBackend } from "./backends/postgres.js";
|
package/lib/server_factory.d.ts
CHANGED
|
@@ -9,7 +9,11 @@ type LayoutEntry = LayoutMeta & {
|
|
|
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
|
+
import { type FluxeConfig } from "./core/config.ts";
|
|
12
14
|
export declare function makeServer(manifest: ResolutionManifest, cells: CellDef<any, any>[], layouts?: LayoutMap, opts?: {
|
|
13
15
|
i18n?: I18n;
|
|
16
|
+
storage?: Storage;
|
|
17
|
+
config?: FluxeConfig;
|
|
14
18
|
}): http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
|
|
15
19
|
export {};
|
package/lib/server_factory.js
CHANGED
|
@@ -21,6 +21,9 @@ import { etagOf, etagMatches } from "./core/etag.js";
|
|
|
21
21
|
import { createRenderCache } from "./core/rendercache.js";
|
|
22
22
|
import { parseChaos } from "./core/chaos.js";
|
|
23
23
|
import { resolveLocale, makeT } from "./core/i18n.js";
|
|
24
|
+
import { parseMultipart, boundaryFromContentType } from "./core/multipart.js";
|
|
25
|
+
import { makeKey } from "./storage/types.js";
|
|
26
|
+
import { loadConfig } from "./core/config.js";
|
|
24
27
|
import { createMemoryBackend } from "./backends/memory.js";
|
|
25
28
|
import { createHttpBackend } from "./backends/http.js";
|
|
26
29
|
// Build backend theo ngôn ngữ (cho live swap trong devtools).
|
|
@@ -37,7 +40,7 @@ function devBackend(lang) {
|
|
|
37
40
|
const url = process.env[`${lang.toUpperCase()}_URL`] ?? DEV_BACKENDS[lang];
|
|
38
41
|
return url ? createHttpBackend(lang, url) : createMemoryBackend();
|
|
39
42
|
}
|
|
40
|
-
import { randomUUID } from "node:crypto";
|
|
43
|
+
import { randomUUID, randomBytes } from "node:crypto";
|
|
41
44
|
const DEV = process.env.NODE_ENV !== "production";
|
|
42
45
|
const SECRET = process.env.FLUXE_SECRET ?? "dev-secret-change-me";
|
|
43
46
|
// Demo user store (password hash scrypt tạo lúc boot). App thật: lấy từ DB.
|
|
@@ -89,6 +92,24 @@ function renderBodyToString(node) {
|
|
|
89
92
|
}
|
|
90
93
|
export function makeServer(manifest, cells, layouts = {}, opts = {}) {
|
|
91
94
|
const i18n = opts.i18n;
|
|
95
|
+
const storage = opts.storage;
|
|
96
|
+
const config = opts.config ?? loadConfig(); // default ← ENV (FLUXE_*) ← override
|
|
97
|
+
const MAX_UPLOAD = config.upload.maxBytes;
|
|
98
|
+
const readBodyBuffer = (req) => new Promise((resolve, reject) => {
|
|
99
|
+
const chunks = [];
|
|
100
|
+
let size = 0;
|
|
101
|
+
req.on("data", (c) => {
|
|
102
|
+
size += c.length;
|
|
103
|
+
if (size > MAX_UPLOAD) {
|
|
104
|
+
req.destroy();
|
|
105
|
+
reject(new FluxeError("upload", "File quá lớn", 413));
|
|
106
|
+
}
|
|
107
|
+
else
|
|
108
|
+
chunks.push(c);
|
|
109
|
+
});
|
|
110
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
111
|
+
req.on("error", reject);
|
|
112
|
+
});
|
|
92
113
|
// Cells được TIÊM từ app (DI) — engine không import ngược vào app/. Thêm trang = sửa app/app.ts.
|
|
93
114
|
const matchRoute = makeRouter(cells);
|
|
94
115
|
const byId = new Map(cells.map((c) => [c.id, c]));
|
|
@@ -96,10 +117,10 @@ export function makeServer(manifest, cells, layouts = {}, opts = {}) {
|
|
|
96
117
|
const backends = backendsFromManifest(manifest);
|
|
97
118
|
const backendFor = (id) => backends.byCell.get(id) ?? backends.default;
|
|
98
119
|
const broker = createBroker(); // realtime pub/sub (Trục 4g, bản 1-node)
|
|
99
|
-
const actionLimit = createRateLimiter(
|
|
120
|
+
const actionLimit = createRateLimiter(config.rateLimit); // per-IP cho action (FLUXE_RATELIMIT_*)
|
|
100
121
|
const recorder = createRecorder(); // request log (observability)
|
|
101
122
|
const presence = createPresence(); // ai đang online per topic (Trục 4g)
|
|
102
|
-
const renderCache = createRenderCache({ maxKeys:
|
|
123
|
+
const renderCache = createRenderCache({ maxKeys: config.renderCache.maxKeys }); // FLUXE_RENDERCACHE_MAX_KEYS
|
|
103
124
|
let clientJs; // ý A: đọc dist/client.js 1 lần (zero-copy: tái dùng buffer)
|
|
104
125
|
return http.createServer(async (req, res) => {
|
|
105
126
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -182,6 +203,43 @@ export function makeServer(manifest, cells, layouts = {}, opts = {}) {
|
|
|
182
203
|
});
|
|
183
204
|
return res.end(`<p>Đã đăng xuất. <a href="/">trang chủ</a></p>`);
|
|
184
205
|
}
|
|
206
|
+
// File upload: POST /__upload/<field> → parse multipart → storage.put. CSRF + giới hạn size.
|
|
207
|
+
if (url.pathname.startsWith("/__upload/") && req.method === "POST") {
|
|
208
|
+
if (!storage) {
|
|
209
|
+
res.writeHead(501);
|
|
210
|
+
return res.end(JSON.stringify({ error: { code: "no_storage", message: "Chưa cấu hình storage", status: 501 } }));
|
|
211
|
+
}
|
|
212
|
+
if (!cookies.csrf || req.headers["x-csrf-token"] !== cookies.csrf)
|
|
213
|
+
throw new FluxeError("csrf", "CSRF không hợp lệ", 403);
|
|
214
|
+
const boundary = boundaryFromContentType(req.headers["content-type"]);
|
|
215
|
+
if (!boundary)
|
|
216
|
+
throw new FluxeError("upload", "Cần multipart/form-data", 400);
|
|
217
|
+
const files = parseMultipart(await readBodyBuffer(req), boundary).filter((p) => p.filename);
|
|
218
|
+
if (!files.length)
|
|
219
|
+
throw new FluxeError("upload", "Không có file", 400);
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const f of files) {
|
|
222
|
+
const key = makeKey(f.filename, randomBytes(8).toString("hex"));
|
|
223
|
+
out.push(await storage.put(key, f.data, { contentType: f.contentType }));
|
|
224
|
+
}
|
|
225
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
226
|
+
return res.end(JSON.stringify(out.length === 1 ? out[0] : out));
|
|
227
|
+
}
|
|
228
|
+
// Serve file: GET /__file/<key> → storage.get → stream về.
|
|
229
|
+
if (url.pathname.startsWith("/__file/") && req.method === "GET") {
|
|
230
|
+
if (!storage) {
|
|
231
|
+
res.writeHead(404);
|
|
232
|
+
return res.end();
|
|
233
|
+
}
|
|
234
|
+
const key = decodeURIComponent(url.pathname.slice("/__file/".length));
|
|
235
|
+
const file = await storage.get(key);
|
|
236
|
+
if (!file) {
|
|
237
|
+
res.writeHead(404);
|
|
238
|
+
return res.end();
|
|
239
|
+
}
|
|
240
|
+
res.writeHead(200, { "content-type": file.contentType ?? "application/octet-stream", "content-length": String(file.size) });
|
|
241
|
+
return res.end(file.data);
|
|
242
|
+
}
|
|
185
243
|
if (url.pathname.startsWith("/__sse/")) {
|
|
186
244
|
// Realtime channel (SSE): giữ kết nối, đẩy event khi publish trên topic. ?id= → presence.
|
|
187
245
|
const topic = decodeURIComponent(url.pathname.slice("/__sse/".length));
|
|
@@ -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
|
+
}
|