nervur 0.20.0 → 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 +229 -20
- package/dist/being/being.d.ts +1 -0
- package/dist/being/being.js +5 -1
- package/dist/being/faculty.d.ts +1 -0
- package/dist/being/faculty.js +13 -7
- package/dist/being/index.d.ts +1 -0
- package/dist/being/index.js +1 -0
- package/dist/being/kind.d.ts +4 -0
- package/dist/being/kind.js +28 -0
- package/dist/being/types.d.ts +3 -4
- 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 +13 -0
- package/dist/cli/command.js +237 -0
- package/dist/cli/harbor.d.ts +19 -0
- package/dist/cli/harbor.js +99 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +14 -0
- package/dist/contract/index.d.ts +16 -2
- package/dist/contract/index.js +13 -3
- 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 +9 -4
- package/dist/folder/index.js +89 -84
- package/dist/harbor/carrying.d.ts +17 -0
- package/dist/harbor/carrying.js +38 -0
- package/dist/harbor/catalogue.d.ts +27 -0
- package/dist/harbor/catalogue.js +101 -0
- package/dist/harbor/dock.d.ts +32 -3
- package/dist/harbor/dock.js +98 -12
- package/dist/harbor/harbor.d.ts +27 -8
- package/dist/harbor/harbor.js +217 -26
- package/dist/harbor/index.d.ts +8 -2
- package/dist/harbor/index.js +8 -1
- package/dist/harbor/package.d.ts +12 -0
- package/dist/harbor/package.js +130 -0
- package/dist/harbor/probe.d.ts +18 -0
- package/dist/harbor/probe.js +15 -0
- package/dist/harbor/registry.d.ts +6 -3
- package/dist/harbor/registry.js +83 -7
- package/dist/harbor/relay.d.ts +15 -0
- package/dist/harbor/relay.js +387 -0
- package/dist/harbor/root-line.d.ts +9 -0
- package/dist/harbor/root-line.js +38 -0
- package/dist/harbor/terrain.d.ts +14 -1
- 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 +5 -4
- package/dist/index.js +12 -6
- 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 +11 -3
- package/dist/pointer/bodies.js +47 -18
- package/dist/pointer/index.d.ts +1 -1
- package/dist/pointer/index.js +1 -1
- package/dist/pointer/world.d.ts +12 -5
- package/dist/pointer/world.js +29 -14
- 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 +6 -4
- 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/quo/keys.js +1 -1
- package/dist/tcp/frame.d.ts +25 -0
- package/dist/tcp/frame.js +64 -0
- package/dist/tcp/index.d.ts +24 -0
- package/dist/tcp/index.js +260 -0
- package/dist/ward/index.d.ts +3 -2
- package/dist/ward/index.js +2 -1
- package/dist/ward/pilot.d.ts +8 -0
- package/dist/ward/pilot.js +50 -0
- package/dist/ward/stance.d.ts +4 -4
- package/dist/ward/stance.js +10 -7
- package/dist/ward/ward-being.d.ts +4 -1
- package/dist/ward/ward-being.js +8 -2
- package/dist/ward/ward.d.ts +11 -2
- package/dist/ward/ward.js +41 -5
- package/package.json +28 -2
- package/src/being/being.ts +5 -1
- package/src/being/faculty.ts +14 -7
- package/src/being/index.ts +1 -0
- package/src/being/kind.ts +30 -0
- package/src/being/types.ts +8 -6
- 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 +224 -0
- package/src/cli/harbor.ts +103 -0
- package/src/cli/main.ts +13 -0
- package/src/contract/index.ts +42 -10
- package/src/edge/index.ts +359 -0
- package/src/edge.ts +10 -0
- package/src/folder/index.ts +87 -84
- package/src/harbor/carrying.ts +56 -0
- package/src/harbor/catalogue.ts +125 -0
- package/src/harbor/dock.ts +122 -15
- package/src/harbor/harbor.ts +240 -36
- package/src/harbor/index.ts +10 -3
- package/src/harbor/package.ts +147 -0
- package/src/harbor/probe.ts +34 -0
- package/src/harbor/registry.ts +73 -9
- package/src/harbor/relay.ts +399 -0
- package/src/harbor/root-line.ts +39 -0
- package/src/harbor/terrain.ts +31 -3
- package/src/http/index.ts +150 -0
- package/src/http/websocket.ts +124 -0
- package/src/index.ts +39 -6
- 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 +45 -16
- package/src/pointer/index.ts +1 -1
- package/src/pointer/world.ts +38 -19
- package/src/quo/address.ts +87 -0
- package/src/quo/door.ts +6 -4
- package/src/quo/index.ts +1 -0
- package/src/quo/invitation.ts +10 -6
- package/src/quo/keys.ts +1 -1
- package/src/stand/main.ts +1 -0
- package/src/stand/stand.ts +87 -6
- package/src/tcp/index.ts +284 -0
- package/src/ward/index.ts +3 -2
- package/src/ward/pilot.ts +50 -0
- package/src/ward/stance.ts +14 -8
- package/src/ward/ward-being.ts +9 -3
- package/src/ward/ward.ts +53 -6
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
8
|
+
export const OP = { continuation: 0x0, text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xa };
|
|
9
|
+
export const acceptKey = (key) => createHash('sha1')
|
|
10
|
+
.update(key + GUID)
|
|
11
|
+
.digest('base64');
|
|
12
|
+
export const isClientKey = (key) => typeof key === 'string' && /^[A-Za-z0-9+/]{22}==$/.test(key);
|
|
13
|
+
// One frame as a server writes it: final, unmasked.
|
|
14
|
+
export const frameOf = (opcode, payload = new Uint8Array(0)) => {
|
|
15
|
+
const length = payload.length;
|
|
16
|
+
const extended = length < 126 ? 0 : length < 65_536 ? 2 : 8;
|
|
17
|
+
const out = new Uint8Array(2 + extended + length);
|
|
18
|
+
const view = new DataView(out.buffer);
|
|
19
|
+
out[0] = 0x80 | opcode;
|
|
20
|
+
out[1] = extended === 0 ? length : extended === 2 ? 126 : 127;
|
|
21
|
+
if (extended === 2)
|
|
22
|
+
view.setUint16(2, length);
|
|
23
|
+
if (extended === 8)
|
|
24
|
+
view.setBigUint64(2, BigInt(length));
|
|
25
|
+
out.set(payload, 2 + extended);
|
|
26
|
+
return out;
|
|
27
|
+
};
|
|
28
|
+
// Frames a client wrote, masked, read as they come.
|
|
29
|
+
export class WsReader {
|
|
30
|
+
#max;
|
|
31
|
+
#buffer = new Uint8Array(0);
|
|
32
|
+
#parts = null;
|
|
33
|
+
#partsLength = 0;
|
|
34
|
+
#partsText = false;
|
|
35
|
+
#done = false;
|
|
36
|
+
constructor(max) {
|
|
37
|
+
this.#max = max;
|
|
38
|
+
}
|
|
39
|
+
push(chunk) {
|
|
40
|
+
if (this.#done)
|
|
41
|
+
return [];
|
|
42
|
+
const joined = new Uint8Array(this.#buffer.length + chunk.length);
|
|
43
|
+
joined.set(this.#buffer);
|
|
44
|
+
joined.set(chunk, this.#buffer.length);
|
|
45
|
+
this.#buffer = joined;
|
|
46
|
+
const out = [];
|
|
47
|
+
while (this.#buffer.length >= 2) {
|
|
48
|
+
const b0 = this.#buffer[0];
|
|
49
|
+
const b1 = this.#buffer[1];
|
|
50
|
+
const fin = (b0 & 0x80) !== 0;
|
|
51
|
+
const opcode = b0 & 0x0f;
|
|
52
|
+
const masked = (b1 & 0x80) !== 0;
|
|
53
|
+
if ((b0 & 0x70) !== 0 || !masked)
|
|
54
|
+
return this.#bad(out);
|
|
55
|
+
let length = b1 & 0x7f;
|
|
56
|
+
let at = 2;
|
|
57
|
+
const view = new DataView(this.#buffer.buffer, this.#buffer.byteOffset, this.#buffer.length);
|
|
58
|
+
if (length === 126) {
|
|
59
|
+
if (this.#buffer.length < 4)
|
|
60
|
+
break;
|
|
61
|
+
length = view.getUint16(2);
|
|
62
|
+
at = 4;
|
|
63
|
+
}
|
|
64
|
+
else if (length === 127) {
|
|
65
|
+
if (this.#buffer.length < 10)
|
|
66
|
+
break;
|
|
67
|
+
const long = view.getBigUint64(2);
|
|
68
|
+
if (long > BigInt(this.#max))
|
|
69
|
+
return this.#bad(out);
|
|
70
|
+
length = Number(long);
|
|
71
|
+
at = 10;
|
|
72
|
+
}
|
|
73
|
+
const control = opcode >= 0x8;
|
|
74
|
+
if (control && (length > 125 || !fin))
|
|
75
|
+
return this.#bad(out);
|
|
76
|
+
if (!control && this.#partsLength + length > this.#max)
|
|
77
|
+
return this.#bad(out);
|
|
78
|
+
if (this.#buffer.length < at + 4 + length)
|
|
79
|
+
break;
|
|
80
|
+
const mask = this.#buffer.subarray(at, at + 4);
|
|
81
|
+
const payload = this.#buffer.slice(at + 4, at + 4 + length);
|
|
82
|
+
for (let i = 0; i < payload.length; i += 1)
|
|
83
|
+
payload[i] ^= mask[i & 3];
|
|
84
|
+
this.#buffer = this.#buffer.slice(at + 4 + length);
|
|
85
|
+
if (opcode === OP.close) {
|
|
86
|
+
this.#done = true;
|
|
87
|
+
out.push({ type: 'close' });
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
if (opcode === OP.ping)
|
|
91
|
+
out.push({ type: 'ping', data: payload });
|
|
92
|
+
else if (opcode === OP.pong)
|
|
93
|
+
continue;
|
|
94
|
+
else if (opcode === OP.text || opcode === OP.binary) {
|
|
95
|
+
if (this.#parts !== null)
|
|
96
|
+
return this.#bad(out);
|
|
97
|
+
if (fin)
|
|
98
|
+
out.push(opcode === OP.text ? { type: 'text' } : { type: 'binary', data: payload });
|
|
99
|
+
else {
|
|
100
|
+
this.#parts = [payload];
|
|
101
|
+
this.#partsLength = payload.length;
|
|
102
|
+
this.#partsText = opcode === OP.text;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else if (opcode === OP.continuation) {
|
|
106
|
+
if (this.#parts === null)
|
|
107
|
+
return this.#bad(out);
|
|
108
|
+
this.#parts.push(payload);
|
|
109
|
+
this.#partsLength += payload.length;
|
|
110
|
+
if (fin) {
|
|
111
|
+
const whole = new Uint8Array(this.#partsLength);
|
|
112
|
+
let o = 0;
|
|
113
|
+
for (const part of this.#parts) {
|
|
114
|
+
whole.set(part, o);
|
|
115
|
+
o += part.length;
|
|
116
|
+
}
|
|
117
|
+
out.push(this.#partsText ? { type: 'text' } : { type: 'binary', data: whole });
|
|
118
|
+
this.#parts = null;
|
|
119
|
+
this.#partsLength = 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else
|
|
123
|
+
return this.#bad(out);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
#bad(out) {
|
|
128
|
+
this.#done = true;
|
|
129
|
+
this.#buffer = new Uint8Array(0);
|
|
130
|
+
out.push({ type: 'bad' });
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { Being, canonical, digest, Faculty, isSilence, isWord, OWNER, silence, told, WARD, word } from './being/index.ts';
|
|
2
2
|
export type { Answer, Ask, Asker, AskSpec, BeingClass, BeingLike, Blueprint, Invitation, Json, JsonObject, Occupants, Reply, Schema, Silence, Stance, Standing, Standings, Wanted, Word, WordName } from './being/index.ts';
|
|
3
3
|
export { readInvitation } from './quo/index.ts';
|
|
4
|
-
export { Carrier, Clock, Custody, Entropy, Memory, type Rows } from './contract/index.ts';
|
|
5
|
-
export { CEILING, DEFAULT, DEPTH, ownerAsk, pilots, WardBeing, type Steward, type WardBeingStance } from './ward/index.ts';
|
|
6
|
-
export { BOX, Dock, Harbor, Registry, Terrain } from './harbor/index.ts';
|
|
7
|
-
export { CryptoEntropy, HeldCustody, NoCarrier, PointerCarrier, PointerTerrain, SystemClock, VolatileMemory, World, type PointerParts } from './pointer/index.ts';
|
|
4
|
+
export { Carrier, Clock, Custody, Entropy, Loader, Memory, type Loaded, type Module, type Rows } from './contract/index.ts';
|
|
5
|
+
export { CEILING, DEFAULT, DEPTH, ownerAsk, Pilot, pilots, WardBeing, type Steward, type WardBeingStance } from './ward/index.ts';
|
|
6
|
+
export { BOX, CarrierFaculty, Catalogue, copyPackage, Dock, Harbor, addGround, grounds, readRootRequest, Registry, Push, PUSH, RELAY, RELAY_CLIENT, rootLine, Terrain, UNDIALED, type Dialed, type Failures, type Ground, type GroundFaculty, type GroundProbe, type Opening, type Probing, type RootRequest, } from './harbor/index.ts';
|
|
7
|
+
export { CryptoEntropy, HeldCustody, HeldLoader, NoCarrier, PointerCarrier, PointerTerrain, SystemClock, VolatileMemory, World, type PointerParts } from './pointer/index.ts';
|
|
8
|
+
export { NEUTRAL, WEB, WEB_OPENING, WEB_PUSH, WebDialer, webFaculty, webPush, type WebListen, type WebListener, type WebServe } from './line/index.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
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.js';
|
|
6
|
+
import { neutralGround } from './line/index.js';
|
|
4
7
|
// Beings, faculties, and what they are told.
|
|
5
8
|
export { Being, canonical, digest, Faculty, isSilence, isWord, OWNER, silence, told, WARD, word } from './being/index.js';
|
|
6
9
|
export { readInvitation } from './quo/index.js';
|
|
7
10
|
// What a terrain gives a harbor.
|
|
8
|
-
export { Carrier, Clock, Custody, Entropy, Memory } from './contract/index.js';
|
|
11
|
+
export { Carrier, Clock, Custody, Entropy, Loader, Memory } from './contract/index.js';
|
|
9
12
|
// Wards and the ward-being.
|
|
10
|
-
export { CEILING, DEFAULT, DEPTH, ownerAsk, pilots, WardBeing } from './ward/index.js';
|
|
11
|
-
// The onion.
|
|
12
|
-
export { BOX, Dock, Harbor, Registry, Terrain } from './harbor/index.js';
|
|
13
|
+
export { CEILING, DEFAULT, DEPTH, ownerAsk, Pilot, pilots, WardBeing } from './ward/index.js';
|
|
14
|
+
// The onion, and the one line its root's asks cross from any front.
|
|
15
|
+
export { BOX, CarrierFaculty, Catalogue, copyPackage, Dock, Harbor, addGround, grounds, readRootRequest, Registry, Push, PUSH, RELAY, RELAY_CLIENT, rootLine, Terrain, UNDIALED, } from './harbor/index.js';
|
|
13
16
|
// The pointer dock: a whole world in one process.
|
|
14
|
-
export { CryptoEntropy, HeldCustody, NoCarrier, PointerCarrier, PointerTerrain, SystemClock, VolatileMemory, World } from './pointer/index.js';
|
|
17
|
+
export { CryptoEntropy, HeldCustody, HeldLoader, NoCarrier, PointerCarrier, PointerTerrain, SystemClock, VolatileMemory, World } from './pointer/index.js';
|
|
18
|
+
export { NEUTRAL, WEB, WEB_OPENING, WEB_PUSH, WebDialer, webFaculty, webPush } from './line/index.js';
|
|
19
|
+
// The ground that fits everywhere, tried after any a platform entry adds.
|
|
20
|
+
addGround(neutralGround);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Ground } from '../harbor/index.ts';
|
|
2
|
+
import { type WebListen } from './web.ts';
|
|
3
|
+
export declare const answerPost: (ground: Ground, listen: WebListen, request: Request) => Promise<Response>;
|
|
4
|
+
export type HeldSide = {
|
|
5
|
+
send(body: Uint8Array): void;
|
|
6
|
+
close(): void;
|
|
7
|
+
};
|
|
8
|
+
export declare const answerHeld: (ground: Ground, side: HeldSide, message: Uint8Array | string) => void;
|
|
@@ -0,0 +1,60 @@
|
|
|
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.js';
|
|
11
|
+
import { nothingBody, readBody, replyBody } from './frame.js';
|
|
12
|
+
import { MAX_POST, readPost } from './web.js';
|
|
13
|
+
const allowedOrigin = (origins, origin) => {
|
|
14
|
+
if (origin === null)
|
|
15
|
+
return null;
|
|
16
|
+
if (origins.includes('*'))
|
|
17
|
+
return '*';
|
|
18
|
+
return origins.includes(origin) ? origin : null;
|
|
19
|
+
};
|
|
20
|
+
export const answerPost = async (ground, listen, request) => {
|
|
21
|
+
const headers = new Headers();
|
|
22
|
+
const allowed = allowedOrigin(listen.origins, request.headers.get('origin'));
|
|
23
|
+
if (allowed !== null)
|
|
24
|
+
headers.set('access-control-allow-origin', allowed);
|
|
25
|
+
if (new URL(request.url).pathname !== listen.path)
|
|
26
|
+
return new Response(null, { status: 404, headers });
|
|
27
|
+
if (request.method === 'OPTIONS') {
|
|
28
|
+
if (allowed !== null) {
|
|
29
|
+
headers.set('access-control-allow-methods', 'POST');
|
|
30
|
+
headers.set('access-control-allow-headers', 'content-type');
|
|
31
|
+
}
|
|
32
|
+
return new Response(null, { status: 204, headers });
|
|
33
|
+
}
|
|
34
|
+
if (request.method !== 'POST') {
|
|
35
|
+
headers.set('allow', 'POST, OPTIONS');
|
|
36
|
+
return new Response(null, { status: 405, headers });
|
|
37
|
+
}
|
|
38
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
39
|
+
const ask = body.length > MAX_POST ? null : readPost(body);
|
|
40
|
+
if (ask === null)
|
|
41
|
+
return new Response(null, { status: 400, headers });
|
|
42
|
+
const reply = await ground.arrive(ask.pk, ask.box).catch(() => null);
|
|
43
|
+
if (reply === null || reply.length === 0)
|
|
44
|
+
return new Response(null, { status: 204, headers });
|
|
45
|
+
headers.set('content-type', 'application/octet-stream');
|
|
46
|
+
return new Response(new Uint8Array(reply), { status: 200, headers });
|
|
47
|
+
};
|
|
48
|
+
// One message on a held line: an ask answered on the same line, a text
|
|
49
|
+
// message or a body that is no frame closing it, and anything else let be.
|
|
50
|
+
export const answerHeld = (ground, side, message) => {
|
|
51
|
+
const frame = typeof message === 'string' ? null : readBody(message);
|
|
52
|
+
if (frame === null)
|
|
53
|
+
return side.close();
|
|
54
|
+
if (frame.kind !== 'ask')
|
|
55
|
+
return;
|
|
56
|
+
void ground
|
|
57
|
+
.arrive(hex(frame.pk), frame.box)
|
|
58
|
+
.catch(() => null)
|
|
59
|
+
.then((reply) => side.send(reply === null ? nothingBody(frame.id) : replyBody(frame.id, reply)));
|
|
60
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare const TCP = "org.nervur.tcp";
|
|
2
|
+
export declare const TCP_OPENING: {
|
|
3
|
+
readonly key: 'tcp';
|
|
4
|
+
readonly kind: "org.nervur.tcp";
|
|
5
|
+
};
|
|
6
|
+
export declare const ASK = 0;
|
|
7
|
+
export declare const REPLY = 1;
|
|
8
|
+
export declare const NOTHING = 2;
|
|
9
|
+
export declare const MAX_BODY = 1048645;
|
|
10
|
+
export type Frame = {
|
|
11
|
+
kind: 'ask';
|
|
12
|
+
id: number;
|
|
13
|
+
pk: Uint8Array;
|
|
14
|
+
box: Uint8Array;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'reply';
|
|
17
|
+
id: number;
|
|
18
|
+
box: Uint8Array;
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'nothing';
|
|
21
|
+
id: number;
|
|
22
|
+
};
|
|
23
|
+
export declare const askBody: (id: number, pk: Uint8Array, box: Uint8Array) => Uint8Array;
|
|
24
|
+
export declare const replyBody: (id: number, box: Uint8Array) => Uint8Array;
|
|
25
|
+
export declare const nothingBody: (id: number) => Uint8Array;
|
|
26
|
+
export declare const askFrame: (id: number, pk: Uint8Array, box: Uint8Array) => Uint8Array;
|
|
27
|
+
export declare const replyFrame: (id: number, box: Uint8Array) => Uint8Array;
|
|
28
|
+
export declare const nothingFrame: (id: number) => Uint8Array;
|
|
29
|
+
export declare const readBody: (b: Uint8Array) => Frame | null;
|
|
30
|
+
export declare class FrameReader {
|
|
31
|
+
#private;
|
|
32
|
+
get bad(): boolean;
|
|
33
|
+
push(chunk: Uint8Array): Frame[];
|
|
34
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
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.js';
|
|
14
|
+
// The kind of a TCP carrier faculty, whichever ground's body it is, and
|
|
15
|
+
// the faculty a ground that speaks TCP opens at genesis under `tcp`.
|
|
16
|
+
export const TCP = 'org.nervur.tcp';
|
|
17
|
+
export const TCP_OPENING = { key: 'tcp', kind: TCP };
|
|
18
|
+
export const ASK = 0;
|
|
19
|
+
export const REPLY = 1;
|
|
20
|
+
export const NOTHING = 2;
|
|
21
|
+
export const MAX_BODY = 1_048_645;
|
|
22
|
+
const PK = 64;
|
|
23
|
+
const body = (kind, id, ...rest) => {
|
|
24
|
+
const head = new Uint8Array(5);
|
|
25
|
+
head[0] = kind;
|
|
26
|
+
new DataView(head.buffer).setUint32(1, id >>> 0);
|
|
27
|
+
return concat(head, ...rest);
|
|
28
|
+
};
|
|
29
|
+
const framed = (b) => {
|
|
30
|
+
const length = new Uint8Array(4);
|
|
31
|
+
new DataView(length.buffer).setUint32(0, b.length);
|
|
32
|
+
return concat(length, b);
|
|
33
|
+
};
|
|
34
|
+
export const askBody = (id, pk, box) => body(ASK, id, pk, box);
|
|
35
|
+
export const replyBody = (id, box) => body(REPLY, id, box);
|
|
36
|
+
export const nothingBody = (id) => body(NOTHING, id);
|
|
37
|
+
export const askFrame = (id, pk, box) => framed(askBody(id, pk, box));
|
|
38
|
+
export const replyFrame = (id, box) => framed(replyBody(id, box));
|
|
39
|
+
export const nothingFrame = (id) => framed(nothingBody(id));
|
|
40
|
+
// Whether a body of this length, and this first byte when known, is no frame.
|
|
41
|
+
const notAFrame = (length, kind) => length > MAX_BODY || length < 5 || (kind !== undefined && (kind > NOTHING || (kind === ASK && length < 5 + PK) || (kind === NOTHING && length !== 5)));
|
|
42
|
+
// One whole body, read, or null for bytes that are no frame.
|
|
43
|
+
export const readBody = (b) => {
|
|
44
|
+
if (notAFrame(b.length, b[0]))
|
|
45
|
+
return null;
|
|
46
|
+
const id = new DataView(b.buffer, b.byteOffset).getUint32(1);
|
|
47
|
+
if (b[0] === ASK)
|
|
48
|
+
return { kind: 'ask', id, pk: b.slice(5, 5 + PK), box: b.slice(5 + PK) };
|
|
49
|
+
if (b[0] === REPLY)
|
|
50
|
+
return { kind: 'reply', id, box: b.slice(5) };
|
|
51
|
+
return { kind: 'nothing', id };
|
|
52
|
+
};
|
|
53
|
+
// Frames out of a stream, each with its length in front.
|
|
54
|
+
export class FrameReader {
|
|
55
|
+
#buffer = new Uint8Array(0);
|
|
56
|
+
#bad = false;
|
|
57
|
+
// True once bytes that are not a frame arrived; nothing is read after.
|
|
58
|
+
get bad() {
|
|
59
|
+
return this.#bad;
|
|
60
|
+
}
|
|
61
|
+
push(chunk) {
|
|
62
|
+
if (this.#bad)
|
|
63
|
+
return [];
|
|
64
|
+
this.#buffer = this.#buffer.length === 0 ? chunk.slice() : concat(this.#buffer, chunk);
|
|
65
|
+
const out = [];
|
|
66
|
+
while (this.#buffer.length >= 4) {
|
|
67
|
+
const length = new DataView(this.#buffer.buffer, this.#buffer.byteOffset).getUint32(0);
|
|
68
|
+
if (notAFrame(length, this.#buffer.length > 4 ? this.#buffer[4] : undefined)) {
|
|
69
|
+
this.#bad = true;
|
|
70
|
+
this.#buffer = new Uint8Array(0);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
if (this.#buffer.length < 4 + length)
|
|
74
|
+
break;
|
|
75
|
+
const frame = readBody(this.#buffer.subarray(4, 4 + length));
|
|
76
|
+
this.#buffer = this.#buffer.slice(4 + length);
|
|
77
|
+
out.push(frame);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { HeldLoader, PointerTerrain } from '../pointer/index.js';
|
|
2
|
+
import { webPush } from './push.js';
|
|
3
|
+
import { WEB_OPENING, webFaculty } from './web.js';
|
|
4
|
+
export const NEUTRAL = 'neutral';
|
|
5
|
+
export const neutralGround = {
|
|
6
|
+
name: NEUTRAL,
|
|
7
|
+
fits: () => true,
|
|
8
|
+
terrain: ({ modules = [] }) => new PointerTerrain({ loader: new HeldLoader(modules), faculties: [webFaculty(), webPush], opens: [WEB_OPENING] }),
|
|
9
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ASK, askBody, askFrame, FrameReader, MAX_BODY, NOTHING, nothingBody, nothingFrame, readBody, REPLY, replyBody, replyFrame, TCP, TCP_OPENING, type Frame } from './frame.ts';
|
|
2
|
+
export { answerHeld, answerPost, type HeldSide } from './answer.ts';
|
|
3
|
+
export { NEUTRAL, neutralGround } from './ground.ts';
|
|
4
|
+
export { base64url, PUSH_TTL, pushEndpoint, VAPID_LIFE, WEB_PUSH, webPush } from './push.ts';
|
|
5
|
+
export { MAX_POST, MIN_POST, readPost, WEB, WEB_OPENING, WebDialer, webFaculty, type WebListen, type WebListener, type WebServe } from './web.ts';
|
|
@@ -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 } from './frame.js';
|
|
6
|
+
export { answerHeld, answerPost } from './answer.js';
|
|
7
|
+
export { NEUTRAL, neutralGround } from './ground.js';
|
|
8
|
+
export { base64url, PUSH_TTL, pushEndpoint, VAPID_LIFE, WEB_PUSH, webPush } from './push.js';
|
|
9
|
+
export { MAX_POST, MIN_POST, readPost, WEB, WEB_OPENING, WebDialer, webFaculty } from './web.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type GroundFaculty } from '../harbor/index.ts';
|
|
2
|
+
export declare const WEB_PUSH = "org.nervur.web-push";
|
|
3
|
+
export declare const PUSH_TTL = 60;
|
|
4
|
+
export declare const VAPID_LIFE = 43200;
|
|
5
|
+
export declare const base64url: (bytes: Uint8Array) => string;
|
|
6
|
+
export declare const pushEndpoint: (value: unknown) => URL | null;
|
|
7
|
+
export declare const webPush: GroundFaculty;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { concat, utf8 } from '../crypto/index.js';
|
|
2
|
+
import { Push } from '../harbor/index.js';
|
|
3
|
+
import { ownerAsk } from '../ward/index.js';
|
|
4
|
+
export const WEB_PUSH = 'org.nervur.web-push';
|
|
5
|
+
// How long a push service holds a ring for a device, in seconds.
|
|
6
|
+
export const PUSH_TTL = 60;
|
|
7
|
+
// How long a VAPID token stands, in seconds, inside RFC 8292's day.
|
|
8
|
+
export const VAPID_LIFE = 43_200;
|
|
9
|
+
const EC = { name: 'ECDSA', namedCurve: 'P-256' };
|
|
10
|
+
export const base64url = (bytes) => {
|
|
11
|
+
let s = '';
|
|
12
|
+
for (const b of bytes)
|
|
13
|
+
s += String.fromCharCode(b);
|
|
14
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
15
|
+
};
|
|
16
|
+
const unbase64url = (text) => Uint8Array.from(atob(text.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
|
17
|
+
const local = (host) => host === '127.0.0.1' || host === 'localhost' || host === '[::1]';
|
|
18
|
+
// A push endpoint: an https URL, or plain http to this machine alone.
|
|
19
|
+
export const pushEndpoint = (value) => {
|
|
20
|
+
if (typeof value !== 'string')
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(value);
|
|
24
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && local(url.hostname)) ? url : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const isSubject = (value) => typeof value === 'string' && /^(mailto:.+|https:\/\/.+)$/.test(value);
|
|
31
|
+
export const webPush = () => class WebPush extends Push {
|
|
32
|
+
static kind = WEB_PUSH;
|
|
33
|
+
static asks = {
|
|
34
|
+
subject: ownerAsk('the contact a push service reaches, a mailto: or https: URI', { required: ['subject'] }),
|
|
35
|
+
key: { description: 'the key a device subscribes to pushes with', input: { type: 'object' } },
|
|
36
|
+
ring: { description: 'a ring with no content to a push endpoint', input: { type: 'object', required: ['endpoint'] } },
|
|
37
|
+
};
|
|
38
|
+
#drawing;
|
|
39
|
+
subject(args) {
|
|
40
|
+
if (!isSubject(args.subject))
|
|
41
|
+
return { error: 'subject is a mailto: or https: URI' };
|
|
42
|
+
this.cells.subject = args.subject;
|
|
43
|
+
return { subject: args.subject };
|
|
44
|
+
}
|
|
45
|
+
async key() {
|
|
46
|
+
return { key: WebPush.#public(await this.#vapid()) };
|
|
47
|
+
}
|
|
48
|
+
async ring(args) {
|
|
49
|
+
const endpoint = pushEndpoint(args.endpoint);
|
|
50
|
+
if (endpoint === null)
|
|
51
|
+
return { error: 'endpoint is an https URL' };
|
|
52
|
+
const sub = this.cells.subject;
|
|
53
|
+
if (!isSubject(sub))
|
|
54
|
+
return { error: 'no subject is set' };
|
|
55
|
+
const vapid = await this.#vapid();
|
|
56
|
+
const signing = await crypto.subtle.importKey('jwk', { kty: 'EC', crv: 'P-256', ...vapid }, EC, false, ['sign']);
|
|
57
|
+
const part = (value) => base64url(utf8(JSON.stringify(value)));
|
|
58
|
+
const unsigned = `${part({ typ: 'JWT', alg: 'ES256' })}.${part({ aud: endpoint.origin, exp: Math.floor(Date.now() / 1000) + VAPID_LIFE, sub })}`;
|
|
59
|
+
const signature = new Uint8Array(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, signing, new Uint8Array(utf8(unsigned))));
|
|
60
|
+
let status;
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetch(endpoint, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { TTL: String(PUSH_TTL), Urgency: 'high', 'Content-Length': '0', Authorization: `vapid t=${unsigned}.${base64url(signature)}, k=${WebPush.#public(vapid)}` },
|
|
65
|
+
});
|
|
66
|
+
status = response.status;
|
|
67
|
+
await response.body?.cancel();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { error: 'the push service was not reached' };
|
|
71
|
+
}
|
|
72
|
+
if (status === 404 || status === 410)
|
|
73
|
+
return { gone: true };
|
|
74
|
+
return status >= 200 && status < 300 ? { rung: true } : { error: `the push service answered ${String(status)}` };
|
|
75
|
+
}
|
|
76
|
+
static #public({ x, y }) {
|
|
77
|
+
return base64url(concat(Uint8Array.of(4), unbase64url(x), unbase64url(y)));
|
|
78
|
+
}
|
|
79
|
+
#vapid() {
|
|
80
|
+
const kept = this.cells.vapid;
|
|
81
|
+
if (kept)
|
|
82
|
+
return Promise.resolve(kept);
|
|
83
|
+
this.#drawing ??= (async () => {
|
|
84
|
+
const pair = await crypto.subtle.generateKey(EC, true, ['sign']);
|
|
85
|
+
const { d, x, y } = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
|
86
|
+
const vapid = { d: d, x: x, y: y };
|
|
87
|
+
this.cells.vapid = vapid;
|
|
88
|
+
return vapid;
|
|
89
|
+
})();
|
|
90
|
+
return this.#drawing;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Dialed, type Ground, type GroundFaculty } from '../harbor/index.ts';
|
|
2
|
+
export declare const MIN_POST = 65;
|
|
3
|
+
export declare const MAX_POST: number;
|
|
4
|
+
export declare class WebDialer {
|
|
5
|
+
#private;
|
|
6
|
+
dial(at: string, pk: string, bytes: Uint8Array): Promise<Dialed>;
|
|
7
|
+
close(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export type WebListen = {
|
|
10
|
+
host: string;
|
|
11
|
+
port: number;
|
|
12
|
+
path: string;
|
|
13
|
+
origins: string[];
|
|
14
|
+
};
|
|
15
|
+
export interface WebListener {
|
|
16
|
+
readonly at: string[];
|
|
17
|
+
readonly port: number;
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export type WebServe = (ground: Ground, listen: WebListen) => Promise<WebListener>;
|
|
21
|
+
export declare const WEB = "org.nervur.web";
|
|
22
|
+
export declare const WEB_OPENING: {
|
|
23
|
+
readonly key: 'web';
|
|
24
|
+
readonly kind: "org.nervur.web";
|
|
25
|
+
};
|
|
26
|
+
export declare const webFaculty: (serve?: WebServe) => GroundFaculty;
|
|
27
|
+
export declare const readPost: (body: Uint8Array) => {
|
|
28
|
+
pk: string;
|
|
29
|
+
box: Uint8Array;
|
|
30
|
+
} | null;
|