@flaredev/cli 0.6.2 → 0.7.1
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 +140 -32
- package/package.json +2 -2
- package/templates/app/lib/resource/handlers.ts +15 -54
- package/templates/app/lib/resource/http.ts +92 -0
- package/templates/app/lib/resource/index.ts +1 -0
- package/templates/next/lib/resource/index.ts +1 -0
- package/templates/next/lib/storage.ts +33 -7
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]) {
|
|
@@ -5132,6 +5205,8 @@ ${pc22.red("Out of sync:")} ${stale} file(s) would change. Run ${pc22.bold("flar
|
|
|
5132
5205
|
}
|
|
5133
5206
|
|
|
5134
5207
|
// src/commands/user-role.ts
|
|
5208
|
+
import { rmSync as rmSync9, writeFileSync as writeFileSync21 } from "fs";
|
|
5209
|
+
import { join as join28 } from "path";
|
|
5135
5210
|
import pc23 from "picocolors";
|
|
5136
5211
|
var ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
5137
5212
|
var EMAIL = /^[^\s@'"\\]+@[^\s@'"\\]+\.[^\s@'"\\]+$/;
|
|
@@ -5144,6 +5219,7 @@ async function setUserRole(email, role, options2 = {}) {
|
|
|
5144
5219
|
const log2 = options2.log ?? ((message) => console.log(message));
|
|
5145
5220
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
5146
5221
|
const sql2 = userRoleSql(email, role);
|
|
5222
|
+
if (readStack(appRoot) === "next") return setUserRoleOnPrisma(appRoot, email, role, log2);
|
|
5147
5223
|
const roles = await listRoles(options2);
|
|
5148
5224
|
if (roles && !roles.includes(role)) {
|
|
5149
5225
|
log2(pc23.red(`No role "${role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
|
|
@@ -5167,13 +5243,45 @@ ${result.output.trim()}`);
|
|
|
5167
5243
|
log2(pc23.green(`${updated[0].email} now has role "${updated[0].role}" (${options2.remote ? "remote" : "local"}).`));
|
|
5168
5244
|
return 0;
|
|
5169
5245
|
}
|
|
5246
|
+
async function setUserRoleOnPrisma(appRoot, email, role, log2) {
|
|
5247
|
+
const runner = join28(appRoot, ".flare-role-runner.mts");
|
|
5248
|
+
const script = [
|
|
5249
|
+
`import "dotenv/config";`,
|
|
5250
|
+
`import { prisma } from "./lib/db";`,
|
|
5251
|
+
"",
|
|
5252
|
+
`const client = prisma as unknown as { user: { updateMany(args: unknown): Promise<{ count: number }> }; $disconnect(): Promise<void> };`,
|
|
5253
|
+
`const { count } = await client.user.updateMany({`,
|
|
5254
|
+
` where: { email: { equals: ${JSON.stringify(email)}, mode: "insensitive" } },`,
|
|
5255
|
+
` data: { role: ${JSON.stringify(role)} },`,
|
|
5256
|
+
`});`,
|
|
5257
|
+
`await client.$disconnect();`,
|
|
5258
|
+
`// 3 means "no account with that email", which the CLI turns into a useful message.`,
|
|
5259
|
+
`if (count === 0) process.exit(3);`,
|
|
5260
|
+
""
|
|
5261
|
+
].join("\n");
|
|
5262
|
+
writeFileSync21(runner, script);
|
|
5263
|
+
try {
|
|
5264
|
+
const tsx = resolveBin(appRoot, "tsx", "tsx");
|
|
5265
|
+
const code = await runNode([tsx, runner], appRoot);
|
|
5266
|
+
if (code === 3) {
|
|
5267
|
+
log2(pc23.red(`No account with the email ${email}.`));
|
|
5268
|
+
log2(pc23.dim("Sign up first, then set the role."));
|
|
5269
|
+
return 1;
|
|
5270
|
+
}
|
|
5271
|
+
if (code !== 0) return code;
|
|
5272
|
+
log2(pc23.green(`\u2714 ${email} is now ${role}.`));
|
|
5273
|
+
return 0;
|
|
5274
|
+
} finally {
|
|
5275
|
+
rmSync9(runner, { force: true });
|
|
5276
|
+
}
|
|
5277
|
+
}
|
|
5170
5278
|
|
|
5171
5279
|
// src/utils/install.ts
|
|
5172
5280
|
import { spawn as spawn5 } from "child_process";
|
|
5173
5281
|
import { existsSync as existsSync26, readFileSync as readFileSync17, readdirSync as readdirSync10 } from "fs";
|
|
5174
|
-
import { join as
|
|
5282
|
+
import { join as join29 } from "path";
|
|
5175
5283
|
function expectedPackages(dir) {
|
|
5176
|
-
const npmLock =
|
|
5284
|
+
const npmLock = join29(dir, "package-lock.json");
|
|
5177
5285
|
if (existsSync26(npmLock)) {
|
|
5178
5286
|
try {
|
|
5179
5287
|
const lock = JSON.parse(readFileSync17(npmLock, "utf8"));
|
|
@@ -5182,7 +5290,7 @@ function expectedPackages(dir) {
|
|
|
5182
5290
|
return 0;
|
|
5183
5291
|
}
|
|
5184
5292
|
}
|
|
5185
|
-
const pnpmLock =
|
|
5293
|
+
const pnpmLock = join29(dir, "pnpm-lock.yaml");
|
|
5186
5294
|
if (existsSync26(pnpmLock)) {
|
|
5187
5295
|
try {
|
|
5188
5296
|
const text = readFileSync17(pnpmLock, "utf8");
|
|
@@ -5195,7 +5303,7 @@ function expectedPackages(dir) {
|
|
|
5195
5303
|
return 0;
|
|
5196
5304
|
}
|
|
5197
5305
|
function installedPackages(dir) {
|
|
5198
|
-
const root =
|
|
5306
|
+
const root = join29(dir, "node_modules");
|
|
5199
5307
|
if (!existsSync26(root)) return 0;
|
|
5200
5308
|
let total = 0;
|
|
5201
5309
|
try {
|
|
@@ -5203,7 +5311,7 @@ function installedPackages(dir) {
|
|
|
5203
5311
|
if (!entry.isDirectory() || entry.name === ".bin") continue;
|
|
5204
5312
|
if (entry.name.startsWith("@")) {
|
|
5205
5313
|
try {
|
|
5206
|
-
total += readdirSync10(
|
|
5314
|
+
total += readdirSync10(join29(root, entry.name)).length;
|
|
5207
5315
|
} catch {
|
|
5208
5316
|
}
|
|
5209
5317
|
} else if (entry.name !== ".pnpm" && entry.name !== ".vite") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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.1"
|
|
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";
|
|
@@ -21,10 +21,26 @@ const client = new AwsClient({
|
|
|
21
21
|
|
|
22
22
|
const endpoint = `${process.env.R2_ENDPOINT ?? ""}/${process.env.R2_BUCKET ?? ""}`;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* A key as a URL path.
|
|
26
|
+
*
|
|
27
|
+
* Each segment is escaped on its own, so the slashes in "products/2026/09/a.png" stay
|
|
28
|
+
* slashes. Escaping the whole key would turn them into %2F, which is a different object
|
|
29
|
+
* name to S3 even where a particular server is forgiving about it.
|
|
30
|
+
*/
|
|
31
|
+
const path = (key: string) => key.split("/").map(encodeURIComponent).join("/");
|
|
32
|
+
|
|
33
|
+
/** Raise a bucket error rather than letting a failed write look like a successful one. */
|
|
34
|
+
async function ok(response: Response, doing: string, key: string): Promise<Response> {
|
|
35
|
+
if (response.ok) return response;
|
|
36
|
+
const detail = await response.text().catch(() => "");
|
|
37
|
+
throw new Error(`Storage ${doing} failed for "${key}": ${response.status} ${detail.slice(0, 300)}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
/** The bucket, shaped the way @flaredev/core expects, over plain HTTP calls. */
|
|
25
41
|
const bucket = {
|
|
26
42
|
async get(key: string) {
|
|
27
|
-
const response = await client.fetch(`${endpoint}/${
|
|
43
|
+
const response = await client.fetch(`${endpoint}/${path(key)}`);
|
|
28
44
|
if (!response.ok || !response.body) return null;
|
|
29
45
|
return {
|
|
30
46
|
body: response.body,
|
|
@@ -35,15 +51,25 @@ const bucket = {
|
|
|
35
51
|
};
|
|
36
52
|
},
|
|
37
53
|
async put(key: string, value: ReadableStream | ArrayBuffer | string | null, options?: { httpMetadata?: { contentType?: string } }) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
// S3 needs a Content-Length, and a stream has no length to send — it answers
|
|
55
|
+
// "411 MissingContentLength" and stores nothing. Uploads are capped by the field's
|
|
56
|
+
// own maxBytes long before they reach here, so reading one into memory is safe and
|
|
57
|
+
// is what makes the length known.
|
|
58
|
+
//
|
|
59
|
+
// The length is set by hand rather than left to fetch. Next patches the global
|
|
60
|
+
// fetch, and in that runtime it does not derive content-length even from a buffered
|
|
61
|
+
// body — so S3 still answers 411 and stores nothing. Buffering everything through
|
|
62
|
+
// Response keeps one path for streams, buffers and strings alike.
|
|
63
|
+
const body = value === null ? new ArrayBuffer(0) : await new Response(value as BodyInit).arrayBuffer();
|
|
64
|
+
const headers: Record<string, string> = { "content-length": String(body.byteLength) };
|
|
65
|
+
if (options?.httpMetadata?.contentType) headers["content-type"] = options.httpMetadata.contentType;
|
|
66
|
+
await ok(await client.fetch(`${endpoint}/${path(key)}`, { method: "PUT", body, headers }), "upload", key);
|
|
43
67
|
},
|
|
44
68
|
async delete(keys: string | string[]) {
|
|
45
69
|
for (const key of [keys].flat()) {
|
|
46
|
-
await client.fetch(`${endpoint}/${
|
|
70
|
+
const response = await client.fetch(`${endpoint}/${path(key)}`, { method: "DELETE" });
|
|
71
|
+
// S3 answers 204 for a key that was never there, which is the outcome we wanted.
|
|
72
|
+
if (response.status !== 404) await ok(response, "delete", key);
|
|
47
73
|
}
|
|
48
74
|
},
|
|
49
75
|
};
|