@arcadible/sdk 1.5.2-next.0 → 1.5.3-next.6
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/README.md +6 -8
- package/dist/bus/protocol.d.ts +2 -0
- package/dist/bus/protocol.js +8 -2
- package/dist/client.d.ts +23 -0
- package/dist/client.js +15 -1
- package/dist/host.d.ts +7 -0
- package/dist/host.js +7 -0
- package/dist/index.d.ts +4 -1
- package/dist/players/client.d.ts +15 -5
- package/dist/players/client.js +30 -6
- package/dist/players/host.d.ts +1 -0
- package/dist/players/host.js +10 -1
- package/dist/players/rules.d.ts +6 -3
- package/dist/players/rules.js +4 -1
- package/dist/players/server.d.ts +1 -0
- package/dist/players/server.js +19 -1
- package/dist/runner/client.d.ts +34 -0
- package/dist/runner/client.js +45 -0
- package/dist/runner/rules.d.ts +12 -0
- package/dist/runner/rules.js +36 -0
- package/dist/runner/server.d.ts +3 -0
- package/dist/runner/server.js +20 -0
- package/dist/tickets/client.d.ts +20 -0
- package/dist/tickets/client.js +30 -0
- package/dist/tickets/rules.d.ts +26 -0
- package/dist/tickets/rules.js +76 -0
- package/dist/tickets/server.d.ts +7 -0
- package/dist/tickets/server.js +76 -0
- package/dist/values/client.d.ts +67 -0
- package/dist/values/client.js +184 -0
- package/dist/values/rules.d.ts +55 -0
- package/dist/values/rules.js +104 -0
- package/dist/values/server.d.ts +7 -0
- package/dist/values/server.js +71 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The game's side of `tickets`: `arcadible.tickets`, a ticket for one of
|
|
2
|
+
// the game's servers. The game sends it and never reads it; what it says is
|
|
3
|
+
// its server's to check.
|
|
4
|
+
import { ArcadibleError, Codes } from "../bus/protocol.js";
|
|
5
|
+
import { isAnswer, Names, serverOf } from "./rules.js";
|
|
6
|
+
// [function]
|
|
7
|
+
export function createTicketsClient(bus) {
|
|
8
|
+
const tickets = {
|
|
9
|
+
async get(server) {
|
|
10
|
+
const origin = serverOf(server);
|
|
11
|
+
if (origin === null) {
|
|
12
|
+
throw new ArcadibleError(Codes.Invalid, "tickets.get takes a server's address, such as https://api.example.com.");
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const answer = await bus.request(Names.Get, { server: origin }, isAnswer);
|
|
16
|
+
return answer.ticket;
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
// no page to ask (standing alone), one from before tickets, or one
|
|
20
|
+
// that didn't answer in time: there's no ticket now.
|
|
21
|
+
if (err instanceof ArcadibleError &&
|
|
22
|
+
(err.code === Codes.Unsupported || err.code === Codes.Timeout)) {
|
|
23
|
+
throw new ArcadibleError(Codes.Unavailable, "A ticket can't be had right now. Try again.");
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
return { tickets };
|
|
30
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const Names: {
|
|
2
|
+
readonly Get: "tickets.get";
|
|
3
|
+
};
|
|
4
|
+
export type TicketAnswer = {
|
|
5
|
+
ticket: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The origin of one of a game's servers, from any address of it the game
|
|
9
|
+
* fetches: `https://api.example.com/session` is `https://api.example.com`.
|
|
10
|
+
* A ticket is for the origin, so the path never matters. Any web address
|
|
11
|
+
* has one; which servers get a ticket is the page's to say (a play page
|
|
12
|
+
* takes `https` ones the game declared, a local one the developer's own
|
|
13
|
+
* machine too), so a game's copy of this never needs to change. `null` for
|
|
14
|
+
* what isn't a web address.
|
|
15
|
+
*/
|
|
16
|
+
export declare function serverOf(value: unknown): string | null;
|
|
17
|
+
/** Whether a value is a ticket, as either end hands one over. */
|
|
18
|
+
export declare function isTicket(value: unknown): value is string;
|
|
19
|
+
/** Whether a value is the page's answer: a ticket. */
|
|
20
|
+
export declare function isAnswer(value: unknown): value is TicketAnswer;
|
|
21
|
+
/**
|
|
22
|
+
* How long a ticket is good for, in milliseconds, from its own `iat` and
|
|
23
|
+
* `exp`, read without checking it: the page only decides how long to keep
|
|
24
|
+
* it, and never trusts it. `null` when it doesn't say.
|
|
25
|
+
*/
|
|
26
|
+
export declare function lifetimeOf(ticket: string): number | null;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// `tickets` on the bus, shared by both ends: a ticket for one of the game's
|
|
2
|
+
// servers, asked for by its address.
|
|
3
|
+
// [import]
|
|
4
|
+
import { isObject } from "../bus/protocol.js";
|
|
5
|
+
// [declare]
|
|
6
|
+
export const Names = {
|
|
7
|
+
// a ticket, a request with `{ server }`, an origin, answered `{ ticket }`.
|
|
8
|
+
Get: "tickets.get",
|
|
9
|
+
};
|
|
10
|
+
// The longest address a game asks with, and the largest ticket a page
|
|
11
|
+
// hands over: a ticket is a few hundred bytes.
|
|
12
|
+
const MAX_SERVER = 2048;
|
|
13
|
+
const MAX_TICKET = 4096;
|
|
14
|
+
// A ticket is a JWT: three base64url parts.
|
|
15
|
+
const TICKET = /^[\w-]+\.[\w-]+\.[\w-]+$/u;
|
|
16
|
+
// [function]
|
|
17
|
+
/**
|
|
18
|
+
* The origin of one of a game's servers, from any address of it the game
|
|
19
|
+
* fetches: `https://api.example.com/session` is `https://api.example.com`.
|
|
20
|
+
* A ticket is for the origin, so the path never matters. Any web address
|
|
21
|
+
* has one; which servers get a ticket is the page's to say (a play page
|
|
22
|
+
* takes `https` ones the game declared, a local one the developer's own
|
|
23
|
+
* machine too), so a game's copy of this never needs to change. `null` for
|
|
24
|
+
* what isn't a web address.
|
|
25
|
+
*/
|
|
26
|
+
export function serverOf(value) {
|
|
27
|
+
if (typeof value !== "string" || value.length > MAX_SERVER)
|
|
28
|
+
return null;
|
|
29
|
+
let url;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(value);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (url.username !== "" || url.password !== "")
|
|
37
|
+
return null;
|
|
38
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
39
|
+
return null;
|
|
40
|
+
return url.origin;
|
|
41
|
+
}
|
|
42
|
+
// [function]
|
|
43
|
+
/** Whether a value is a ticket, as either end hands one over. */
|
|
44
|
+
export function isTicket(value) {
|
|
45
|
+
return (typeof value === "string" &&
|
|
46
|
+
value.length <= MAX_TICKET &&
|
|
47
|
+
TICKET.test(value));
|
|
48
|
+
}
|
|
49
|
+
// [function]
|
|
50
|
+
/** Whether a value is the page's answer: a ticket. */
|
|
51
|
+
export function isAnswer(value) {
|
|
52
|
+
return isObject(value) && isTicket(value["ticket"]);
|
|
53
|
+
}
|
|
54
|
+
// [function]
|
|
55
|
+
/**
|
|
56
|
+
* How long a ticket is good for, in milliseconds, from its own `iat` and
|
|
57
|
+
* `exp`, read without checking it: the page only decides how long to keep
|
|
58
|
+
* it, and never trusts it. `null` when it doesn't say.
|
|
59
|
+
*/
|
|
60
|
+
export function lifetimeOf(ticket) {
|
|
61
|
+
try {
|
|
62
|
+
const part = (ticket.split(".")[1] ?? "")
|
|
63
|
+
.replace(/-/gu, "+")
|
|
64
|
+
.replace(/_/gu, "/");
|
|
65
|
+
const claims = JSON.parse(atob(part));
|
|
66
|
+
if (!isObject(claims))
|
|
67
|
+
return null;
|
|
68
|
+
const { exp, iat } = claims;
|
|
69
|
+
if (typeof exp !== "number" || typeof iat !== "number")
|
|
70
|
+
return null;
|
|
71
|
+
return exp > iat ? (exp - iat) * 1000 : null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The page's side of `tickets`: a ticket for one of the game's servers,
|
|
2
|
+
// from wherever the page gets them (`mint`), kept while it's good. The page
|
|
3
|
+
// holds no key and reads no ticket: it asks, and hands over what came back.
|
|
4
|
+
// [import]
|
|
5
|
+
import { ArcadibleError, Codes, isObject, log, } from "../bus/protocol.js";
|
|
6
|
+
import { isTicket, lifetimeOf, Names, serverOf } from "./rules.js";
|
|
7
|
+
// A ticket is let go this long before it expires, so one handed over is
|
|
8
|
+
// still good when it reaches the game's server.
|
|
9
|
+
export const TICKET_MARGIN = 60_000;
|
|
10
|
+
// The codes a page's `mint` may refuse with, which reach the game as they
|
|
11
|
+
// are; anything else is `unavailable`.
|
|
12
|
+
const REFUSALS = new Set([
|
|
13
|
+
Codes.Busy,
|
|
14
|
+
Codes.Invalid,
|
|
15
|
+
Codes.Unavailable,
|
|
16
|
+
]);
|
|
17
|
+
const REFUSED = "The play page couldn't give a ticket.";
|
|
18
|
+
// [function]
|
|
19
|
+
export function ticketsServer(host) {
|
|
20
|
+
const now = host.now ?? (() => Date.now());
|
|
21
|
+
// by server, for the page's life: who's playing never changes under a
|
|
22
|
+
// page, so a game that reloads in its frame is handed the same ticket.
|
|
23
|
+
const kept = new Map();
|
|
24
|
+
const asked = new Map();
|
|
25
|
+
const mint = async function (server) {
|
|
26
|
+
let ticket;
|
|
27
|
+
const at = now();
|
|
28
|
+
try {
|
|
29
|
+
ticket = await host.mint(server);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (err instanceof ArcadibleError)
|
|
33
|
+
throw err;
|
|
34
|
+
const refused = isObject(err) ? err["code"] : undefined;
|
|
35
|
+
if (typeof refused === "string" && REFUSALS.has(refused)) {
|
|
36
|
+
throw new ArcadibleError(refused, REFUSED);
|
|
37
|
+
}
|
|
38
|
+
log("warn", "a ticket couldn't be made.", err);
|
|
39
|
+
throw new ArcadibleError(Codes.Unavailable, "A ticket can't be had right now. Try again.");
|
|
40
|
+
}
|
|
41
|
+
if (!isTicket(ticket)) {
|
|
42
|
+
log("error", "the page's ticket isn't one.");
|
|
43
|
+
throw new ArcadibleError(Codes.Unavailable, "A ticket can't be had right now. Try again.");
|
|
44
|
+
}
|
|
45
|
+
// kept by its own lifetime, counted from when it was asked for, so the
|
|
46
|
+
// device's clock never matters; one that doesn't say isn't kept.
|
|
47
|
+
const lifetime = lifetimeOf(ticket);
|
|
48
|
+
if (lifetime !== null && lifetime > TICKET_MARGIN) {
|
|
49
|
+
kept.set(server, { ticket, until: at + lifetime - TICKET_MARGIN });
|
|
50
|
+
}
|
|
51
|
+
return ticket;
|
|
52
|
+
};
|
|
53
|
+
const get = async function (params) {
|
|
54
|
+
const server = serverOf(params["server"]);
|
|
55
|
+
if (server === null || server !== params["server"]) {
|
|
56
|
+
throw new ArcadibleError(Codes.Invalid, "tickets.get takes a server's origin.");
|
|
57
|
+
}
|
|
58
|
+
const held = kept.get(server);
|
|
59
|
+
if (held && now() < held.until)
|
|
60
|
+
return { ticket: held.ticket };
|
|
61
|
+
kept.delete(server);
|
|
62
|
+
// calls made at once are one mint.
|
|
63
|
+
let asking = asked.get(server);
|
|
64
|
+
if (!asking) {
|
|
65
|
+
asking = mint(server).finally(() => asked.delete(server));
|
|
66
|
+
asked.set(server, asking);
|
|
67
|
+
}
|
|
68
|
+
return { ticket: await asking };
|
|
69
|
+
};
|
|
70
|
+
return () => ({
|
|
71
|
+
handlers: {
|
|
72
|
+
// beside the queue: it waits on the network, not on storage.
|
|
73
|
+
[Names.Get]: { run: get, parallel: true },
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Bus } from "../bus/client.js";
|
|
2
|
+
import type { Player } from "../players/rules.js";
|
|
3
|
+
import { type Entry, type Item, type Listed, type ListOptions, type Value, type Written } from "./rules.js";
|
|
4
|
+
/**
|
|
5
|
+
* Whose the values are: `"developer"`, what the game's developer published,
|
|
6
|
+
* and `"game"`, what the game shares with every player, each to read; or a
|
|
7
|
+
* player, `arcadible.players.primary`, whose own the game reads and writes.
|
|
8
|
+
*/
|
|
9
|
+
export type Scope = "developer" | "game" | Player;
|
|
10
|
+
/** A scope's values to read: `values`' calls, with the scope already named. */
|
|
11
|
+
export interface ReadableValues {
|
|
12
|
+
read(key: string): Promise<Value | null>;
|
|
13
|
+
read(keys: readonly string[]): Promise<(Value | null)[]>;
|
|
14
|
+
list(options?: ListOptions): Promise<Listed>;
|
|
15
|
+
}
|
|
16
|
+
/** A player's values to read and write: `values`' calls, with the player already named. */
|
|
17
|
+
export interface ScopedValues extends ReadableValues {
|
|
18
|
+
write(key: string, value: unknown, options?: {
|
|
19
|
+
revision?: number;
|
|
20
|
+
}): Promise<Written>;
|
|
21
|
+
write(items: readonly Item[]): Promise<Written[]>;
|
|
22
|
+
remove(key: string | readonly string[]): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/** The game's data kept on Arcadible. */
|
|
25
|
+
export interface Values {
|
|
26
|
+
/**
|
|
27
|
+
* The value under a key, with its revision, or `null` for a key with
|
|
28
|
+
* none. Rejects with `denied` for a player's values while a guest plays,
|
|
29
|
+
* or for a player who isn't `players.primary`; `busy` (too many calls);
|
|
30
|
+
* or `unavailable` (offline, or off Arcadible; worth trying again).
|
|
31
|
+
*/
|
|
32
|
+
read(scope: Scope, key: string): Promise<Value | null>;
|
|
33
|
+
/** The values under the keys, in their order, in one trip: each `null` for a key with none. */
|
|
34
|
+
read(scope: Scope, keys: readonly string[]): Promise<(Value | null)[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Replaces a key's value, whole, in the player's own values. With
|
|
37
|
+
* `revision`, only if it's the value's now (`0`: only if there's none);
|
|
38
|
+
* otherwise it rejects with `conflict`. Also rejects with `too_large` (a
|
|
39
|
+
* value past 64 KB) and `full` (the player's storage is used up).
|
|
40
|
+
*/
|
|
41
|
+
write(scope: Player, key: string, value: unknown, options?: {
|
|
42
|
+
revision?: number;
|
|
43
|
+
}): Promise<Written>;
|
|
44
|
+
/** Writes several of the player's values in one trip, all of them or none. */
|
|
45
|
+
write(scope: Player, items: readonly Item[]): Promise<Written[]>;
|
|
46
|
+
/** Removes what the key, or each of the keys, holds in the player's values, if anything. */
|
|
47
|
+
remove(scope: Player, key: string | readonly string[]): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* A scope's keys in order, by byte, each with its revision, time, and
|
|
50
|
+
* size, never its value: those that start with `prefix`, after the key
|
|
51
|
+
* `after`, 50 by default and 100 at most. `next` is the key to pass as
|
|
52
|
+
* `after` for the following page, or `null` at the end.
|
|
53
|
+
*/
|
|
54
|
+
list(scope: Scope, options?: ListOptions): Promise<Listed>;
|
|
55
|
+
/**
|
|
56
|
+
* The same calls for one scope, named once:
|
|
57
|
+
* `values.scope(player).read(key)` is `values.read(player, key)`. The
|
|
58
|
+
* developer's and the game's have only `read` and `list`.
|
|
59
|
+
*/
|
|
60
|
+
scope(scope: "developer" | "game"): ReadableValues;
|
|
61
|
+
scope(scope: Player): ScopedValues;
|
|
62
|
+
}
|
|
63
|
+
export type ValuesClient = {
|
|
64
|
+
values: Values;
|
|
65
|
+
};
|
|
66
|
+
export type { Entry, Item, Listed, ListOptions, Value, Written };
|
|
67
|
+
export declare function createValuesClient(bus: Bus): ValuesClient;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The game's side of `values`: `arcadible.values`, the game's data kept on
|
|
2
|
+
// Arcadible, JSON under a key, in a scope. A game reads what its developer
|
|
3
|
+
// published and what the game shares, and reads and writes its own
|
|
4
|
+
// player's, wherever they play.
|
|
5
|
+
import { ArcadibleError, Codes, isObject } from "../bus/protocol.js";
|
|
6
|
+
import { KEY, LIST_DEFAULT, listedOf, MAX_KEYS, MAX_LIST, Names, PREFIX, valuesOf, writtenOf, } from "./rules.js";
|
|
7
|
+
const UNAVAILABLE = "The game's values can't be reached right now. Try again.";
|
|
8
|
+
// [function]
|
|
9
|
+
function invalid(message) {
|
|
10
|
+
return new ArcadibleError(Codes.Invalid, message);
|
|
11
|
+
}
|
|
12
|
+
// [function]
|
|
13
|
+
// what a call says of its scope: its name, and the player's id for a
|
|
14
|
+
// player's.
|
|
15
|
+
function scopeOf(scope) {
|
|
16
|
+
if (scope === "developer" || scope === "game")
|
|
17
|
+
return { scope };
|
|
18
|
+
if (isObject(scope) && typeof scope["id"] === "string") {
|
|
19
|
+
return { player: scope["id"], scope: "player" };
|
|
20
|
+
}
|
|
21
|
+
throw invalid('A scope is "developer", "game", or a player: arcadible.players.primary.');
|
|
22
|
+
}
|
|
23
|
+
// [function]
|
|
24
|
+
// a call's keys: at least one, no more than a call takes, each a key, none
|
|
25
|
+
// twice.
|
|
26
|
+
function keysOf(keys) {
|
|
27
|
+
if (keys.length === 0)
|
|
28
|
+
throw invalid("A call names at least one key.");
|
|
29
|
+
if (keys.length > MAX_KEYS) {
|
|
30
|
+
throw new ArcadibleError(Codes.TooMany, `A call names up to ${MAX_KEYS} keys.`);
|
|
31
|
+
}
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
if (typeof key !== "string" || !KEY.test(key)) {
|
|
34
|
+
throw invalid("A key is 1 to 128 letters, digits, -, _, ., and /: " +
|
|
35
|
+
JSON.stringify(key));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (new Set(keys).size !== keys.length) {
|
|
39
|
+
throw invalid("A call names each key once.");
|
|
40
|
+
}
|
|
41
|
+
return keys;
|
|
42
|
+
}
|
|
43
|
+
// [function]
|
|
44
|
+
// a value as JSON keeps it: `undefined`, a function, or a number that isn't
|
|
45
|
+
// one would be dropped or changed on the way, so each is refused here.
|
|
46
|
+
function jsonOf(key, value) {
|
|
47
|
+
let text;
|
|
48
|
+
try {
|
|
49
|
+
text = JSON.stringify(value, (_, each) => {
|
|
50
|
+
if (typeof each === "number" && !Number.isFinite(each)) {
|
|
51
|
+
throw new TypeError("not finite");
|
|
52
|
+
}
|
|
53
|
+
return each;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
text = undefined;
|
|
58
|
+
}
|
|
59
|
+
if (text === undefined) {
|
|
60
|
+
throw invalid(`The value for ${JSON.stringify(key)} isn't JSON.`);
|
|
61
|
+
}
|
|
62
|
+
return JSON.parse(text);
|
|
63
|
+
}
|
|
64
|
+
// [function]
|
|
65
|
+
// the page's answer: `{ text }`, its server's answer as JSON text.
|
|
66
|
+
function isAnswer(value) {
|
|
67
|
+
return isObject(value) && typeof value["text"] === "string";
|
|
68
|
+
}
|
|
69
|
+
// [function]
|
|
70
|
+
export function createValuesClient(bus) {
|
|
71
|
+
// a call on the bus, answered with the page's server's data. With no page
|
|
72
|
+
// to ask (standing alone), one from before values, or one that didn't
|
|
73
|
+
// answer in time, the values can't be reached now.
|
|
74
|
+
const ask = async function (name, params) {
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
// as text, so the call's size on the bus is its JSON's
|
|
78
|
+
text = (await bus.request(name, { body: JSON.stringify(params) }, isAnswer)).text;
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
if (err instanceof ArcadibleError &&
|
|
82
|
+
(err.code === Codes.Unsupported || err.code === Codes.Timeout)) {
|
|
83
|
+
throw new ArcadibleError(Codes.Unavailable, UNAVAILABLE);
|
|
84
|
+
}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
// the page's server's answer, `{ data }`
|
|
88
|
+
let answer;
|
|
89
|
+
try {
|
|
90
|
+
answer = JSON.parse(text);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
answer = null;
|
|
94
|
+
}
|
|
95
|
+
if (!isObject(answer) || !("data" in answer)) {
|
|
96
|
+
throw new ArcadibleError(Codes.Unavailable, UNAVAILABLE);
|
|
97
|
+
}
|
|
98
|
+
return answer["data"];
|
|
99
|
+
};
|
|
100
|
+
const unreadable = () => new ArcadibleError(Codes.Unavailable, UNAVAILABLE);
|
|
101
|
+
const read = async function (scope, key) {
|
|
102
|
+
const many = Array.isArray(key);
|
|
103
|
+
const keys = keysOf(many ? key : [key]);
|
|
104
|
+
const values = valuesOf(await ask(Names.Read, { ...scopeOf(scope), keys }), keys.length);
|
|
105
|
+
if (!values)
|
|
106
|
+
throw unreadable();
|
|
107
|
+
return many ? values : (values[0] ?? null);
|
|
108
|
+
};
|
|
109
|
+
const write = async function (scope, first, value, options) {
|
|
110
|
+
const many = Array.isArray(first);
|
|
111
|
+
const given = many
|
|
112
|
+
? first
|
|
113
|
+
: [
|
|
114
|
+
{
|
|
115
|
+
key: first,
|
|
116
|
+
value,
|
|
117
|
+
...(options?.revision === undefined
|
|
118
|
+
? {}
|
|
119
|
+
: { revision: options.revision }),
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
if (!given.every(isObject)) {
|
|
123
|
+
throw invalid("An item to write is { key, value }, with a revision or not.");
|
|
124
|
+
}
|
|
125
|
+
const keys = keysOf(given.map((item) => item.key));
|
|
126
|
+
const items = given.map((item, at) => {
|
|
127
|
+
const { revision } = item;
|
|
128
|
+
if (revision !== undefined &&
|
|
129
|
+
(!Number.isSafeInteger(revision) || revision < 0)) {
|
|
130
|
+
throw invalid("A revision is a value's own, from a read or a write, or 0.");
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
key: keys[at] ?? "",
|
|
134
|
+
value: jsonOf(keys[at] ?? "", item.value),
|
|
135
|
+
...(revision === undefined ? {} : { revision }),
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
const written = writtenOf(await ask(Names.Write, { ...scopeOf(scope), items }), items.length);
|
|
139
|
+
const [only] = written ?? [];
|
|
140
|
+
if (!written || !only)
|
|
141
|
+
throw unreadable();
|
|
142
|
+
return many ? written : only;
|
|
143
|
+
};
|
|
144
|
+
const remove = async function (scope, key) {
|
|
145
|
+
const keys = keysOf(Array.isArray(key) ? key : [key]);
|
|
146
|
+
await ask(Names.Remove, { ...scopeOf(scope), keys });
|
|
147
|
+
};
|
|
148
|
+
const list = async function (scope, options = {}) {
|
|
149
|
+
const { after = "", limit = LIST_DEFAULT, prefix = "" } = options;
|
|
150
|
+
if (typeof prefix !== "string" || !PREFIX.test(prefix)) {
|
|
151
|
+
throw invalid("A prefix is what a key starts with: a key's letters.");
|
|
152
|
+
}
|
|
153
|
+
if (typeof after !== "string" || !PREFIX.test(after)) {
|
|
154
|
+
throw invalid("`after` is a key: a list's `next`.");
|
|
155
|
+
}
|
|
156
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIST) {
|
|
157
|
+
throw invalid(`A list gives 1 to ${MAX_LIST} keys.`);
|
|
158
|
+
}
|
|
159
|
+
const listed = listedOf(await ask(Names.List, { ...scopeOf(scope), after, limit, prefix }));
|
|
160
|
+
if (!listed)
|
|
161
|
+
throw unreadable();
|
|
162
|
+
return listed;
|
|
163
|
+
};
|
|
164
|
+
// the same calls with the scope named once: nothing of its own. Every
|
|
165
|
+
// scope's has all four at run time, and the types leave `write` and
|
|
166
|
+
// `remove` off the developer's and the game's, which Arcadible refuses a
|
|
167
|
+
// game anyway.
|
|
168
|
+
const scoped = function (scope) {
|
|
169
|
+
return {
|
|
170
|
+
list: (options) => list(scope, options),
|
|
171
|
+
read: ((key) => read(scope, key)),
|
|
172
|
+
remove: (key) => remove(scope, key),
|
|
173
|
+
write: ((first, value, options) => write(scope, first, value, options)),
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
const values = {
|
|
177
|
+
list,
|
|
178
|
+
read: read,
|
|
179
|
+
remove,
|
|
180
|
+
scope: scoped,
|
|
181
|
+
write: write,
|
|
182
|
+
};
|
|
183
|
+
return { values };
|
|
184
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export declare const Names: {
|
|
2
|
+
readonly List: "values.list";
|
|
3
|
+
readonly Read: "values.read";
|
|
4
|
+
readonly Remove: "values.remove";
|
|
5
|
+
readonly Write: "values.write";
|
|
6
|
+
};
|
|
7
|
+
/** A value, with the revision a write may name to replace it. */
|
|
8
|
+
export type Value = {
|
|
9
|
+
key: string;
|
|
10
|
+
value: unknown;
|
|
11
|
+
revision: number;
|
|
12
|
+
/** When it was written, as an ISO time. */
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
};
|
|
15
|
+
/** What a write left: the value's new revision. */
|
|
16
|
+
export type Written = {
|
|
17
|
+
key: string;
|
|
18
|
+
revision: number;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
};
|
|
21
|
+
/** A key in a list, without its value: its size is the value's, in bytes. */
|
|
22
|
+
export type Entry = {
|
|
23
|
+
key: string;
|
|
24
|
+
revision: number;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
size: number;
|
|
27
|
+
};
|
|
28
|
+
/** A value to write. With `revision`, only if it's the value's now; `0`, only if there's none. */
|
|
29
|
+
export type Item = {
|
|
30
|
+
key: string;
|
|
31
|
+
value: unknown;
|
|
32
|
+
revision?: number;
|
|
33
|
+
};
|
|
34
|
+
/** What a list takes: a prefix, the key to go on after, and how many. */
|
|
35
|
+
export type ListOptions = {
|
|
36
|
+
prefix?: string;
|
|
37
|
+
after?: string;
|
|
38
|
+
limit?: number;
|
|
39
|
+
};
|
|
40
|
+
/** A list's page: `next` is the key to pass as `after`, or `null` at the end. */
|
|
41
|
+
export type Listed = {
|
|
42
|
+
entries: Entry[];
|
|
43
|
+
next: string | null;
|
|
44
|
+
};
|
|
45
|
+
export declare const KEY: RegExp;
|
|
46
|
+
export declare const PREFIX: RegExp;
|
|
47
|
+
export declare const MAX_KEYS = 32;
|
|
48
|
+
export declare const MAX_LIST = 100;
|
|
49
|
+
export declare const LIST_DEFAULT = 50;
|
|
50
|
+
/** A read's answer for `count` keys: each a value or `null`, or `null` for what isn't one. */
|
|
51
|
+
export declare function valuesOf(data: unknown, count: number): (Value | null)[] | null;
|
|
52
|
+
/** A write's answer for `count` items, or `null` for what isn't one. */
|
|
53
|
+
export declare function writtenOf(data: unknown, count: number): Written[] | null;
|
|
54
|
+
/** A list's answer, or `null` for what isn't one. */
|
|
55
|
+
export declare function listedOf(data: unknown): Listed | null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// `values` on the bus, shared by both ends: a game's data kept on
|
|
2
|
+
// Arcadible, JSON under a key, in a scope. A game reads the developer's
|
|
3
|
+
// and the game's, and reads and writes its own player's.
|
|
4
|
+
// [import]
|
|
5
|
+
import { isObject } from "../bus/protocol.js";
|
|
6
|
+
// [declare]
|
|
7
|
+
export const Names = {
|
|
8
|
+
// each a request with `{ body }`, the call as JSON text: its scope
|
|
9
|
+
// (`scope`, and `player`, an id, for a player's), and its keys, items, or
|
|
10
|
+
// a list's options; answered `{ text }`, the page's server's answer as it
|
|
11
|
+
// came. As text, a call's size on the bus is its JSON's, which is what
|
|
12
|
+
// the limits count, and the page reads neither.
|
|
13
|
+
List: "values.list",
|
|
14
|
+
Read: "values.read",
|
|
15
|
+
Remove: "values.remove",
|
|
16
|
+
Write: "values.write",
|
|
17
|
+
};
|
|
18
|
+
// A key: 1 to 128 letters, digits, `-`, `_`, `.`, and `/`; and what one may
|
|
19
|
+
// start with.
|
|
20
|
+
export const KEY = /^[A-Za-z0-9_./-]{1,128}$/u;
|
|
21
|
+
export const PREFIX = /^[A-Za-z0-9_./-]{0,128}$/u;
|
|
22
|
+
// The most keys one call names, and one list gives.
|
|
23
|
+
export const MAX_KEYS = 32;
|
|
24
|
+
export const MAX_LIST = 100;
|
|
25
|
+
export const LIST_DEFAULT = 50;
|
|
26
|
+
// [function]
|
|
27
|
+
// a row of an answer, as the page's server sends it, in the game's words:
|
|
28
|
+
// `null` for one that isn't a row.
|
|
29
|
+
function rowOf(entry) {
|
|
30
|
+
if (!isObject(entry) ||
|
|
31
|
+
typeof entry["key"] !== "string" ||
|
|
32
|
+
typeof entry["revision"] !== "number" ||
|
|
33
|
+
typeof entry["updated_at"] !== "string") {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
key: entry["key"],
|
|
38
|
+
revision: entry["revision"],
|
|
39
|
+
size: typeof entry["size"] === "number" ? entry["size"] : 0,
|
|
40
|
+
updatedAt: entry["updated_at"],
|
|
41
|
+
value: entry["value"],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// [function]
|
|
45
|
+
/** A read's answer for `count` keys: each a value or `null`, or `null` for what isn't one. */
|
|
46
|
+
export function valuesOf(data, count) {
|
|
47
|
+
if (!Array.isArray(data) || data.length !== count)
|
|
48
|
+
return null;
|
|
49
|
+
const values = [];
|
|
50
|
+
for (const entry of data) {
|
|
51
|
+
if (entry === null) {
|
|
52
|
+
values.push(null);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const row = rowOf(entry);
|
|
56
|
+
if (!row)
|
|
57
|
+
return null;
|
|
58
|
+
values.push({
|
|
59
|
+
key: row.key,
|
|
60
|
+
revision: row.revision,
|
|
61
|
+
updatedAt: row.updatedAt,
|
|
62
|
+
value: row.value,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return values;
|
|
66
|
+
}
|
|
67
|
+
// [function]
|
|
68
|
+
/** A write's answer for `count` items, or `null` for what isn't one. */
|
|
69
|
+
export function writtenOf(data, count) {
|
|
70
|
+
if (!Array.isArray(data) || data.length !== count)
|
|
71
|
+
return null;
|
|
72
|
+
const written = [];
|
|
73
|
+
for (const entry of data) {
|
|
74
|
+
const row = rowOf(entry);
|
|
75
|
+
if (!row)
|
|
76
|
+
return null;
|
|
77
|
+
written.push({
|
|
78
|
+
key: row.key,
|
|
79
|
+
revision: row.revision,
|
|
80
|
+
updatedAt: row.updatedAt,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return written;
|
|
84
|
+
}
|
|
85
|
+
// [function]
|
|
86
|
+
/** A list's answer, or `null` for what isn't one. */
|
|
87
|
+
export function listedOf(data) {
|
|
88
|
+
if (!isObject(data) || !Array.isArray(data["entries"]))
|
|
89
|
+
return null;
|
|
90
|
+
const entries = [];
|
|
91
|
+
for (const entry of data["entries"]) {
|
|
92
|
+
const row = rowOf(entry);
|
|
93
|
+
if (!row)
|
|
94
|
+
return null;
|
|
95
|
+
entries.push({
|
|
96
|
+
key: row.key,
|
|
97
|
+
revision: row.revision,
|
|
98
|
+
size: row.size,
|
|
99
|
+
updatedAt: row.updatedAt,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const next = data["next"];
|
|
103
|
+
return { entries, next: typeof next === "string" ? next : null };
|
|
104
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Module } from "../bus/server.js";
|
|
2
|
+
/** A call's verb, as the page's server names its routes. */
|
|
3
|
+
export type Verb = "list" | "read" | "remove" | "write";
|
|
4
|
+
export type ValuesHost = {
|
|
5
|
+
call(verb: Verb, body: string): Promise<string>;
|
|
6
|
+
};
|
|
7
|
+
export declare function valuesServer(host: ValuesHost): () => Module;
|