slopkit 0.3.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.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The server's half of sync, over a replicated store (`slopkit/server/store`) and the app's sync contract
3
+ * (`slopkit/api`): pull and push handlers, their routes with telemetry, the poke socket, and `serveSync`, which serves
4
+ * them all on the kit's web server. An app supplies its tables, its commands and what its server says of itself.
5
+ *
6
+ * - **A push acts only on the history it was tapped against:** one naming another dataset, or a revision this store
7
+ * doesn't know, runs none of its commands.
8
+ * - **Authorization before the ledger, every time,** replays included; then each command runs once through the ledger,
9
+ * in order, and its receipt carries the revision where its effects are.
10
+ * - **Served before replied:** the store records each reply's revision (`served`) before the reply leaves.
11
+ * - **The socket only hints:** a poke carries the store's revision when it moves and as a heartbeat; the page pulls
12
+ * whatever it hears.
13
+ * - **Telemetry is written, never trusted:** the page's events go to the log under the caller's identity.
14
+ */
15
+ import type { KitOutcome, PullReply, PullRequest, PushReply, PushRequest, SyncApi } from "../api.ts";
16
+ import type { CommandKey, Ledger } from "./ledger.ts";
17
+ import type { Logger } from "./log.ts";
18
+ import type { PullLimits, PullOptions } from "./pull.ts";
19
+ import type { SyncStore } from "./store.ts";
20
+ import { serveWeb } from "./web.ts";
21
+ import type { Caller, RawRoute, Route, Socket, WebOptions } from "./web.ts";
22
+ export type { KitOutcome } from "../api.ts";
23
+ /** The kit's own refusal messages, said the same in every app. */
24
+ export declare const KIT_ANSWERS: {
25
+ replaced: string;
26
+ history: string;
27
+ forbidden: (name: string) => string;
28
+ reused: (id: number) => string;
29
+ };
30
+ /**
31
+ * Nothing when the api's outcome union admits the kit's own answers, else `never`: where the handlers meet the api,
32
+ * an outcome schema that would refuse them doesn't compile.
33
+ */
34
+ type Admits<O> = KitOutcome extends O ? unknown : never;
35
+ /** How a command acts: only in the store, inside its claim's transaction, or elsewhere after it. */
36
+ export type Act<K> = {
37
+ local: () => K;
38
+ } | {
39
+ local: () => K | undefined;
40
+ remote: () => Promise<K>;
41
+ };
42
+ /** One command a push ran: its key, the command, what the ledger kept, and whether it acted just now. */
43
+ export type Ran<C, K> = {
44
+ key: CommandKey;
45
+ command: C;
46
+ kept: K;
47
+ fresh: boolean;
48
+ };
49
+ /** Who asks, as the handlers read it: a `Caller`, or anything carrying its identities and whether a person. */
50
+ export type Who = {
51
+ identities: readonly string[];
52
+ person: boolean;
53
+ };
54
+ /** What `serveSync` and the routes need of the handlers. */
55
+ export type Sync<R, S, C, O, V extends Who = Caller> = {
56
+ pull: (request: PullRequest, caller: V) => Promise<PullReply<R, S, O>>;
57
+ push: (request: PushRequest<C>, caller: V) => Promise<PushReply<O>>;
58
+ revision: () => number;
59
+ subscribe: (listener: () => void) => () => void;
60
+ };
61
+ /** A row read from SQLite, as `createPull` hands it to `read`. */
62
+ type HistoryOptions<R> = PullOptions<R, Record<string, readonly unknown[]>, never, never, never>["paged"];
63
+ /**
64
+ * Whose commands these are: every person is the one owner (single user by design), so a person is one principal
65
+ * whichever login they came through; each service or key is its own.
66
+ */
67
+ export declare function principalOf(caller: Who): string;
68
+ export declare function syncHandlers<R extends Record<string, {
69
+ id: string | number;
70
+ }>, S, C extends {
71
+ name: string;
72
+ }, O, K extends {
73
+ kind: string;
74
+ }, H extends keyof R & string, V extends Who = Caller>(options: {
75
+ api: SyncApi<R, S, C, O>;
76
+ store: SyncStore;
77
+ ledger: Ledger<K>;
78
+ log: Logger;
79
+ /** What the server says of itself, with every pull. */
80
+ server: (caller: V) => S;
81
+ /** The history: the table paged newest first (`slopkit/server/pull`), and its row key in the api. */
82
+ history: HistoryOptions<R[H]> & {
83
+ row: H;
84
+ };
85
+ /** The other row keys' rows changed after `since`, or every live row for 0. */
86
+ unpaged: (since: number) => {
87
+ [Key in Exclude<keyof R, H>]: R[Key][];
88
+ };
89
+ /** Every live row of every table (what a complete copy holds), for a verifying pull's checksum. */
90
+ everything: () => {
91
+ [Key in keyof R]: readonly R[Key][];
92
+ };
93
+ /** The history rows (by key) a receipt names: they come in the reply that carries it. */
94
+ names: (kept: K) => readonly (string | number)[];
95
+ limits: PullLimits;
96
+ act: (command: C, caller: V, key: CommandKey) => Act<K>;
97
+ /** A kept receipt as callers see it (a link as its row now); `(kept) => kept` when they are the same. */
98
+ outcome: (kept: K) => O;
99
+ /** Commands only a person's login may run. */
100
+ personOnly?: (command: C) => boolean;
101
+ principal?: (caller: V) => string;
102
+ /** After a push's commands ran, before its reply: what each did. */
103
+ pushed?: (ran: Ran<C, K>[], caller: V) => void;
104
+ }): Sync<R, S, C, O | KitOutcome, V> & {
105
+ changed: () => void;
106
+ };
107
+ /**
108
+ * The sync contract's routes: pull, push and telemetry. Each telemetry event is one `page` log line at `info`, its
109
+ * attributes the event's own with the kit's (`client`, `at`, `person`) set last, so a page can't claim another sender.
110
+ */
111
+ export declare function syncRoutes<R, S, C, O>(api: SyncApi<R, S, C, O>, handlers: Pick<Sync<R, S, C, NoInfer<O> | KitOutcome>, "pull" | "push"> & {
112
+ page: Logger;
113
+ } & Admits<NoInfer<O>>): Route[];
114
+ /**
115
+ * The poke socket: one poke with the revision on open, one for the changes within `gatherMs` (carrying the revision
116
+ * 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.
117
+ */
118
+ export declare function pokes<R, S, C, O>(api: SyncApi<R, S, C, O>, options: {
119
+ revision: () => number;
120
+ subscribe: (listener: () => void) => () => void;
121
+ beatMs?: number;
122
+ gatherMs?: number;
123
+ }): Socket;
124
+ /** The install manifest's fields an app sets. */
125
+ export type Manifest = {
126
+ id: string;
127
+ name: string;
128
+ short_name: string;
129
+ description?: string;
130
+ start_url: string;
131
+ scope: string;
132
+ display: "standalone" | "fullscreen" | "minimal-ui" | "browser";
133
+ background_color: string;
134
+ theme_color: string;
135
+ icons: {
136
+ src: string;
137
+ sizes: string;
138
+ type: string;
139
+ purpose?: string;
140
+ }[];
141
+ };
142
+ /**
143
+ * An app's web server: `serveWeb` with the sync routes, the poke socket, the session cookie `<name>_session` and the
144
+ * install manifest made from the name (black, `/icon.svg`), each field overridable. Extra routes are served beside them.
145
+ */
146
+ /** What `serveSync` takes: `serveWeb`'s options, less what it makes from these. */
147
+ export type SyncServerOptions<R, S, C, O> = Omit<WebOptions, "routes" | "socket" | "cookie" | "manifest"> & {
148
+ name: string;
149
+ api: SyncApi<R, S, C, O>;
150
+ sync: Sync<R, S, C, O | KitOutcome>;
151
+ /** Where the page's telemetry is written. */
152
+ page: Logger;
153
+ routes?: (Route | RawRoute)[];
154
+ manifest?: Partial<Manifest>;
155
+ };
156
+ export declare function serveSync<R, S, C, O>(options: SyncServerOptions<R, S, C, NoInfer<O>> & {
157
+ api: SyncApi<R, S, C, O>;
158
+ } & Admits<NoInfer<O>>): Promise<Awaited<ReturnType<typeof serveWeb>>>;
@@ -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,11 +1,12 @@
1
+ import type { IncomingMessage } from "node:http";
1
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
12
  /**
@@ -22,9 +23,53 @@ export type Route = {
22
23
  method: "GET" | "POST";
23
24
  path: string;
24
25
  person: boolean;
26
+ /** Its own request body limit; `serveWeb`'s `bodyLimit` when absent. */
27
+ bodyLimit?: number;
25
28
  run: (input: unknown, caller: Caller) => Promise<unknown>;
26
29
  };
27
- 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;
28
73
  export type Socket = {
29
74
  path: string;
30
75
  /** A socket opened: return what to do when it closes. `send` takes JSON-ready values. */
@@ -39,11 +84,15 @@ export type WebOptions = {
39
84
  assets: string;
40
85
  /** The install manifest. */
41
86
  manifest: Record<string, unknown>;
42
- routes: Route[];
87
+ /** Contract routes (`route`) and routes that take and answer bytes (`rawRoute`). */
88
+ routes: (Route | RawRoute)[];
43
89
  socket?: Socket;
44
90
  /** The session cookie's name. */
45
91
  cookie: string;
46
- /** Request bodies past this many bytes are refused; default 64 KiB. Give a push endpoint `PUSH_BYTES` or more. */
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
+ */
47
96
  bodyLimit?: number;
48
97
  };
49
98
  export declare function serveWeb(options: WebOptions): Promise<{