@flaredev/cli 0.6.2 → 0.7.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/dist/index.js
CHANGED
|
@@ -2310,31 +2310,23 @@ ${lines.join("\n")}
|
|
|
2310
2310
|
const imports = [...used].sort((a, b) => a.table.localeCompare(b.table)).map((resource) => `import { ${tableExport(resource)} } from "./schema/${resource.table}";`);
|
|
2311
2311
|
return [`import { relations } from "drizzle-orm";`, ...imports, "", blocks.join("\n")].join("\n");
|
|
2312
2312
|
}
|
|
2313
|
-
var
|
|
2313
|
+
var routeHeader = (entry, stack) => {
|
|
2314
2314
|
const local = resourceLocal(entry.stem);
|
|
2315
|
-
const source = stack === "next" ? {
|
|
2316
|
-
imports: [`import {
|
|
2317
|
-
|
|
2318
|
-
} : {
|
|
2319
|
-
imports: [
|
|
2320
|
-
`import { createResourceHandlers, drizzleRows } from "@/lib/resource";`,
|
|
2321
|
-
`import { getDb } from "@/db";`,
|
|
2322
|
-
`import { ${tableExport(entry.resource)} } from "@/db/schema";`
|
|
2323
|
-
],
|
|
2324
|
-
// Named rather than implied: the adapter is the one thing about this route
|
|
2325
|
-
// that differs between stacks, so it is written out.
|
|
2326
|
-
wiring: [` rows: drizzleRows(${tableExport(entry.resource)}, getDb),`]
|
|
2315
|
+
const source = stack === "next" ? { imports: [`import { prisma } from "@/lib/db";`], rows: `prismaRows(prisma.${camelCase4(entry.resource.name)}, prisma)` } : {
|
|
2316
|
+
imports: [`import { getDb } from "@/db";`, `import { ${tableExport(entry.resource)} } from "@/db/schema";`],
|
|
2317
|
+
rows: `drizzleRows(${tableExport(entry.resource)}, getDb)`
|
|
2327
2318
|
};
|
|
2328
|
-
|
|
2319
|
+
const adapter = stack === "next" ? "prismaRows" : "drizzleRows";
|
|
2320
|
+
return (helpers) => [
|
|
2321
|
+
`import { createResourceStore, ${[adapter, ...helpers].join(", ")} } from "@/lib/resource";`,
|
|
2329
2322
|
...source.imports,
|
|
2330
2323
|
`import { authorize, currentUser } from "@/lib/api";`,
|
|
2331
2324
|
`import { revalidateResource } from "@/lib/cache";`,
|
|
2332
2325
|
`import ${local} from "@/resources/${entry.stem}.resource";`,
|
|
2333
2326
|
"",
|
|
2334
|
-
`const
|
|
2327
|
+
`const store = createResourceStore({`,
|
|
2335
2328
|
` resource: ${local},`,
|
|
2336
|
-
|
|
2337
|
-
` authorize,`,
|
|
2329
|
+
` rows: ${source.rows},`,
|
|
2338
2330
|
` currentUser,`,
|
|
2339
2331
|
` onChange: revalidateResource,`,
|
|
2340
2332
|
`});`,
|
|
@@ -2342,18 +2334,99 @@ var handlerImports = (entry, stack = "cloudflare") => {
|
|
|
2342
2334
|
];
|
|
2343
2335
|
};
|
|
2344
2336
|
function renderCollectionRoute(entry, stack = "cloudflare") {
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2337
|
+
const local = resourceLocal(entry.stem);
|
|
2338
|
+
const plural = entry.resource.pluralLabel.toLowerCase();
|
|
2339
|
+
return [
|
|
2340
|
+
...routeHeader(entry, stack)(["crossOrigin", "drain", "failureResponse", "problem", "readJson", "respond"]),
|
|
2341
|
+
`/** GET /api/${entry.resource.slug} \u2014 list ${plural}. Query: page, perPage, sort, q, filter[field], cursor. */`,
|
|
2342
|
+
`export async function GET(request: Request): Promise<Response> {`,
|
|
2343
|
+
` const denied = await authorize({ request, resource: ${local}, action: "list" });`,
|
|
2344
|
+
` if (denied) return denied;`,
|
|
2345
|
+
"",
|
|
2346
|
+
` return respond(await store.list(new URL(request.url).searchParams));`,
|
|
2347
|
+
`}`,
|
|
2348
|
+
"",
|
|
2349
|
+
`/** POST /api/${entry.resource.slug} \u2014 create one. */`,
|
|
2350
|
+
`export async function POST(request: Request): Promise<Response> {`,
|
|
2351
|
+
` try {`,
|
|
2352
|
+
` const denied = await authorize({ request, resource: ${local}, action: "create" });`,
|
|
2353
|
+
` if (denied) return denied;`,
|
|
2354
|
+
` if (crossOrigin(request)) return problem(403, "Cross-origin request blocked.");`,
|
|
2355
|
+
"",
|
|
2356
|
+
` const read = await readJson(request);`,
|
|
2357
|
+
` if ("response" in read) return read.response;`,
|
|
2358
|
+
"",
|
|
2359
|
+
` const result = await store.create(read.body);`,
|
|
2360
|
+
` if (!result.ok) return failureResponse(result);`,
|
|
2361
|
+
"",
|
|
2362
|
+
' const location = `${new URL(request.url).pathname.replace(/\\/$/, "")}/${result.data.id as string}`;',
|
|
2363
|
+
` return Response.json(result.data, { status: 201, headers: { location } });`,
|
|
2364
|
+
` } finally {`,
|
|
2365
|
+
` // A body left unread breaks the next request through wrangler's dev proxy.`,
|
|
2366
|
+
` await drain(request);`,
|
|
2367
|
+
` }`,
|
|
2368
|
+
`}`,
|
|
2369
|
+
""
|
|
2370
|
+
].join("\n");
|
|
2348
2371
|
}
|
|
2349
2372
|
function renderItemRoute(entry, stack = "cloudflare") {
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2373
|
+
const local = resourceLocal(entry.stem);
|
|
2374
|
+
const one = entry.resource.label.toLowerCase();
|
|
2375
|
+
const write = (method, action, call, doc) => [
|
|
2376
|
+
`/** ${doc} */`,
|
|
2377
|
+
`export async function ${method}(request: Request, context: RouteContext): Promise<Response> {`,
|
|
2378
|
+
` try {`,
|
|
2379
|
+
` const { id } = await context.params;`,
|
|
2380
|
+
` const denied = await authorize({ request, resource: ${local}, action: "${action}", id });`,
|
|
2381
|
+
` if (denied) return denied;`,
|
|
2382
|
+
` if (crossOrigin(request)) return problem(403, "Cross-origin request blocked.");`,
|
|
2383
|
+
"",
|
|
2384
|
+
...call.split("\n"),
|
|
2385
|
+
` } finally {`,
|
|
2386
|
+
` await drain(request);`,
|
|
2387
|
+
` }`,
|
|
2388
|
+
`}`,
|
|
2356
2389
|
""
|
|
2390
|
+
];
|
|
2391
|
+
return [
|
|
2392
|
+
...routeHeader(entry, stack)(["crossOrigin", "drain", "failureResponse", "problem", "readJson", "respond", "type RouteContext"]),
|
|
2393
|
+
`/** GET /api/${entry.resource.slug}/[id] \u2014 read one ${one}. */`,
|
|
2394
|
+
`export async function GET(request: Request, context: RouteContext): Promise<Response> {`,
|
|
2395
|
+
` const { id } = await context.params;`,
|
|
2396
|
+
` const denied = await authorize({ request, resource: ${local}, action: "read", id });`,
|
|
2397
|
+
` if (denied) return denied;`,
|
|
2398
|
+
"",
|
|
2399
|
+
` return respond(await store.get(id));`,
|
|
2400
|
+
`}`,
|
|
2401
|
+
"",
|
|
2402
|
+
...write(
|
|
2403
|
+
"PATCH",
|
|
2404
|
+
"update",
|
|
2405
|
+
[
|
|
2406
|
+
` const read = await readJson(request);`,
|
|
2407
|
+
` if ("response" in read) return read.response;`,
|
|
2408
|
+
"",
|
|
2409
|
+
` return respond(await store.update(id, read.body));`
|
|
2410
|
+
].join("\n"),
|
|
2411
|
+
`PATCH /api/${entry.resource.slug}/[id] \u2014 change some fields of one ${one}.`
|
|
2412
|
+
),
|
|
2413
|
+
...write(
|
|
2414
|
+
"PUT",
|
|
2415
|
+
"update",
|
|
2416
|
+
[
|
|
2417
|
+
` const read = await readJson(request);`,
|
|
2418
|
+
` if ("response" in read) return read.response;`,
|
|
2419
|
+
"",
|
|
2420
|
+
` return respond(await store.replace(id, read.body));`
|
|
2421
|
+
].join("\n"),
|
|
2422
|
+
`PUT /api/${entry.resource.slug}/[id] \u2014 replace one ${one} entirely.`
|
|
2423
|
+
),
|
|
2424
|
+
...write(
|
|
2425
|
+
"DELETE",
|
|
2426
|
+
"delete",
|
|
2427
|
+
[` const result = await store.delete(id);`, ` return result.ok ? new Response(null, { status: 204 }) : failureResponse(result);`].join("\n"),
|
|
2428
|
+
`DELETE /api/${entry.resource.slug}/[id] \u2014 remove one ${one}.`
|
|
2429
|
+
)
|
|
2357
2430
|
].join("\n");
|
|
2358
2431
|
}
|
|
2359
2432
|
var adminImports = (entry) => [
|
|
@@ -2645,7 +2718,7 @@ function findOrphans(appRoot, all) {
|
|
|
2645
2718
|
}
|
|
2646
2719
|
function ensureSupportFiles(appRoot, log2) {
|
|
2647
2720
|
const stack = readStack(appRoot);
|
|
2648
|
-
const engine = ["rows.ts", "query.ts", "store.ts", "handlers.ts", "index.ts", stack === "next" ? "prisma-rows.ts" : "drizzle-rows.ts"].map(
|
|
2721
|
+
const engine = ["rows.ts", "query.ts", "http.ts", "store.ts", "handlers.ts", "index.ts", stack === "next" ? "prisma-rows.ts" : "drizzle-rows.ts"].map(
|
|
2649
2722
|
(name) => `lib/resource/${name}`
|
|
2650
2723
|
);
|
|
2651
2724
|
for (const path of ["lib/api.ts", "lib/cache.ts", ...engine]) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Muke Johnbaptist",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"jiti": "^2.7.0",
|
|
42
42
|
"jsonc-parser": "^3.3.1",
|
|
43
43
|
"picocolors": "^1.1.1",
|
|
44
|
-
"@flaredev/core": "0.
|
|
44
|
+
"@flaredev/core": "0.7.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -9,6 +9,17 @@
|
|
|
9
9
|
// `flare update` applies the parts you choose. Neither runs unless you ask.
|
|
10
10
|
|
|
11
11
|
import type { Resource } from "@flaredev/core";
|
|
12
|
+
import {
|
|
13
|
+
crossOrigin,
|
|
14
|
+
drain,
|
|
15
|
+
failureResponse,
|
|
16
|
+
problem,
|
|
17
|
+
readJson,
|
|
18
|
+
respond,
|
|
19
|
+
type Authorize,
|
|
20
|
+
type AuthorizeContext,
|
|
21
|
+
type RouteContext,
|
|
22
|
+
} from "./http";
|
|
12
23
|
import type { ResourceRows } from "./rows";
|
|
13
24
|
import {
|
|
14
25
|
createResourceStore,
|
|
@@ -21,15 +32,7 @@ import {
|
|
|
21
32
|
|
|
22
33
|
export type { ResourceAction } from "./store";
|
|
23
34
|
|
|
24
|
-
export
|
|
25
|
-
request: Request;
|
|
26
|
-
resource: Resource;
|
|
27
|
-
action: ResourceAction;
|
|
28
|
-
id?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Return a Response (e.g. 401/403) to deny the request; return nothing to allow it. */
|
|
32
|
-
export type Authorize = (context: AuthorizeContext) => Response | void | Promise<Response | void>;
|
|
35
|
+
export type { Authorize, AuthorizeContext } from "./http";
|
|
33
36
|
|
|
34
37
|
export interface ResourceHandlerOptions {
|
|
35
38
|
resource: Resource;
|
|
@@ -42,59 +45,17 @@ export interface ResourceHandlerOptions {
|
|
|
42
45
|
onChange?: (event: ChangeEvent) => void | Promise<void>;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
type RouteContext = { params: Promise<{ id: string }> };
|
|
46
|
-
|
|
47
|
-
const problem = (status: number, error: string, extra: object = {}) => Response.json({ error, ...extra }, { status });
|
|
48
|
-
|
|
49
|
-
function failureResponse(failure: Failure): Response {
|
|
50
|
-
if (failure.queryIssues) return problem(failure.status, failure.error, { issues: failure.queryIssues });
|
|
51
|
-
const extra: Record<string, unknown> = {};
|
|
52
|
-
if (failure.issues) extra.issues = failure.issues;
|
|
53
|
-
if (failure.field) extra.field = failure.field;
|
|
54
|
-
return problem(failure.status, failure.error, extra);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const respond = <T>(result: Result<T>, init?: ResponseInit) =>
|
|
58
|
-
result.ok ? Response.json(result.data, init) : failureResponse(result);
|
|
59
|
-
|
|
60
48
|
/** REST route handlers for a resource: thin HTTP wrappers over `createResourceStore`. */
|
|
61
49
|
export function createResourceHandlers(options: ResourceHandlerOptions) {
|
|
62
50
|
const { resource, authorize } = options;
|
|
63
51
|
const store = createResourceStore(options);
|
|
64
52
|
|
|
65
|
-
/**
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
return origin !== null && origin !== new URL(request.url).origin;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
async function readBody(request: Request): Promise<{ body: unknown } | { response: Response }> {
|
|
72
|
-
if (crossOrigin(request)) return { response: problem(403, "Cross-origin request blocked.") };
|
|
73
|
-
const type = request.headers.get("content-type") ?? "";
|
|
74
|
-
if (!/^application\/json\b/i.test(type)) return { response: problem(415, "Send JSON with Content-Type: application/json.") };
|
|
75
|
-
try {
|
|
76
|
-
return { body: await request.json() };
|
|
77
|
-
} catch {
|
|
78
|
-
return { response: problem(400, "Request body isn't valid JSON.") };
|
|
79
|
-
}
|
|
80
|
-
}
|
|
53
|
+
/** The cross-origin guard and the JSON read, in the order a write needs them. */
|
|
54
|
+
const readBody = async (request: Request): Promise<{ body: unknown } | { response: Response }> =>
|
|
55
|
+
crossOrigin(request) ? { response: problem(403, "Cross-origin request blocked.") } : readJson(request);
|
|
81
56
|
|
|
82
57
|
const guard = async (context: AuthorizeContext) => (await authorize(context)) ?? undefined;
|
|
83
58
|
|
|
84
|
-
/**
|
|
85
|
-
* Read and drop a body the handler didn't consume (a 401/403/415 answered early).
|
|
86
|
-
* Answering with it unread breaks the next request through wrangler's local dev
|
|
87
|
-
* proxy ("Network connection lost"). Streams, so memory stays flat.
|
|
88
|
-
*/
|
|
89
|
-
async function drain(request: Request) {
|
|
90
|
-
if (!request.body || request.bodyUsed) return;
|
|
91
|
-
try {
|
|
92
|
-
const reader = request.body.getReader();
|
|
93
|
-
while (!(await reader.read()).done);
|
|
94
|
-
} catch {
|
|
95
|
-
// The client went away.
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
59
|
const draining =
|
|
99
60
|
<Rest extends unknown[]>(handler: (request: Request, ...rest: Rest) => Promise<Response>) =>
|
|
100
61
|
async (request: Request, ...rest: Rest): Promise<Response> => {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// This file is yours.
|
|
2
|
+
//
|
|
3
|
+
// The small pieces a route handler needs: how a failure becomes a status code, how a
|
|
4
|
+
// JSON body is read safely, and the two guards that are easy to forget. Routes call
|
|
5
|
+
// these by name so the request flow is visible where it happens, rather than hidden
|
|
6
|
+
// behind a factory.
|
|
7
|
+
//
|
|
8
|
+
// `flare diff` shows how your copy differs from the version Flare ships, and
|
|
9
|
+
// `flare update` applies the parts you choose. Neither runs unless you ask.
|
|
10
|
+
|
|
11
|
+
import type { Resource } from "@flaredev/core";
|
|
12
|
+
import type { Failure, ResourceAction, Result } from "./store";
|
|
13
|
+
|
|
14
|
+
export interface AuthorizeContext {
|
|
15
|
+
request: Request;
|
|
16
|
+
resource: Resource;
|
|
17
|
+
action: ResourceAction;
|
|
18
|
+
id?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Return a Response (401/403) to deny the request; return nothing to allow it. */
|
|
22
|
+
export type Authorize = (context: AuthorizeContext) => Response | void | Promise<Response | void>;
|
|
23
|
+
|
|
24
|
+
/** The context Next.js and vinext hand a route under `[id]`. */
|
|
25
|
+
export type RouteContext = { params: Promise<{ id: string }> };
|
|
26
|
+
|
|
27
|
+
/** A JSON error response. */
|
|
28
|
+
export const problem = (status: number, error: string, extra: object = {}) => Response.json({ error, ...extra }, { status });
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A store failure as HTTP.
|
|
32
|
+
*
|
|
33
|
+
* The status is decided in store.ts, where the reason is known: 422 with field issues
|
|
34
|
+
* for validation, 409 with the column for a unique conflict, 404 for a missing row.
|
|
35
|
+
*/
|
|
36
|
+
export function failureResponse(failure: Failure): Response {
|
|
37
|
+
if (failure.queryIssues) return problem(failure.status, failure.error, { issues: failure.queryIssues });
|
|
38
|
+
const extra: Record<string, unknown> = {};
|
|
39
|
+
if (failure.issues) extra.issues = failure.issues;
|
|
40
|
+
if (failure.field) extra.field = failure.field;
|
|
41
|
+
return problem(failure.status, failure.error, extra);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A store result as HTTP: the data, or the failure above. */
|
|
45
|
+
export const respond = <T>(result: Result<T>, init?: ResponseInit) => (result.ok ? Response.json(result.data, init) : failureResponse(result));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether this write came from another site.
|
|
49
|
+
*
|
|
50
|
+
* Browsers always send `Origin` on POST, PATCH, PUT and DELETE, so a mismatch is a
|
|
51
|
+
* cross-site request and a request with no Origin at all is not from a browser form.
|
|
52
|
+
* This is the CSRF guard; a route that writes should call it.
|
|
53
|
+
*/
|
|
54
|
+
export const crossOrigin = (request: Request) => {
|
|
55
|
+
const origin = request.headers.get("origin");
|
|
56
|
+
return origin !== null && origin !== new URL(request.url).origin;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The request's JSON body, or the response to send instead.
|
|
61
|
+
*
|
|
62
|
+
* Returns `{ body }` or `{ response }`, so a caller can't forget to handle the bad
|
|
63
|
+
* cases: the wrong content type, or a body that isn't JSON.
|
|
64
|
+
*/
|
|
65
|
+
export async function readJson(request: Request): Promise<{ body: unknown } | { response: Response }> {
|
|
66
|
+
const type = request.headers.get("content-type") ?? "";
|
|
67
|
+
if (!/^application\/json\b/i.test(type)) return { response: problem(415, "Send JSON with Content-Type: application/json.") };
|
|
68
|
+
try {
|
|
69
|
+
return { body: await request.json() };
|
|
70
|
+
} catch {
|
|
71
|
+
return { response: problem(400, "Request body isn't valid JSON.") };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read and discard a body the handler never consumed.
|
|
77
|
+
*
|
|
78
|
+
* A request answered early — 401, 403, 415 — still has its body waiting. Replying
|
|
79
|
+
* without reading it breaks the *next* request through wrangler's local dev proxy
|
|
80
|
+
* ("Network connection lost"), which is a baffling thing to debug. Routes call this in
|
|
81
|
+
* a `finally`, so it happens whichever way they returned. It streams, so memory stays
|
|
82
|
+
* flat however large the body was.
|
|
83
|
+
*/
|
|
84
|
+
export async function drain(request: Request): Promise<void> {
|
|
85
|
+
if (!request.body || request.bodyUsed) return;
|
|
86
|
+
try {
|
|
87
|
+
const reader = request.body.getReader();
|
|
88
|
+
while (!(await reader.read()).done);
|
|
89
|
+
} catch {
|
|
90
|
+
// The client went away.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// which is the whole contract — about fifty lines in rows.ts, and the reason a
|
|
9
9
|
// descriptor doesn't care which database it is on.
|
|
10
10
|
export * from "./rows";
|
|
11
|
+
export * from "./http";
|
|
11
12
|
export * from "./query";
|
|
12
13
|
export * from "./store";
|
|
13
14
|
export * from "./handlers";
|