dog
Version:
Durable Object Groups
446 lines (440 loc) • 12.4 kB
JavaScript
// src/internal/headers.ts
var GROUPID = "x-dog-group-identifier";
var CLIENTID = "x-dog-client-identifier";
var OBJECTID = "x-dog-replica-identifier";
var NEIGHBORID = "x-dog-neighbor-identifier";
var TARGETID = "x-dog-target-identifier";
var ISEMPTY = "x-dog-client-empty";
// src/internal/routes.ts
var CLOSE = "/~$~/close";
var NEIGHBOR = "/~$~/jello";
var BROADCAST = "/~$~/message";
var WHISPER = "/~$~/whisper";
var GOSSIP = "/~$~/gossip";
var IDENTIFY = "/~$~/identify";
// src/internal/utils.ts
var Encoder = /* @__PURE__ */ new TextEncoder();
var STATUS_CODES = {
"400": "Bad Request",
"401": "Unauthorized",
"403": "Forbidden",
"404": "Not Found",
"405": "Method Not Allowed",
"411": "Length Required",
"413": "Payload Too Large",
"422": "Unprocessable Entity",
"426": "Upgrade Required"
};
function abort(code, message) {
message = message || STATUS_CODES[code];
let length = Encoder.encode(message).byteLength;
return new Response(message, {
status: code,
statusText: STATUS_CODES[code],
headers: {
"Connection": "close",
"Content-Type": "text/plain",
"Content-Length": String(length)
}
});
}
function validate(req, replicaid) {
let oid = req.headers.get(OBJECTID);
if (oid == null)
throw new Error("Missing: Replica ID");
if (replicaid && oid !== replicaid)
throw new Error("Mismatch: Replica ID");
let gid = req.headers.get(GROUPID);
if (gid == null)
throw new Error("Missing: Group ID");
let nid = req.headers.get(NEIGHBORID);
let rid = req.headers.get(CLIENTID) || nid;
if (rid == null)
throw new Error("Missing: Request ID");
let tid = req.headers.get(TARGETID);
return { gid, rid, oid, tid };
}
function load(ns, uid) {
let doid = ns.idFromString(uid);
return ns.get(doid);
}
// src/group.ts
var Group = class {
#child;
#mapping;
#kids;
#sorted;
#current;
constructor(state, env) {
this.uid = state.id.toString();
this.#mapping = new Map();
this.#kids = new Map();
this.#sorted = [];
let refs = this.link(env);
this.#child = refs.child;
}
receive(req) {
return abort(404);
}
clusterize(req, target) {
return target.newUniqueId();
}
async fetch(input, init) {
let request = new Request(input, init);
let { pathname } = new URL(request.url, "http://dog");
if (pathname === CLOSE) {
try {
return await this.#close(request);
} catch (err) {
return abort(400, err.message);
}
}
if (pathname === IDENTIFY) {
try {
request.headers.set(OBJECTID, "");
let { rid } = validate(request);
return this.#identify(request, rid);
} catch (err) {
return abort(400, err.message);
}
}
return this.receive(request);
}
async #identify(request, rid) {
let alive;
let sid = this.#mapping.get(rid) || this.#current || this.#sorted[0];
if (sid != null)
alive = this.#kids.get(sid);
if (alive != null && this.limit >= ++alive) {
} else {
let pair = this.#sorted.length > 0 && await this.#sort();
if (pair) {
sid = pair[0];
alive = pair[1] + 1;
} else {
sid = (await this.clusterize(request, this.#child)).toString();
this.#welcome(sid);
alive = 1;
}
}
this.#current = alive < this.limit ? sid : void 0;
this.#mapping.set(rid, sid);
this.#kids.set(sid, alive);
return new Response(sid);
}
async #welcome(nid) {
let items = [...this.#kids.keys()];
this.#sorted.unshift(nid);
this.#kids.set(nid, 1);
if (items.length > 0) {
await Promise.all(items.map((sid) => Promise.all([
this.#introduce(nid, sid),
this.#introduce(sid, nid)
])));
}
}
#introduce(stranger, target) {
let headers = new Headers();
headers.set(OBJECTID, target);
headers.set(NEIGHBORID, stranger);
headers.set(GROUPID, this.uid);
let url = new URL(NEIGHBOR, "http://dog");
let stub = load(this.#child, target);
return stub.fetch(url.href, { headers });
}
async #sort() {
let tuples = [...this.#kids];
if (tuples.length > 1) {
tuples.sort((a, b) => a[1] - b[1]);
}
let bucket;
let i = 0, list = [];
for (; i < tuples.length; i++) {
if (tuples[i][1] < this.limit) {
if (!bucket)
bucket = tuples[i];
list.push(tuples[i][0]);
}
}
this.#sorted = list;
return bucket;
}
async #close(req) {
var { rid, oid, gid } = validate(req);
if (gid !== this.uid)
throw new Error("Mismatch: Group ID");
let alive = this.#kids.get(oid);
if (alive == null)
throw new Error("Unknown: Replica ID");
alive = Math.max(0, --alive);
this.#kids.set(oid, alive);
if (req.headers.get(ISEMPTY) === "1") {
this.#mapping.delete(rid);
}
let bucket = await this.#sort();
this.#current = bucket ? bucket[0] : void 0;
return new Response("OK");
}
};
// src/replica.ts
function send(conns, msg) {
for (let ws of conns)
ws.send(msg);
}
var Replica = class {
#pool;
#neighbors;
#parent;
#self;
#gid;
constructor(state, env) {
this.uid = state.id.toString();
this.#neighbors = new Set();
this.#pool = new Map();
let refs = this.link(env);
this.#parent = refs.parent;
this.#self = refs.self;
}
async connect(req) {
if (req.method !== "GET")
return abort(405);
let value = req.headers.get("upgrade");
if (value !== "websocket")
return abort(426);
value = (req.headers.get("sec-websocket-key") || "").trim();
if (!/^[+/0-9A-Za-z]{22}==$/.test(value))
return abort(400);
value = req.headers.get("sec-websocket-version");
if (value !== "13")
return abort(400);
try {
var { rid, gid } = validate(req, this.uid);
} catch (err) {
return abort(400, err.message);
}
let { 0: client, 1: server } = new WebSocketPair();
server.accept();
let socket = {
uid: rid,
send: server.send.bind(server),
close: server.close.bind(server),
broadcast: this.#broadcast.bind(this, gid, rid),
whisper: this.#whisper.bind(this, gid, rid),
emit: this.#emit.bind(this, rid)
};
let closer = async (evt) => {
try {
if (evt.type === "error" && this.onerror)
await this.onerror(socket);
else if (this.onclose)
await this.onclose(socket);
} finally {
let state2 = this.#pool.get(rid);
let isEmpty;
if (!state2 || state2.socket.size < 2) {
this.#pool.delete(rid);
isEmpty = true;
} else {
state2.socket.delete(server);
this.#pool.set(rid, state2);
isEmpty = false;
}
await this.#close(rid, gid, isEmpty);
server.close();
}
};
server.addEventListener("close", closer);
server.addEventListener("error", closer);
if (this.onmessage) {
server.addEventListener("message", (evt) => {
this.onmessage(socket, evt.data);
});
}
if (this.onopen) {
await this.onopen(socket);
}
let state = this.#pool.get(rid) || {
group: gid,
socket: new Set()
};
state.socket.add(server);
this.#pool.set(rid, state);
return new Response(null, {
status: 101,
statusText: "Switching Protocols",
webSocket: client
});
}
async gossip(msg) {
if (this.#neighbors.size < 1)
return [];
let list = await this.#dispatch({
group: this.#gid,
sender: this.uid,
route: GOSSIP,
body: msg == null ? msg : JSON.stringify(msg)
});
return Promise.all(list.map((r) => r.json()));
}
async fetch(input, init) {
let request = new Request(input, init);
try {
var { pathname } = new URL(request.url, "foo://");
var { rid, gid, tid } = validate(request, this.uid);
} catch (err) {
return abort(400, err.message);
}
if (pathname === NEIGHBOR) {
this.#gid = this.#gid || gid;
this.#neighbors.add(rid);
return new Response();
}
if (pathname === BROADCAST) {
try {
this.#emit(rid, await request.text());
return new Response();
} catch (err) {
let msg = err.stack;
return abort(400, msg || "Error parsing broadcast message");
}
}
if (pathname === WHISPER) {
try {
if (!tid)
throw new Error("Missing: Target ID");
let state = this.#pool.get(tid);
if (state)
send(state.socket, await request.text());
return new Response();
} catch (err) {
let msg = err.stack;
return abort(400, msg || "Error parsing whisper message");
}
}
if (pathname === GOSSIP) {
try {
if (!this.ongossip)
throw new Error("Missing: `ongossip` handler");
let payload = await this.ongossip(await request.json());
let body = payload == null ? null : JSON.stringify(payload);
let headers = { "Content-Type": "application/json" };
return new Response(body, { headers });
} catch (err) {
let msg = err.stack;
return abort(400, msg || "Error while gossiping");
}
}
let res;
try {
return res = await this.receive(request);
} catch (err) {
let stack = err.stack;
return res = abort(400, stack || "Error in `receive` method");
} finally {
if (res.status !== 101) {
await this.#close(rid, gid, true);
}
}
}
emit(msg) {
this.#emit(this.#gid, msg, true);
}
broadcast(msg) {
return this.#broadcast(this.#gid, this.uid, msg, true);
}
whisper(target, msg) {
return this.#whisper(this.#gid, this.uid, target, msg);
}
#emit(sender, msg, self) {
if (typeof msg === "object") {
msg = JSON.stringify(msg);
}
for (let [rid, state] of this.#pool) {
if (self || rid !== sender)
send(state.socket, msg);
}
}
async #broadcast(group, sender, msg, self) {
let body = typeof msg === "object" ? JSON.stringify(msg) : msg;
this.#emit(sender, body, self);
await this.#dispatch({
group,
sender,
body,
route: BROADCAST
});
}
async #dispatch(params) {
let list = [...this.#neighbors];
if (list.length < 1)
return;
let commons = {
[NEIGHBORID]: this.uid,
[GROUPID]: params.group,
[CLIENTID]: params.sender
};
if (params.target) {
commons[TARGETID] = params.target;
}
let url = new URL(params.route, "http://dog");
return Promise.all(list.map((sid) => {
let stub = load(this.#self, sid);
let headers = new Headers(commons);
headers.set(OBJECTID, sid);
return stub.fetch(url.href, {
method: "POST",
headers,
body: params.body
});
}));
}
async #whisper(group, sender, target, msg) {
if (sender === target)
return;
let body = typeof msg === "object" ? JSON.stringify(msg) : msg;
let state = this.#pool.get(target);
if (state)
return send(state.socket, body);
await this.#dispatch({
group,
sender,
target,
body,
route: WHISPER
});
}
async #close(rid, gid, isEmpty) {
let headers = new Headers();
headers.set(GROUPID, gid);
headers.set(OBJECTID, this.uid);
headers.set(CLIENTID, rid);
headers.set(ISEMPTY, isEmpty ? "1" : "0");
let url = new URL(CLOSE, "http://dog");
let group = load(this.#parent, gid);
await group.fetch(url.href, { headers });
}
};
// src/glue.ts
var identify = async function(gid, rid, family) {
let group = family.parent.get(gid);
let request = new Request("http://dog" + IDENTIFY);
request.headers.set(GROUPID, gid.toString());
request.headers.set(CLIENTID, rid);
let text = await group.fetch(request).then((r) => r.text());
let sid = family.child.idFromString(text);
let stub = family.child.get(sid);
let prev = stub.fetch.bind(stub);
stub.fetch = function(input, init) {
let request2 = new Request(input, init);
request2.headers.set(CLIENTID, rid);
request2.headers.set(OBJECTID, stub.id.toString());
request2.headers.set(GROUPID, gid.toString());
return prev(request2);
};
return stub;
};
export {
Group,
Replica,
identify
};