slopkit 0.2.0 → 0.3.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/DEPS.md +2 -2
- package/README.md +10 -0
- package/dist/client.d.ts +16 -3
- package/dist/client.js +21 -2
- package/dist/conformance/client.test.js +7 -7
- package/dist/conformance/fixtures.d.ts +1 -0
- package/dist/conformance/fixtures.js +5 -1
- package/dist/conformance/outbox-bound.test.d.ts +1 -0
- package/dist/conformance/outbox-bound.test.js +268 -0
- package/dist/conformance/push-bytes.test.d.ts +1 -0
- package/dist/conformance/push-bytes.test.js +174 -0
- package/dist/conformance/push-refusal.test.d.ts +1 -0
- package/dist/conformance/push-refusal.test.js +644 -0
- package/dist/contract.d.ts +19 -6
- package/dist/contract.js +76 -9
- package/dist/server/pull.js +158 -67
- package/dist/server/web.d.ts +9 -5
- package/dist/server/web.js +29 -12
- package/dist/sync.d.ts +84 -2
- package/dist/sync.js +276 -59
- package/dist/web/replica.js +46 -4
- package/package.json +1 -1
package/dist/contract.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* server (which checks requests and its own replies) and the page (which checks replies, since a proxy in between can
|
|
4
4
|
* answer anything, such as an HTML login page). Failures are a closed set of kinds; a caller handles each by kind.
|
|
5
5
|
*/
|
|
6
|
-
import { check, object, string } from "./schema.js";
|
|
6
|
+
import { check, object, optional, string } from "./schema.js";
|
|
7
7
|
export function endpoint(method, path, request, response, options = {}) {
|
|
8
8
|
return { method, path, request, response, person: options.person ?? false };
|
|
9
9
|
}
|
|
@@ -16,8 +16,25 @@ export const STATUS = {
|
|
|
16
16
|
unavailable: 503,
|
|
17
17
|
offline: 503,
|
|
18
18
|
};
|
|
19
|
+
const KINDS = [
|
|
20
|
+
"signed-out",
|
|
21
|
+
"forbidden",
|
|
22
|
+
"invalid",
|
|
23
|
+
"conflict",
|
|
24
|
+
"upstream",
|
|
25
|
+
"unavailable",
|
|
26
|
+
"offline",
|
|
27
|
+
];
|
|
28
|
+
/** A body that was due as JSON and wasn't. */
|
|
29
|
+
const UNREADABLE = Symbol("unreadable");
|
|
19
30
|
/** A failure's body, as `serveWeb` answers it: its message is shown when there is one. */
|
|
20
31
|
const ERROR_BODY = object({ message: string() });
|
|
32
|
+
/** The kit's own error body: a kind it can send, its message, and the schema path of a refused request. */
|
|
33
|
+
const KIT_BODY = object({ kind: string(), message: string(), path: optional(string()) });
|
|
34
|
+
/**
|
|
35
|
+
* A status's kind when no kit body says otherwise. A wrong endpoint or method and a body past the server's limit are
|
|
36
|
+
* `invalid` (no command path: incompatible); a timeout or rate limit is `unavailable`, retried.
|
|
37
|
+
*/
|
|
21
38
|
function kindOf(status) {
|
|
22
39
|
if (status === 401) {
|
|
23
40
|
return "signed-out";
|
|
@@ -28,6 +45,9 @@ function kindOf(status) {
|
|
|
28
45
|
if (status === 409) {
|
|
29
46
|
return "conflict";
|
|
30
47
|
}
|
|
48
|
+
if (status === 408 || status === 429) {
|
|
49
|
+
return "unavailable";
|
|
50
|
+
}
|
|
31
51
|
if (status === 502 || status === 504) {
|
|
32
52
|
return "upstream";
|
|
33
53
|
}
|
|
@@ -36,6 +56,21 @@ function kindOf(status) {
|
|
|
36
56
|
}
|
|
37
57
|
return "invalid";
|
|
38
58
|
}
|
|
59
|
+
/** The longest a `Retry-After` defers the next try: a bad header can delay sync, never stop it. */
|
|
60
|
+
export const RETRY_AFTER_MAX_MS = 3_600_000;
|
|
61
|
+
/**
|
|
62
|
+
* `Retry-After` as milliseconds from now, capped at `RETRY_AFTER_MAX_MS`: delay seconds or an HTTP date; undefined
|
|
63
|
+
* when absent or unreadable.
|
|
64
|
+
*/
|
|
65
|
+
function retryAfter(value) {
|
|
66
|
+
if (value === null) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const at = /^\d+$/.test(value.trim())
|
|
70
|
+
? Number(value.trim()) * 1000
|
|
71
|
+
: Date.parse(value) - Date.now();
|
|
72
|
+
return Number.isNaN(at) ? undefined : Math.min(RETRY_AFTER_MAX_MS, Math.max(0, at));
|
|
73
|
+
}
|
|
39
74
|
/** GET requests carry their input as one `q` query parameter of JSON; POST as the body. */
|
|
40
75
|
export function encodeUrl(route, request) {
|
|
41
76
|
if (route.method === "POST") {
|
|
@@ -44,8 +79,9 @@ export function encodeUrl(route, request) {
|
|
|
44
79
|
return `${route.path}?q=${encodeURIComponent(JSON.stringify(request))}`;
|
|
45
80
|
}
|
|
46
81
|
/**
|
|
47
|
-
* Call an endpoint from a browser (or any fetch). Every outcome is a `Result`: thrown errors,
|
|
48
|
-
* proxy's login page means signed out), error statuses and
|
|
82
|
+
* Call an endpoint from a browser (or any fetch). Every outcome is a `Result`: thrown errors, redirects and successful
|
|
83
|
+
* HTML pages (a proxy's login page means signed out), error statuses, and successes that aren't JSON or break the
|
|
84
|
+
* schema (`unreadable`) all become a `Failure`. An error is `refused` only with the kit's own body.
|
|
49
85
|
*/
|
|
50
86
|
export async function call(route, request, options = {}) {
|
|
51
87
|
const send = options.fetch ?? fetch;
|
|
@@ -70,29 +106,60 @@ export async function call(route, request, options = {}) {
|
|
|
70
106
|
failure: { kind: "offline", message: error instanceof Error ? error.message : String(error) },
|
|
71
107
|
};
|
|
72
108
|
}
|
|
73
|
-
// A redirect (an opaque one, in a browser) or
|
|
109
|
+
// A redirect (an opaque one, in a browser) or a successful HTML page is a proxy sending the browser to sign in. An
|
|
110
|
+
// HTML error page keeps its status's kind.
|
|
74
111
|
const type = response.headers.get("content-type") ?? "";
|
|
75
112
|
if (response.type === "opaqueredirect" ||
|
|
76
113
|
(response.status >= 300 && response.status < 400) ||
|
|
77
|
-
type.includes("text/html")) {
|
|
114
|
+
(response.ok && type.includes("text/html"))) {
|
|
78
115
|
return { ok: false, failure: { kind: "signed-out", message: "the login expired" } };
|
|
79
116
|
}
|
|
80
117
|
const body = type.includes("application/json")
|
|
81
|
-
? await response.json().catch(() =>
|
|
82
|
-
:
|
|
118
|
+
? await response.json().catch(() => UNREADABLE)
|
|
119
|
+
: UNREADABLE;
|
|
83
120
|
if (!response.ok) {
|
|
121
|
+
const kit = check(KIT_BODY, body);
|
|
122
|
+
const named = kit.ok ? KINDS.find((each) => each === kit.value.kind) : undefined;
|
|
123
|
+
// Only the kit's own body, naming a kind its status carries, is the server's refusal; anything else is a status.
|
|
124
|
+
const own = kit.ok && named !== undefined && STATUS[named] === response.status;
|
|
125
|
+
const kind = own ? named : kindOf(response.status);
|
|
126
|
+
const after = kind === "unavailable" ? retryAfter(response.headers.get("retry-after")) : undefined;
|
|
127
|
+
if (own) {
|
|
128
|
+
const path = kit.value.path;
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
failure: {
|
|
132
|
+
kind,
|
|
133
|
+
message: kit.value.message,
|
|
134
|
+
refused: true,
|
|
135
|
+
...(path === undefined ? {} : { path }),
|
|
136
|
+
...(after === undefined ? {} : { retryAfterMs: after }),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
84
140
|
const error = check(ERROR_BODY, body);
|
|
85
141
|
return {
|
|
86
142
|
ok: false,
|
|
87
143
|
failure: {
|
|
88
|
-
kind
|
|
144
|
+
kind,
|
|
89
145
|
message: error.ok ? error.value.message : `HTTP ${response.status}`,
|
|
146
|
+
...(after === undefined ? {} : { retryAfterMs: after }),
|
|
90
147
|
},
|
|
91
148
|
};
|
|
92
149
|
}
|
|
150
|
+
// A success the page can't read may still have acted: unreadable, never a refusal.
|
|
151
|
+
if (body === UNREADABLE) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
failure: { kind: "unreadable", message: `reply: not JSON (HTTP ${response.status})` },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
93
157
|
const parsed = check(route.response, body);
|
|
94
158
|
if (!parsed.ok) {
|
|
95
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
failure: { kind: "unreadable", message: `reply: ${parsed.error.message}` },
|
|
162
|
+
};
|
|
96
163
|
}
|
|
97
164
|
return { ok: true, value: parsed.value };
|
|
98
165
|
}
|
package/dist/server/pull.js
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* to the rows and bytes it leaves, and with no room at all the cursor stays where it was. A reset carries one row of history at least, then as many receipts as fit, then more rows; one
|
|
20
20
|
* that can't fit even so (the other tables alone are over the caps) is never sent: `pull` throws, a server fault
|
|
21
21
|
* the page sees as unavailable and retries.
|
|
22
|
+
* - **A reply costs about one encoding.** Each candidate is measured, not built: the reply as `respond` makes it with no
|
|
23
|
+
* rows, plus each row's own encoding (every row read and encoded once) and a comma between rows; counts run once per
|
|
24
|
+
* reply, and a candidate's coverage is read from the rows it fetched. The chosen reply is built once, and checked: one
|
|
25
|
+
* whose bytes aren't its rows' (a `respond` that reshapes rows) is chosen again by building each candidate whole.
|
|
22
26
|
* - **Receipts carry their rows.** A row a receipt names is in the reply that carries the receipt, even below the page,
|
|
23
27
|
* so a command settles only on a copy that holds its effects. A paging client names the commands it waits on (any of
|
|
24
28
|
* its pages', at most a push's worth); a receipt left out for room is asked for again by its next pull.
|
|
@@ -92,6 +96,8 @@ export function createPull(options) {
|
|
|
92
96
|
named: db.prepare(`select * from ${paged.table} where ${key} = ?`),
|
|
93
97
|
};
|
|
94
98
|
const encode = (dataset, row) => JSON.stringify([dataset, paged.version, ...values(row, order)]);
|
|
99
|
+
/** Whether a row is in the order group `at`. */
|
|
100
|
+
const same = (row, at) => values(row, group).every((value, index) => value === at[index]);
|
|
95
101
|
/** A cursor's order values, or undefined for one this history can't honour. */
|
|
96
102
|
function decode(text, dataset) {
|
|
97
103
|
let parsed;
|
|
@@ -112,27 +118,6 @@ export function createPull(options) {
|
|
|
112
118
|
? at.filter((value) => typeof value === "string" || typeof value === "number")
|
|
113
119
|
: undefined;
|
|
114
120
|
}
|
|
115
|
-
/** Where a page ending at `last` leaves the copy, at this revision. */
|
|
116
|
-
function place(dataset, from, last, more) {
|
|
117
|
-
const total = tally(sql.total.get());
|
|
118
|
-
if (last === undefined || !more) {
|
|
119
|
-
return { from, next: null, through: null, held: total, total };
|
|
120
|
-
}
|
|
121
|
-
const at = values(last, group);
|
|
122
|
-
const split = sql.split.get(...at, ...values(last, [key])) !== undefined;
|
|
123
|
-
const above = split ? sql.above.get(...at) : undefined;
|
|
124
|
-
return {
|
|
125
|
-
from,
|
|
126
|
-
next: encode(dataset, last),
|
|
127
|
-
through: split
|
|
128
|
-
? above === undefined
|
|
129
|
-
? null
|
|
130
|
-
: paged.through(Object.values(above))
|
|
131
|
-
: paged.through(at),
|
|
132
|
-
held: tally(sql.held.get(...values(last, order))),
|
|
133
|
-
total,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
121
|
function assemble(request, receipts) {
|
|
137
122
|
const dataset = options.dataset();
|
|
138
123
|
const revision = options.revision();
|
|
@@ -159,60 +144,166 @@ export function createPull(options) {
|
|
|
159
144
|
receipts: [...receipts(after)],
|
|
160
145
|
});
|
|
161
146
|
}
|
|
147
|
+
// Counted once per reply: every candidate page is placed from these and from the rows it was read with.
|
|
148
|
+
let total;
|
|
149
|
+
const count = () => (total ??= tally(sql.total.get()));
|
|
150
|
+
const above = new Map();
|
|
151
|
+
/** The lowest live order group above `at` (whole, so its values are the coverage boundary), once per group. */
|
|
152
|
+
const higher = (at) => {
|
|
153
|
+
const id = JSON.stringify(at);
|
|
154
|
+
if (!above.has(id)) {
|
|
155
|
+
const found = sql.above.get(...at);
|
|
156
|
+
above.set(id, found === undefined ? null : values(found, group));
|
|
157
|
+
}
|
|
158
|
+
return above.get(id) ?? null;
|
|
159
|
+
};
|
|
160
|
+
/** Each row read and encoded once, by key: a row is the same everywhere in one reply (one transaction). */
|
|
161
|
+
const encoded = new Map();
|
|
162
|
+
const row = (each) => {
|
|
163
|
+
let found = encoded.get(each[key]);
|
|
164
|
+
if (found === undefined) {
|
|
165
|
+
const value = paged.read(each);
|
|
166
|
+
found = { value, bytes: Buffer.byteLength(JSON.stringify(value)) };
|
|
167
|
+
encoded.set(each[key], found);
|
|
168
|
+
}
|
|
169
|
+
return found;
|
|
170
|
+
};
|
|
162
171
|
/**
|
|
163
|
-
* A reply: `size` rows of `page` (fetched with one row more, to know if older ones are left
|
|
164
|
-
* `
|
|
165
|
-
*
|
|
172
|
+
* A reply: `size` rows of `page` (fetched with one row more, to know if older ones are left, and live rows strictly
|
|
173
|
+
* below the cursor `fixed.at`, or the newest ones), the first `count` of `kept` receipts with the rows they name,
|
|
174
|
+
* and `extra` history rows beside them; once each, as the store holds them now. `held` counts the rows at or
|
|
175
|
+
* above the cursor (none on a reset). `measure` says whether one fits the caps without building it: the reply as
|
|
176
|
+
* `respond` makes it with no rows, plus each row's own encoded bytes and a comma between rows. `make` builds it.
|
|
166
177
|
*/
|
|
167
|
-
const reply = (fixed, page, kept, extra) => {
|
|
178
|
+
const reply = (fixed, page, kept, extra, held) => {
|
|
168
179
|
const named = kept.map((each) => options.receipts.names(each).flatMap((name) => sql.named.all(name)));
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Where a page of `size` rows leaves the copy: its last row (or the cursor), whether a live row of its group
|
|
182
|
+
* lies below it (the next row fetched), the group above it (in the page, else one query), and `held`.
|
|
183
|
+
*/
|
|
184
|
+
const place = (size) => {
|
|
185
|
+
const last = page[size - 1] ?? fixed.at;
|
|
186
|
+
const next = page[size];
|
|
187
|
+
if (last === undefined || next === undefined) {
|
|
188
|
+
return { from: fixed.from, next: null, through: null, held: count(), total: count() };
|
|
173
189
|
}
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
190
|
+
const at = values(last, group);
|
|
191
|
+
let through = at;
|
|
192
|
+
// A page ending inside a group leaves it out: the boundary is the lowest whole group above.
|
|
193
|
+
if (same(next, at)) {
|
|
194
|
+
const inPage = page.slice(0, Math.max(0, size - 1)).findLast((each) => !same(each, at));
|
|
195
|
+
through = inPage === undefined ? higher(at) : values(inPage, group);
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
from: fixed.from,
|
|
199
|
+
next: encode(dataset, last),
|
|
200
|
+
through: through === null ? null : paged.through(through),
|
|
201
|
+
held: held() + size,
|
|
202
|
+
total: count(),
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
const rows = (size, taken) => {
|
|
206
|
+
const out = new Map();
|
|
207
|
+
for (const each of [...page.slice(0, size), ...extra, ...named.slice(0, taken).flat()]) {
|
|
208
|
+
out.set(each[key], out.get(each[key]) ?? each);
|
|
209
|
+
}
|
|
210
|
+
return [...out.values()];
|
|
211
|
+
};
|
|
212
|
+
const pulled = (size, taken, carried) => ({
|
|
213
|
+
...base,
|
|
214
|
+
...fixed,
|
|
215
|
+
rows: carried,
|
|
216
|
+
receipts: kept.slice(0, taken),
|
|
217
|
+
page: place(size),
|
|
218
|
+
});
|
|
219
|
+
const fits = (carried, bytes) => carried + sizeOf(fixed.unpaged) <= limits.maxRows && bytes <= limits.maxBytes;
|
|
220
|
+
return {
|
|
221
|
+
/** The rows fetched for the page, one more than it may carry when older ones are left. */
|
|
222
|
+
fetched: page.length,
|
|
223
|
+
receipts: kept.length,
|
|
224
|
+
/** The reply's encoded bytes and whether it fits, from its rows' own encodings. */
|
|
225
|
+
measure: (size, taken = kept.length) => {
|
|
226
|
+
const carried = rows(size, taken);
|
|
227
|
+
let bytes = answer(pulled(size, taken, [])).bytes + Math.max(0, carried.length - 1);
|
|
228
|
+
for (const each of carried) {
|
|
229
|
+
bytes += row(each).bytes;
|
|
230
|
+
}
|
|
231
|
+
return { bytes, fits: fits(carried.length, bytes) };
|
|
232
|
+
},
|
|
233
|
+
/** The reply itself, and whether it fits, encoded whole. */
|
|
234
|
+
make: (size, taken = kept.length) => {
|
|
235
|
+
const carried = rows(size, taken);
|
|
236
|
+
const made = answer(pulled(size, taken, carried.map((each) => row(each).value)));
|
|
237
|
+
return { made, fits: fits(carried.length, made.bytes) };
|
|
238
|
+
},
|
|
183
239
|
};
|
|
184
240
|
};
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
?
|
|
192
|
-
:
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
241
|
+
/** A delta's reply, and the newest page's: each read once, when first asked for. */
|
|
242
|
+
let delta;
|
|
243
|
+
let fresh;
|
|
244
|
+
const forDelta = () => {
|
|
245
|
+
if (delta === undefined) {
|
|
246
|
+
const unpaged = options.unpaged(since);
|
|
247
|
+
const room = cursor === null || cursor === undefined ? 0 : limits.pageRows;
|
|
248
|
+
const page = room === 0 ? [] : sql.older.all(...(cursor ?? []), room + 1);
|
|
249
|
+
let held;
|
|
250
|
+
// The cursor as the row it stands for: a reply with no older row leaves the copy there.
|
|
251
|
+
const at = cursor === null || cursor === undefined
|
|
252
|
+
? {}
|
|
253
|
+
: {
|
|
254
|
+
at: Object.fromEntries(order.map((column, index) => [column, cursor[index] ?? null])),
|
|
255
|
+
};
|
|
256
|
+
delta = reply({ reset: false, from: request.page ?? null, unpaged, ...at }, page, receipts(since), sql.changes.all(since),
|
|
257
|
+
// The live rows at or above the cursor: with the page's own rows, every row at or above its last.
|
|
258
|
+
() => (held ??= cursor ? tally(sql.held.get(...cursor)) : 0));
|
|
202
259
|
}
|
|
260
|
+
return delta;
|
|
261
|
+
};
|
|
262
|
+
const forReset = () => {
|
|
263
|
+
fresh ??= reply({ reset: true, from: null, unpaged: options.unpaged(0) }, sql.top.all(limits.firstRows + 1), receipts(0), [], () => 0);
|
|
264
|
+
return fresh;
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* The reply's shape by `fits`: a delta that fits by itself takes the older page's rows and bytes it leaves;
|
|
268
|
+
* otherwise everything, from the newest page: one row of it at least, then as many receipts (with their rows) as
|
|
269
|
+
* fit, then as many more of the first rows. A receipt left out is asked for again by the next pull.
|
|
270
|
+
*/
|
|
271
|
+
const choose = (fits) => {
|
|
272
|
+
if (!reset) {
|
|
273
|
+
const built = forDelta();
|
|
274
|
+
// Only a delta (with its receipts' rows and the other tables) that can't fit by itself starts the copy over.
|
|
275
|
+
if (fits(built, 0)) {
|
|
276
|
+
const room = cursor === null ? 0 : limits.pageRows;
|
|
277
|
+
const most = Math.min(room, built.fetched);
|
|
278
|
+
return {
|
|
279
|
+
built,
|
|
280
|
+
size: largest(1, most, (size) => fits(built, size)) ?? 0,
|
|
281
|
+
receipts: built.receipts,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const built = forReset();
|
|
286
|
+
const least = Math.min(1, built.fetched);
|
|
287
|
+
const kept = largest(0, built.receipts, (at) => fits(built, least, at));
|
|
288
|
+
if (kept === undefined) {
|
|
289
|
+
throw new Error(`pull: the reply can't fit its caps (${limits.maxRows} rows, ${limits.maxBytes} bytes), even with one row of history`);
|
|
290
|
+
}
|
|
291
|
+
const most = Math.min(limits.firstRows, built.fetched);
|
|
292
|
+
return {
|
|
293
|
+
built,
|
|
294
|
+
size: largest(least, most, (at) => fits(built, at, kept)) ?? least,
|
|
295
|
+
receipts: kept,
|
|
296
|
+
};
|
|
297
|
+
};
|
|
298
|
+
// Measured from each row's own encoding, then built once. A reply whose encoding isn't its rows' (a `respond`
|
|
299
|
+
// that reshapes rows) is chosen again by building each candidate whole.
|
|
300
|
+
const measured = choose((built, size, taken) => built.measure(size, taken).fits);
|
|
301
|
+
const made = measured.built.make(measured.size, measured.receipts);
|
|
302
|
+
if (made.made.bytes === measured.built.measure(measured.size, measured.receipts).bytes) {
|
|
303
|
+
return made.made;
|
|
203
304
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const kept = receipts(0);
|
|
207
|
-
const page = sql.top.all(limits.firstRows + 1);
|
|
208
|
-
const least = Math.min(1, page.length);
|
|
209
|
-
const build = reply({ reset: true, from: null, unpaged: options.unpaged(0) }, page, kept, []);
|
|
210
|
-
const count = largest(0, kept.length, (at) => build(least, at).fits);
|
|
211
|
-
if (count === undefined) {
|
|
212
|
-
throw new Error(`pull: the reply can't fit its caps (${limits.maxRows} rows, ${limits.maxBytes} bytes), even with one row of history`);
|
|
213
|
-
}
|
|
214
|
-
const most = Math.min(limits.firstRows, page.length);
|
|
215
|
-
return build(largest(least, most, (at) => build(at, count).fits) ?? least, count).made;
|
|
305
|
+
const exact = choose((built, size, taken) => built.make(size, taken).fits);
|
|
306
|
+
return exact.built.make(exact.size, exact.receipts).made;
|
|
216
307
|
}
|
|
217
308
|
/** The reply to one request, its encoded size, and what it carries before `respond` shaped it. */
|
|
218
309
|
function pull(request, caller) {
|
package/dist/server/web.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Endpoint,
|
|
1
|
+
import type { Endpoint, RefusalKind } from "../contract.ts";
|
|
2
2
|
import type { WebConfig } from "./config.ts";
|
|
3
3
|
import type { Logger } from "./log.ts";
|
|
4
4
|
/** Who asked: their identities, whether a person (not a service token), and how they signed in. */
|
|
@@ -8,10 +8,14 @@ export type Caller = {
|
|
|
8
8
|
login: "access" | "oidc";
|
|
9
9
|
request: string;
|
|
10
10
|
};
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* A handler's refusal, answered as a typed failure: the kit's error body, so the page can tell it from a proxy's.
|
|
13
|
+
* `path` names where a request broke its schema (`request.commands[1].command.value`).
|
|
14
|
+
*/
|
|
12
15
|
export declare class Refusal extends Error {
|
|
13
|
-
readonly kind:
|
|
14
|
-
|
|
16
|
+
readonly kind: RefusalKind;
|
|
17
|
+
readonly path: string | undefined;
|
|
18
|
+
constructor(kind: RefusalKind, message: string, path?: string);
|
|
15
19
|
}
|
|
16
20
|
/** A route: an endpoint and its handler, typed together and run untyped by the server. */
|
|
17
21
|
export type Route = {
|
|
@@ -39,7 +43,7 @@ export type WebOptions = {
|
|
|
39
43
|
socket?: Socket;
|
|
40
44
|
/** The session cookie's name. */
|
|
41
45
|
cookie: string;
|
|
42
|
-
/** Request bodies past this are refused; default 64
|
|
46
|
+
/** Request bodies past this many bytes are refused; default 64 KiB. Give a push endpoint `PUSH_BYTES` or more. */
|
|
43
47
|
bodyLimit?: number;
|
|
44
48
|
};
|
|
45
49
|
export declare function serveWeb(options: WebOptions): Promise<{
|
package/dist/server/web.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* from the page's own Origin. Responses carry a strict CSP and no framing.
|
|
6
6
|
*
|
|
7
7
|
* Routes are contract endpoints: the request is checked against its schema before the handler runs and the reply
|
|
8
|
-
* after, so neither side can drift from the contract. Failures answer `{ kind, message }` with the kind's status.
|
|
8
|
+
* after, so neither side can drift from the contract. Failures answer `{ kind, message, path? }` with the kind's status.
|
|
9
9
|
* The socket is a hint channel: the app sends small messages (pokes, pings); nothing correct depends on it.
|
|
10
10
|
* Every API request is logged with an id, status, time and whether a person or a service asked.
|
|
11
11
|
*/
|
|
@@ -32,12 +32,17 @@ const SECURITY = {
|
|
|
32
32
|
"x-frame-options": "DENY",
|
|
33
33
|
"content-security-policy": "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; manifest-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
|
34
34
|
};
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* A handler's refusal, answered as a typed failure: the kit's error body, so the page can tell it from a proxy's.
|
|
37
|
+
* `path` names where a request broke its schema (`request.commands[1].command.value`).
|
|
38
|
+
*/
|
|
36
39
|
export class Refusal extends Error {
|
|
37
40
|
kind;
|
|
38
|
-
|
|
41
|
+
path;
|
|
42
|
+
constructor(kind, message, path) {
|
|
39
43
|
super(message);
|
|
40
44
|
this.kind = kind;
|
|
45
|
+
this.path = path;
|
|
41
46
|
}
|
|
42
47
|
}
|
|
43
48
|
export function route(endpoint, handle) {
|
|
@@ -52,7 +57,7 @@ export function route(endpoint, handle) {
|
|
|
52
57
|
}
|
|
53
58
|
catch (error) {
|
|
54
59
|
if (error instanceof Invalid) {
|
|
55
|
-
throw new Refusal("invalid", error.message);
|
|
60
|
+
throw new Refusal("invalid", error.message, error.path);
|
|
56
61
|
}
|
|
57
62
|
throw error;
|
|
58
63
|
}
|
|
@@ -90,18 +95,30 @@ function send(request, response, status, body, headers = {}) {
|
|
|
90
95
|
});
|
|
91
96
|
response.end(request.method === "HEAD" ? undefined : bytes);
|
|
92
97
|
}
|
|
93
|
-
function failure(request, response, kind, message) {
|
|
94
|
-
send(request, response, STATUS[kind], JSON.stringify({ kind, message }), {
|
|
95
|
-
"content-type": "application/json",
|
|
96
|
-
});
|
|
98
|
+
function failure(request, response, kind, message, path) {
|
|
99
|
+
send(request, response, STATUS[kind], JSON.stringify({ kind, message, ...(path === undefined ? {} : { path }) }), { "content-type": "application/json" });
|
|
97
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* The body as JSON: its bytes collected and counted against `limit`, then decoded once, so a character split between
|
|
103
|
+
* chunks arrives whole. Bytes that aren't UTF-8 are refused, never replaced.
|
|
104
|
+
*/
|
|
98
105
|
async function readBody(request, limit) {
|
|
99
|
-
|
|
106
|
+
const chunks = [];
|
|
107
|
+
let size = 0;
|
|
100
108
|
for await (const chunk of request) {
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
110
|
+
size += bytes.length;
|
|
111
|
+
if (size > limit) {
|
|
103
112
|
throw new Refusal("invalid", "the request is too large");
|
|
104
113
|
}
|
|
114
|
+
chunks.push(bytes);
|
|
115
|
+
}
|
|
116
|
+
let text;
|
|
117
|
+
try {
|
|
118
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new Refusal("invalid", "the request is not UTF-8");
|
|
105
122
|
}
|
|
106
123
|
try {
|
|
107
124
|
return text ? JSON.parse(text) : {};
|
|
@@ -212,7 +229,7 @@ export async function serveWeb(options) {
|
|
|
212
229
|
reason: message,
|
|
213
230
|
});
|
|
214
231
|
}
|
|
215
|
-
failure(request, response, kind, error instanceof Refusal ? message : "the server failed; see its log");
|
|
232
|
+
failure(request, response, kind, error instanceof Refusal ? message : "the server failed; see its log", error instanceof Refusal ? error.path : undefined);
|
|
216
233
|
}
|
|
217
234
|
log(status < 400 ? "info" : "warn", "request", {
|
|
218
235
|
request: caller.request,
|