slopkit 0.2.0 → 0.4.0-rc.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.
Files changed (52) hide show
  1. package/DEPS.md +5 -3
  2. package/README.md +103 -1
  3. package/RULES.md +26 -6
  4. package/dist/api.d.ts +93 -0
  5. package/dist/api.js +111 -0
  6. package/dist/client.d.ts +21 -4
  7. package/dist/client.js +21 -2
  8. package/dist/conformance/agent-access.test.d.ts +1 -0
  9. package/dist/conformance/agent-access.test.js +74 -0
  10. package/dist/conformance/client.test.js +7 -7
  11. package/dist/conformance/fixtures.d.ts +1 -0
  12. package/dist/conformance/fixtures.js +5 -1
  13. package/dist/conformance/outbox-bound.test.d.ts +1 -0
  14. package/dist/conformance/outbox-bound.test.js +268 -0
  15. package/dist/conformance/paging-state.test.js +1 -0
  16. package/dist/conformance/push-bytes.test.d.ts +1 -0
  17. package/dist/conformance/push-bytes.test.js +174 -0
  18. package/dist/conformance/push-refusal.test.d.ts +1 -0
  19. package/dist/conformance/push-refusal.test.js +644 -0
  20. package/dist/conformance/states.test.js +19 -8
  21. package/dist/contract.d.ts +19 -6
  22. package/dist/contract.js +76 -9
  23. package/dist/server/auth.d.ts +4 -3
  24. package/dist/server/auth.js +14 -6
  25. package/dist/server/cli.d.ts +117 -0
  26. package/dist/server/cli.js +1534 -0
  27. package/dist/server/config.d.ts +4 -1
  28. package/dist/server/config.js +2 -2
  29. package/dist/server/keys.d.ts +17 -0
  30. package/dist/server/keys.js +127 -0
  31. package/dist/server/pull.js +158 -67
  32. package/dist/server/store.d.ts +101 -0
  33. package/dist/server/store.js +347 -0
  34. package/dist/server/sync.d.ts +158 -0
  35. package/dist/server/sync.js +293 -0
  36. package/dist/server/web.d.ts +62 -9
  37. package/dist/server/web.js +260 -35
  38. package/dist/states.d.ts +52 -4
  39. package/dist/states.js +20 -0
  40. package/dist/sync.d.ts +86 -2
  41. package/dist/sync.js +277 -59
  42. package/dist/test/agents.d.ts +19 -0
  43. package/dist/test/agents.js +62 -0
  44. package/dist/test/page.d.ts +91 -0
  45. package/dist/test/page.js +763 -0
  46. package/dist/test/states.d.ts +44 -7
  47. package/dist/test/states.js +152 -0
  48. package/dist/web/replica.d.ts +7 -0
  49. package/dist/web/replica.js +260 -47
  50. package/dist/web/runtime.d.ts +102 -0
  51. package/dist/web/runtime.js +335 -0
  52. package/package.json +39 -1
@@ -0,0 +1,293 @@
1
+ import { digest } from "../digest.js";
2
+ import { check } from "../schema.js";
3
+ import { createPull } from "./pull.js";
4
+ import { route, serveWeb } from "./web.js";
5
+ /** The kit's own refusal messages, said the same in every app. */
6
+ export const KIT_ANSWERS = {
7
+ replaced: "the server's data was replaced since this was tapped; tap again",
8
+ history: "this page's copy is from another history of the server's data; it starts over",
9
+ forbidden: (name) => `only a person's login can ${name}`,
10
+ reused: (id) => `command ${id} was already used for another command`,
11
+ };
12
+ /**
13
+ * The kit's own answers must come through the app's outcome schema, which checks every reply, or they'd never reach a
14
+ * page: refused when the routes are made. The type check (`Admits`) can't see a schema's bounds; this can.
15
+ */
16
+ function admitted(api) {
17
+ const answers = [
18
+ { kind: "failed", reason: "conflict", message: KIT_ANSWERS.replaced },
19
+ { kind: "failed", reason: "conflict", message: KIT_ANSWERS.history },
20
+ { kind: "failed", reason: "conflict", message: KIT_ANSWERS.reused(Number.MAX_SAFE_INTEGER) },
21
+ { kind: "failed", reason: "forbidden", message: KIT_ANSWERS.forbidden("command") },
22
+ { kind: "retired" },
23
+ ];
24
+ const outcome = api.push.response;
25
+ for (const answer of answers) {
26
+ const parsed = check(outcome, {
27
+ results: [{ id: 1, outcome: answer, revision: 0 }],
28
+ revision: 0,
29
+ });
30
+ if (!parsed.ok) {
31
+ throw new Error(`the outcome schema refuses the kit's own answer: ${parsed.error.message}`);
32
+ }
33
+ }
34
+ }
35
+ /**
36
+ * Whose commands these are: every person is the one owner (single user by design), so a person is one principal
37
+ * whichever login they came through; each service or key is its own.
38
+ */
39
+ export function principalOf(caller) {
40
+ if (caller.person) {
41
+ return "person";
42
+ }
43
+ return caller.identities.toSorted().join(" ") || "service";
44
+ }
45
+ export function syncHandlers(options) {
46
+ const { api, store, ledger, log } = options;
47
+ const listeners = new Set();
48
+ const principal = options.principal ?? principalOf;
49
+ const { outcome } = options;
50
+ const changed = () => {
51
+ for (const listener of listeners) {
52
+ listener();
53
+ }
54
+ };
55
+ async function pull(request, caller) {
56
+ if (caller.person && request.dataset === store.dataset() && store.wentBack(request.since)) {
57
+ log("warn", "a page holds more than the store: it was restored or went back; a new dataset", {
58
+ seen: request.since,
59
+ revision: store.revision(),
60
+ });
61
+ }
62
+ const server = options.server(caller);
63
+ const who = principal(caller);
64
+ const { row, ...paged } = options.history;
65
+ const pulls = createPull({
66
+ db: store.db,
67
+ dataset: () => store.dataset(),
68
+ revision: () => store.revision(),
69
+ known: (since) => store.known(since),
70
+ paged,
71
+ unpaged: options.unpaged,
72
+ receipts: {
73
+ read: (ask) => ask.commands === undefined
74
+ ? ledger
75
+ .receipts(who, request.client, ask.since)
76
+ .map((each) => ({ ...each, client: request.client }))
77
+ : ledger.receiptsOf(who, ask.commands),
78
+ names: (each) => options.names(each.receipt),
79
+ },
80
+ limits: options.limits,
81
+ respond: (pulled) => {
82
+ const receipts = pulled.receipts.map((each) => ({
83
+ id: each.id,
84
+ outcome: outcome(each.receipt),
85
+ revision: each.revision,
86
+ ...(each.client === request.client ? {} : { client: each.client }),
87
+ }));
88
+ const rows = { ...pulled.unpaged, [row]: pulled.rows };
89
+ // SAFETY: the history's rows under its key and the other keys' from `unpaged` are every row key of R, each
90
+ // its rows; the reply's own fields follow.
91
+ // oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-unsafe-type-assertion
92
+ return {
93
+ ...rows,
94
+ dataset: pulled.dataset,
95
+ revision: pulled.revision,
96
+ reset: pulled.reset,
97
+ commands: receipts,
98
+ server,
99
+ ...(pulled.page === undefined ? {} : { page: pulled.page }),
100
+ };
101
+ },
102
+ });
103
+ const { reply } = pulls.pull(request, caller);
104
+ const partial = reply.page !== undefined && reply.page.next !== null;
105
+ const all = request.verify === true && !partial ? options.everything() : undefined;
106
+ store.served(reply.revision);
107
+ if (all === undefined) {
108
+ return reply;
109
+ }
110
+ return { ...reply, checksum: await digest(api.rows.map((key) => all[key])) };
111
+ }
112
+ async function command(key, each, caller) {
113
+ if (!caller.person && options.personOnly?.(each) === true) {
114
+ return {
115
+ outcome: {
116
+ kind: "failed",
117
+ reason: "forbidden",
118
+ message: KIT_ANSWERS.forbidden(each.name),
119
+ },
120
+ };
121
+ }
122
+ const act = options.act(each, caller, key);
123
+ const ran = await ledger.run(key, { name: each.name, args: JSON.stringify(each) }, {
124
+ local: act.local,
125
+ remote: "remote" in act
126
+ ? act.remote
127
+ : () => Promise.reject(new Error(`${each.name} acts only in the store`)),
128
+ });
129
+ if (ran.kind === "elsewhere") {
130
+ return undefined;
131
+ }
132
+ if (ran.kind === "retired") {
133
+ return { outcome: { kind: "retired" } };
134
+ }
135
+ if (ran.kind === "conflict") {
136
+ return {
137
+ outcome: {
138
+ kind: "failed",
139
+ reason: "conflict",
140
+ message: KIT_ANSWERS.reused(key.id),
141
+ },
142
+ };
143
+ }
144
+ if (ran.fresh) {
145
+ const { kind } = ran.receipt;
146
+ const attributes = { client: key.client, id: key.id };
147
+ log(kind === "failed" ? "warn" : "info", `command ${each.name} ${kind}`, attributes);
148
+ }
149
+ return {
150
+ outcome: outcome(ran.receipt),
151
+ ran: { key, command: each, kept: ran.receipt, fresh: ran.fresh },
152
+ };
153
+ }
154
+ async function push(request, caller) {
155
+ const refuse = (message) => {
156
+ const revision = store.revision();
157
+ store.served(revision);
158
+ return {
159
+ results: request.commands.map(({ id }) => ({
160
+ id,
161
+ outcome: { kind: "failed", reason: "conflict", message },
162
+ revision,
163
+ })),
164
+ revision,
165
+ };
166
+ };
167
+ // Tapped against another store (this one was replaced or restored since): nothing runs.
168
+ if (request.dataset !== null && request.dataset !== store.dataset()) {
169
+ return refuse(KIT_ANSWERS.replaced);
170
+ }
171
+ // A page holding a revision this history never had, or no longer remembers: its taps may have run in a history
172
+ // that is gone, so none runs here, and its next pull starts it over.
173
+ if (!store.known(request.since)) {
174
+ return refuse(KIT_ANSWERS.history);
175
+ }
176
+ const results = [];
177
+ const ran = [];
178
+ for (const { id, command: each } of request.commands) {
179
+ const key = { principal: principal(caller), client: request.client, id };
180
+ // oxlint-disable-next-line no-await-in-loop -- a push's commands act in the order they were tapped
181
+ const done = await command(key, each, caller);
182
+ if (done === undefined) {
183
+ continue;
184
+ }
185
+ if (done.ran !== undefined) {
186
+ ran.push(done.ran);
187
+ }
188
+ // Where its effects are visible: the receipt's own revision (a replay too), else the store's now.
189
+ results.push({
190
+ id,
191
+ outcome: done.outcome,
192
+ revision: ledger.revisionOf(key) ?? store.revision(),
193
+ });
194
+ }
195
+ // The revision the reply carries is served before anything else can move it (`pushed` may write).
196
+ const revision = store.revision();
197
+ store.served(revision);
198
+ changed();
199
+ options.pushed?.(ran, caller);
200
+ return { results, revision };
201
+ }
202
+ return {
203
+ pull,
204
+ push,
205
+ revision: () => store.revision(),
206
+ subscribe: (listener) => {
207
+ listeners.add(listener);
208
+ return () => {
209
+ listeners.delete(listener);
210
+ };
211
+ },
212
+ changed,
213
+ };
214
+ }
215
+ /**
216
+ * The sync contract's routes: pull, push and telemetry. Each telemetry event is one `page` log line at `info`, its
217
+ * attributes the event's own with the kit's (`client`, `at`, `person`) set last, so a page can't claim another sender.
218
+ */
219
+ export function syncRoutes(api, handlers) {
220
+ admitted(api);
221
+ return [
222
+ route(api.pull, (request, caller) => handlers.pull(request, caller)),
223
+ route(api.push, (request, caller) => handlers.push(request, caller)),
224
+ route(api.telemetry, (request, caller) => {
225
+ for (const event of request.events) {
226
+ handlers.page("info", event.name, {
227
+ ...event.attributes,
228
+ client: request.client,
229
+ at: event.at,
230
+ person: caller.person,
231
+ });
232
+ }
233
+ return { ok: true };
234
+ }),
235
+ ];
236
+ }
237
+ /**
238
+ * The poke socket: one poke with the revision on open, one for the changes within `gatherMs` (carrying the revision
239
+ * when it is sent), and a beat every `beatMs`, so a lost poke is made up for and a quiet socket is a dead one.
240
+ */
241
+ export function pokes(api, options) {
242
+ return {
243
+ path: api.socket.path,
244
+ open: (send) => {
245
+ const poke = () => {
246
+ send({ type: "poke", revision: options.revision() });
247
+ };
248
+ let pending;
249
+ const unsubscribe = options.subscribe(() => {
250
+ pending ??= setTimeout(() => {
251
+ pending = undefined;
252
+ poke();
253
+ }, options.gatherMs ?? 50);
254
+ });
255
+ poke();
256
+ const alive = setInterval(poke, options.beatMs ?? 20_000);
257
+ return () => {
258
+ unsubscribe();
259
+ clearTimeout(pending);
260
+ clearInterval(alive);
261
+ };
262
+ },
263
+ };
264
+ }
265
+ const NAME = /^[a-z][a-z0-9-]{0,31}$/;
266
+ export async function serveSync(options) {
267
+ const given = options;
268
+ const { name, api, sync, page, routes = [], manifest = {}, ...web } = given;
269
+ if (!NAME.test(name)) {
270
+ throw new Error(`serveSync: not an app name: ${name}`);
271
+ }
272
+ return await serveWeb({
273
+ ...web,
274
+ cookie: `${name}_session`,
275
+ manifest: {
276
+ id: "/",
277
+ name,
278
+ short_name: name,
279
+ start_url: "/",
280
+ scope: "/",
281
+ display: "standalone",
282
+ background_color: "#000000",
283
+ theme_color: "#000000",
284
+ icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" }],
285
+ ...manifest,
286
+ },
287
+ routes: [
288
+ ...syncRoutes(api, { pull: sync.pull, push: sync.push, page }),
289
+ ...routes,
290
+ ],
291
+ socket: pokes(api, { revision: sync.revision, subscribe: sync.subscribe }),
292
+ });
293
+ }
@@ -1,26 +1,75 @@
1
- import type { Endpoint, Failure } from "../contract.ts";
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { Endpoint, RefusalKind } from "../contract.ts";
2
3
  import type { WebConfig } from "./config.ts";
3
4
  import type { Logger } from "./log.ts";
4
- /** Who asked: their identities, whether a person (not a service token), and how they signed in. */
5
+ /** Who asked: their identities, whether a person (not a service token or a key), and how they signed in. */
5
6
  export type Caller = {
6
7
  identities: string[];
7
8
  person: boolean;
8
- login: "access" | "oidc";
9
+ login: "access" | "oidc" | "key";
9
10
  request: string;
10
11
  };
11
- /** A handler's refusal, answered as a typed failure. */
12
+ /**
13
+ * A handler's refusal, answered as a typed failure: the kit's error body, so the page can tell it from a proxy's.
14
+ * `path` names where a request broke its schema (`request.commands[1].command.value`).
15
+ */
12
16
  export declare class Refusal extends Error {
13
- readonly kind: Failure["kind"];
14
- constructor(kind: Failure["kind"], message: string);
17
+ readonly kind: RefusalKind;
18
+ readonly path: string | undefined;
19
+ constructor(kind: RefusalKind, message: string, path?: string);
15
20
  }
16
21
  /** A route: an endpoint and its handler, typed together and run untyped by the server. */
17
22
  export type Route = {
18
23
  method: "GET" | "POST";
19
24
  path: string;
20
25
  person: boolean;
26
+ /** Its own request body limit; `serveWeb`'s `bodyLimit` when absent. */
27
+ bodyLimit?: number;
21
28
  run: (input: unknown, caller: Caller) => Promise<unknown>;
22
29
  };
23
- export declare function route<Req, Res>(endpoint: Endpoint<Req, Res>, handle: (request: Req, caller: Caller) => Res | Promise<Res>): Route;
30
+ export declare function route<Req, Res>(endpoint: Endpoint<Req, Res>, handle: (request: Req, caller: Caller) => Res | Promise<Res>, options?: {
31
+ bodyLimit?: number;
32
+ }): Route;
33
+ /**
34
+ * What a raw route's handler gets: the URL, what a `/*` path matched past its prefix (`{ "*": rest }`, decoded once;
35
+ * `{}` for an exact path), the bounded body, the headers.
36
+ */
37
+ export type RawRequest = {
38
+ url: URL;
39
+ params: Record<string, string>;
40
+ body: Buffer | undefined;
41
+ headers: IncomingMessage["headers"];
42
+ };
43
+ /**
44
+ * What a raw route answers. `cache`: `immutable` for content named by its hash (kept for a year, by this browser only),
45
+ * else `no-store`. The security headers are always the server's: `headers` can add others (`etag`,
46
+ * `content-disposition`), never replace those; a `content-security-policy` only adds directives that tighten the
47
+ * server's (`content-security-policy: sandbox` for bytes someone else made).
48
+ */
49
+ export type RawReply = {
50
+ status: number;
51
+ type: string;
52
+ body: Buffer | string;
53
+ cache?: "immutable" | "no-store";
54
+ headers?: Record<string, string>;
55
+ };
56
+ /** A route that takes and answers bytes, behind every guard a contract route has. */
57
+ export type RawRoute = {
58
+ raw: true;
59
+ method: "GET" | "POST";
60
+ /** Exact, or a prefix ending in `/*`. */
61
+ path: string;
62
+ person: boolean;
63
+ bodyLimit: number;
64
+ handle: (request: RawRequest, caller: Caller) => RawReply | Promise<RawReply>;
65
+ };
66
+ export declare function rawRoute(definition: {
67
+ method: "GET" | "POST";
68
+ path: string;
69
+ person?: boolean;
70
+ bodyLimit?: number;
71
+ handle: RawRoute["handle"];
72
+ }): RawRoute;
24
73
  export type Socket = {
25
74
  path: string;
26
75
  /** A socket opened: return what to do when it closes. `send` takes JSON-ready values. */
@@ -35,11 +84,15 @@ export type WebOptions = {
35
84
  assets: string;
36
85
  /** The install manifest. */
37
86
  manifest: Record<string, unknown>;
38
- routes: Route[];
87
+ /** Contract routes (`route`) and routes that take and answer bytes (`rawRoute`). */
88
+ routes: (Route | RawRoute)[];
39
89
  socket?: Socket;
40
90
  /** The session cookie's name. */
41
91
  cookie: string;
42
- /** Request bodies past this are refused; default 64 KB. */
92
+ /**
93
+ * Request bodies past this many bytes are refused, unless a route sets its own; default 64 KiB. Give a push endpoint
94
+ * `PUSH_BYTES` or more.
95
+ */
43
96
  bodyLimit?: number;
44
97
  };
45
98
  export declare function serveWeb(options: WebOptions): Promise<{