@flaredev/cli 0.6.1 → 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
@@ -323,7 +323,7 @@ function installArgs(pm) {
323
323
  }
324
324
 
325
325
  // src/commands/create.ts
326
- var ALLOW_BUILDS = ["'@prisma/engines': true", "esbuild: true", "prisma: true", "sharp: false", "workerd: true"].map((line) => ` ${line}`).join("\n");
326
+ var ALLOW_BUILDS = ["'@prisma/engines': false", "esbuild: true", "prisma: true", "sharp: false", "workerd: true"].map((line) => ` ${line}`).join("\n");
327
327
  function toAppName(dir) {
328
328
  const name = basename(resolve2(dir)).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
329
329
  if (!name) throw new Error(`Cannot derive an app name from "${dir}". Use letters, digits, and dashes.`);
@@ -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 handlerImports = (entry, stack = "cloudflare") => {
2313
+ var routeHeader = (entry, stack) => {
2314
2314
  const local = resourceLocal(entry.stem);
2315
- const source = stack === "next" ? {
2316
- imports: [`import { createResourceHandlers, prismaRows } from "@/lib/resource";`, `import { prisma } from "@/lib/db";`],
2317
- wiring: [` rows: prismaRows(prisma.${camelCase4(entry.resource.name)}, prisma),`]
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
- return [
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 handlers = createResourceHandlers({`,
2327
+ `const store = createResourceStore({`,
2335
2328
  ` resource: ${local},`,
2336
- ...source.wiring,
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
- return [...handlerImports(entry, stack), "export const GET = handlers.collection.GET;", "export const POST = handlers.collection.POST;", ""].join(
2346
- "\n"
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
- return [
2351
- ...handlerImports(entry, stack),
2352
- "export const GET = handlers.item.GET;",
2353
- "export const PATCH = handlers.item.PATCH;",
2354
- "export const PUT = handlers.item.PUT;",
2355
- "export const DELETE = handlers.item.DELETE;",
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.6.1",
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.6.1"
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 interface AuthorizeContext {
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
- /** Deny cross-site writes: browsers send Origin on POST/PATCH/PUT/DELETE. */
66
- const crossOrigin = (request: Request) => {
67
- const origin = request.headers.get("origin");
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
  // contract — about fifty lines in rows.ts, and the reason a descriptor doesn't care
9
9
  // 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";
@@ -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";