@epoch32/visage 0.0.0-stage → 0.0.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/LICENSE +373 -0
- package/README.md +110 -2
- package/dist/bundle.dev.js +4875 -0
- package/dist/index.js +9633 -0
- package/dist/src/api/index.js +4002 -0
- package/dist/src/charts/index.js +2717 -0
- package/dist/src/devtools/index.js +4677 -0
- package/dist/src/dom/index.js +1550 -0
- package/dist/src/form/index.js +1415 -0
- package/dist/src/index.js +11826 -0
- package/dist/src/jsx-runtime/index.js +1630 -0
- package/dist/src/query/index.js +2422 -0
- package/dist/src/router/index.js +1856 -0
- package/dist/src/rpc/index.js +3484 -0
- package/dist/src/server/index.js +1173 -0
- package/dist/src/signals/index.js +1077 -0
- package/dist/src/style/index.js +2340 -0
- package/dist/src/template/index.js +1149 -0
- package/dist/src/ui/index.js +4155 -0
- package/dist/src/virtual/index.js +1955 -0
- package/dist/src/vite/index.js +984 -0
- package/dist/vite/index.js +707 -0
- package/package.json +140 -4
- package/src/api/client.ts +371 -0
- package/src/api/file-router.ts +183 -0
- package/src/api/handler.ts +326 -0
- package/src/api/index.ts +71 -0
- package/src/api/middleware.ts +54 -0
- package/src/api/pragma.ts +155 -0
- package/src/api/procedure.ts +172 -0
- package/src/api/types.ts +249 -0
- package/src/charts/Area.tsx +111 -0
- package/src/charts/Axis.tsx +126 -0
- package/src/charts/Bar.tsx +86 -0
- package/src/charts/CartesianGrid.tsx +72 -0
- package/src/charts/Chart.tsx +269 -0
- package/src/charts/Cursor.tsx +42 -0
- package/src/charts/Legend.tsx +56 -0
- package/src/charts/Line.tsx +106 -0
- package/src/charts/Pie.tsx +88 -0
- package/src/charts/Tooltip.tsx +127 -0
- package/src/charts/context.ts +30 -0
- package/src/charts/index.ts +14 -0
- package/src/charts/path.ts +155 -0
- package/src/charts/scale.ts +133 -0
- package/src/charts/types.ts +50 -0
- package/src/devtools/DomTab.tsx +134 -0
- package/src/devtools/GraphInspector.tsx +196 -0
- package/src/devtools/GraphTab.tsx +315 -0
- package/src/devtools/QueryTab.tsx +200 -0
- package/src/devtools/RpcTab.tsx +190 -0
- package/src/devtools/SignalsTab.tsx +129 -0
- package/src/devtools/component.tsx +243 -0
- package/src/devtools/highlighter.ts +134 -0
- package/src/devtools/hub.ts +175 -0
- package/src/devtools/index.ts +20 -0
- package/src/devtools/styles.ts +313 -0
- package/src/devtools/types.ts +45 -0
- package/src/dom/async.ts +232 -0
- package/src/dom/control-flow.ts +353 -0
- package/src/dom/index.ts +32 -0
- package/src/dom/lazy.ts +82 -0
- package/src/dom/render.ts +286 -0
- package/src/dom/resource.ts +120 -0
- package/src/form/form.ts +218 -0
- package/src/form/index.ts +17 -0
- package/src/form/schema.ts +299 -0
- package/src/index.ts +413 -0
- package/src/jsx-dev-runtime/index.ts +1 -0
- package/src/jsx-runtime/index.ts +112 -0
- package/src/jsx-runtime/jsx.d.ts +278 -0
- package/src/query/create-mutation.ts +64 -0
- package/src/query/create-query.ts +426 -0
- package/src/query/focus-manager.ts +68 -0
- package/src/query/index.ts +63 -0
- package/src/query/mutation-cache.ts +138 -0
- package/src/query/online-manager.ts +67 -0
- package/src/query/query-cache.ts +369 -0
- package/src/query/query-client.ts +248 -0
- package/src/query/types.ts +221 -0
- package/src/query/utils.ts +132 -0
- package/src/router/index.ts +19 -0
- package/src/router/router.tsx +352 -0
- package/src/rpc/client.ts +317 -0
- package/src/rpc/error.ts +70 -0
- package/src/rpc/index.ts +52 -0
- package/src/rpc/procedure.ts +88 -0
- package/src/rpc/router.ts +105 -0
- package/src/rpc/server.ts +511 -0
- package/src/rpc/types.ts +328 -0
- package/src/server/hydrate.ts +21 -0
- package/src/server/index.ts +10 -0
- package/src/server/render-to-stream.ts +31 -0
- package/src/server/render-to-string.ts +125 -0
- package/src/signals/computed.ts +217 -0
- package/src/signals/context.ts +89 -0
- package/src/signals/effect.ts +161 -0
- package/src/signals/index.ts +57 -0
- package/src/signals/scope.ts +132 -0
- package/src/signals/signal.ts +203 -0
- package/src/signals/store.ts +163 -0
- package/src/signals/system.ts +124 -0
- package/src/signals/types.ts +64 -0
- package/src/style/component.tsx +122 -0
- package/src/style/create-style.ts +229 -0
- package/src/style/cx.ts +75 -0
- package/src/style/hash.ts +19 -0
- package/src/style/index.ts +8 -0
- package/src/style/layout.tsx +406 -0
- package/src/style/manager.ts +87 -0
- package/src/style/parser.ts +257 -0
- package/src/style/scale.ts +237 -0
- package/src/template/index.ts +4 -0
- package/src/template/template.ts +126 -0
- package/src/ui/accordion.tsx +286 -0
- package/src/ui/command-dialog.tsx +49 -0
- package/src/ui/command.tsx +400 -0
- package/src/ui/data-table.tsx +322 -0
- package/src/ui/dialog.tsx +362 -0
- package/src/ui/dropdown-menu.tsx +381 -0
- package/src/ui/icon.tsx +69 -0
- package/src/ui/index.ts +138 -0
- package/src/ui/layout.tsx +13 -0
- package/src/ui/popover.tsx +249 -0
- package/src/ui/switch.tsx +114 -0
- package/src/ui/tabs.tsx +297 -0
- package/src/ui/toast.tsx +280 -0
- package/src/ui/tooltip.tsx +262 -0
- package/src/ui/utils.ts +110 -0
- package/src/virtual/index.ts +12 -0
- package/src/virtual/virtual-list.tsx +185 -0
- package/src/virtual/virtualizer.ts +274 -0
- package/src/vite/hmr.ts +38 -0
- package/src/vite/index.ts +3 -0
- package/src/vite/lucide.ts +124 -0
- package/src/vite/plugin.ts +102 -0
- package/src/vite/rpc-middleware.ts +82 -0
- package/src/vite/types.ts +65 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visage Web Standard API Fetch Handler & Request Dispatcher
|
|
3
|
+
*/
|
|
4
|
+
import { validateWithSchema } from "../form/schema";
|
|
5
|
+
import { ERROR_CODE_TO_STATUS, RPCError } from "../rpc/error";
|
|
6
|
+
import { buildFileRouter } from "./file-router";
|
|
7
|
+
import { executeMiddlewareChain } from "./middleware";
|
|
8
|
+
import type { ApiProcedure, ApiRouterConfig, FileRouteModule, RouteMatch } from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Handle CORS preflight & headers
|
|
12
|
+
*/
|
|
13
|
+
function handleCors(req: Request, config?: ApiRouterConfig["cors"]): Response | null {
|
|
14
|
+
if (!config) return null;
|
|
15
|
+
|
|
16
|
+
const origin = req.headers.get("Origin") || "*";
|
|
17
|
+
const isAllowed =
|
|
18
|
+
!config.origin ||
|
|
19
|
+
config.origin === "*" ||
|
|
20
|
+
(typeof config.origin === "function" && config.origin(origin)) ||
|
|
21
|
+
(Array.isArray(config.origin) && config.origin.includes(origin)) ||
|
|
22
|
+
config.origin === origin;
|
|
23
|
+
|
|
24
|
+
if (req.method === "OPTIONS") {
|
|
25
|
+
const headers = new Headers();
|
|
26
|
+
if (isAllowed) {
|
|
27
|
+
headers.set("Access-Control-Allow-Origin", origin);
|
|
28
|
+
}
|
|
29
|
+
headers.set(
|
|
30
|
+
"Access-Control-Allow-Methods",
|
|
31
|
+
(config.methods || ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]).join(", ")
|
|
32
|
+
);
|
|
33
|
+
headers.set(
|
|
34
|
+
"Access-Control-Allow-Headers",
|
|
35
|
+
(config.allowedHeaders || ["Content-Type", "Authorization", "x-visage-rpc"]).join(", ")
|
|
36
|
+
);
|
|
37
|
+
if (config.credentials) {
|
|
38
|
+
headers.set("Access-Control-Allow-Credentials", "true");
|
|
39
|
+
}
|
|
40
|
+
if (config.maxAge) {
|
|
41
|
+
headers.set("Access-Control-Max-Age", config.maxAge.toString());
|
|
42
|
+
}
|
|
43
|
+
return new Response(null, { status: 204, headers });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function applyCorsHeaders(res: Response, req: Request, config?: ApiRouterConfig["cors"]): Response {
|
|
50
|
+
if (!config) return res;
|
|
51
|
+
const origin = req.headers.get("Origin") || "*";
|
|
52
|
+
res.headers.set("Access-Control-Allow-Origin", origin);
|
|
53
|
+
if (config.credentials) {
|
|
54
|
+
res.headers.set("Access-Control-Allow-Credentials", "true");
|
|
55
|
+
}
|
|
56
|
+
return res;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a standard Web Fetch API handler from compiled route manifests
|
|
61
|
+
*/
|
|
62
|
+
export function createApiHandler(
|
|
63
|
+
routes: RouteMatch[],
|
|
64
|
+
config: ApiRouterConfig = {}
|
|
65
|
+
): (req: Request) => Promise<Response> {
|
|
66
|
+
const prefix = config.prefix ? `/${config.prefix.replace(/^\/+|\/+$/g, "")}` : "";
|
|
67
|
+
|
|
68
|
+
return async (req: Request): Promise<Response> => {
|
|
69
|
+
// 1. CORS Preflight Check
|
|
70
|
+
const corsPreflight = handleCors(req, config.cors);
|
|
71
|
+
if (corsPreflight) return corsPreflight;
|
|
72
|
+
|
|
73
|
+
const url = new URL(req.url);
|
|
74
|
+
let pathname = url.pathname;
|
|
75
|
+
|
|
76
|
+
if (prefix && pathname.startsWith(prefix)) {
|
|
77
|
+
pathname = pathname.slice(prefix.length) || "/";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Route Matching
|
|
81
|
+
let matchedRoute: RouteMatch | null = null;
|
|
82
|
+
const pathParams: Record<string, string> = {};
|
|
83
|
+
|
|
84
|
+
for (const route of routes) {
|
|
85
|
+
const match = pathname.match(route.pattern);
|
|
86
|
+
if (match) {
|
|
87
|
+
matchedRoute = route;
|
|
88
|
+
route.paramNames.forEach((name, i) => {
|
|
89
|
+
pathParams[name] = decodeURIComponent(match[i + 1] || "");
|
|
90
|
+
});
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!matchedRoute) {
|
|
96
|
+
return applyCorsHeaders(
|
|
97
|
+
new Response(
|
|
98
|
+
JSON.stringify({
|
|
99
|
+
ok: false,
|
|
100
|
+
error: { code: "NOT_FOUND", message: `Route not found: ${pathname}` }
|
|
101
|
+
}),
|
|
102
|
+
{ status: 404, headers: { "Content-Type": "application/json" } }
|
|
103
|
+
),
|
|
104
|
+
req,
|
|
105
|
+
config.cors
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const method = req.method.toUpperCase();
|
|
111
|
+
let procedure: ApiProcedure | undefined = matchedRoute.procedures.default;
|
|
112
|
+
|
|
113
|
+
if (!procedure) {
|
|
114
|
+
if (method === "GET") {
|
|
115
|
+
procedure = matchedRoute.procedures.GET || matchedRoute.procedures.get;
|
|
116
|
+
} else if (method === "POST") {
|
|
117
|
+
procedure = matchedRoute.procedures.POST || matchedRoute.procedures.post;
|
|
118
|
+
} else if (method === "PUT") {
|
|
119
|
+
procedure = matchedRoute.procedures.PUT || matchedRoute.procedures.put;
|
|
120
|
+
} else if (method === "DELETE") {
|
|
121
|
+
procedure =
|
|
122
|
+
matchedRoute.procedures.DELETE ||
|
|
123
|
+
matchedRoute.procedures.del ||
|
|
124
|
+
matchedRoute.procedures.delete;
|
|
125
|
+
} else if (method === "PATCH") {
|
|
126
|
+
procedure = matchedRoute.procedures.PATCH || matchedRoute.procedures.patch;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 3. Execute Middleware Chain (File-level + Procedure-level)
|
|
131
|
+
const mwFns = [
|
|
132
|
+
...matchedRoute.middlewares.map((m) => m.fn),
|
|
133
|
+
...(procedure?.middlewares || [])
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const { response: earlyResponse, ctx } = await executeMiddlewareChain(
|
|
137
|
+
mwFns,
|
|
138
|
+
req,
|
|
139
|
+
url,
|
|
140
|
+
pathParams
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (earlyResponse) {
|
|
144
|
+
return applyCorsHeaders(earlyResponse, req, config.cors);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 4. Custom Raw Handler
|
|
148
|
+
if (matchedRoute.handler) {
|
|
149
|
+
const res = await matchedRoute.handler(req, ctx);
|
|
150
|
+
return applyCorsHeaders(res, req, config.cors);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!procedure) {
|
|
154
|
+
return applyCorsHeaders(
|
|
155
|
+
new Response(
|
|
156
|
+
JSON.stringify({
|
|
157
|
+
ok: false,
|
|
158
|
+
error: {
|
|
159
|
+
code: "METHOD_NOT_ALLOWED",
|
|
160
|
+
message: `Method ${method} not allowed on ${pathname}`
|
|
161
|
+
}
|
|
162
|
+
}),
|
|
163
|
+
{ status: 405, headers: { "Content-Type": "application/json" } }
|
|
164
|
+
),
|
|
165
|
+
req,
|
|
166
|
+
config.cors
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Parse Input
|
|
171
|
+
let rawInput: any = {};
|
|
172
|
+
if (method === "GET") {
|
|
173
|
+
const inputQuery = url.searchParams.get("input");
|
|
174
|
+
if (inputQuery) {
|
|
175
|
+
try {
|
|
176
|
+
rawInput = JSON.parse(inputQuery);
|
|
177
|
+
} catch {
|
|
178
|
+
rawInput = inputQuery;
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
// Flatten searchParams
|
|
182
|
+
const spObj: Record<string, string> = {};
|
|
183
|
+
url.searchParams.forEach((val, key) => {
|
|
184
|
+
spObj[key] = val;
|
|
185
|
+
});
|
|
186
|
+
if (Object.keys(spObj).length > 0) {
|
|
187
|
+
rawInput = spObj;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
const contentType = req.headers.get("Content-Type") || "";
|
|
192
|
+
if (contentType.includes("application/json")) {
|
|
193
|
+
try {
|
|
194
|
+
rawInput = await req.json();
|
|
195
|
+
} catch {
|
|
196
|
+
rawInput = {};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Merge Path Params into Input if object
|
|
202
|
+
if (typeof rawInput === "object" && rawInput !== null) {
|
|
203
|
+
rawInput = { ...pathParams, ...rawInput };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Validate Input Schema
|
|
207
|
+
let validatedInput = rawInput;
|
|
208
|
+
if (procedure.options.input) {
|
|
209
|
+
const validation = await validateWithSchema(procedure.options.input, rawInput);
|
|
210
|
+
if (!validation.success) {
|
|
211
|
+
return applyCorsHeaders(
|
|
212
|
+
new Response(
|
|
213
|
+
JSON.stringify({
|
|
214
|
+
ok: false,
|
|
215
|
+
error: {
|
|
216
|
+
code: "BAD_REQUEST",
|
|
217
|
+
message: "Validation Error",
|
|
218
|
+
issues: validation.issues
|
|
219
|
+
}
|
|
220
|
+
}),
|
|
221
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
222
|
+
),
|
|
223
|
+
req,
|
|
224
|
+
config.cors
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
validatedInput = validation.data;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Run Procedure
|
|
231
|
+
if (procedure.type === "subscription") {
|
|
232
|
+
// SSE Real-time Streaming
|
|
233
|
+
const generator = procedure.options.resolve({
|
|
234
|
+
input: validatedInput,
|
|
235
|
+
ctx,
|
|
236
|
+
req,
|
|
237
|
+
params: pathParams
|
|
238
|
+
}) as AsyncGenerator<any, void, unknown>;
|
|
239
|
+
|
|
240
|
+
const stream = new ReadableStream({
|
|
241
|
+
async start(controller) {
|
|
242
|
+
const encoder = new TextEncoder();
|
|
243
|
+
try {
|
|
244
|
+
for await (const chunk of generator) {
|
|
245
|
+
const payload = `data: ${JSON.stringify(chunk)}\n\n`;
|
|
246
|
+
controller.enqueue(encoder.encode(payload));
|
|
247
|
+
}
|
|
248
|
+
controller.close();
|
|
249
|
+
} catch (err: any) {
|
|
250
|
+
controller.error(err);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const sseRes = new Response(stream, {
|
|
256
|
+
headers: {
|
|
257
|
+
"Content-Type": "text/event-stream",
|
|
258
|
+
"Cache-Control": "no-cache",
|
|
259
|
+
Connection: "keep-alive"
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
return applyCorsHeaders(sseRes, req, config.cors);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const result = await procedure.options.resolve({
|
|
266
|
+
input: validatedInput,
|
|
267
|
+
ctx,
|
|
268
|
+
req,
|
|
269
|
+
params: pathParams
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const successRes = new Response(JSON.stringify({ ok: true, data: result }), {
|
|
273
|
+
status: 200,
|
|
274
|
+
headers: { "Content-Type": "application/json" }
|
|
275
|
+
});
|
|
276
|
+
return applyCorsHeaders(successRes, req, config.cors);
|
|
277
|
+
} catch (err: any) {
|
|
278
|
+
if (config.onError) {
|
|
279
|
+
const customRes = await config.onError(err, req);
|
|
280
|
+
return applyCorsHeaders(customRes, req, config.cors);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const statusCode =
|
|
284
|
+
err instanceof RPCError
|
|
285
|
+
? err.statusCode
|
|
286
|
+
: ERROR_CODE_TO_STATUS[err?.code as keyof typeof ERROR_CODE_TO_STATUS] || 500;
|
|
287
|
+
|
|
288
|
+
const code = err instanceof RPCError ? err.code : "INTERNAL_SERVER_ERROR";
|
|
289
|
+
const message = err?.message || "Internal Server Error";
|
|
290
|
+
|
|
291
|
+
const errRes = new Response(JSON.stringify({ ok: false, error: { code, message } }), {
|
|
292
|
+
status: statusCode,
|
|
293
|
+
headers: { "Content-Type": "application/json" }
|
|
294
|
+
});
|
|
295
|
+
return applyCorsHeaders(errRes, req, config.cors);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* High-level createApi factory for file-based API routers
|
|
302
|
+
*/
|
|
303
|
+
export function createApi(
|
|
304
|
+
files: Record<string, FileRouteModule | { default?: any; [key: string]: any }>,
|
|
305
|
+
sourceMapOrConfig?: Record<string, string> | ApiRouterConfig,
|
|
306
|
+
maybeConfig?: ApiRouterConfig
|
|
307
|
+
) {
|
|
308
|
+
const sourceMap =
|
|
309
|
+
sourceMapOrConfig && !("prefix" in sourceMapOrConfig) && !("cors" in sourceMapOrConfig)
|
|
310
|
+
? (sourceMapOrConfig as Record<string, string>)
|
|
311
|
+
: {};
|
|
312
|
+
|
|
313
|
+
const config =
|
|
314
|
+
maybeConfig ||
|
|
315
|
+
(sourceMapOrConfig && ("prefix" in sourceMapOrConfig || "cors" in sourceMapOrConfig)
|
|
316
|
+
? (sourceMapOrConfig as ApiRouterConfig)
|
|
317
|
+
: {});
|
|
318
|
+
|
|
319
|
+
const routes = buildFileRouter(files, sourceMap);
|
|
320
|
+
const fetchHandler = createApiHandler(routes, config);
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
routes,
|
|
324
|
+
fetch: fetchHandler
|
|
325
|
+
};
|
|
326
|
+
}
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visage File-Based API & Opinionated RPC Framework (visage/api)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Re-export standard schema validation & RPC errors
|
|
6
|
+
export {
|
|
7
|
+
Schema,
|
|
8
|
+
type SchemaLike,
|
|
9
|
+
type StandardSchemaV1,
|
|
10
|
+
type ValidationIssue,
|
|
11
|
+
type ValidationResult,
|
|
12
|
+
v,
|
|
13
|
+
validateWithSchema
|
|
14
|
+
} from "../form/schema";
|
|
15
|
+
export {
|
|
16
|
+
ERROR_CODE_TO_STATUS,
|
|
17
|
+
RPCError,
|
|
18
|
+
STATUS_TO_ERROR_CODE
|
|
19
|
+
} from "../rpc/error";
|
|
20
|
+
export { createApiClient } from "./client";
|
|
21
|
+
export {
|
|
22
|
+
buildFileRouter,
|
|
23
|
+
compileRoutePattern,
|
|
24
|
+
type FileRouteEntry,
|
|
25
|
+
normalizeFilePath
|
|
26
|
+
} from "./file-router";
|
|
27
|
+
export {
|
|
28
|
+
createApi,
|
|
29
|
+
createApiHandler
|
|
30
|
+
} from "./handler";
|
|
31
|
+
export { executeMiddlewareChain } from "./middleware";
|
|
32
|
+
export {
|
|
33
|
+
globToRegExp,
|
|
34
|
+
matchesAnyPattern,
|
|
35
|
+
parsePragma,
|
|
36
|
+
shouldApplyMiddleware
|
|
37
|
+
} from "./pragma";
|
|
38
|
+
export {
|
|
39
|
+
defineMiddleware,
|
|
40
|
+
mutation,
|
|
41
|
+
ProcedureBuilder,
|
|
42
|
+
procedure,
|
|
43
|
+
query,
|
|
44
|
+
subscription
|
|
45
|
+
} from "./procedure";
|
|
46
|
+
export type {
|
|
47
|
+
ApiCallOptions,
|
|
48
|
+
ApiClient,
|
|
49
|
+
ApiClientHeadersRule,
|
|
50
|
+
ApiClientOptions,
|
|
51
|
+
ApiEndpointMethods,
|
|
52
|
+
ApiMethodCaller,
|
|
53
|
+
ApiProcedure,
|
|
54
|
+
ApiRouterConfig,
|
|
55
|
+
ApiSubscriptionCallbacks,
|
|
56
|
+
ApiSubscriptionCaller,
|
|
57
|
+
ApiSubscriptionHandle,
|
|
58
|
+
FileRouteModule,
|
|
59
|
+
HttpMethod,
|
|
60
|
+
InferApiInput,
|
|
61
|
+
InferApiOutput,
|
|
62
|
+
MiddlewareContext,
|
|
63
|
+
MiddlewareDefinition,
|
|
64
|
+
MiddlewareFn,
|
|
65
|
+
MiddlewarePragma,
|
|
66
|
+
NextFn,
|
|
67
|
+
ProcedureOptions,
|
|
68
|
+
ProcedureType,
|
|
69
|
+
RouteMatch,
|
|
70
|
+
RPCErrorCode
|
|
71
|
+
} from "./types";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visage Middleware Execution Engine & Context Propagation
|
|
3
|
+
*/
|
|
4
|
+
import type { MiddlewareFn, NextFn } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Execute a chain of middlewares, accumulating context and returning either an early Response or merged Context
|
|
8
|
+
*/
|
|
9
|
+
export async function executeMiddlewareChain(
|
|
10
|
+
middlewares: MiddlewareFn[],
|
|
11
|
+
req: Request,
|
|
12
|
+
url: URL,
|
|
13
|
+
params: Record<string, string>,
|
|
14
|
+
initialCtx: Record<string, any> = {}
|
|
15
|
+
): Promise<{ response?: Response; ctx: Record<string, any> }> {
|
|
16
|
+
let currentCtx = { ...initialCtx };
|
|
17
|
+
let earlyResponse: Response | undefined;
|
|
18
|
+
|
|
19
|
+
let index = 0;
|
|
20
|
+
|
|
21
|
+
const runner = async (): Promise<void> => {
|
|
22
|
+
if (index >= middlewares.length) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const currentMw = middlewares[index++]!;
|
|
27
|
+
const next: NextFn = async (enriched) => {
|
|
28
|
+
if (enriched && typeof enriched === "object") {
|
|
29
|
+
currentCtx = { ...currentCtx, ...enriched };
|
|
30
|
+
}
|
|
31
|
+
await runner();
|
|
32
|
+
return currentCtx;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const result = await currentMw({
|
|
36
|
+
req,
|
|
37
|
+
ctx: currentCtx,
|
|
38
|
+
url,
|
|
39
|
+
params,
|
|
40
|
+
next
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (result instanceof Response) {
|
|
44
|
+
earlyResponse = result;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
await runner();
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
response: earlyResponse,
|
|
52
|
+
ctx: currentCtx
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visage API Pragma Parser & Path Pattern Matcher
|
|
3
|
+
* Parses `// @visage-middleware` pragmas at the top of middleware files.
|
|
4
|
+
*/
|
|
5
|
+
import type { MiddlewarePragma } from "./types";
|
|
6
|
+
|
|
7
|
+
const PRAGMA_REGEX =
|
|
8
|
+
/(?:\/\/\s*@visage-middleware\s+([^\r\n]+))|(?:\/\*\s*@visage-middleware\s+([\s\S]*?)\*\/)/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse top-level `// @visage-middleware` pragma from file source code
|
|
12
|
+
*/
|
|
13
|
+
export function parsePragma(sourceCode: string): MiddlewarePragma {
|
|
14
|
+
if (!sourceCode || typeof sourceCode !== "string") {
|
|
15
|
+
return { cascade: true, match: ["*"], exclude: [], priority: 0 };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const match = sourceCode.match(PRAGMA_REGEX);
|
|
19
|
+
if (!match) {
|
|
20
|
+
return { cascade: true, match: ["*"], exclude: [], priority: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const rawDirective = (match[1] || match[2] || "").trim();
|
|
24
|
+
const pragma: MiddlewarePragma = {
|
|
25
|
+
cascade: true,
|
|
26
|
+
match: ["*"],
|
|
27
|
+
exclude: [],
|
|
28
|
+
priority: 0
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Parse comma-separated or key-value options: cascade: true, match: ["..."], exclude: ["..."]
|
|
32
|
+
const pairs = rawDirective.split(/,(?![^[]*\])/);
|
|
33
|
+
for (const pair of pairs) {
|
|
34
|
+
const colonIdx = pair.indexOf(":");
|
|
35
|
+
if (colonIdx === -1) continue;
|
|
36
|
+
|
|
37
|
+
const key = pair.slice(0, colonIdx).trim().toLowerCase();
|
|
38
|
+
const valStr = pair.slice(colonIdx + 1).trim();
|
|
39
|
+
|
|
40
|
+
if (key === "cascade") {
|
|
41
|
+
pragma.cascade = valStr === "true" || valStr === "1";
|
|
42
|
+
} else if (key === "priority") {
|
|
43
|
+
const num = Number.parseInt(valStr, 10);
|
|
44
|
+
if (!Number.isNaN(num)) pragma.priority = num;
|
|
45
|
+
} else if (key === "match" || key === "exclude") {
|
|
46
|
+
let items: string[] = [];
|
|
47
|
+
if (valStr.startsWith("[") && valStr.endsWith("]")) {
|
|
48
|
+
try {
|
|
49
|
+
items = JSON.parse(valStr.replace(/'/g, '"'));
|
|
50
|
+
} catch {
|
|
51
|
+
items = valStr
|
|
52
|
+
.slice(1, -1)
|
|
53
|
+
.split(",")
|
|
54
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
items = [valStr.replace(/^['"]|['"]$/g, "")];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (key === "match") pragma.match = items;
|
|
62
|
+
if (key === "exclude") pragma.exclude = items;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return pragma;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Convert a glob-like pattern (e.g. `admin/*`, `users/**`, `stats`) into a RegExp
|
|
71
|
+
*/
|
|
72
|
+
export function globToRegExp(pattern: string): RegExp {
|
|
73
|
+
const normalized = pattern.trim().replace(/^\/+|\/+$/g, "");
|
|
74
|
+
if (normalized === "*" || normalized === "**" || normalized === "") {
|
|
75
|
+
return /^.*$/;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const escaped = normalized
|
|
79
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
80
|
+
.replace(/\*\*/g, "___DOUBLE_STAR___")
|
|
81
|
+
.replace(/\*/g, "[^/]+")
|
|
82
|
+
.replace(/___DOUBLE_STAR___/g, ".*");
|
|
83
|
+
|
|
84
|
+
return new RegExp(`^(?:/)?${escaped}(?:/)?$`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Test whether a normalized route path matches any glob pattern in a list
|
|
89
|
+
*/
|
|
90
|
+
export function matchesAnyPattern(routePath: string, patterns: string[]): boolean {
|
|
91
|
+
if (!patterns || patterns.length === 0) return false;
|
|
92
|
+
const cleanPath = routePath.replace(/^\/+|\/+$/g, "");
|
|
93
|
+
|
|
94
|
+
for (const pat of patterns) {
|
|
95
|
+
const cleanPat = pat.replace(/^\/+|\/+$/g, "");
|
|
96
|
+
if (cleanPat === "*" || cleanPat === "**") return true;
|
|
97
|
+
if (cleanPat === cleanPath) return true;
|
|
98
|
+
if (globToRegExp(cleanPat).test(cleanPath)) return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Evaluate if a middleware definition should apply to a specific route
|
|
106
|
+
*/
|
|
107
|
+
export function shouldApplyMiddleware(
|
|
108
|
+
pragma: MiddlewarePragma,
|
|
109
|
+
routePath: string,
|
|
110
|
+
routeDir: string,
|
|
111
|
+
middlewareDir: string
|
|
112
|
+
): boolean {
|
|
113
|
+
const normRouteDir = routeDir.replace(/^\/+|\/+$/g, "");
|
|
114
|
+
const normMwDir = middlewareDir.replace(/^\/+|\/+$/g, "");
|
|
115
|
+
|
|
116
|
+
// 1. Ancestor check: routeDir must be equal to or a subfolder of middlewareDir
|
|
117
|
+
const isDirectDir = normRouteDir === normMwDir;
|
|
118
|
+
const isSubDir =
|
|
119
|
+
normMwDir === "" ? normRouteDir !== "" : normRouteDir.startsWith(`${normMwDir}/`);
|
|
120
|
+
|
|
121
|
+
if (!isDirectDir && !isSubDir) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 2. Cascading check: if route is in a subfolder and cascade is false, do not apply
|
|
126
|
+
const isCascade = pragma.cascade ?? true;
|
|
127
|
+
if (isSubDir && !isCascade) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 3. Relative route path within the middleware's domain
|
|
132
|
+
let relativePath = routePath.replace(/^\/+|\/+$/g, "");
|
|
133
|
+
if (normMwDir && relativePath.startsWith(normMwDir)) {
|
|
134
|
+
relativePath = relativePath.slice(normMwDir.length).replace(/^\/+/, "");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 4. Exclude rule takes immediate precedence
|
|
138
|
+
if (pragma.exclude && pragma.exclude.length > 0) {
|
|
139
|
+
if (
|
|
140
|
+
matchesAnyPattern(routePath, pragma.exclude) ||
|
|
141
|
+
matchesAnyPattern(relativePath, pragma.exclude)
|
|
142
|
+
) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 5. Match rule
|
|
148
|
+
if (!pragma.match || pragma.match.length === 0 || pragma.match.includes("*")) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
matchesAnyPattern(routePath, pragma.match) || matchesAnyPattern(relativePath, pragma.match)
|
|
154
|
+
);
|
|
155
|
+
}
|