nervur 0.20.1 → 0.20.2
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 +130 -8
- package/dist/browser/index.d.ts +35 -0
- package/dist/browser/index.js +188 -0
- package/dist/browser/worker.d.ts +12 -0
- package/dist/browser/worker.js +40 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +7 -0
- package/dist/cli/command.d.ts +1 -1
- package/dist/cli/command.js +59 -16
- package/dist/cli/harbor.d.ts +9 -11
- package/dist/cli/harbor.js +81 -123
- package/dist/contract/index.d.ts +2 -1
- package/dist/contract/index.js +3 -7
- package/dist/edge/index.d.ts +57 -0
- package/dist/edge/index.js +306 -0
- package/dist/edge.d.ts +1 -0
- package/dist/edge.js +8 -0
- package/dist/folder/index.d.ts +4 -0
- package/dist/folder/index.js +57 -14
- package/dist/harbor/carrying.d.ts +17 -0
- package/dist/harbor/carrying.js +38 -0
- package/dist/harbor/catalogue.d.ts +2 -0
- package/dist/harbor/catalogue.js +28 -20
- package/dist/harbor/dock.d.ts +22 -3
- package/dist/harbor/dock.js +69 -13
- package/dist/harbor/harbor.d.ts +14 -4
- package/dist/harbor/harbor.js +144 -15
- package/dist/harbor/index.d.ts +6 -3
- package/dist/harbor/index.js +4 -1
- package/dist/harbor/package.d.ts +2 -0
- package/dist/harbor/package.js +15 -0
- package/dist/harbor/probe.d.ts +18 -0
- package/dist/harbor/probe.js +15 -0
- package/dist/harbor/registry.d.ts +1 -0
- package/dist/harbor/registry.js +11 -0
- package/dist/harbor/relay.d.ts +15 -0
- package/dist/harbor/relay.js +387 -0
- package/dist/harbor/root-line.js +4 -2
- package/dist/harbor/terrain.d.ts +12 -0
- package/dist/harbor/terrain.js +19 -0
- package/dist/http/index.d.ts +4 -0
- package/dist/http/index.js +156 -0
- package/dist/http/websocket.d.ts +29 -0
- package/dist/http/websocket.js +133 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +8 -2
- package/dist/line/answer.d.ts +8 -0
- package/dist/line/answer.js +60 -0
- package/dist/line/frame.d.ts +34 -0
- package/dist/line/frame.js +81 -0
- package/dist/line/ground.d.ts +3 -0
- package/dist/line/ground.js +9 -0
- package/dist/line/index.d.ts +5 -0
- package/dist/line/index.js +9 -0
- package/dist/line/push.d.ts +7 -0
- package/dist/line/push.js +92 -0
- package/dist/line/web.d.ts +30 -0
- package/dist/line/web.js +181 -0
- package/dist/node/index.d.ts +32 -0
- package/dist/node/index.js +164 -0
- package/dist/node.d.ts +1 -0
- package/dist/node.js +7 -0
- package/dist/pointer/bodies.d.ts +2 -0
- package/dist/pointer/bodies.js +10 -0
- package/dist/pointer/world.d.ts +7 -1
- package/dist/pointer/world.js +6 -0
- package/dist/quo/address.d.ts +19 -0
- package/dist/quo/address.js +84 -0
- package/dist/quo/door.d.ts +1 -1
- package/dist/quo/door.js +4 -2
- package/dist/quo/index.d.ts +1 -0
- package/dist/quo/index.js +1 -0
- package/dist/quo/invitation.d.ts +1 -0
- package/dist/quo/invitation.js +9 -5
- package/dist/tcp/frame.d.ts +0 -4
- package/dist/tcp/frame.js +0 -10
- package/dist/tcp/index.d.ts +8 -2
- package/dist/tcp/index.js +123 -27
- package/dist/ward/index.d.ts +2 -2
- package/dist/ward/index.js +1 -1
- package/dist/ward/stance.d.ts +2 -0
- package/dist/ward/stance.js +3 -1
- package/dist/ward/ward-being.d.ts +2 -1
- package/dist/ward/ward-being.js +1 -1
- package/dist/ward/ward.d.ts +7 -0
- package/dist/ward/ward.js +6 -0
- package/package.json +21 -2
- package/src/browser/index.ts +207 -0
- package/src/browser/worker.ts +66 -0
- package/src/browser.ts +9 -0
- package/src/cli/command.ts +53 -17
- package/src/cli/harbor.ts +82 -116
- package/src/contract/index.ts +11 -8
- package/src/edge/index.ts +359 -0
- package/src/edge.ts +10 -0
- package/src/folder/index.ts +57 -12
- package/src/harbor/carrying.ts +56 -0
- package/src/harbor/catalogue.ts +32 -17
- package/src/harbor/dock.ts +88 -17
- package/src/harbor/harbor.ts +146 -17
- package/src/harbor/index.ts +6 -3
- package/src/harbor/package.ts +16 -0
- package/src/harbor/probe.ts +34 -0
- package/src/harbor/registry.ts +11 -0
- package/src/harbor/relay.ts +399 -0
- package/src/harbor/root-line.ts +4 -1
- package/src/harbor/terrain.ts +28 -2
- package/src/http/index.ts +150 -0
- package/src/http/websocket.ts +124 -0
- package/src/index.ts +35 -2
- package/src/line/answer.ts +59 -0
- package/src/line/frame.ts +88 -0
- package/src/line/ground.ts +19 -0
- package/src/line/index.ts +9 -0
- package/src/line/push.ts +112 -0
- package/src/line/web.ts +227 -0
- package/src/node/index.ts +170 -0
- package/src/node.ts +9 -0
- package/src/pointer/bodies.ts +11 -0
- package/src/pointer/world.ts +18 -2
- package/src/quo/address.ts +87 -0
- package/src/quo/door.ts +4 -2
- package/src/quo/index.ts +1 -0
- package/src/quo/invitation.ts +10 -6
- package/src/stand/stand.ts +60 -21
- package/src/tcp/index.ts +142 -31
- package/src/ward/index.ts +2 -2
- package/src/ward/stance.ts +6 -1
- package/src/ward/ward-being.ts +2 -2
- package/src/ward/ward.ts +16 -0
- package/src/tcp/frame.ts +0 -78
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The server side of RFC 6455, as far as a held line needs it: the accept
|
|
3
|
+
// key, frames written plain, and a reader of masked frames that joins
|
|
4
|
+
// fragments into messages and stops at the first byte that breaks the RFC
|
|
5
|
+
// or a message longer than it takes.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
9
|
+
export const OP = { continuation: 0x0, text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xa } as const;
|
|
10
|
+
|
|
11
|
+
export const acceptKey = (key: string): string =>
|
|
12
|
+
createHash('sha1')
|
|
13
|
+
.update(key + GUID)
|
|
14
|
+
.digest('base64');
|
|
15
|
+
|
|
16
|
+
export const isClientKey = (key: unknown): key is string => typeof key === 'string' && /^[A-Za-z0-9+/]{22}==$/.test(key);
|
|
17
|
+
|
|
18
|
+
// One frame as a server writes it: final, unmasked.
|
|
19
|
+
export const frameOf = (opcode: number, payload: Uint8Array = new Uint8Array(0)): Uint8Array => {
|
|
20
|
+
const length = payload.length;
|
|
21
|
+
const extended = length < 126 ? 0 : length < 65_536 ? 2 : 8;
|
|
22
|
+
const out = new Uint8Array(2 + extended + length);
|
|
23
|
+
const view = new DataView(out.buffer);
|
|
24
|
+
out[0] = 0x80 | opcode;
|
|
25
|
+
out[1] = extended === 0 ? length : extended === 2 ? 126 : 127;
|
|
26
|
+
if (extended === 2) view.setUint16(2, length);
|
|
27
|
+
if (extended === 8) view.setBigUint64(2, BigInt(length));
|
|
28
|
+
out.set(payload, 2 + extended);
|
|
29
|
+
return out;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type WsEvent = { type: 'binary'; data: Uint8Array } | { type: 'text' } | { type: 'ping'; data: Uint8Array } | { type: 'close' } | { type: 'bad' };
|
|
33
|
+
|
|
34
|
+
// Frames a client wrote, masked, read as they come.
|
|
35
|
+
export class WsReader {
|
|
36
|
+
readonly #max: number;
|
|
37
|
+
#buffer = new Uint8Array(0);
|
|
38
|
+
#parts: Uint8Array[] | null = null;
|
|
39
|
+
#partsLength = 0;
|
|
40
|
+
#partsText = false;
|
|
41
|
+
#done = false;
|
|
42
|
+
|
|
43
|
+
constructor(max: number) {
|
|
44
|
+
this.#max = max;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
push(chunk: Uint8Array): WsEvent[] {
|
|
48
|
+
if (this.#done) return [];
|
|
49
|
+
const joined = new Uint8Array(this.#buffer.length + chunk.length);
|
|
50
|
+
joined.set(this.#buffer);
|
|
51
|
+
joined.set(chunk, this.#buffer.length);
|
|
52
|
+
this.#buffer = joined;
|
|
53
|
+
const out: WsEvent[] = [];
|
|
54
|
+
while (this.#buffer.length >= 2) {
|
|
55
|
+
const b0 = this.#buffer[0]!;
|
|
56
|
+
const b1 = this.#buffer[1]!;
|
|
57
|
+
const fin = (b0 & 0x80) !== 0;
|
|
58
|
+
const opcode = b0 & 0x0f;
|
|
59
|
+
const masked = (b1 & 0x80) !== 0;
|
|
60
|
+
if ((b0 & 0x70) !== 0 || !masked) return this.#bad(out);
|
|
61
|
+
let length = b1 & 0x7f;
|
|
62
|
+
let at = 2;
|
|
63
|
+
const view = new DataView(this.#buffer.buffer, this.#buffer.byteOffset, this.#buffer.length);
|
|
64
|
+
if (length === 126) {
|
|
65
|
+
if (this.#buffer.length < 4) break;
|
|
66
|
+
length = view.getUint16(2);
|
|
67
|
+
at = 4;
|
|
68
|
+
} else if (length === 127) {
|
|
69
|
+
if (this.#buffer.length < 10) break;
|
|
70
|
+
const long = view.getBigUint64(2);
|
|
71
|
+
if (long > BigInt(this.#max)) return this.#bad(out);
|
|
72
|
+
length = Number(long);
|
|
73
|
+
at = 10;
|
|
74
|
+
}
|
|
75
|
+
const control = opcode >= 0x8;
|
|
76
|
+
if (control && (length > 125 || !fin)) return this.#bad(out);
|
|
77
|
+
if (!control && this.#partsLength + length > this.#max) return this.#bad(out);
|
|
78
|
+
if (this.#buffer.length < at + 4 + length) break;
|
|
79
|
+
const mask = this.#buffer.subarray(at, at + 4);
|
|
80
|
+
const payload = this.#buffer.slice(at + 4, at + 4 + length);
|
|
81
|
+
for (let i = 0; i < payload.length; i += 1) payload[i]! ^= mask[i & 3]!;
|
|
82
|
+
this.#buffer = this.#buffer.slice(at + 4 + length);
|
|
83
|
+
if (opcode === OP.close) {
|
|
84
|
+
this.#done = true;
|
|
85
|
+
out.push({ type: 'close' });
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
if (opcode === OP.ping) out.push({ type: 'ping', data: payload });
|
|
89
|
+
else if (opcode === OP.pong) continue;
|
|
90
|
+
else if (opcode === OP.text || opcode === OP.binary) {
|
|
91
|
+
if (this.#parts !== null) return this.#bad(out);
|
|
92
|
+
if (fin) out.push(opcode === OP.text ? { type: 'text' } : { type: 'binary', data: payload });
|
|
93
|
+
else {
|
|
94
|
+
this.#parts = [payload];
|
|
95
|
+
this.#partsLength = payload.length;
|
|
96
|
+
this.#partsText = opcode === OP.text;
|
|
97
|
+
}
|
|
98
|
+
} else if (opcode === OP.continuation) {
|
|
99
|
+
if (this.#parts === null) return this.#bad(out);
|
|
100
|
+
this.#parts.push(payload);
|
|
101
|
+
this.#partsLength += payload.length;
|
|
102
|
+
if (fin) {
|
|
103
|
+
const whole = new Uint8Array(this.#partsLength);
|
|
104
|
+
let o = 0;
|
|
105
|
+
for (const part of this.#parts) {
|
|
106
|
+
whole.set(part, o);
|
|
107
|
+
o += part.length;
|
|
108
|
+
}
|
|
109
|
+
out.push(this.#partsText ? { type: 'text' } : { type: 'binary', data: whole });
|
|
110
|
+
this.#parts = null;
|
|
111
|
+
this.#partsLength = 0;
|
|
112
|
+
}
|
|
113
|
+
} else return this.#bad(out);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#bad(out: WsEvent[]): WsEvent[] {
|
|
119
|
+
this.#done = true;
|
|
120
|
+
this.#buffer = new Uint8Array(0);
|
|
121
|
+
out.push({ type: 'bad' });
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// `nervur`: what a stranger writes a world with. It imports no platform, so
|
|
3
|
-
// it runs wherever JavaScript runs. A disk is `nervur/folder`.
|
|
3
|
+
// it runs wherever JavaScript runs. A disk is `nervur/folder`. Node reads
|
|
4
|
+
// `node.ts` under this name instead, which adds the Node ground.
|
|
5
|
+
import { addGround } from './harbor/index.ts';
|
|
6
|
+
import { neutralGround } from './line/index.ts';
|
|
4
7
|
|
|
5
8
|
// Beings, faculties, and what they are told.
|
|
6
9
|
export { Being, canonical, digest, Faculty, isSilence, isWord, OWNER, silence, told, WARD, word } from './being/index.ts';
|
|
@@ -14,7 +17,37 @@ export { Carrier, Clock, Custody, Entropy, Loader, Memory, type Loaded, type Mod
|
|
|
14
17
|
export { CEILING, DEFAULT, DEPTH, ownerAsk, Pilot, pilots, WardBeing, type Steward, type WardBeingStance } from './ward/index.ts';
|
|
15
18
|
|
|
16
19
|
// The onion, and the one line its root's asks cross from any front.
|
|
17
|
-
export {
|
|
20
|
+
export {
|
|
21
|
+
BOX,
|
|
22
|
+
CarrierFaculty,
|
|
23
|
+
Catalogue,
|
|
24
|
+
copyPackage,
|
|
25
|
+
Dock,
|
|
26
|
+
Harbor,
|
|
27
|
+
addGround,
|
|
28
|
+
grounds,
|
|
29
|
+
readRootRequest,
|
|
30
|
+
Registry,
|
|
31
|
+
Push,
|
|
32
|
+
PUSH,
|
|
33
|
+
RELAY,
|
|
34
|
+
RELAY_CLIENT,
|
|
35
|
+
rootLine,
|
|
36
|
+
Terrain,
|
|
37
|
+
UNDIALED,
|
|
38
|
+
type Dialed,
|
|
39
|
+
type Failures,
|
|
40
|
+
type Ground,
|
|
41
|
+
type GroundFaculty,
|
|
42
|
+
type GroundProbe,
|
|
43
|
+
type Opening,
|
|
44
|
+
type Probing,
|
|
45
|
+
type RootRequest,
|
|
46
|
+
} from './harbor/index.ts';
|
|
18
47
|
|
|
19
48
|
// The pointer dock: a whole world in one process.
|
|
20
49
|
export { CryptoEntropy, HeldCustody, HeldLoader, NoCarrier, PointerCarrier, PointerTerrain, SystemClock, VolatileMemory, World, type PointerParts } from './pointer/index.ts';
|
|
50
|
+
export { NEUTRAL, WEB, WEB_OPENING, WEB_PUSH, WebDialer, webFaculty, webPush, type WebListen, type WebListener, type WebServe } from './line/index.ts';
|
|
51
|
+
|
|
52
|
+
// The ground that fits everywhere, tried after any a platform entry adds.
|
|
53
|
+
addGround(neutralGround);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The web carrier's inbound side on the standard Request and Response,
|
|
3
|
+
// for a ground whose engine hands its code a request: a post answered, and
|
|
4
|
+
// a held line's messages, whatever socket the engine gives.
|
|
5
|
+
//
|
|
6
|
+
// A post is one ask: 200 with the reply's box, or 204 for nothing. A post
|
|
7
|
+
// to another path is 404, another method 405, a body that is no ask 400.
|
|
8
|
+
// A page of an origin the listen names, or of any under `*`, is answered
|
|
9
|
+
// as the CORS protocol says, its preflight included.
|
|
10
|
+
import { hex } from '../crypto/index.ts';
|
|
11
|
+
import type { Ground } from '../harbor/index.ts';
|
|
12
|
+
import { nothingBody, readBody, replyBody } from './frame.ts';
|
|
13
|
+
import { MAX_POST, readPost, type WebListen } from './web.ts';
|
|
14
|
+
|
|
15
|
+
const allowedOrigin = (origins: readonly string[], origin: string | null): string | null => {
|
|
16
|
+
if (origin === null) return null;
|
|
17
|
+
if (origins.includes('*')) return '*';
|
|
18
|
+
return origins.includes(origin) ? origin : null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const answerPost = async (ground: Ground, listen: WebListen, request: Request): Promise<Response> => {
|
|
22
|
+
const headers = new Headers();
|
|
23
|
+
const allowed = allowedOrigin(listen.origins, request.headers.get('origin'));
|
|
24
|
+
if (allowed !== null) headers.set('access-control-allow-origin', allowed);
|
|
25
|
+
if (new URL(request.url).pathname !== listen.path) return new Response(null, { status: 404, headers });
|
|
26
|
+
if (request.method === 'OPTIONS') {
|
|
27
|
+
if (allowed !== null) {
|
|
28
|
+
headers.set('access-control-allow-methods', 'POST');
|
|
29
|
+
headers.set('access-control-allow-headers', 'content-type');
|
|
30
|
+
}
|
|
31
|
+
return new Response(null, { status: 204, headers });
|
|
32
|
+
}
|
|
33
|
+
if (request.method !== 'POST') {
|
|
34
|
+
headers.set('allow', 'POST, OPTIONS');
|
|
35
|
+
return new Response(null, { status: 405, headers });
|
|
36
|
+
}
|
|
37
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
38
|
+
const ask = body.length > MAX_POST ? null : readPost(body);
|
|
39
|
+
if (ask === null) return new Response(null, { status: 400, headers });
|
|
40
|
+
const reply = await ground.arrive(ask.pk, ask.box).catch(() => null);
|
|
41
|
+
if (reply === null || reply.length === 0) return new Response(null, { status: 204, headers });
|
|
42
|
+
headers.set('content-type', 'application/octet-stream');
|
|
43
|
+
return new Response(new Uint8Array(reply), { status: 200, headers });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// What a held line answers with: a binary message back, or the line closed.
|
|
47
|
+
export type HeldSide = { send(body: Uint8Array): void; close(): void };
|
|
48
|
+
|
|
49
|
+
// One message on a held line: an ask answered on the same line, a text
|
|
50
|
+
// message or a body that is no frame closing it, and anything else let be.
|
|
51
|
+
export const answerHeld = (ground: Ground, side: HeldSide, message: Uint8Array | string): void => {
|
|
52
|
+
const frame = typeof message === 'string' ? null : readBody(message);
|
|
53
|
+
if (frame === null) return side.close();
|
|
54
|
+
if (frame.kind !== 'ask') return;
|
|
55
|
+
void ground
|
|
56
|
+
.arrive(hex(frame.pk), frame.box)
|
|
57
|
+
.catch(() => null)
|
|
58
|
+
.then((reply) => side.send(reply === null ? nothingBody(frame.id) : replyBody(frame.id, reply)));
|
|
59
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The frames of `quo/CARRIER-TCP.md`, which the held line of
|
|
3
|
+
// `quo/CARRIER-WEB.md` carries too, one body to a message:
|
|
4
|
+
//
|
|
5
|
+
// frame = length (4, big-endian) || body
|
|
6
|
+
// body = kind (1) || id (4, big-endian) || rest
|
|
7
|
+
// 00 ask rest = ward pk (64) || box
|
|
8
|
+
// 01 reply rest = box
|
|
9
|
+
// 02 nothing rest = empty
|
|
10
|
+
//
|
|
11
|
+
// A reader says bytes are not a frame as soon as they say so, and reads
|
|
12
|
+
// nothing after them.
|
|
13
|
+
import { concat } from '../crypto/index.ts';
|
|
14
|
+
|
|
15
|
+
// The kind of a TCP carrier faculty, whichever ground's body it is, and
|
|
16
|
+
// the faculty a ground that speaks TCP opens at genesis under `tcp`.
|
|
17
|
+
export const TCP = 'org.nervur.tcp';
|
|
18
|
+
export const TCP_OPENING = { key: 'tcp', kind: TCP } as const;
|
|
19
|
+
|
|
20
|
+
export const ASK = 0;
|
|
21
|
+
export const REPLY = 1;
|
|
22
|
+
export const NOTHING = 2;
|
|
23
|
+
export const MAX_BODY = 1_048_645;
|
|
24
|
+
const PK = 64;
|
|
25
|
+
|
|
26
|
+
export type Frame = { kind: 'ask'; id: number; pk: Uint8Array; box: Uint8Array } | { kind: 'reply'; id: number; box: Uint8Array } | { kind: 'nothing'; id: number };
|
|
27
|
+
|
|
28
|
+
const body = (kind: number, id: number, ...rest: Uint8Array[]): Uint8Array => {
|
|
29
|
+
const head = new Uint8Array(5);
|
|
30
|
+
head[0] = kind;
|
|
31
|
+
new DataView(head.buffer).setUint32(1, id >>> 0);
|
|
32
|
+
return concat(head, ...rest);
|
|
33
|
+
};
|
|
34
|
+
const framed = (b: Uint8Array): Uint8Array => {
|
|
35
|
+
const length = new Uint8Array(4);
|
|
36
|
+
new DataView(length.buffer).setUint32(0, b.length);
|
|
37
|
+
return concat(length, b);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const askBody = (id: number, pk: Uint8Array, box: Uint8Array): Uint8Array => body(ASK, id, pk, box);
|
|
41
|
+
export const replyBody = (id: number, box: Uint8Array): Uint8Array => body(REPLY, id, box);
|
|
42
|
+
export const nothingBody = (id: number): Uint8Array => body(NOTHING, id);
|
|
43
|
+
export const askFrame = (id: number, pk: Uint8Array, box: Uint8Array): Uint8Array => framed(askBody(id, pk, box));
|
|
44
|
+
export const replyFrame = (id: number, box: Uint8Array): Uint8Array => framed(replyBody(id, box));
|
|
45
|
+
export const nothingFrame = (id: number): Uint8Array => framed(nothingBody(id));
|
|
46
|
+
|
|
47
|
+
// Whether a body of this length, and this first byte when known, is no frame.
|
|
48
|
+
const notAFrame = (length: number, kind: number | undefined): boolean =>
|
|
49
|
+
length > MAX_BODY || length < 5 || (kind !== undefined && (kind > NOTHING || (kind === ASK && length < 5 + PK) || (kind === NOTHING && length !== 5)));
|
|
50
|
+
|
|
51
|
+
// One whole body, read, or null for bytes that are no frame.
|
|
52
|
+
export const readBody = (b: Uint8Array): Frame | null => {
|
|
53
|
+
if (notAFrame(b.length, b[0])) return null;
|
|
54
|
+
const id = new DataView(b.buffer, b.byteOffset).getUint32(1);
|
|
55
|
+
if (b[0] === ASK) return { kind: 'ask', id, pk: b.slice(5, 5 + PK), box: b.slice(5 + PK) };
|
|
56
|
+
if (b[0] === REPLY) return { kind: 'reply', id, box: b.slice(5) };
|
|
57
|
+
return { kind: 'nothing', id };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Frames out of a stream, each with its length in front.
|
|
61
|
+
export class FrameReader {
|
|
62
|
+
#buffer: Uint8Array = new Uint8Array(0);
|
|
63
|
+
#bad = false;
|
|
64
|
+
|
|
65
|
+
// True once bytes that are not a frame arrived; nothing is read after.
|
|
66
|
+
get bad(): boolean {
|
|
67
|
+
return this.#bad;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
push(chunk: Uint8Array): Frame[] {
|
|
71
|
+
if (this.#bad) return [];
|
|
72
|
+
this.#buffer = this.#buffer.length === 0 ? chunk.slice() : concat(this.#buffer, chunk);
|
|
73
|
+
const out: Frame[] = [];
|
|
74
|
+
while (this.#buffer.length >= 4) {
|
|
75
|
+
const length = new DataView(this.#buffer.buffer, this.#buffer.byteOffset).getUint32(0);
|
|
76
|
+
if (notAFrame(length, this.#buffer.length > 4 ? this.#buffer[4] : undefined)) {
|
|
77
|
+
this.#bad = true;
|
|
78
|
+
this.#buffer = new Uint8Array(0);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
if (this.#buffer.length < 4 + length) break;
|
|
82
|
+
const frame = readBody(this.#buffer.subarray(4, 4 + length))!;
|
|
83
|
+
this.#buffer = this.#buffer.slice(4 + length);
|
|
84
|
+
out.push(frame);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The ground that fits wherever JavaScript runs, and the one the main
|
|
3
|
+
// entry adds: the pointer bodies, so the harbor lives as long as its
|
|
4
|
+
// process, the modules it is handed, and the web as its one carrier,
|
|
5
|
+
// opened at genesis to ask, with Web Push beside it. A ground whose
|
|
6
|
+
// bodies keep a harbor, a browser's or an edge's, is added by its own
|
|
7
|
+
// entry and tried before this one.
|
|
8
|
+
import type { GroundProbe } from '../harbor/index.ts';
|
|
9
|
+
import { HeldLoader, PointerTerrain } from '../pointer/index.ts';
|
|
10
|
+
import { webPush } from './push.ts';
|
|
11
|
+
import { WEB_OPENING, webFaculty } from './web.ts';
|
|
12
|
+
|
|
13
|
+
export const NEUTRAL = 'neutral';
|
|
14
|
+
|
|
15
|
+
export const neutralGround: GroundProbe = {
|
|
16
|
+
name: NEUTRAL,
|
|
17
|
+
fits: () => true,
|
|
18
|
+
terrain: ({ modules = [] }) => new PointerTerrain({ loader: new HeldLoader(modules), faculties: [webFaculty(), webPush], opens: [WEB_OPENING] }),
|
|
19
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The lines Quo is carried on, with no platform under them: the frames
|
|
3
|
+
// both published carriers share, the web carrier on fetch and the
|
|
4
|
+
// WebSocket client, and a ring with no content as Web Push sends it.
|
|
5
|
+
export { ASK, askBody, askFrame, FrameReader, MAX_BODY, NOTHING, nothingBody, nothingFrame, readBody, REPLY, replyBody, replyFrame, TCP, TCP_OPENING, type Frame } from './frame.ts';
|
|
6
|
+
export { answerHeld, answerPost, type HeldSide } from './answer.ts';
|
|
7
|
+
export { NEUTRAL, neutralGround } from './ground.ts';
|
|
8
|
+
export { base64url, PUSH_TTL, pushEndpoint, VAPID_LIFE, WEB_PUSH, webPush } from './push.ts';
|
|
9
|
+
export { MAX_POST, MIN_POST, readPost, WEB, WEB_OPENING, WebDialer, webFaculty, type WebListen, type WebListener, type WebServe } from './web.ts';
|
package/src/line/push.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A ring with no content, as Web Push sends it: RFC 8030 with no payload,
|
|
3
|
+
// so nothing is encrypted and nothing is said, and RFC 8292's VAPID, so
|
|
4
|
+
// the push service knows who rings. It runs on fetch and Web Crypto, in
|
|
5
|
+
// every engine. The device a ring wakes asks its relay client to `ring`.
|
|
6
|
+
//
|
|
7
|
+
// subject { subject } the contact a push service reaches, a mailto:
|
|
8
|
+
// or https: URI, the owner's to set
|
|
9
|
+
// key the key a device subscribes with, as
|
|
10
|
+
// PushManager takes it
|
|
11
|
+
// ring { endpoint } a ring to that endpoint: `rung`, `gone` where
|
|
12
|
+
// the push service has let it go, or an error
|
|
13
|
+
//
|
|
14
|
+
// The signing key is drawn at the first `key` or `ring` and kept in the
|
|
15
|
+
// faculty's cells, sealed with the rest of the harbor.
|
|
16
|
+
import type { AskSpec, JsonObject } from '../being/index.ts';
|
|
17
|
+
import { concat, utf8 } from '../crypto/index.ts';
|
|
18
|
+
import { Push, type GroundFaculty } from '../harbor/index.ts';
|
|
19
|
+
import { ownerAsk } from '../ward/index.ts';
|
|
20
|
+
|
|
21
|
+
export const WEB_PUSH = 'org.nervur.web-push';
|
|
22
|
+
// How long a push service holds a ring for a device, in seconds.
|
|
23
|
+
export const PUSH_TTL = 60;
|
|
24
|
+
// How long a VAPID token stands, in seconds, inside RFC 8292's day.
|
|
25
|
+
export const VAPID_LIFE = 43_200;
|
|
26
|
+
|
|
27
|
+
type Vapid = { d: string; x: string; y: string };
|
|
28
|
+
const EC = { name: 'ECDSA', namedCurve: 'P-256' } as const;
|
|
29
|
+
|
|
30
|
+
export const base64url = (bytes: Uint8Array): string => {
|
|
31
|
+
let s = '';
|
|
32
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
33
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
34
|
+
};
|
|
35
|
+
const unbase64url = (text: string): Uint8Array => Uint8Array.from(atob(text.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
|
36
|
+
const local = (host: string): boolean => host === '127.0.0.1' || host === 'localhost' || host === '[::1]';
|
|
37
|
+
|
|
38
|
+
// A push endpoint: an https URL, or plain http to this machine alone.
|
|
39
|
+
export const pushEndpoint = (value: unknown): URL | null => {
|
|
40
|
+
if (typeof value !== 'string') return null;
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(value);
|
|
43
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && local(url.hostname)) ? url : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const isSubject = (value: unknown): value is string => typeof value === 'string' && /^(mailto:.+|https:\/\/.+)$/.test(value);
|
|
49
|
+
|
|
50
|
+
export const webPush: GroundFaculty = () =>
|
|
51
|
+
class WebPush extends Push {
|
|
52
|
+
static override readonly kind: string = WEB_PUSH;
|
|
53
|
+
static override asks: Record<string, AskSpec> = {
|
|
54
|
+
subject: ownerAsk('the contact a push service reaches, a mailto: or https: URI', { required: ['subject'] }),
|
|
55
|
+
key: { description: 'the key a device subscribes to pushes with', input: { type: 'object' } },
|
|
56
|
+
ring: { description: 'a ring with no content to a push endpoint', input: { type: 'object', required: ['endpoint'] } },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
#drawing: Promise<Vapid> | undefined;
|
|
60
|
+
|
|
61
|
+
subject(args: JsonObject): JsonObject {
|
|
62
|
+
if (!isSubject(args.subject)) return { error: 'subject is a mailto: or https: URI' };
|
|
63
|
+
this.cells.subject = args.subject;
|
|
64
|
+
return { subject: args.subject };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async key(): Promise<JsonObject> {
|
|
68
|
+
return { key: WebPush.#public(await this.#vapid()) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async ring(args: JsonObject): Promise<JsonObject> {
|
|
72
|
+
const endpoint = pushEndpoint(args.endpoint);
|
|
73
|
+
if (endpoint === null) return { error: 'endpoint is an https URL' };
|
|
74
|
+
const sub = this.cells.subject;
|
|
75
|
+
if (!isSubject(sub)) return { error: 'no subject is set' };
|
|
76
|
+
const vapid = await this.#vapid();
|
|
77
|
+
const signing = await crypto.subtle.importKey('jwk', { kty: 'EC', crv: 'P-256', ...vapid }, EC, false, ['sign']);
|
|
78
|
+
const part = (value: JsonObject) => base64url(utf8(JSON.stringify(value)));
|
|
79
|
+
const unsigned = `${part({ typ: 'JWT', alg: 'ES256' })}.${part({ aud: endpoint.origin, exp: Math.floor(Date.now() / 1000) + VAPID_LIFE, sub })}`;
|
|
80
|
+
const signature = new Uint8Array(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, signing, new Uint8Array(utf8(unsigned))));
|
|
81
|
+
let status: number;
|
|
82
|
+
try {
|
|
83
|
+
const response = await fetch(endpoint, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { TTL: String(PUSH_TTL), Urgency: 'high', 'Content-Length': '0', Authorization: `vapid t=${unsigned}.${base64url(signature)}, k=${WebPush.#public(vapid)}` },
|
|
86
|
+
});
|
|
87
|
+
status = response.status;
|
|
88
|
+
await response.body?.cancel();
|
|
89
|
+
} catch {
|
|
90
|
+
return { error: 'the push service was not reached' };
|
|
91
|
+
}
|
|
92
|
+
if (status === 404 || status === 410) return { gone: true };
|
|
93
|
+
return status >= 200 && status < 300 ? { rung: true } : { error: `the push service answered ${String(status)}` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static #public({ x, y }: Vapid): string {
|
|
97
|
+
return base64url(concat(Uint8Array.of(4), unbase64url(x), unbase64url(y)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#vapid(): Promise<Vapid> {
|
|
101
|
+
const kept = this.cells.vapid as Vapid | undefined;
|
|
102
|
+
if (kept) return Promise.resolve(kept);
|
|
103
|
+
this.#drawing ??= (async () => {
|
|
104
|
+
const pair = await crypto.subtle.generateKey(EC, true, ['sign']);
|
|
105
|
+
const { d, x, y } = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
|
106
|
+
const vapid = { d: d!, x: x!, y: y! };
|
|
107
|
+
this.cells.vapid = vapid;
|
|
108
|
+
return vapid;
|
|
109
|
+
})();
|
|
110
|
+
return this.#drawing;
|
|
111
|
+
}
|
|
112
|
+
};
|