@midsummer-ai/sdk 0.0.0-stage → 0.1.0-next.1305
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +177 -0
- package/README.md +4 -2
- package/agents/contract.d.mts +9 -0
- package/agents/contract.mjs +221 -0
- package/agents/index.d.mts +128 -0
- package/agents/index.mjs +203 -0
- package/agents/stand-in.d.mts +35 -0
- package/agents/stand-in.mjs +149 -0
- package/ai/contract.d.mts +10 -0
- package/ai/contract.mjs +77 -0
- package/ai/index.d.mts +94 -0
- package/ai/index.mjs +193 -0
- package/ai/stand-in.d.mts +7 -0
- package/ai/stand-in.mjs +41 -0
- package/build-SQ44jwSa.d.mts +41 -0
- package/client.d.mts +45 -0
- package/client.mjs +106 -0
- package/connection-CxD_Nry5.mjs +1911 -0
- package/contract.d.mts +10 -0
- package/contract.mjs +469 -0
- package/database/build.d.mts +2 -0
- package/database/build.mjs +579 -0
- package/database/client.d.mts +60 -0
- package/database/client.mjs +133 -0
- package/database/contract.d.mts +11 -0
- package/database/contract.mjs +164 -0
- package/database/errors.d.mts +23 -0
- package/database/errors.mjs +13 -0
- package/database/server.d.mts +3 -0
- package/database/server.mjs +320 -0
- package/database/stand-in.d.mts +99 -0
- package/database/stand-in.mjs +430 -0
- package/email/client.d.mts +34 -0
- package/email/client.mjs +131 -0
- package/email/contract.d.mts +11 -0
- package/email/contract.mjs +99 -0
- package/email/index.d.mts +74 -0
- package/email/index.mjs +59 -0
- package/email/smtp.d.mts +22 -0
- package/email/smtp.mjs +296 -0
- package/email/stand-in.d.mts +13 -0
- package/email/stand-in.mjs +51 -0
- package/index-DdoK0y7D.d.mts +192 -0
- package/index-sgdyoA2P.d.mts +212 -0
- package/index.d.mts +2 -0
- package/index.mjs +137 -0
- package/names-DfCBKupJ.mjs +115 -0
- package/package.json +152 -4
- package/predict-Drfm_8pO.mjs +179 -0
- package/queues/contract.d.mts +13 -0
- package/queues/contract.mjs +278 -0
- package/queues/index.d.mts +184 -0
- package/queues/index.mjs +126 -0
- package/queues/stand-in.d.mts +7 -0
- package/queues/stand-in.mjs +110 -0
- package/queues/wire.d.mts +9 -0
- package/queues/wire.mjs +199 -0
- package/s3.d.mts +43 -0
- package/s3.mjs +1003 -0
- package/schema-BF-Y1EVu.mjs +142 -0
- package/schema-DS8x1Dn8.mjs +115 -0
- package/schema-f7E9uzUn.d.mts +74 -0
- package/server-C7O7CICU.d.mts +182 -0
- package/socket.d.mts +67 -0
- package/socket.mjs +263 -0
- package/stand-in.d.mts +7 -0
- package/stand-in.mjs +185 -0
- package/store-DDuOr-_F.mjs +237 -0
- package/tabs-4FLTfg1c.mjs +457 -0
- package/vector/contract.d.mts +10 -0
- package/vector/contract.mjs +635 -0
- package/vector/index.d.mts +2 -0
- package/vector/index.mjs +108 -0
- package/vector/stand-in.d.mts +11 -0
- package/vector/stand-in.mjs +169 -0
- package/vector/wire.d.mts +25 -0
- package/vector/wire.mjs +175 -0
- package/wire.d.mts +54 -0
- package/wire.mjs +731 -0
package/ai/index.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
//#region src/ai/index.ts
|
|
2
|
+
var AiError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, why) {
|
|
5
|
+
super(why);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "AiError";
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
/** Checks a question before it leaves the caller. */
|
|
11
|
+
function checked(input) {
|
|
12
|
+
if (Boolean(input.prompt) === Boolean(input.messages)) throw new AiError("invalid", "Give a prompt or messages.");
|
|
13
|
+
if (input.messages && !input.messages.length) throw new AiError("invalid", "Give at least one message.");
|
|
14
|
+
if (input.maxTokens !== void 0 && !(Number.isInteger(input.maxTokens) && input.maxTokens >= 1 && input.maxTokens <= 32e3)) throw new AiError("invalid", "maxTokens is a whole number from 1 to 32,000.");
|
|
15
|
+
if (input.temperature !== void 0 && !(input.temperature >= 0 && input.temperature <= 2)) throw new AiError("invalid", "temperature is from 0 to 2.");
|
|
16
|
+
return input;
|
|
17
|
+
}
|
|
18
|
+
function ai(engine) {
|
|
19
|
+
return {
|
|
20
|
+
async complete(input) {
|
|
21
|
+
let text = "";
|
|
22
|
+
for await (const event of engine.ask(checked(input))) if ("text" in event) text += event.text;
|
|
23
|
+
else return {
|
|
24
|
+
...event.done,
|
|
25
|
+
text
|
|
26
|
+
};
|
|
27
|
+
throw new AiError("unavailable", "The answer ended early. Try again.");
|
|
28
|
+
},
|
|
29
|
+
stream(input) {
|
|
30
|
+
let settle, fail;
|
|
31
|
+
const done = new Promise((resolve, reject) => {
|
|
32
|
+
settle = resolve;
|
|
33
|
+
fail = reject;
|
|
34
|
+
});
|
|
35
|
+
done.catch(() => void 0);
|
|
36
|
+
let asked;
|
|
37
|
+
try {
|
|
38
|
+
asked = checked(input);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
fail(error);
|
|
41
|
+
return {
|
|
42
|
+
done,
|
|
43
|
+
[Symbol.asyncIterator]: () => ({ next: () => Promise.reject(error) })
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function* texts() {
|
|
47
|
+
try {
|
|
48
|
+
for await (const event of engine.ask(asked)) if ("text" in event) yield event.text;
|
|
49
|
+
else return settle(event.done);
|
|
50
|
+
throw new AiError("unavailable", "The answer ended early. Try again.");
|
|
51
|
+
} catch (error) {
|
|
52
|
+
fail(error);
|
|
53
|
+
throw error;
|
|
54
|
+
} finally {
|
|
55
|
+
fail(new AiError("unavailable", "The answer was not read to its end."));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
done,
|
|
60
|
+
[Symbol.asyncIterator]: texts
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** One line of the wire, checked whole: a piece of text, what the call spent, or why it stopped. Anything else is a broken answer. */
|
|
66
|
+
function parsed(line) {
|
|
67
|
+
const broken = () => new AiError("unavailable", "The answer could not be read. Try again.");
|
|
68
|
+
let event;
|
|
69
|
+
try {
|
|
70
|
+
event = JSON.parse(line);
|
|
71
|
+
} catch {
|
|
72
|
+
throw broken();
|
|
73
|
+
}
|
|
74
|
+
const object = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
75
|
+
const count = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
76
|
+
if (!object(event)) throw broken();
|
|
77
|
+
if (typeof event.text === "string") return { text: event.text };
|
|
78
|
+
const done = event.done;
|
|
79
|
+
if (object(done)) {
|
|
80
|
+
if (typeof done.model !== "string" || !count(done.inputTokens) || !count(done.outputTokens) || typeof done.credits !== "number" || !(done.credits >= 0) || typeof done.catalogue !== "string") throw broken();
|
|
81
|
+
return { done: {
|
|
82
|
+
model: done.model,
|
|
83
|
+
inputTokens: done.inputTokens,
|
|
84
|
+
outputTokens: done.outputTokens,
|
|
85
|
+
credits: done.credits,
|
|
86
|
+
catalogue: done.catalogue
|
|
87
|
+
} };
|
|
88
|
+
}
|
|
89
|
+
const error = event.error;
|
|
90
|
+
if (object(error) && (error.code === void 0 || typeof error.code === "string") && (error.message === void 0 || typeof error.message === "string")) return { error: {
|
|
91
|
+
...typeof error.code === "string" ? { code: error.code } : {},
|
|
92
|
+
...typeof error.message === "string" ? { message: error.message } : {}
|
|
93
|
+
} };
|
|
94
|
+
throw broken();
|
|
95
|
+
}
|
|
96
|
+
const CODES = {
|
|
97
|
+
400: "invalid",
|
|
98
|
+
401: "forbidden",
|
|
99
|
+
403: "forbidden",
|
|
100
|
+
409: "conflict",
|
|
101
|
+
429: "quota"
|
|
102
|
+
};
|
|
103
|
+
const CODE_NAMES = [
|
|
104
|
+
"invalid",
|
|
105
|
+
"forbidden",
|
|
106
|
+
"quota",
|
|
107
|
+
"conflict",
|
|
108
|
+
"unavailable"
|
|
109
|
+
];
|
|
110
|
+
/**
|
|
111
|
+
* The platform's AI Gateway at `origin` (the API's), for the AI key `key`: each call is one POST to
|
|
112
|
+
* `<origin>/v1/ai/ask`, which replies with newline-delimited JSON events as the Model writes.
|
|
113
|
+
*/
|
|
114
|
+
function aiClient(origin, options) {
|
|
115
|
+
const url = `${origin.replace(/\/+$/, "")}/v1/ai/ask`;
|
|
116
|
+
const send = options.fetch ?? ((input, init) => fetch(input, init));
|
|
117
|
+
return { async *ask(input) {
|
|
118
|
+
const leave = new AbortController();
|
|
119
|
+
let response;
|
|
120
|
+
try {
|
|
121
|
+
response = await send(url, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
signal: leave.signal,
|
|
124
|
+
headers: { "Content-Type": "application/json" },
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
...input,
|
|
127
|
+
key: options.key,
|
|
128
|
+
stream: true
|
|
129
|
+
})
|
|
130
|
+
});
|
|
131
|
+
} catch {
|
|
132
|
+
throw new AiError("unavailable", "The AI Gateway could not be reached. Try again.");
|
|
133
|
+
}
|
|
134
|
+
if (!response.ok || !response.body) {
|
|
135
|
+
const why = await response.json().then((body) => typeof body.error === "string" ? body.error : void 0, () => void 0);
|
|
136
|
+
throw new AiError(CODES[response.status] ?? "unavailable", why ?? `The AI Gateway answered ${response.status}.`);
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
yield* eventsOf(response.body);
|
|
140
|
+
} finally {
|
|
141
|
+
leave.abort();
|
|
142
|
+
}
|
|
143
|
+
} };
|
|
144
|
+
}
|
|
145
|
+
/** The events of one call's newline-delimited JSON, as the AI Gateway streams them; cancelled when the reader leaves. */
|
|
146
|
+
async function* eventsOf(body) {
|
|
147
|
+
const decoder = new TextDecoder();
|
|
148
|
+
let pending = "";
|
|
149
|
+
const reader = body.getReader();
|
|
150
|
+
try {
|
|
151
|
+
while (true) {
|
|
152
|
+
let read;
|
|
153
|
+
try {
|
|
154
|
+
read = await reader.read();
|
|
155
|
+
} catch {
|
|
156
|
+
throw new AiError("unavailable", "The answer was cut off. Try again.");
|
|
157
|
+
}
|
|
158
|
+
pending += read.done ? decoder.decode() : decoder.decode(read.value, { stream: true });
|
|
159
|
+
let end;
|
|
160
|
+
while ((end = pending.indexOf("\n")) >= 0) {
|
|
161
|
+
const line = pending.slice(0, end).trim();
|
|
162
|
+
pending = pending.slice(end + 1);
|
|
163
|
+
if (!line) continue;
|
|
164
|
+
const event = parsed(line);
|
|
165
|
+
if ("error" in event) throw new AiError(event.error.code && CODE_NAMES.includes(event.error.code) ? event.error.code : "unavailable", event.error.message ?? "The model could not answer. Try again.");
|
|
166
|
+
yield event;
|
|
167
|
+
if ("done" in event) return;
|
|
168
|
+
}
|
|
169
|
+
if (read.done) throw new AiError("unavailable", "The answer ended early. Try again.");
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
await reader.cancel().catch(() => void 0);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* What Midsummer hands a Deployment as `context.ai`: the Team's AI Gateway, with no key. Each call
|
|
177
|
+
* streams the events `aiClient` reads.
|
|
178
|
+
*
|
|
179
|
+
* const answer = await ai(boundAi(context.ai)).complete({ prompt: "Hello" })
|
|
180
|
+
*/
|
|
181
|
+
function boundAi(handle) {
|
|
182
|
+
return { async *ask(input) {
|
|
183
|
+
let body;
|
|
184
|
+
try {
|
|
185
|
+
body = await handle.ask(input);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
throw new AiError("unavailable", error instanceof Error ? error.message : "The AI Gateway could not be reached. Try again.");
|
|
188
|
+
}
|
|
189
|
+
yield* eventsOf(body);
|
|
190
|
+
} };
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
export { AiError, ai, aiClient, boundAi };
|
package/ai/stand-in.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { AiError } from "./index.mjs";
|
|
2
|
+
//#region src/ai/stand-in.ts
|
|
3
|
+
/**
|
|
4
|
+
* AI's Stand-in: Model calls replied to on this computer, for `midsummer dev` and tests, with no
|
|
5
|
+
* Model behind them. It passes the contract (`@midsummer-ai/sdk/ai/contract`); its replies are only
|
|
6
|
+
* an echo of the question, so code can be run end to end without spending credits.
|
|
7
|
+
*
|
|
8
|
+
* const models = ai(aiStandIn())
|
|
9
|
+
* await models.complete({ prompt: "Hi" }) // { text: "You asked: Hi", model: "stand-in", credits: 0, … }
|
|
10
|
+
*
|
|
11
|
+
* It offers the Models it is given (`stand-in` unless told), the first the default. Tokens are
|
|
12
|
+
* counted at about four characters a token, and cost nothing.
|
|
13
|
+
*/
|
|
14
|
+
function aiStandIn(options = {}) {
|
|
15
|
+
const models = options.models?.length ? options.models : ["stand-in"];
|
|
16
|
+
const tokens = (text) => Math.ceil(text.length / 4);
|
|
17
|
+
return { async *ask(input) {
|
|
18
|
+
const model = input.model ?? models[0];
|
|
19
|
+
if (!models.includes(model)) throw new AiError("invalid", `There is no model ${model}.`);
|
|
20
|
+
const messages = [...input.system ? [{
|
|
21
|
+
role: "system",
|
|
22
|
+
content: input.system
|
|
23
|
+
}] : [], ...input.messages ?? [{
|
|
24
|
+
role: "user",
|
|
25
|
+
content: input.prompt ?? ""
|
|
26
|
+
}]];
|
|
27
|
+
const last = messages.filter((message) => message.role !== "system").at(-1);
|
|
28
|
+
if (last?.role !== "user" || !last.content.trim()) throw new AiError("invalid", "End with a user message that asks something.");
|
|
29
|
+
const answer = `You asked: ${last.content}`.slice(0, (input.maxTokens ?? 1024) * 4);
|
|
30
|
+
for (const piece of answer.match(/\S+\s*/g) ?? []) yield { text: piece };
|
|
31
|
+
yield { done: {
|
|
32
|
+
model,
|
|
33
|
+
inputTokens: tokens(messages.map((message) => message.content).join("")),
|
|
34
|
+
outputTokens: tokens(answer),
|
|
35
|
+
credits: 0,
|
|
36
|
+
catalogue: "stand-in"
|
|
37
|
+
} };
|
|
38
|
+
} };
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { aiStandIn };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { r as Shape, t as IndexDeclaration } from "./schema-f7E9uzUn.mjs";
|
|
2
|
+
//#region src/database/build.d.ts
|
|
3
|
+
/** A file the Database would refuse; the message gives the file, and the line when there is one. */
|
|
4
|
+
declare class Refused extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
interface BuiltFunction {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly kind: "query" | "mutation" | "action" | "internalQuery" | "internalMutation" | "internalAction";
|
|
10
|
+
/** The built file it is in, and the export that holds it. */
|
|
11
|
+
readonly file: string;
|
|
12
|
+
readonly export: string;
|
|
13
|
+
/** Its `args` fields and `returns` shape as written, for its types: null where they are not literals. */
|
|
14
|
+
readonly args: Readonly<Record<string, Shape | null>> | null;
|
|
15
|
+
readonly returns: Shape | null;
|
|
16
|
+
}
|
|
17
|
+
interface Built {
|
|
18
|
+
/** The folder's modules as JavaScript, by `<name>.mjs`, each import of the folder naming its built file. */
|
|
19
|
+
readonly files: Readonly<Record<string, string>>;
|
|
20
|
+
/** Which author file each built one came from. */
|
|
21
|
+
readonly from: Readonly<Record<string, string>>;
|
|
22
|
+
readonly functions: ReadonlyArray<BuiltFunction>;
|
|
23
|
+
/** The schema's tables by field, and their indexes; null without `schema.ts`. */
|
|
24
|
+
readonly tables: Readonly<Record<string, Readonly<Record<string, Shape>>>> | null;
|
|
25
|
+
readonly indexes: Readonly<Record<string, Readonly<Record<string, IndexDeclaration>>>> | null;
|
|
26
|
+
/** The files of `db/_generated/`. */
|
|
27
|
+
readonly generated: {
|
|
28
|
+
readonly "api.mjs": string;
|
|
29
|
+
readonly "api.d.ts": string;
|
|
30
|
+
readonly "dataModel.d.ts": string;
|
|
31
|
+
readonly "server.d.ts": string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Whether a file directly in `db/` is a source: types (`.d.ts`) and dotfiles are none. */
|
|
35
|
+
declare const isSource: (name: string) => boolean;
|
|
36
|
+
/** Builds a `db/` folder's sources: `{ <file>: text }`, its top level. Throws `Refused`. */
|
|
37
|
+
declare function buildDatabase(sources: Readonly<Record<string, string>>, strip: (ts: string) => string): Built;
|
|
38
|
+
/** A shape as a TypeScript type. */
|
|
39
|
+
declare function typeOf(shape: Shape | null | undefined): string;
|
|
40
|
+
//#endregion
|
|
41
|
+
export { isSource as a, buildDatabase as i, BuiltFunction as n, typeOf as o, Refused as r, Built as t };
|
package/client.d.mts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { m as StorageEngine } from "./index-sgdyoA2P.mjs";
|
|
2
|
+
import { S3Credential } from "./s3.mjs";
|
|
3
|
+
import { Transport } from "./wire.mjs";
|
|
4
|
+
import { socket } from "./socket.mjs";
|
|
5
|
+
//#region src/client.d.ts
|
|
6
|
+
declare const SOCKET_PATH = "/storage/socket";
|
|
7
|
+
declare const FRAMES_PATH = "/storage/frames";
|
|
8
|
+
type WebSocketLike = NonNullable<Parameters<typeof socket>[1]["WebSocket"]>;
|
|
9
|
+
type Fetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
10
|
+
/**
|
|
11
|
+
* Storage's engine at `origin` (the API's, `https://api.<site>`) for the Storage Key `key`: one socket,
|
|
12
|
+
* batched HTTP where it cannot open. `close` ends the connection; the next call opens another.
|
|
13
|
+
*/
|
|
14
|
+
declare function client(origin: string, options: {
|
|
15
|
+
readonly key: string;
|
|
16
|
+
readonly secret: string;
|
|
17
|
+
readonly fetch?: Fetch;
|
|
18
|
+
readonly WebSocket?: WebSocketLike;
|
|
19
|
+
readonly clock?: () => number;
|
|
20
|
+
}): StorageEngine & {
|
|
21
|
+
close(): void;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A transport over HTTP signed with a Storage Key: each frame is one POST to `url`, replied to by
|
|
25
|
+
* `serveSigned`, and sent once more when the request or its answer's body fails and the frame is
|
|
26
|
+
* `repeatable`. A refused key is `forbidden`; a server that cannot be reached, or replies with anything but
|
|
27
|
+
* a frame, is `unavailable`.
|
|
28
|
+
*/
|
|
29
|
+
declare function signedHttp(url: string, options: {
|
|
30
|
+
readonly key: string;
|
|
31
|
+
readonly secret: string;
|
|
32
|
+
readonly fetch?: Fetch;
|
|
33
|
+
readonly clock?: () => number;
|
|
34
|
+
}): Transport;
|
|
35
|
+
/**
|
|
36
|
+
* Serves `signedHttp` transports: a POST of one frame, signed by a key `credentials` knows, gets one
|
|
37
|
+
* answer frame from `engine` as that key reaches it (`keyed`). Anything else is 405 or 401.
|
|
38
|
+
*/
|
|
39
|
+
declare function serveSigned(options: {
|
|
40
|
+
readonly engine: StorageEngine;
|
|
41
|
+
readonly credentials: (accessKeyId: string, named?: ReadonlyArray<string>) => S3Credential | null | Promise<S3Credential | null>;
|
|
42
|
+
readonly clock?: () => number;
|
|
43
|
+
}): (request: Request) => Promise<Response>;
|
|
44
|
+
//#endregion
|
|
45
|
+
export { FRAMES_PATH, SOCKET_PATH, client, serveSigned, signedHttp };
|
package/client.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { h as StorageError } from "./names-DfCBKupJ.mjs";
|
|
2
|
+
import { bucketsOf, remote, repeatable, serve } from "./wire.mjs";
|
|
3
|
+
import { keyed, sameHex, sign, socket } from "./socket.mjs";
|
|
4
|
+
//#region src/client.ts
|
|
5
|
+
/**
|
|
6
|
+
* Storage from code holding a Storage Key, as an app reaches it: calls are written as usual and travel
|
|
7
|
+
* over one WebSocket, so a client pays for one request when it connects, not one a call. Where a
|
|
8
|
+
* connection cannot open, they travel as batched HTTP, signed with the same key.
|
|
9
|
+
*
|
|
10
|
+
* const files = storage(client("https://api.<site>", { key, secret }))
|
|
11
|
+
* await files.bucket("avatars").put("ada.png", bytes)
|
|
12
|
+
*
|
|
13
|
+
* The calls made in the same turn travel as one batch frame either way. A call whose connection drops
|
|
14
|
+
* before it gets a reply (a server going away on deploy) is sent again on a new connection when the wire
|
|
15
|
+
* says repeating it is safe (`repeatable` in `@midsummer-ai/sdk/wire`), and is otherwise `unavailable`.
|
|
16
|
+
*
|
|
17
|
+
* Signed HTTP is one POST of a frame to `<origin>/storage/frames`, with
|
|
18
|
+
* `authorization: Midsummer-Key key=<id>, time=<ms>, signature=<hex>`: the HMAC-SHA256, under the key's
|
|
19
|
+
* secret, of the time and the frame's SHA-256 (`<time>\n<hex>`). The secret never travels, and a server
|
|
20
|
+
* (`serveSigned`) refuses a signature more than five minutes from its own clock.
|
|
21
|
+
*/
|
|
22
|
+
const SOCKET_PATH = "/storage/socket";
|
|
23
|
+
const FRAMES_PATH = "/storage/frames";
|
|
24
|
+
const SKEW_MS = 3e5;
|
|
25
|
+
/**
|
|
26
|
+
* Storage's engine at `origin` (the API's, `https://api.<site>`) for the Storage Key `key`: one socket,
|
|
27
|
+
* batched HTTP where it cannot open. `close` ends the connection; the next call opens another.
|
|
28
|
+
*/
|
|
29
|
+
function client(origin, options) {
|
|
30
|
+
const base = origin.replace(/\/+$/, "");
|
|
31
|
+
const http = signedHttp(`${base}${FRAMES_PATH}`, options);
|
|
32
|
+
const transport = socket(`${base.replace(/^http/, "ws")}${SOCKET_PATH}`, {
|
|
33
|
+
...options,
|
|
34
|
+
connections: 1,
|
|
35
|
+
fallback: http
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
...remote(transport, { batch: true }),
|
|
39
|
+
close: () => transport.close()
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function sha256(bytes) {
|
|
43
|
+
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A transport over HTTP signed with a Storage Key: each frame is one POST to `url`, replied to by
|
|
47
|
+
* `serveSigned`, and sent once more when the request or its answer's body fails and the frame is
|
|
48
|
+
* `repeatable`. A refused key is `forbidden`; a server that cannot be reached, or replies with anything but
|
|
49
|
+
* a frame, is `unavailable`.
|
|
50
|
+
*/
|
|
51
|
+
function signedHttp(url, options) {
|
|
52
|
+
const send = options.fetch ?? ((to, init) => fetch(to, init));
|
|
53
|
+
const clock = options.clock ?? Date.now;
|
|
54
|
+
return { async send(frame) {
|
|
55
|
+
const time = clock();
|
|
56
|
+
const signature = await sign(options.secret, `${time}\n${await sha256(frame)}`);
|
|
57
|
+
for (let sends = 1;; sends += 1) {
|
|
58
|
+
const lost = () => {
|
|
59
|
+
if (sends === 2 || !repeatable(frame)) throw new StorageError("unavailable", "Storage could not be reached");
|
|
60
|
+
};
|
|
61
|
+
let response;
|
|
62
|
+
try {
|
|
63
|
+
response = await send(url, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {
|
|
66
|
+
authorization: `Midsummer-Key key=${options.key}, time=${time}, signature=${signature}`,
|
|
67
|
+
"content-type": "application/octet-stream"
|
|
68
|
+
},
|
|
69
|
+
body: frame
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
lost();
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (response.status === 401 || response.status === 403) throw new StorageError("forbidden", "Storage refused the key");
|
|
76
|
+
if (response.status !== 200) throw new StorageError("unavailable", `Storage answered ${response.status}`);
|
|
77
|
+
try {
|
|
78
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
79
|
+
} catch {
|
|
80
|
+
lost();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Serves `signedHttp` transports: a POST of one frame, signed by a key `credentials` knows, gets one
|
|
87
|
+
* answer frame from `engine` as that key reaches it (`keyed`). Anything else is 405 or 401.
|
|
88
|
+
*/
|
|
89
|
+
function serveSigned(options) {
|
|
90
|
+
const clock = options.clock ?? Date.now;
|
|
91
|
+
return async (request) => {
|
|
92
|
+
if (request.method !== "POST") return new Response(null, { status: 405 });
|
|
93
|
+
const said = /^Midsummer-Key key=([^,\s]+), time=(\d+), signature=([0-9a-f]{64})$/.exec(request.headers.get("authorization") ?? "");
|
|
94
|
+
const frame = new Uint8Array(await request.arrayBuffer());
|
|
95
|
+
if (!said || Math.abs(clock() - Number(said[2])) > SKEW_MS) return new Response(null, { status: 401 });
|
|
96
|
+
const credential = await options.credentials(said[1], bucketsOf(frame));
|
|
97
|
+
if (!credential || !sameHex(await sign(credential.secret, `${said[2]}\n${await sha256(frame)}`), said[3])) return new Response(null, { status: 401 });
|
|
98
|
+
const answer = await serve(keyed(options.engine, credential))(frame);
|
|
99
|
+
return new Response(answer, {
|
|
100
|
+
status: 200,
|
|
101
|
+
headers: { "content-type": "application/octet-stream" }
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
export { FRAMES_PATH, SOCKET_PATH, client, serveSigned, signedHttp };
|