@fluixi/api 0.0.0-stage → 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +37 -2
- package/dist/client.cjs +103 -0
- package/dist/client.d.ts +95 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +87 -0
- package/dist/client.mjs +82 -0
- package/dist/errors.cjs +63 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +55 -0
- package/dist/errors.mjs +42 -0
- package/dist/index.cjs +667 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.mjs +5442 -0
- package/dist/input.cjs +91 -0
- package/dist/input.d.ts +15 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +73 -0
- package/dist/input.mjs +68 -0
- package/dist/middleware.cjs +41 -0
- package/dist/middleware.d.ts +9 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +19 -0
- package/dist/middleware.mjs +20 -0
- package/dist/openapi.cjs +282 -0
- package/dist/openapi.d.ts +60 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +312 -0
- package/dist/openapi.mjs +259 -0
- package/dist/rate-limit.cjs +158 -0
- package/dist/rate-limit.d.ts +69 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +121 -0
- package/dist/rate-limit.mjs +4935 -0
- package/dist/route.cjs +192 -0
- package/dist/route.d.ts +97 -0
- package/dist/route.d.ts.map +1 -0
- package/dist/route.js +98 -0
- package/dist/route.mjs +169 -0
- package/dist/security.cjs +222 -0
- package/dist/security.d.ts +64 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +135 -0
- package/dist/security.mjs +4997 -0
- package/dist/standard-schema.cjs +18 -0
- package/dist/standard-schema.d.ts +39 -0
- package/dist/standard-schema.d.ts.map +1 -0
- package/dist/standard-schema.js +6 -0
- package/dist/standard-schema.mjs +0 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +54 -3
package/dist/route.cjs
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/route.ts
|
|
21
|
+
var route_exports = {};
|
|
22
|
+
__export(route_exports, {
|
|
23
|
+
CONTRACT: () => CONTRACT,
|
|
24
|
+
REVALIDATE_HEADER: () => REVALIDATE_HEADER,
|
|
25
|
+
contractOf: () => contractOf,
|
|
26
|
+
route: () => route,
|
|
27
|
+
setErrorReporter: () => setErrorReporter
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(route_exports);
|
|
30
|
+
|
|
31
|
+
// src/errors.ts
|
|
32
|
+
var ApiError = class extends Error {
|
|
33
|
+
constructor(status, code, message, details) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "ApiError";
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.details = details;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var unauthorized = (message = "Sign in to continue.") => new ApiError(401, "unauthorized", message);
|
|
42
|
+
var forbidden = (message = "Not allowed.") => new ApiError(403, "forbidden", message);
|
|
43
|
+
function toApiError(error) {
|
|
44
|
+
if (error instanceof ApiError) return { error, internal: false };
|
|
45
|
+
const { status, code, message } = error ?? {};
|
|
46
|
+
if (status === 401 || status === 403) {
|
|
47
|
+
if (typeof code === "string" && typeof message === "string") return { error: new ApiError(status, code, message), internal: false };
|
|
48
|
+
return { error: status === 401 ? unauthorized() : forbidden(), internal: false };
|
|
49
|
+
}
|
|
50
|
+
return { error: new ApiError(500, "internal", "Something went wrong on our side."), internal: true };
|
|
51
|
+
}
|
|
52
|
+
function errorResponse(error) {
|
|
53
|
+
const body = { error: { code: error.code, message: error.message } };
|
|
54
|
+
if (error.details !== void 0) body.error.details = error.details;
|
|
55
|
+
return new Response(JSON.stringify(body), {
|
|
56
|
+
status: error.status,
|
|
57
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/input.ts
|
|
62
|
+
var DEFAULT_BODY_LIMIT = 1024 * 1024;
|
|
63
|
+
function readQuery(url) {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [key, value] of url.searchParams) {
|
|
66
|
+
const seen = out[key];
|
|
67
|
+
if (seen === void 0) out[key] = value;
|
|
68
|
+
else out[key] = Array.isArray(seen) ? [...seen, value] : [seen, value];
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
async function readJson(request, limit = DEFAULT_BODY_LIMIT) {
|
|
73
|
+
const declared = Number(request.headers.get("content-length"));
|
|
74
|
+
if (Number.isFinite(declared) && declared > limit) throw tooLarge(limit);
|
|
75
|
+
if (!request.body) return void 0;
|
|
76
|
+
const type = request.headers.get("content-type") ?? "";
|
|
77
|
+
const reader = request.body.getReader();
|
|
78
|
+
const chunks = [];
|
|
79
|
+
let size = 0;
|
|
80
|
+
for (; ; ) {
|
|
81
|
+
const { done, value } = await reader.read();
|
|
82
|
+
if (done) break;
|
|
83
|
+
size += value.byteLength;
|
|
84
|
+
if (size > limit) {
|
|
85
|
+
await reader.cancel();
|
|
86
|
+
throw tooLarge(limit);
|
|
87
|
+
}
|
|
88
|
+
chunks.push(value);
|
|
89
|
+
}
|
|
90
|
+
if (size === 0) return void 0;
|
|
91
|
+
if (!isJson(type)) {
|
|
92
|
+
throw new ApiError(415, "unsupported_media_type", "Send the body as application/json.");
|
|
93
|
+
}
|
|
94
|
+
const bytes = new Uint8Array(size);
|
|
95
|
+
let offset = 0;
|
|
96
|
+
for (const chunk of chunks) {
|
|
97
|
+
bytes.set(chunk, offset);
|
|
98
|
+
offset += chunk.byteLength;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
102
|
+
} catch {
|
|
103
|
+
throw new ApiError(400, "invalid_json", "The body is not valid JSON.");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function isJson(type) {
|
|
107
|
+
const mime = type.split(";")[0].trim().toLowerCase();
|
|
108
|
+
return mime === "application/json" || mime.endsWith("+json");
|
|
109
|
+
}
|
|
110
|
+
function tooLarge(limit) {
|
|
111
|
+
return new ApiError(413, "payload_too_large", `The body is larger than ${limit} bytes.`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/route.ts
|
|
115
|
+
var CONTRACT = /* @__PURE__ */ Symbol.for("fluixi.api.contract");
|
|
116
|
+
var report = (error, request) => {
|
|
117
|
+
console.error(`[fluixi/api] ${request.method} ${new URL(request.url).pathname}`, error);
|
|
118
|
+
};
|
|
119
|
+
function setErrorReporter(reporter) {
|
|
120
|
+
report = reporter;
|
|
121
|
+
}
|
|
122
|
+
function route(contract) {
|
|
123
|
+
const handle = async (request, context = {}) => {
|
|
124
|
+
try {
|
|
125
|
+
const auth = contract.guard ? await contract.guard.check(request) : void 0;
|
|
126
|
+
const issues = [];
|
|
127
|
+
const params = await check(contract.params, context.params ?? {}, "params", issues);
|
|
128
|
+
const query = await check(contract.query, readQuery(new URL(request.url)), "query", issues);
|
|
129
|
+
const body = contract.body ? await check(contract.body, await readJson(request, contract.bodyLimit), "body", issues) : void 0;
|
|
130
|
+
if (issues.length) throw new ApiError(400, "validation_failed", "The request is not valid.", issues);
|
|
131
|
+
const input = { params, query, body, auth, request };
|
|
132
|
+
const out = await contract.handler(input);
|
|
133
|
+
const stale = !contract.revalidates ? [] : typeof contract.revalidates === "function" ? contract.revalidates(out, input) : contract.revalidates;
|
|
134
|
+
if (out instanceof Response) return withRevalidate(out, stale);
|
|
135
|
+
if (out === void 0 && !contract.response) return withRevalidate(new Response(null, { status: contract.status ?? 204 }), stale);
|
|
136
|
+
let value = out;
|
|
137
|
+
if (contract.response && contract.validateResponse !== false) {
|
|
138
|
+
const result = await contract.response["~standard"].validate(out);
|
|
139
|
+
if (result.issues) {
|
|
140
|
+
throw Object.assign(new Error("Response does not match the route contract."), {
|
|
141
|
+
issues: result.issues.map((issue) => toIssue(issue, "response"))
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
value = result.value;
|
|
145
|
+
}
|
|
146
|
+
return withRevalidate(
|
|
147
|
+
new Response(JSON.stringify(value ?? null), {
|
|
148
|
+
status: contract.status ?? 200,
|
|
149
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
150
|
+
}),
|
|
151
|
+
stale
|
|
152
|
+
);
|
|
153
|
+
} catch (thrown) {
|
|
154
|
+
const { error, internal } = toApiError(thrown);
|
|
155
|
+
if (internal) report(thrown, request);
|
|
156
|
+
return errorResponse(error);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
Object.defineProperty(handle, CONTRACT, { value: contract });
|
|
160
|
+
return handle;
|
|
161
|
+
}
|
|
162
|
+
var REVALIDATE_HEADER = "x-fx-revalidate";
|
|
163
|
+
function withRevalidate(response, names) {
|
|
164
|
+
if (!names.length || !response.ok) return response;
|
|
165
|
+
const value = names.join(", ");
|
|
166
|
+
try {
|
|
167
|
+
response.headers.append(REVALIDATE_HEADER, value);
|
|
168
|
+
return response;
|
|
169
|
+
} catch {
|
|
170
|
+
const headers = new Headers(response.headers);
|
|
171
|
+
headers.append(REVALIDATE_HEADER, value);
|
|
172
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function contractOf(handler) {
|
|
176
|
+
return typeof handler === "function" ? handler[CONTRACT] : void 0;
|
|
177
|
+
}
|
|
178
|
+
async function check(schema, value, where, issues) {
|
|
179
|
+
if (!schema) return value;
|
|
180
|
+
const result = await schema["~standard"].validate(value);
|
|
181
|
+
if (result.issues) {
|
|
182
|
+
for (const issue of result.issues) issues.push(toIssue(issue, where));
|
|
183
|
+
return void 0;
|
|
184
|
+
}
|
|
185
|
+
return result.value;
|
|
186
|
+
}
|
|
187
|
+
function toIssue(issue, where) {
|
|
188
|
+
const path = (issue.path ?? []).map(
|
|
189
|
+
(segment) => String(typeof segment === "object" && segment !== null ? segment.key : segment)
|
|
190
|
+
);
|
|
191
|
+
return { path: [where, ...path].join("."), message: issue.message };
|
|
192
|
+
}
|
package/dist/route.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A route with a contract: what it accepts and what it returns, as Standard Schema.
|
|
3
|
+
*
|
|
4
|
+
* `route()` returns an ordinary `@fluixi/start` API handler, `(request, { params })` to a
|
|
5
|
+
* `Response`, so a file under `src/api` exports it and start routes to it unchanged. Before
|
|
6
|
+
* the handler runs, params, query and body are validated and every problem is reported at
|
|
7
|
+
* once; the handler receives the validated values, typed. What it returns goes through the
|
|
8
|
+
* response schema too, so unknown fields are dropped before anything leaves the server.
|
|
9
|
+
*/
|
|
10
|
+
import type { StandardSchemaV1 as Schema } from './standard-schema.js';
|
|
11
|
+
type AnySchema = Schema | undefined;
|
|
12
|
+
type AnyGuard = Guard<any> | undefined;
|
|
13
|
+
type GuardOutput<G extends AnyGuard> = G extends Guard<infer A> ? A : undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Decides who may call a route. It runs before anything is read from the request, so a caller
|
|
16
|
+
* who may not call it learns nothing about what it accepts. What `check` returns reaches the
|
|
17
|
+
* handler as `auth`; a refusal is thrown, with a 401 or 403 `status`.
|
|
18
|
+
*
|
|
19
|
+
* Auth packages provide them (`signedIn()`, `member()` in `@fluixi/auth/server`), matched by
|
|
20
|
+
* shape, so this package depends on none of them.
|
|
21
|
+
*/
|
|
22
|
+
export interface Guard<A = unknown> {
|
|
23
|
+
check(request: Request): A | Promise<A>;
|
|
24
|
+
/** For the API description: the security scheme a caller satisfies, under a stable name. */
|
|
25
|
+
security?: {
|
|
26
|
+
name: string;
|
|
27
|
+
scheme: Record<string, unknown>;
|
|
28
|
+
scopes?: string[];
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The refusals it answers with, for the API description. `[401]` when not given. 503 for a
|
|
32
|
+
* guard that asks another service (a token's issuer, say) and refuses while it cannot.
|
|
33
|
+
*/
|
|
34
|
+
refuses?: readonly (401 | 403 | 503)[];
|
|
35
|
+
}
|
|
36
|
+
type Output<S extends AnySchema, Fallback> = S extends Schema ? Schema.InferOutput<S> : Fallback;
|
|
37
|
+
type Input<S extends AnySchema, Fallback> = S extends Schema ? Schema.InferInput<S> : Fallback;
|
|
38
|
+
/** What a handler receives: the validated input, and the request for anything else. */
|
|
39
|
+
export interface RouteInput<P extends AnySchema, Q extends AnySchema, B extends AnySchema, G extends AnyGuard = undefined> {
|
|
40
|
+
params: Output<P, Record<string, string>>;
|
|
41
|
+
query: Output<Q, Record<string, string | string[]>>;
|
|
42
|
+
body: Output<B, undefined>;
|
|
43
|
+
/** What the guard resolved: the user, their membership. `undefined` without a guard. */
|
|
44
|
+
auth: GuardOutput<G>;
|
|
45
|
+
request: Request;
|
|
46
|
+
}
|
|
47
|
+
export interface RouteContract<P extends AnySchema = undefined, Q extends AnySchema = undefined, B extends AnySchema = undefined, R extends AnySchema = undefined, G extends AnyGuard = undefined> {
|
|
48
|
+
/** Who may call it. Without one, anyone. */
|
|
49
|
+
guard?: G;
|
|
50
|
+
/** Path parameters, from the file name: `[id].ts` gives `{ id }`. */
|
|
51
|
+
params?: P;
|
|
52
|
+
/** The query string, with repeated keys as arrays. */
|
|
53
|
+
query?: Q;
|
|
54
|
+
/** The JSON body. Without a schema the body is not read, and the handler may read it itself. */
|
|
55
|
+
body?: B;
|
|
56
|
+
/** What the handler returns. Applied on the way out: unknown fields are dropped, transforms run. */
|
|
57
|
+
response?: R;
|
|
58
|
+
/** Status of a successful response. 200, or 204 when the handler returns nothing. */
|
|
59
|
+
status?: number;
|
|
60
|
+
/** Largest body accepted, in bytes. 1 MiB by default. */
|
|
61
|
+
bodyLimit?: number;
|
|
62
|
+
/** Apply `response` to what the handler returns. On by default; off only for a measured reason. */
|
|
63
|
+
validateResponse?: boolean;
|
|
64
|
+
/** For the API description: a one-line summary, a longer description, grouping tags. */
|
|
65
|
+
summary?: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
tags?: string[];
|
|
68
|
+
/** A stable name for the operation in the API description. Derived from method and path otherwise. */
|
|
69
|
+
operationId?: string;
|
|
70
|
+
/** Marked deprecated in the API description. */
|
|
71
|
+
deprecated?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Cached data a success changes, by name (`['orgs']`), or a function of the result and the
|
|
74
|
+
* input. Sent as `x-fx-revalidate`; `@fluixi/api/client` passes the names to the router, which
|
|
75
|
+
* drops those entries and reloads the routes on screen.
|
|
76
|
+
*/
|
|
77
|
+
revalidates?: string[] | ((result: unknown, input: RouteInput<P, Q, B, G>) => string[]);
|
|
78
|
+
handler(input: RouteInput<P, Q, B, G>): Input<R, unknown> | Response | Promise<Input<R, unknown> | Response>;
|
|
79
|
+
}
|
|
80
|
+
/** Where a route's contract is kept on its handler, for the API description and the client. */
|
|
81
|
+
export declare const CONTRACT: unique symbol;
|
|
82
|
+
/** A contracted route, as `@fluixi/start` calls it. */
|
|
83
|
+
export interface RouteHandler<P extends AnySchema = AnySchema, Q extends AnySchema = AnySchema, B extends AnySchema = AnySchema, R extends AnySchema = AnySchema, G extends AnyGuard = AnyGuard> {
|
|
84
|
+
(request: Request, context?: {
|
|
85
|
+
params?: Record<string, string>;
|
|
86
|
+
}): Promise<Response>;
|
|
87
|
+
readonly [CONTRACT]: RouteContract<P, Q, B, R, G>;
|
|
88
|
+
}
|
|
89
|
+
/** Send unexpected route errors somewhere else, a logger or an error tracker. */
|
|
90
|
+
export declare function setErrorReporter(reporter: (error: unknown, request: Request) => void): void;
|
|
91
|
+
export declare function route<P extends AnySchema = undefined, Q extends AnySchema = undefined, B extends AnySchema = undefined, R extends AnySchema = undefined, G extends AnyGuard = undefined>(contract: RouteContract<P, Q, B, R, G>): RouteHandler<P, Q, B, R, G>;
|
|
92
|
+
/** The header that names cached data a response made stale. */
|
|
93
|
+
export declare const REVALIDATE_HEADER = "x-fx-revalidate";
|
|
94
|
+
/** The contract a route was declared with, or undefined for a plain handler. */
|
|
95
|
+
export declare function contractOf(handler: unknown): RouteContract<AnySchema, AnySchema, AnySchema, AnySchema, AnyGuard> | undefined;
|
|
96
|
+
export {};
|
|
97
|
+
//# sourceMappingURL=route.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../src/route.ts"],"names":[],"mappings":"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"}
|
package/dist/route.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ApiError, errorResponse, toApiError } from './errors.js';
|
|
2
|
+
import { readJson, readQuery } from './input.js';
|
|
3
|
+
/** Where a route's contract is kept on its handler, for the API description and the client. */
|
|
4
|
+
export const CONTRACT = Symbol.for('fluixi.api.contract');
|
|
5
|
+
/** Where a failure no one meant to report goes: logged, never sent. `console.error` by default. */
|
|
6
|
+
let report = (error, request) => {
|
|
7
|
+
console.error(`[fluixi/api] ${request.method} ${new URL(request.url).pathname}`, error);
|
|
8
|
+
};
|
|
9
|
+
/** Send unexpected route errors somewhere else, a logger or an error tracker. */
|
|
10
|
+
export function setErrorReporter(reporter) {
|
|
11
|
+
report = reporter;
|
|
12
|
+
}
|
|
13
|
+
export function route(contract) {
|
|
14
|
+
const handle = async (request, context = {}) => {
|
|
15
|
+
try {
|
|
16
|
+
const auth = contract.guard ? await contract.guard.check(request) : undefined;
|
|
17
|
+
const issues = [];
|
|
18
|
+
const params = await check(contract.params, context.params ?? {}, 'params', issues);
|
|
19
|
+
const query = await check(contract.query, readQuery(new URL(request.url)), 'query', issues);
|
|
20
|
+
const body = contract.body
|
|
21
|
+
? await check(contract.body, await readJson(request, contract.bodyLimit), 'body', issues)
|
|
22
|
+
: undefined;
|
|
23
|
+
if (issues.length)
|
|
24
|
+
throw new ApiError(400, 'validation_failed', 'The request is not valid.', issues);
|
|
25
|
+
const input = { params, query, body, auth, request };
|
|
26
|
+
const out = await contract.handler(input);
|
|
27
|
+
const stale = !contract.revalidates
|
|
28
|
+
? []
|
|
29
|
+
: typeof contract.revalidates === 'function'
|
|
30
|
+
? contract.revalidates(out, input)
|
|
31
|
+
: contract.revalidates;
|
|
32
|
+
if (out instanceof Response)
|
|
33
|
+
return withRevalidate(out, stale);
|
|
34
|
+
if (out === undefined && !contract.response)
|
|
35
|
+
return withRevalidate(new Response(null, { status: contract.status ?? 204 }), stale);
|
|
36
|
+
let value = out;
|
|
37
|
+
if (contract.response && contract.validateResponse !== false) {
|
|
38
|
+
const result = await contract.response['~standard'].validate(out);
|
|
39
|
+
if (result.issues) {
|
|
40
|
+
// The handler broke its own contract: a server fault, never the caller's to see.
|
|
41
|
+
throw Object.assign(new Error('Response does not match the route contract.'), {
|
|
42
|
+
issues: result.issues.map((issue) => toIssue(issue, 'response')),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
value = result.value;
|
|
46
|
+
}
|
|
47
|
+
return withRevalidate(new Response(JSON.stringify(value ?? null), {
|
|
48
|
+
status: contract.status ?? 200,
|
|
49
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
50
|
+
}), stale);
|
|
51
|
+
}
|
|
52
|
+
catch (thrown) {
|
|
53
|
+
const { error, internal } = toApiError(thrown);
|
|
54
|
+
if (internal)
|
|
55
|
+
report(thrown, request);
|
|
56
|
+
return errorResponse(error);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
Object.defineProperty(handle, CONTRACT, { value: contract });
|
|
60
|
+
return handle;
|
|
61
|
+
}
|
|
62
|
+
/** The header that names cached data a response made stale. */
|
|
63
|
+
export const REVALIDATE_HEADER = 'x-fx-revalidate';
|
|
64
|
+
/** Names the stale data on a successful response; a refused one changed nothing. */
|
|
65
|
+
function withRevalidate(response, names) {
|
|
66
|
+
if (!names.length || !response.ok)
|
|
67
|
+
return response;
|
|
68
|
+
const value = names.join(', ');
|
|
69
|
+
try {
|
|
70
|
+
response.headers.append(REVALIDATE_HEADER, value);
|
|
71
|
+
return response;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Immutable headers (a fetched or redirect Response): copy it.
|
|
75
|
+
const headers = new Headers(response.headers);
|
|
76
|
+
headers.append(REVALIDATE_HEADER, value);
|
|
77
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** The contract a route was declared with, or undefined for a plain handler. */
|
|
81
|
+
export function contractOf(handler) {
|
|
82
|
+
return typeof handler === 'function' ? handler[CONTRACT] : undefined;
|
|
83
|
+
}
|
|
84
|
+
async function check(schema, value, where, issues) {
|
|
85
|
+
if (!schema)
|
|
86
|
+
return value;
|
|
87
|
+
const result = await schema['~standard'].validate(value);
|
|
88
|
+
if (result.issues) {
|
|
89
|
+
for (const issue of result.issues)
|
|
90
|
+
issues.push(toIssue(issue, where));
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return result.value;
|
|
94
|
+
}
|
|
95
|
+
function toIssue(issue, where) {
|
|
96
|
+
const path = (issue.path ?? []).map((segment) => String(typeof segment === 'object' && segment !== null ? segment.key : segment));
|
|
97
|
+
return { path: [where, ...path].join('.'), message: issue.message };
|
|
98
|
+
}
|
package/dist/route.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var ApiError = class extends Error {
|
|
3
|
+
constructor(status, code, message, details) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "ApiError";
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.details = details;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var unauthorized = (message = "Sign in to continue.") => new ApiError(401, "unauthorized", message);
|
|
12
|
+
var forbidden = (message = "Not allowed.") => new ApiError(403, "forbidden", message);
|
|
13
|
+
function toApiError(error) {
|
|
14
|
+
if (error instanceof ApiError) return { error, internal: false };
|
|
15
|
+
const { status, code, message } = error ?? {};
|
|
16
|
+
if (status === 401 || status === 403) {
|
|
17
|
+
if (typeof code === "string" && typeof message === "string") return { error: new ApiError(status, code, message), internal: false };
|
|
18
|
+
return { error: status === 401 ? unauthorized() : forbidden(), internal: false };
|
|
19
|
+
}
|
|
20
|
+
return { error: new ApiError(500, "internal", "Something went wrong on our side."), internal: true };
|
|
21
|
+
}
|
|
22
|
+
function errorResponse(error) {
|
|
23
|
+
const body = { error: { code: error.code, message: error.message } };
|
|
24
|
+
if (error.details !== void 0) body.error.details = error.details;
|
|
25
|
+
return new Response(JSON.stringify(body), {
|
|
26
|
+
status: error.status,
|
|
27
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/input.ts
|
|
32
|
+
var DEFAULT_BODY_LIMIT = 1024 * 1024;
|
|
33
|
+
function readQuery(url) {
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const [key, value] of url.searchParams) {
|
|
36
|
+
const seen = out[key];
|
|
37
|
+
if (seen === void 0) out[key] = value;
|
|
38
|
+
else out[key] = Array.isArray(seen) ? [...seen, value] : [seen, value];
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
async function readJson(request, limit = DEFAULT_BODY_LIMIT) {
|
|
43
|
+
const declared = Number(request.headers.get("content-length"));
|
|
44
|
+
if (Number.isFinite(declared) && declared > limit) throw tooLarge(limit);
|
|
45
|
+
if (!request.body) return void 0;
|
|
46
|
+
const type = request.headers.get("content-type") ?? "";
|
|
47
|
+
const reader = request.body.getReader();
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let size = 0;
|
|
50
|
+
for (; ; ) {
|
|
51
|
+
const { done, value } = await reader.read();
|
|
52
|
+
if (done) break;
|
|
53
|
+
size += value.byteLength;
|
|
54
|
+
if (size > limit) {
|
|
55
|
+
await reader.cancel();
|
|
56
|
+
throw tooLarge(limit);
|
|
57
|
+
}
|
|
58
|
+
chunks.push(value);
|
|
59
|
+
}
|
|
60
|
+
if (size === 0) return void 0;
|
|
61
|
+
if (!isJson(type)) {
|
|
62
|
+
throw new ApiError(415, "unsupported_media_type", "Send the body as application/json.");
|
|
63
|
+
}
|
|
64
|
+
const bytes = new Uint8Array(size);
|
|
65
|
+
let offset = 0;
|
|
66
|
+
for (const chunk of chunks) {
|
|
67
|
+
bytes.set(chunk, offset);
|
|
68
|
+
offset += chunk.byteLength;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
72
|
+
} catch {
|
|
73
|
+
throw new ApiError(400, "invalid_json", "The body is not valid JSON.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function isJson(type) {
|
|
77
|
+
const mime = type.split(";")[0].trim().toLowerCase();
|
|
78
|
+
return mime === "application/json" || mime.endsWith("+json");
|
|
79
|
+
}
|
|
80
|
+
function tooLarge(limit) {
|
|
81
|
+
return new ApiError(413, "payload_too_large", `The body is larger than ${limit} bytes.`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/route.ts
|
|
85
|
+
var CONTRACT = /* @__PURE__ */ Symbol.for("fluixi.api.contract");
|
|
86
|
+
var report = (error, request) => {
|
|
87
|
+
console.error(`[fluixi/api] ${request.method} ${new URL(request.url).pathname}`, error);
|
|
88
|
+
};
|
|
89
|
+
function setErrorReporter(reporter) {
|
|
90
|
+
report = reporter;
|
|
91
|
+
}
|
|
92
|
+
function route(contract) {
|
|
93
|
+
const handle = async (request, context = {}) => {
|
|
94
|
+
try {
|
|
95
|
+
const auth = contract.guard ? await contract.guard.check(request) : void 0;
|
|
96
|
+
const issues = [];
|
|
97
|
+
const params = await check(contract.params, context.params ?? {}, "params", issues);
|
|
98
|
+
const query = await check(contract.query, readQuery(new URL(request.url)), "query", issues);
|
|
99
|
+
const body = contract.body ? await check(contract.body, await readJson(request, contract.bodyLimit), "body", issues) : void 0;
|
|
100
|
+
if (issues.length) throw new ApiError(400, "validation_failed", "The request is not valid.", issues);
|
|
101
|
+
const input = { params, query, body, auth, request };
|
|
102
|
+
const out = await contract.handler(input);
|
|
103
|
+
const stale = !contract.revalidates ? [] : typeof contract.revalidates === "function" ? contract.revalidates(out, input) : contract.revalidates;
|
|
104
|
+
if (out instanceof Response) return withRevalidate(out, stale);
|
|
105
|
+
if (out === void 0 && !contract.response) return withRevalidate(new Response(null, { status: contract.status ?? 204 }), stale);
|
|
106
|
+
let value = out;
|
|
107
|
+
if (contract.response && contract.validateResponse !== false) {
|
|
108
|
+
const result = await contract.response["~standard"].validate(out);
|
|
109
|
+
if (result.issues) {
|
|
110
|
+
throw Object.assign(new Error("Response does not match the route contract."), {
|
|
111
|
+
issues: result.issues.map((issue) => toIssue(issue, "response"))
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
value = result.value;
|
|
115
|
+
}
|
|
116
|
+
return withRevalidate(
|
|
117
|
+
new Response(JSON.stringify(value ?? null), {
|
|
118
|
+
status: contract.status ?? 200,
|
|
119
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
120
|
+
}),
|
|
121
|
+
stale
|
|
122
|
+
);
|
|
123
|
+
} catch (thrown) {
|
|
124
|
+
const { error, internal } = toApiError(thrown);
|
|
125
|
+
if (internal) report(thrown, request);
|
|
126
|
+
return errorResponse(error);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
Object.defineProperty(handle, CONTRACT, { value: contract });
|
|
130
|
+
return handle;
|
|
131
|
+
}
|
|
132
|
+
var REVALIDATE_HEADER = "x-fx-revalidate";
|
|
133
|
+
function withRevalidate(response, names) {
|
|
134
|
+
if (!names.length || !response.ok) return response;
|
|
135
|
+
const value = names.join(", ");
|
|
136
|
+
try {
|
|
137
|
+
response.headers.append(REVALIDATE_HEADER, value);
|
|
138
|
+
return response;
|
|
139
|
+
} catch {
|
|
140
|
+
const headers = new Headers(response.headers);
|
|
141
|
+
headers.append(REVALIDATE_HEADER, value);
|
|
142
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function contractOf(handler) {
|
|
146
|
+
return typeof handler === "function" ? handler[CONTRACT] : void 0;
|
|
147
|
+
}
|
|
148
|
+
async function check(schema, value, where, issues) {
|
|
149
|
+
if (!schema) return value;
|
|
150
|
+
const result = await schema["~standard"].validate(value);
|
|
151
|
+
if (result.issues) {
|
|
152
|
+
for (const issue of result.issues) issues.push(toIssue(issue, where));
|
|
153
|
+
return void 0;
|
|
154
|
+
}
|
|
155
|
+
return result.value;
|
|
156
|
+
}
|
|
157
|
+
function toIssue(issue, where) {
|
|
158
|
+
const path = (issue.path ?? []).map(
|
|
159
|
+
(segment) => String(typeof segment === "object" && segment !== null ? segment.key : segment)
|
|
160
|
+
);
|
|
161
|
+
return { path: [where, ...path].join("."), message: issue.message };
|
|
162
|
+
}
|
|
163
|
+
export {
|
|
164
|
+
CONTRACT,
|
|
165
|
+
REVALIDATE_HEADER,
|
|
166
|
+
contractOf,
|
|
167
|
+
route,
|
|
168
|
+
setErrorReporter
|
|
169
|
+
};
|