@farm.js/plugin 0.1.0-beta.10 → 0.1.0-beta.11
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/dist/api/index.d.ts +42 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +493 -0
- package/dist/context/index.d.ts +61 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +75 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/middleware/index.d.ts +97 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +469 -0
- package/dist/observability/index.d.ts +190 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +399 -0
- package/dist/rsc/automatic-optimized-boundary.d.ts +11 -0
- package/dist/rsc/automatic-optimized-boundary.d.ts.map +1 -0
- package/dist/rsc/automatic-optimized-boundary.js +162 -0
- package/dist/rsc/build-paths.d.ts +3 -0
- package/dist/rsc/build-paths.d.ts.map +1 -0
- package/dist/rsc/build-paths.js +8 -0
- package/dist/rsc/compatibility.d.ts +8 -0
- package/dist/rsc/compatibility.d.ts.map +1 -0
- package/dist/rsc/compatibility.js +46 -0
- package/dist/rsc/entries/client.d.ts +14 -0
- package/dist/rsc/entries/client.d.ts.map +1 -0
- package/dist/rsc/entries/client.js +283 -0
- package/dist/rsc/entries/rsc.d.ts +13 -0
- package/dist/rsc/entries/rsc.d.ts.map +1 -0
- package/dist/rsc/entries/rsc.js +932 -0
- package/dist/rsc/entries/ssr.d.ts +13 -0
- package/dist/rsc/entries/ssr.d.ts.map +1 -0
- package/dist/rsc/entries/ssr.js +245 -0
- package/dist/rsc/index.d.ts +87 -0
- package/dist/rsc/index.d.ts.map +1 -0
- package/dist/rsc/index.js +1389 -0
- package/dist/rsc/nitro-build.d.ts +36 -0
- package/dist/rsc/nitro-build.d.ts.map +1 -0
- package/dist/rsc/nitro-build.js +396 -0
- package/dist/rsc/optimized-boundary.d.ts +29 -0
- package/dist/rsc/optimized-boundary.d.ts.map +1 -0
- package/dist/rsc/optimized-boundary.js +243 -0
- package/dist/rsc/server-fn-transform.d.ts +6 -0
- package/dist/rsc/server-fn-transform.d.ts.map +1 -0
- package/dist/rsc/server-fn-transform.js +152 -0
- package/dist/rsc/types.d.ts +123 -0
- package/dist/rsc/types.d.ts.map +1 -0
- package/dist/rsc/types.js +1 -0
- package/dist/rsc/vite-plugin-nitro.d.ts +33 -0
- package/dist/rsc/vite-plugin-nitro.d.ts.map +1 -0
- package/dist/rsc/vite-plugin-nitro.js +163 -0
- package/package.json +3 -3
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Farm.js API Routes Plugin
|
|
3
|
+
*
|
|
4
|
+
* Standalone API route support that works with or without RSC.
|
|
5
|
+
*
|
|
6
|
+
* Supports two patterns:
|
|
7
|
+
* 1. File-based routing: /src/api/hello/route.ts -> /api/hello
|
|
8
|
+
* The path is auto-inferred from file location
|
|
9
|
+
*
|
|
10
|
+
* 2. Root routes.ts file: /src/routes.ts with explicit paths
|
|
11
|
+
* createEndpoint('/api/custom', { method: 'GET' }, handler)
|
|
12
|
+
*
|
|
13
|
+
* Uses better-call's createRouter for proper Zod validation and parsing.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { farmApi } from '@farm.js/plugin/api'
|
|
18
|
+
*
|
|
19
|
+
* export default defineConfig({
|
|
20
|
+
* plugins: [farmApi({ srcDir: 'src' })],
|
|
21
|
+
* })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import type { Plugin } from "vite";
|
|
25
|
+
export interface FarmApiOptions {
|
|
26
|
+
/** Source directory containing the api folder (default: 'src') */
|
|
27
|
+
srcDir?: string;
|
|
28
|
+
/** Enable debug logging */
|
|
29
|
+
debug?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface ApiRoute {
|
|
32
|
+
path: string;
|
|
33
|
+
filePath: string;
|
|
34
|
+
methods: string[];
|
|
35
|
+
endpoints: Record<string, any>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Farm.js API Routes Plugin
|
|
39
|
+
*/
|
|
40
|
+
export default function farmApi(options?: FarmApiOptions): Plugin;
|
|
41
|
+
export { farmApi };
|
|
42
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAC;AAIlD,MAAM,WAAW,cAAc;IAC7B,kEAAkE;IAClE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAChC;AAED;;GAEG;AACH,MAAM,CAAC,OAAO,UAAU,OAAO,CAAC,OAAO,GAAE,cAAmB,GAAG,MAAM,CA2gBpE;AAED,OAAO,EAAE,OAAO,EAAE,CAAC"}
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Farm.js API Routes Plugin
|
|
3
|
+
*
|
|
4
|
+
* Standalone API route support that works with or without RSC.
|
|
5
|
+
*
|
|
6
|
+
* Supports two patterns:
|
|
7
|
+
* 1. File-based routing: /src/api/hello/route.ts -> /api/hello
|
|
8
|
+
* The path is auto-inferred from file location
|
|
9
|
+
*
|
|
10
|
+
* 2. Root routes.ts file: /src/routes.ts with explicit paths
|
|
11
|
+
* createEndpoint('/api/custom', { method: 'GET' }, handler)
|
|
12
|
+
*
|
|
13
|
+
* Uses better-call's createRouter for proper Zod validation and parsing.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { farmApi } from '@farm.js/plugin/api'
|
|
18
|
+
*
|
|
19
|
+
* export default defineConfig({
|
|
20
|
+
* plugins: [farmApi({ srcDir: 'src' })],
|
|
21
|
+
* })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import { _withAfterNodeMiddleware } from "@farm.js/core/after";
|
|
25
|
+
import { invokeAPIRouteEndpoint } from "@farm.js/core/api/runtime";
|
|
26
|
+
/**
|
|
27
|
+
* Farm.js API Routes Plugin
|
|
28
|
+
*/
|
|
29
|
+
export default function farmApi(options = {}) {
|
|
30
|
+
const srcDir = options.srcDir ?? "src";
|
|
31
|
+
const debug = options.debug ?? false;
|
|
32
|
+
// API routes cache
|
|
33
|
+
let apiRoutesCache = new Map();
|
|
34
|
+
let apiRouterHandler = null;
|
|
35
|
+
let discoveryComplete = false;
|
|
36
|
+
let discoveryPromise = null;
|
|
37
|
+
let currentServer = null;
|
|
38
|
+
// Debug-only logging for discovery messages with [FARM] prefix
|
|
39
|
+
const log = (message) => {
|
|
40
|
+
if (!debug)
|
|
41
|
+
return;
|
|
42
|
+
try {
|
|
43
|
+
const pc = require("picocolors");
|
|
44
|
+
console.log(pc.dim("[") + pc.bold(pc.blue("FARM")) + pc.dim("]") + " " + pc.gray(message));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
console.log(`[FARM] ${message}`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
// Always log API request/response with [FARM] [API] [METHOD] format
|
|
51
|
+
const logResponse = (method, urlPath, status, duration) => {
|
|
52
|
+
try {
|
|
53
|
+
const pc = require("picocolors");
|
|
54
|
+
let statusColor = pc.green;
|
|
55
|
+
if (status >= 500)
|
|
56
|
+
statusColor = pc.red;
|
|
57
|
+
else if (status >= 400)
|
|
58
|
+
statusColor = pc.yellow;
|
|
59
|
+
else if (status >= 300)
|
|
60
|
+
statusColor = pc.cyan;
|
|
61
|
+
const log = [
|
|
62
|
+
pc.dim("[") + pc.bold(pc.blue("FARM")) + pc.dim("]"),
|
|
63
|
+
pc.dim("[") + pc.bold(pc.cyan("API")) + pc.dim("]"),
|
|
64
|
+
pc.dim("[") + pc.bold(pc.white(method.padEnd(3))) + pc.dim("]"),
|
|
65
|
+
pc.gray(urlPath),
|
|
66
|
+
pc.dim("-"),
|
|
67
|
+
statusColor(status.toString()),
|
|
68
|
+
pc.dim(`(${duration}ms)`),
|
|
69
|
+
].join(" ");
|
|
70
|
+
console.log(log);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
console.log(`[FARM] [API] [${method}] ${urlPath} - ${status} (${duration}ms)`);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
// Create API router handler
|
|
77
|
+
// We use direct invocation for better control over path matching
|
|
78
|
+
const createBetterCallRouter = async () => {
|
|
79
|
+
const totalEndpoints = Array.from(apiRoutesCache.values()).reduce((sum, route) => sum + route.methods.length, 0);
|
|
80
|
+
if (totalEndpoints > 0) {
|
|
81
|
+
// Create a handler that matches routes and invokes endpoints
|
|
82
|
+
apiRouterHandler = async (request) => {
|
|
83
|
+
const url = new URL(request.url);
|
|
84
|
+
const method = request.method;
|
|
85
|
+
const pathname = url.pathname;
|
|
86
|
+
// Find matching route
|
|
87
|
+
const route = apiRoutesCache.get(pathname);
|
|
88
|
+
if (!route) {
|
|
89
|
+
return new Response(JSON.stringify({ error: "Not Found" }), {
|
|
90
|
+
status: 404,
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const endpoint = route.endpoints[method];
|
|
95
|
+
if (!endpoint) {
|
|
96
|
+
return new Response(JSON.stringify({ error: "Method Not Allowed" }), {
|
|
97
|
+
status: 405,
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
return await invokeAPIRouteEndpoint(endpoint, request);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return new Response(JSON.stringify({ error: error.message || "Internal Server Error" }), {
|
|
106
|
+
status: 500,
|
|
107
|
+
headers: { "Content-Type": "application/json" },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
log(`better-call router created with ${totalEndpoints} endpoints`);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
// Fallback handler when better-call is not available
|
|
115
|
+
const createFallbackHandler = () => {
|
|
116
|
+
return async (request) => {
|
|
117
|
+
const url = new URL(request.url);
|
|
118
|
+
const method = request.method;
|
|
119
|
+
// Find matching route
|
|
120
|
+
const route = apiRoutesCache.get(url.pathname);
|
|
121
|
+
if (!route) {
|
|
122
|
+
return new Response(JSON.stringify({ error: "Not Found" }), {
|
|
123
|
+
status: 404,
|
|
124
|
+
headers: { "Content-Type": "application/json" },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const endpoint = route.endpoints[method];
|
|
128
|
+
if (!endpoint) {
|
|
129
|
+
return new Response(JSON.stringify({ error: "Method Not Allowed" }), {
|
|
130
|
+
status: 405,
|
|
131
|
+
headers: { "Content-Type": "application/json" },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
// For fallback, call the endpoint directly (without Zod parsing)
|
|
136
|
+
const result = await endpoint(request);
|
|
137
|
+
if (result instanceof Response) {
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
return new Response(JSON.stringify(result), {
|
|
141
|
+
status: 200,
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
return new Response(JSON.stringify({ error: error.message || "Internal Server Error" }), {
|
|
147
|
+
status: 500,
|
|
148
|
+
headers: { "Content-Type": "application/json" },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
name: "@farm.js/plugin/api",
|
|
155
|
+
enforce: "pre",
|
|
156
|
+
configureServer(server) {
|
|
157
|
+
currentServer = server;
|
|
158
|
+
// Discover API routes
|
|
159
|
+
const discoverAPIRoutes = async (apiDir) => {
|
|
160
|
+
const fs = await import("fs");
|
|
161
|
+
const path = await import("path");
|
|
162
|
+
if (!fs.existsSync(apiDir)) {
|
|
163
|
+
log("No api directory found");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const findRouteFiles = (dir) => {
|
|
167
|
+
const files = [];
|
|
168
|
+
if (!fs.existsSync(dir))
|
|
169
|
+
return files;
|
|
170
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
const fullPath = path.join(dir, entry.name);
|
|
173
|
+
if (entry.isDirectory()) {
|
|
174
|
+
files.push(...findRouteFiles(fullPath));
|
|
175
|
+
}
|
|
176
|
+
else if (entry.name === "route.ts" ||
|
|
177
|
+
entry.name === "route.tsx" ||
|
|
178
|
+
entry.name === "route.js") {
|
|
179
|
+
files.push(fullPath);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return files;
|
|
183
|
+
};
|
|
184
|
+
const routeFiles = findRouteFiles(apiDir);
|
|
185
|
+
// Clear cache for rediscovery
|
|
186
|
+
apiRoutesCache.clear();
|
|
187
|
+
for (const filePath of routeFiles) {
|
|
188
|
+
try {
|
|
189
|
+
const relativePath = path.relative(apiDir, path.dirname(filePath));
|
|
190
|
+
const routePath = "/api/" + (relativePath === "." ? "" : relativePath.replace(/\\/g, "/"));
|
|
191
|
+
const routeModule = await server.ssrLoadModule(filePath);
|
|
192
|
+
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
|
|
193
|
+
const endpoints = {};
|
|
194
|
+
const availableMethods = [];
|
|
195
|
+
for (const method of methods) {
|
|
196
|
+
if (routeModule[method]) {
|
|
197
|
+
availableMethods.push(method);
|
|
198
|
+
endpoints[method] = routeModule[method];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (availableMethods.length > 0) {
|
|
202
|
+
apiRoutesCache.set(routePath, {
|
|
203
|
+
path: routePath,
|
|
204
|
+
filePath,
|
|
205
|
+
methods: availableMethods,
|
|
206
|
+
endpoints,
|
|
207
|
+
});
|
|
208
|
+
log(`API route discovered: ${availableMethods.join(", ")} ${routePath}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
log(`API route load failed at ${filePath}: ${e.message}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Router is created after all routes are discovered (including root routes.ts)
|
|
216
|
+
};
|
|
217
|
+
// Discover routes from root routes.ts file
|
|
218
|
+
const discoverRootRoutes = async () => {
|
|
219
|
+
const fs = await import("fs");
|
|
220
|
+
const path = await import("path");
|
|
221
|
+
const routesFiles = [
|
|
222
|
+
path.join(server.config.root, srcDir, "routes.ts"),
|
|
223
|
+
path.join(server.config.root, srcDir, "routes.tsx"),
|
|
224
|
+
path.join(server.config.root, srcDir, "routes.js"),
|
|
225
|
+
];
|
|
226
|
+
for (const routesFile of routesFiles) {
|
|
227
|
+
if (fs.existsSync(routesFile)) {
|
|
228
|
+
try {
|
|
229
|
+
const routesModule = await server.ssrLoadModule(routesFile);
|
|
230
|
+
// Look for exported endpoints with explicit paths
|
|
231
|
+
for (const [exportName, exportValue] of Object.entries(routesModule)) {
|
|
232
|
+
// Check both object and function exports (better-call returns functions)
|
|
233
|
+
const endpoint = exportValue;
|
|
234
|
+
if (endpoint && endpoint.__path) {
|
|
235
|
+
const routePath = endpoint.__path;
|
|
236
|
+
const method = endpoint.__method || "GET";
|
|
237
|
+
// Add to cache
|
|
238
|
+
const existing = apiRoutesCache.get(routePath);
|
|
239
|
+
if (existing) {
|
|
240
|
+
existing.methods.push(method);
|
|
241
|
+
existing.endpoints[method] = endpoint;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
apiRoutesCache.set(routePath, {
|
|
245
|
+
path: routePath,
|
|
246
|
+
filePath: routesFile,
|
|
247
|
+
methods: [method],
|
|
248
|
+
endpoints: { [method]: endpoint },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
// Root route discovery log omitted
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
log(`Root routes.ts load failed: ${e.message}`);
|
|
257
|
+
}
|
|
258
|
+
break; // Only load the first routes file found
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
// Initialize discovery
|
|
263
|
+
const initializeDiscovery = async () => {
|
|
264
|
+
const path = await import("path");
|
|
265
|
+
const apiDir = path.join(server.config.root, srcDir, "api");
|
|
266
|
+
await discoverAPIRoutes(apiDir);
|
|
267
|
+
await discoverRootRoutes();
|
|
268
|
+
await createBetterCallRouter();
|
|
269
|
+
discoveryComplete = true;
|
|
270
|
+
};
|
|
271
|
+
discoveryPromise = initializeDiscovery().catch((e) => {
|
|
272
|
+
console.error("[FARM] API discovery error:", e);
|
|
273
|
+
});
|
|
274
|
+
// Expose API router for other plugins
|
|
275
|
+
server.__farmApi__ = {
|
|
276
|
+
getHandler: () => apiRouterHandler,
|
|
277
|
+
getRoutes: () => apiRoutesCache,
|
|
278
|
+
isReady: () => discoveryComplete,
|
|
279
|
+
waitForDiscovery: () => discoveryPromise,
|
|
280
|
+
};
|
|
281
|
+
// Add middleware to handle API requests
|
|
282
|
+
return () => {
|
|
283
|
+
server.middlewares.use(_withAfterNodeMiddleware(async (req, res, next) => {
|
|
284
|
+
const url = req.url || "/";
|
|
285
|
+
const pathname = url.split("?")[0];
|
|
286
|
+
const method = req.method || "GET";
|
|
287
|
+
// Only handle /api/ routes
|
|
288
|
+
if (!pathname.startsWith("/api/")) {
|
|
289
|
+
return next();
|
|
290
|
+
}
|
|
291
|
+
// Wait for discovery
|
|
292
|
+
if (discoveryPromise && !discoveryComplete) {
|
|
293
|
+
await discoveryPromise;
|
|
294
|
+
}
|
|
295
|
+
if (!apiRouterHandler) {
|
|
296
|
+
return next();
|
|
297
|
+
}
|
|
298
|
+
const startTime = Date.now();
|
|
299
|
+
try {
|
|
300
|
+
// Execute middleware if available
|
|
301
|
+
const farmMiddleware = server.__farmMiddleware__;
|
|
302
|
+
if (farmMiddleware) {
|
|
303
|
+
await farmMiddleware.waitForDiscovery?.();
|
|
304
|
+
const middlewareData = new Map();
|
|
305
|
+
const handled = await farmMiddleware.execute(req, res, pathname, middlewareData);
|
|
306
|
+
if (handled) {
|
|
307
|
+
const duration = Date.now() - startTime;
|
|
308
|
+
logResponse(method, pathname, res.statusCode || 200, duration);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Convert Node request to Web Request
|
|
313
|
+
const fullUrl = `http://${req.headers.host || "localhost:3000"}${url}`;
|
|
314
|
+
const headers = new Headers();
|
|
315
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
316
|
+
if (value) {
|
|
317
|
+
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Get body for non-GET requests
|
|
321
|
+
let body;
|
|
322
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
323
|
+
body = await new Promise((resolve) => {
|
|
324
|
+
let data = "";
|
|
325
|
+
req.on("data", (chunk) => {
|
|
326
|
+
data += chunk;
|
|
327
|
+
});
|
|
328
|
+
req.on("end", () => {
|
|
329
|
+
resolve(data);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
const request = new Request(fullUrl, {
|
|
334
|
+
method,
|
|
335
|
+
headers,
|
|
336
|
+
body: body || undefined,
|
|
337
|
+
});
|
|
338
|
+
const response = await apiRouterHandler(request);
|
|
339
|
+
const duration = Date.now() - startTime;
|
|
340
|
+
logResponse(method, pathname, response.status, duration);
|
|
341
|
+
// Send response
|
|
342
|
+
res.statusCode = response.status;
|
|
343
|
+
response.headers.forEach((value, key) => {
|
|
344
|
+
res.setHeader(key, value);
|
|
345
|
+
});
|
|
346
|
+
const responseBody = await response.text();
|
|
347
|
+
res.end(responseBody);
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
const duration = Date.now() - startTime;
|
|
351
|
+
logResponse(method, pathname, 500, duration);
|
|
352
|
+
console.error("[FARM] API error:", error);
|
|
353
|
+
res.statusCode = 500;
|
|
354
|
+
res.setHeader("Content-Type", "application/json");
|
|
355
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
356
|
+
}
|
|
357
|
+
}));
|
|
358
|
+
};
|
|
359
|
+
},
|
|
360
|
+
async handleHotUpdate({ file, server, modules }) {
|
|
361
|
+
const fileName = file.split("/").pop() || "";
|
|
362
|
+
// Handle root routes.ts updates
|
|
363
|
+
if (fileName === "routes.ts" || fileName === "routes.tsx" || fileName === "routes.js") {
|
|
364
|
+
log(`Root routes file updated: ${fileName}`);
|
|
365
|
+
// Invalidate all affected modules
|
|
366
|
+
for (const mod of modules) {
|
|
367
|
+
server.moduleGraph.invalidateModule(mod);
|
|
368
|
+
}
|
|
369
|
+
// Clear routes that came from this file and rediscover
|
|
370
|
+
for (const [routePath, route] of apiRoutesCache) {
|
|
371
|
+
if (route.filePath === file) {
|
|
372
|
+
apiRoutesCache.delete(routePath);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
const routesModule = await server.ssrLoadModule(file);
|
|
377
|
+
// Look for exported endpoints with explicit paths
|
|
378
|
+
// Endpoints can be functions (from better-call) or objects
|
|
379
|
+
for (const [exportName, exportValue] of Object.entries(routesModule)) {
|
|
380
|
+
const endpoint = exportValue;
|
|
381
|
+
if (endpoint && endpoint.__path) {
|
|
382
|
+
const routePath = endpoint.__path;
|
|
383
|
+
const method = endpoint.__method || "GET";
|
|
384
|
+
// Add to cache
|
|
385
|
+
const existing = apiRoutesCache.get(routePath);
|
|
386
|
+
if (existing) {
|
|
387
|
+
existing.methods.push(method);
|
|
388
|
+
existing.endpoints[method] = endpoint;
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
apiRoutesCache.set(routePath, {
|
|
392
|
+
path: routePath,
|
|
393
|
+
filePath: file,
|
|
394
|
+
methods: [method],
|
|
395
|
+
endpoints: { [method]: endpoint },
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
log(`Root route reloaded: ${method} ${routePath}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// Recreate better-call router
|
|
402
|
+
await createBetterCallRouter();
|
|
403
|
+
log(`better-call router recreated`);
|
|
404
|
+
}
|
|
405
|
+
catch (e) {
|
|
406
|
+
log(`Root routes.ts HMR failed: ${e.message}`);
|
|
407
|
+
}
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
if (file.includes("/api/") && fileName.startsWith("route.")) {
|
|
411
|
+
const shortPath = file.split("/api/")[1] || file;
|
|
412
|
+
log(`API route updated: ${shortPath}`);
|
|
413
|
+
// Invalidate all affected modules
|
|
414
|
+
for (const mod of modules) {
|
|
415
|
+
server.moduleGraph.invalidateModule(mod);
|
|
416
|
+
}
|
|
417
|
+
// Find the route path for this file
|
|
418
|
+
let routePathToUpdate = null;
|
|
419
|
+
for (const [routePath, route] of apiRoutesCache) {
|
|
420
|
+
if (route.filePath === file) {
|
|
421
|
+
routePathToUpdate = routePath;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (routePathToUpdate) {
|
|
426
|
+
try {
|
|
427
|
+
// Reload the module
|
|
428
|
+
const routeModule = await server.ssrLoadModule(file);
|
|
429
|
+
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
|
|
430
|
+
const endpoints = {};
|
|
431
|
+
const availableMethods = [];
|
|
432
|
+
for (const method of methods) {
|
|
433
|
+
if (routeModule[method]) {
|
|
434
|
+
availableMethods.push(method);
|
|
435
|
+
endpoints[method] = routeModule[method];
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (availableMethods.length > 0) {
|
|
439
|
+
apiRoutesCache.set(routePathToUpdate, {
|
|
440
|
+
path: routePathToUpdate,
|
|
441
|
+
filePath: file,
|
|
442
|
+
methods: availableMethods,
|
|
443
|
+
endpoints,
|
|
444
|
+
});
|
|
445
|
+
log(`API route reloaded: ${routePathToUpdate}`);
|
|
446
|
+
// Recreate better-call router with updated endpoints
|
|
447
|
+
await createBetterCallRouter();
|
|
448
|
+
log(`better-call router recreated`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch (e) {
|
|
452
|
+
log(`API route HMR failed: ${e.message}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
// New route file - discover it
|
|
457
|
+
try {
|
|
458
|
+
const path = await import("path");
|
|
459
|
+
const apiDir = path.join(server.config.root, srcDir, "api");
|
|
460
|
+
const relativePath = path.relative(apiDir, path.dirname(file));
|
|
461
|
+
const routePath = "/api/" + (relativePath === "." ? "" : relativePath.replace(/\\/g, "/"));
|
|
462
|
+
const routeModule = await server.ssrLoadModule(file);
|
|
463
|
+
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
|
|
464
|
+
const endpoints = {};
|
|
465
|
+
const availableMethods = [];
|
|
466
|
+
for (const method of methods) {
|
|
467
|
+
if (routeModule[method]) {
|
|
468
|
+
availableMethods.push(method);
|
|
469
|
+
endpoints[method] = routeModule[method];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (availableMethods.length > 0) {
|
|
473
|
+
apiRoutesCache.set(routePath, {
|
|
474
|
+
path: routePath,
|
|
475
|
+
filePath: file,
|
|
476
|
+
methods: availableMethods,
|
|
477
|
+
endpoints,
|
|
478
|
+
});
|
|
479
|
+
log(`New API route discovered: ${routePath}`);
|
|
480
|
+
// Recreate better-call router with new endpoint
|
|
481
|
+
await createBetterCallRouter();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
catch (e) {
|
|
485
|
+
log(`New API route load failed: ${e.message}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
export { farmApi };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
interface RequestLike {
|
|
2
|
+
url?: string;
|
|
3
|
+
headers: Record<string, string | string[] | undefined>;
|
|
4
|
+
}
|
|
5
|
+
interface PluginContextLike {
|
|
6
|
+
req: {
|
|
7
|
+
set: (key: string, value: any, options?: {
|
|
8
|
+
exposeToPage?: boolean;
|
|
9
|
+
}) => void;
|
|
10
|
+
get: <T = any>(key: string) => T | undefined;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
interface ContextPluginLike {
|
|
14
|
+
name: string;
|
|
15
|
+
enforce?: "pre" | "post";
|
|
16
|
+
beforeRequest?: (req: RequestLike, res: unknown, context: PluginContextLike) => void | Promise<void>;
|
|
17
|
+
afterResponse?: (req: RequestLike, res: {
|
|
18
|
+
statusCode?: number;
|
|
19
|
+
} | unknown, context: PluginContextLike) => void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export interface ContextPluginOptions {
|
|
22
|
+
requestIdHeader?: string;
|
|
23
|
+
localeHeader?: string;
|
|
24
|
+
exposePathname?: boolean;
|
|
25
|
+
beforeRequest?: (payload: {
|
|
26
|
+
requestId: string;
|
|
27
|
+
locale: string;
|
|
28
|
+
pathname: string;
|
|
29
|
+
}) => void;
|
|
30
|
+
afterResponse?: (payload: {
|
|
31
|
+
requestId: string;
|
|
32
|
+
locale: string;
|
|
33
|
+
pathname: string;
|
|
34
|
+
statusCode: number;
|
|
35
|
+
durationMs: number;
|
|
36
|
+
}) => void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Demo plugin that exposes request context to server page props as `props.context`.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { contextPlugin } from "@farm.js/plugin/context";
|
|
44
|
+
*
|
|
45
|
+
* export default defineConfig({
|
|
46
|
+
* plugins: [contextPlugin()],
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```tsx
|
|
52
|
+
* export default function Page(props: any) {
|
|
53
|
+
* const requestId = props.context?.data.get("requestId");
|
|
54
|
+
* const locale = props.context?.data.get("locale");
|
|
55
|
+
* return <div>{requestId} - {locale}</div>;
|
|
56
|
+
* }
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export declare function contextPlugin(options?: ContextPluginOptions): ContextPluginLike;
|
|
60
|
+
export {};
|
|
61
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/context/index.ts"],"names":[],"mappings":"AAEA,UAAU,WAAW;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;CACxD;AAED,UAAU,iBAAiB;IACzB,GAAG,EAAE;QACH,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE;YAAE,YAAY,CAAC,EAAE,OAAO,CAAA;SAAE,KAAK,IAAI,CAAC;QAC7E,GAAG,EAAE,CAAC,CAAC,GAAG,GAAG,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC,GAAG,SAAS,CAAC;KAC9C,CAAC;CACH;AAED,UAAU,iBAAiB;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACzB,aAAa,CAAC,EAAE,CACd,GAAG,EAAE,WAAW,EAChB,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,iBAAiB,KACvB,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,aAAa,CAAC,EAAE,CACd,GAAG,EAAE,WAAW,EAChB,GAAG,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,EACtC,OAAO,EAAE,iBAAiB,KACvB,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAC3F,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE;QACxB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;KACpB,KAAK,IAAI,CAAC;CACZ;AAeD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,iBAAiB,CA0CnF"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
function normalizeHeaderValue(value) {
|
|
3
|
+
if (!value)
|
|
4
|
+
return undefined;
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return value[0];
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
function getHeader(req, name) {
|
|
10
|
+
const direct = normalizeHeaderValue(req.headers[name.toLowerCase()]);
|
|
11
|
+
if (direct)
|
|
12
|
+
return direct;
|
|
13
|
+
const fromRaw = normalizeHeaderValue(req.headers[name]);
|
|
14
|
+
return fromRaw;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Demo plugin that exposes request context to server page props as `props.context`.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { contextPlugin } from "@farm.js/plugin/context";
|
|
22
|
+
*
|
|
23
|
+
* export default defineConfig({
|
|
24
|
+
* plugins: [contextPlugin()],
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* export default function Page(props: any) {
|
|
31
|
+
* const requestId = props.context?.data.get("requestId");
|
|
32
|
+
* const locale = props.context?.data.get("locale");
|
|
33
|
+
* return <div>{requestId} - {locale}</div>;
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function contextPlugin(options = {}) {
|
|
38
|
+
const requestIdHeader = (options.requestIdHeader || "x-request-id").toLowerCase();
|
|
39
|
+
const localeHeader = (options.localeHeader || "x-locale").toLowerCase();
|
|
40
|
+
const exposePathname = options.exposePathname ?? true;
|
|
41
|
+
const beforeRequestCb = options.beforeRequest;
|
|
42
|
+
const afterResponseCb = options.afterResponse;
|
|
43
|
+
return {
|
|
44
|
+
name: "@farm.js/plugin-context",
|
|
45
|
+
enforce: "pre",
|
|
46
|
+
beforeRequest(req, _res, context) {
|
|
47
|
+
const pathname = req.url || "/";
|
|
48
|
+
const requestId = getHeader(req, requestIdHeader) || randomUUID();
|
|
49
|
+
const locale = getHeader(req, localeHeader) || "en";
|
|
50
|
+
context.req.set("requestId", requestId, { exposeToPage: true });
|
|
51
|
+
context.req.set("locale", locale, { exposeToPage: true });
|
|
52
|
+
if (exposePathname) {
|
|
53
|
+
context.req.set("pathname", pathname, { exposeToPage: true });
|
|
54
|
+
}
|
|
55
|
+
// Private value stays available only to plugin hooks.
|
|
56
|
+
context.req.set("internal:requestStart", Date.now());
|
|
57
|
+
if (beforeRequestCb) {
|
|
58
|
+
beforeRequestCb({ requestId, locale, pathname });
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
afterResponse(req, res, context) {
|
|
62
|
+
if (!afterResponseCb)
|
|
63
|
+
return;
|
|
64
|
+
const requestId = context.req.get("requestId") || "unknown";
|
|
65
|
+
const locale = context.req.get("locale") || "en";
|
|
66
|
+
const started = context.req.get("internal:requestStart");
|
|
67
|
+
const pathname = req.url || "/";
|
|
68
|
+
const durationMs = typeof started === "number" ? Date.now() - started : 0;
|
|
69
|
+
const statusCode = typeof res === "object" && res !== null && "statusCode" in res
|
|
70
|
+
? Number(res.statusCode || 200)
|
|
71
|
+
: 200;
|
|
72
|
+
afterResponseCb({ requestId, locale, pathname, statusCode, durationMs });
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|