@flow-os/server 0.0.47-dev.1772052157
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 +0 -0
- package/config/server/routes/api.ts +3 -0
- package/package.json +26 -0
- package/src/bin.ts +3 -0
- package/src/index.ts +4 -0
- package/src/plugin.ts +81 -0
- package/src/production.ts +58 -0
- package/src/route.ts +15 -0
- package/src/router.ts +196 -0
- package/tsconfig.json +17 -0
package/README.md
ADDED
|
File without changes
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flow-os/server",
|
|
3
|
+
"version": "0.0.47-dev.1772052157",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"bin": {
|
|
9
|
+
"flow-os-server": "./src/bin.ts"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"import": "./src/index.ts",
|
|
15
|
+
"default": "./src/index.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"vite": ">=7.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^25.3.0",
|
|
23
|
+
"bun-types": "^1.3.9",
|
|
24
|
+
"vite": ">=7.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/bin.ts
ADDED
package/src/index.ts
ADDED
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import type { Plugin } from 'vite';
|
|
4
|
+
import { createFlowServer, type LoadHandler } from './router.js';
|
|
5
|
+
import type { FlowHandler } from './route.js';
|
|
6
|
+
|
|
7
|
+
type NodeReq = import('node:http').IncomingMessage;
|
|
8
|
+
type NodeRes = import('node:http').ServerResponse;
|
|
9
|
+
|
|
10
|
+
function nodeRequestToWebRequest(req: NodeReq, baseUrl: string): Request {
|
|
11
|
+
const url = (req.url ?? '/').startsWith('http') ? req.url! : baseUrl + (req.url ?? '/');
|
|
12
|
+
const opts: RequestInit = {
|
|
13
|
+
method: req.method ?? 'GET',
|
|
14
|
+
headers: req.headers as HeadersInit,
|
|
15
|
+
};
|
|
16
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
17
|
+
(opts as RequestInit & { duplex?: string }).body = req as unknown as BodyInit;
|
|
18
|
+
(opts as RequestInit & { duplex?: string }).duplex = 'half';
|
|
19
|
+
}
|
|
20
|
+
return new Request(url, opts);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function webResponseToNodeResponse(webRes: Response, res: NodeRes): Promise<void> {
|
|
24
|
+
res.writeHead(webRes.status, Object.fromEntries(webRes.headers));
|
|
25
|
+
if (webRes.body) {
|
|
26
|
+
const buf = Buffer.from(await webRes.arrayBuffer());
|
|
27
|
+
res.end(buf);
|
|
28
|
+
} else {
|
|
29
|
+
res.end();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function flowServerMiddleware(
|
|
34
|
+
dispatch: (pathname: string, method: string, request: Request) => Promise<Response | null>,
|
|
35
|
+
matches: (pathname: string, method: string) => boolean
|
|
36
|
+
): (req: NodeReq, res: NodeRes, next: () => void) => void {
|
|
37
|
+
return (req, res, next) => {
|
|
38
|
+
const pathname = req.url?.split('?')[0] ?? '/';
|
|
39
|
+
const method = req.method ?? 'GET';
|
|
40
|
+
if (!matches(pathname, method)) return next();
|
|
41
|
+
const baseUrl = `http://${req.headers.host ?? 'localhost'}`;
|
|
42
|
+
const request = nodeRequestToWebRequest(req, baseUrl);
|
|
43
|
+
dispatch(pathname, method, request)
|
|
44
|
+
.then((response) => {
|
|
45
|
+
if (response) return webResponseToNodeResponse(response, res);
|
|
46
|
+
next();
|
|
47
|
+
})
|
|
48
|
+
.catch((err) => {
|
|
49
|
+
console.error('[flow-os:server]', err);
|
|
50
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
51
|
+
res.end(JSON.stringify({ error: err?.message ?? String(err) }));
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type FlowServerOptions = {
|
|
57
|
+
/** Cartella route API (file-based). Default: server/routes */
|
|
58
|
+
routesDir?: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type ConnectStack = Array<{ route: string; handle: (req: NodeReq, res: NodeRes, next: () => void) => void }>;
|
|
62
|
+
|
|
63
|
+
export function flowServer(options?: FlowServerOptions): Plugin {
|
|
64
|
+
return {
|
|
65
|
+
name: 'flow-os:server',
|
|
66
|
+
enforce: 'pre',
|
|
67
|
+
apply: 'serve',
|
|
68
|
+
configureServer: async (server) => {
|
|
69
|
+
const root = server.config.root ?? process.cwd();
|
|
70
|
+
const routesDir = options?.routesDir ?? resolve(root, 'server', 'routes');
|
|
71
|
+
const loadHandler: LoadHandler = (filePath) =>
|
|
72
|
+
server.ssrLoadModule(pathToFileURL(filePath).href) as Promise<{ default: FlowHandler }>;
|
|
73
|
+
const { dispatch, matches } = await createFlowServer(routesDir, loadHandler);
|
|
74
|
+
const middleware = flowServerMiddleware(dispatch, matches);
|
|
75
|
+
(server.middlewares as unknown as { stack: ConnectStack }).stack.unshift({
|
|
76
|
+
route: '',
|
|
77
|
+
handle: middleware,
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { createFlowServer } from "./router.js";
|
|
6
|
+
import { flowServerMiddleware } from "./plugin.js";
|
|
7
|
+
import type { FlowHandler } from "./route.js";
|
|
8
|
+
|
|
9
|
+
export type ProductionServerOptions = {
|
|
10
|
+
routesDir?: string;
|
|
11
|
+
staticDir?: string;
|
|
12
|
+
port?: number;
|
|
13
|
+
hostname?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export async function runProductionServer(options: ProductionServerOptions = {}): Promise<void> {
|
|
17
|
+
const cwd = process.cwd();
|
|
18
|
+
const routesDir = resolve(cwd, options.routesDir ?? "server/routes");
|
|
19
|
+
const staticDir = resolve(cwd, options.staticDir ?? "dist");
|
|
20
|
+
const port = options.port ?? Number(process.env.PORT ?? 3000);
|
|
21
|
+
const hostname = options.hostname ?? "0.0.0.0";
|
|
22
|
+
|
|
23
|
+
const loadHandler = async (filePath: string) => {
|
|
24
|
+
const mod = await import(pathToFileURL(filePath).href);
|
|
25
|
+
return mod as { default: FlowHandler };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const { dispatch, matches } = await createFlowServer(routesDir, loadHandler);
|
|
29
|
+
const apiMiddleware = flowServerMiddleware(dispatch, matches);
|
|
30
|
+
|
|
31
|
+
const server = createServer((req, res) => {
|
|
32
|
+
if (matches(req.url?.split("?")[0] ?? "/", req.method ?? "GET")) {
|
|
33
|
+
return apiMiddleware(req, res, () => serveStatic(req, res));
|
|
34
|
+
}
|
|
35
|
+
serveStatic(req, res);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function serveStatic(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) {
|
|
39
|
+
const path = (req.url ?? "/").split("?")[0] || "/";
|
|
40
|
+
const file = path === "/" ? "index.html" : path;
|
|
41
|
+
const filePath = join(staticDir, file.replace(/^\//, ""));
|
|
42
|
+
const stream = existsSync(filePath)
|
|
43
|
+
? createReadStream(filePath)
|
|
44
|
+
: existsSync(join(staticDir, "index.html"))
|
|
45
|
+
? createReadStream(join(staticDir, "index.html"))
|
|
46
|
+
: null;
|
|
47
|
+
if (stream) {
|
|
48
|
+
stream.on("error", () => { res.writeHead(404); res.end(); }).pipe(res);
|
|
49
|
+
} else {
|
|
50
|
+
res.writeHead(404);
|
|
51
|
+
res.end();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
server.listen(port, hostname, () => {
|
|
56
|
+
console.log(`http://${hostname}:${port}`);
|
|
57
|
+
});
|
|
58
|
+
}
|
package/src/route.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type FlowRequest = Request & { params: Record<string, string> };
|
|
2
|
+
|
|
3
|
+
export type FlowHandler = (
|
|
4
|
+
request: FlowRequest
|
|
5
|
+
) => Response | Promise<Response> | object | Promise<object>;
|
|
6
|
+
|
|
7
|
+
export function Route(fn: FlowHandler): FlowHandler {
|
|
8
|
+
return async (request: FlowRequest) => {
|
|
9
|
+
const out = await fn(request);
|
|
10
|
+
if (out instanceof Response) return out;
|
|
11
|
+
return new Response(JSON.stringify(out), {
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { FlowHandler, FlowRequest } from './route.js';
|
|
4
|
+
|
|
5
|
+
type DirentLike = { name: string; isDirectory(): boolean; isFile(): boolean };
|
|
6
|
+
|
|
7
|
+
const METHOD_SUFFIXES = [
|
|
8
|
+
'.get',
|
|
9
|
+
'.post',
|
|
10
|
+
'.put',
|
|
11
|
+
'.delete',
|
|
12
|
+
'.patch',
|
|
13
|
+
'.head',
|
|
14
|
+
'.options',
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type RouteMeta = { path: string; method: string; filePath: string };
|
|
18
|
+
|
|
19
|
+
type Segment = { type: 'literal' | 'param' | 'catch'; value: string };
|
|
20
|
+
|
|
21
|
+
function parseSegment(segment: string): Segment | null {
|
|
22
|
+
const catchAll = segment.match(/^\[\.\.\.(\w+)\]$/);
|
|
23
|
+
if (catchAll) return { type: 'catch', value: catchAll[1] };
|
|
24
|
+
const param = segment.match(/^\[(\w+)\]$/);
|
|
25
|
+
if (param) return { type: 'param', value: param[1] };
|
|
26
|
+
return { type: 'literal', value: segment };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function segmentsToPattern(segments: string[]): string {
|
|
30
|
+
const parts: string[] = [];
|
|
31
|
+
let hasCatchAll = false;
|
|
32
|
+
for (const s of segments) {
|
|
33
|
+
if (hasCatchAll) break;
|
|
34
|
+
const seg = parseSegment(s);
|
|
35
|
+
if (!seg) continue;
|
|
36
|
+
if (seg.type === 'literal') parts.push(seg.value);
|
|
37
|
+
else if (seg.type === 'param') parts.push(':' + seg.value);
|
|
38
|
+
else if (seg.type === 'catch') {
|
|
39
|
+
parts.push('*' + seg.value);
|
|
40
|
+
hasCatchAll = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return '/' + parts.join('/');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Default method per file senza suffisso (.get, .post, ecc.): tutte POST */
|
|
47
|
+
const DEFAULT_METHOD = 'POST';
|
|
48
|
+
|
|
49
|
+
function parseRoutePath(relativePath: string): { path: string; method: string } | null {
|
|
50
|
+
const lower = relativePath.toLowerCase();
|
|
51
|
+
const ext = lower.endsWith('.tsx') ? '.tsx' : '.ts';
|
|
52
|
+
for (const suffix of METHOD_SUFFIXES) {
|
|
53
|
+
if (lower.endsWith(suffix + ext)) {
|
|
54
|
+
const withoutExt = relativePath.slice(0, -(suffix.length + ext.length));
|
|
55
|
+
const segments = withoutExt.split(/[/\\]/).filter(Boolean);
|
|
56
|
+
const path = segmentsToPattern(segments);
|
|
57
|
+
const pathNorm = path === '/index' ? '/' : path;
|
|
58
|
+
const method = suffix.slice(1).toUpperCase();
|
|
59
|
+
return { path: pathNorm, method };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// File senza suffisso (api.ts, users.ts): POST
|
|
63
|
+
if (lower.endsWith(ext)) {
|
|
64
|
+
const withoutExt = relativePath.slice(0, -ext.length);
|
|
65
|
+
const segments = withoutExt.split(/[/\\]/).filter(Boolean);
|
|
66
|
+
const path = segmentsToPattern(segments);
|
|
67
|
+
const pathNorm = path === '/index' ? '/' : path;
|
|
68
|
+
return { path: pathNorm, method: DEFAULT_METHOD };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function walkRoutes(dir: string, base = ''): Promise<RouteMeta[]> {
|
|
74
|
+
let entries: DirentLike[];
|
|
75
|
+
try {
|
|
76
|
+
entries = (await readdir(dir, { withFileTypes: true })) as DirentLike[];
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const routes: RouteMeta[] = [];
|
|
81
|
+
for (const e of entries) {
|
|
82
|
+
const name = String(e.name);
|
|
83
|
+
const rel = base ? `${base}/${name}` : name;
|
|
84
|
+
if (e.isDirectory()) {
|
|
85
|
+
routes.push(...(await walkRoutes(join(dir, name), rel)));
|
|
86
|
+
} else if (e.isFile() && (name.endsWith('.ts') || name.endsWith('.tsx'))) {
|
|
87
|
+
const parsed = parseRoutePath(rel);
|
|
88
|
+
if (parsed) routes.push({ ...parsed, filePath: join(dir, name) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return routes;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function escapeRegex(s: string): string {
|
|
95
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function routePatternToRegex(path: string): { re: RegExp; keys: string[] } {
|
|
99
|
+
const keys: string[] = [];
|
|
100
|
+
const parts = path.split('/').filter(Boolean);
|
|
101
|
+
const pattern =
|
|
102
|
+
'^/' +
|
|
103
|
+
parts
|
|
104
|
+
.map((seg) => {
|
|
105
|
+
if (seg.startsWith('*')) {
|
|
106
|
+
keys.push(seg.slice(1));
|
|
107
|
+
return '(.+)';
|
|
108
|
+
}
|
|
109
|
+
if (seg.startsWith(':')) {
|
|
110
|
+
keys.push(seg.slice(1));
|
|
111
|
+
return '([^/]+)';
|
|
112
|
+
}
|
|
113
|
+
return escapeRegex(seg);
|
|
114
|
+
})
|
|
115
|
+
.join('/') +
|
|
116
|
+
'$';
|
|
117
|
+
return { re: new RegExp(pattern), keys };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type LoadHandler = (filePath: string) => Promise<{ default: FlowHandler }>;
|
|
121
|
+
|
|
122
|
+
type RouteEntry = {
|
|
123
|
+
method: string;
|
|
124
|
+
re: RegExp;
|
|
125
|
+
keys: string[];
|
|
126
|
+
handler: FlowHandler;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export async function createFlowServer(
|
|
130
|
+
routesDir: string,
|
|
131
|
+
loadHandler: LoadHandler
|
|
132
|
+
): Promise<{
|
|
133
|
+
dispatch: (pathname: string, method: string, request: Request) => Promise<Response | null>;
|
|
134
|
+
matches: (pathname: string, method: string) => boolean;
|
|
135
|
+
}> {
|
|
136
|
+
const routeList = await walkRoutes(routesDir);
|
|
137
|
+
const routes: RouteEntry[] = [];
|
|
138
|
+
|
|
139
|
+
for (const r of routeList) {
|
|
140
|
+
try {
|
|
141
|
+
const mod = await loadHandler(r.filePath);
|
|
142
|
+
const handler = mod.default;
|
|
143
|
+
if (!handler) continue;
|
|
144
|
+
const { re, keys } = routePatternToRegex(r.path);
|
|
145
|
+
routes.push({ method: r.method, re, keys, handler });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.warn(`[flow-os:router] skip route ${r.filePath}:`, err);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getMatch(
|
|
152
|
+
pathname: string,
|
|
153
|
+
method: string
|
|
154
|
+
): { handler: FlowHandler; params: Record<string, string> } | null {
|
|
155
|
+
const norm = pathname.split('?')[0] || '/';
|
|
156
|
+
for (const route of routes) {
|
|
157
|
+
if (route.method !== method) continue;
|
|
158
|
+
const m = route.re.exec(norm);
|
|
159
|
+
if (!m) continue;
|
|
160
|
+
const params: Record<string, string> = {};
|
|
161
|
+
route.keys.forEach((k, i) => (params[k] = m[i + 1] ?? ''));
|
|
162
|
+
return { handler: route.handler, params };
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function dispatch(
|
|
168
|
+
pathname: string,
|
|
169
|
+
method: string,
|
|
170
|
+
request: Request
|
|
171
|
+
): Promise<Response | null> {
|
|
172
|
+
const match = getMatch(pathname, method);
|
|
173
|
+
if (!match) return null;
|
|
174
|
+
try {
|
|
175
|
+
const flowReq = request as FlowRequest;
|
|
176
|
+
flowReq.params = match.params;
|
|
177
|
+
const out = await match.handler(flowReq);
|
|
178
|
+
if (out instanceof Response) return out;
|
|
179
|
+
return new Response(JSON.stringify(out), {
|
|
180
|
+
headers: { 'Content-Type': 'application/json' },
|
|
181
|
+
});
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error('[flow-os:router] handler error:', err);
|
|
184
|
+
return new Response(
|
|
185
|
+
JSON.stringify({ error: err instanceof Error ? err.message : String(err) }),
|
|
186
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function matches(pathname: string, method: string): boolean {
|
|
192
|
+
return getMatch(pathname, method) !== null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { dispatch, matches };
|
|
196
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"types": ["bun-types", "node"],
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"declarationMap": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"isolatedModules": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts"],
|
|
16
|
+
"exclude": ["node_modules"]
|
|
17
|
+
}
|