@anterior/brrr 0.1.1

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.
@@ -0,0 +1,91 @@
1
+ import { suite, test } from "node:test";
2
+ import { deepStrictEqual, notDeepStrictEqual } from "node:assert/strict";
3
+ import type { Codec } from "./codec.ts";
4
+
5
+ export async function codecContractTest(codec: Codec) {
6
+ await suite("store-contract", async () => {
7
+ const cases: Record<string, [unknown[], unknown[]]> = {
8
+ simple: [
9
+ [1, 2],
10
+ [1, 2],
11
+ ],
12
+ "simple-object": [[{ b: 2, a: 1 }], [{ a: 1, b: 2 }]],
13
+ "nested object": [
14
+ [{ b: { c: 3, a: 1 }, a: 2 }],
15
+ [{ a: 2, b: { a: 1, c: 3 } }],
16
+ ],
17
+ "array of objects": [
18
+ [
19
+ [
20
+ { b: 2, a: 1 },
21
+ { d: 4, c: 3 },
22
+ ],
23
+ ],
24
+ [
25
+ [
26
+ { a: 1, b: 2 },
27
+ { c: 3, d: 4 },
28
+ ],
29
+ ],
30
+ ],
31
+ "complex object": [
32
+ [{ a: 1, b: { c: 3, d: [4, 5] }, e: "6" }],
33
+ [{ e: "6", b: { d: [4, 5], c: 3 }, a: 1 }],
34
+ ],
35
+ nulls: [
36
+ [{ a: null, b: [1, 2, null], c: { d: null } }],
37
+ [{ c: { d: null }, b: [1, 2, null], a: null }],
38
+ ],
39
+ undefineds: [
40
+ [{ a: undefined, b: [1, 2, undefined], c: { d: undefined } }],
41
+ [{ c: { d: undefined }, b: [1, 2, undefined], a: undefined }],
42
+ ],
43
+ empties: [[{ a: {}, b: [] }], [{ b: [], a: {} }]],
44
+ "mixed types": [
45
+ [{ a: 1, b: "2", c: true, d: null }],
46
+ [{ d: null, c: true, b: "2", a: 1 }],
47
+ ],
48
+ } as const;
49
+
50
+ await suite("deterministic call hash", async () => {
51
+ for (const [name, args] of Object.entries(cases)) {
52
+ await test(name, async () => {
53
+ // sanity check
54
+ deepStrictEqual(...args);
55
+
56
+ deepStrictEqual(
57
+ await codec.encodeCall("foo", args[0]),
58
+ await codec.encodeCall("foo", args[1]),
59
+ );
60
+ });
61
+ }
62
+ });
63
+
64
+ await suite(
65
+ "round trip: encodeCall -> invokeTask -> decodeReturn",
66
+ async () => {
67
+ async function identify<T>(a: T): Promise<T> {
68
+ return a;
69
+ }
70
+
71
+ for (const [name, args] of Object.entries(cases)) {
72
+ await test(name, async () => {
73
+ const call = await codec.encodeCall(identify.name, [args[0]]);
74
+ const result = await codec.invokeTask(call, identify);
75
+ const decoded = await codec.decodeReturn(identify.name, result);
76
+ deepStrictEqual(decoded, await identify(args[1]));
77
+ });
78
+ }
79
+ },
80
+ );
81
+
82
+ await test("different arguments produce different hashes", async () => {
83
+ const a = await codec.encodeCall("foo", [1, 2]);
84
+ const b = await codec.encodeCall("foo", [2, 1]);
85
+ const c = await codec.encodeCall("bar", [1, 2]);
86
+ notDeepStrictEqual(a, b);
87
+ notDeepStrictEqual(a, c);
88
+ notDeepStrictEqual(b, c);
89
+ });
90
+ });
91
+ }
package/src/codec.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { Call } from "./call.ts";
2
+
3
+ export interface Codec {
4
+ encodeCall<A extends unknown[]>(taskName: string, args: A): Promise<Call>;
5
+
6
+ invokeTask<A extends unknown[], R>(
7
+ call: Call,
8
+ task: (...args: A) => Promise<R>,
9
+ ): Promise<Uint8Array>;
10
+
11
+ decodeReturn(taskName: string, payload: Uint8Array): unknown;
12
+ }
@@ -0,0 +1,186 @@
1
+ import type { Call } from "./call.ts";
2
+ import { type Cache, Memory, type Store } from "./store.ts";
3
+ import { SpawnLimitError } from "./errors.ts";
4
+ import { randomUUID } from "node:crypto";
5
+ import type { Publisher, Subscriber } from "./emitter.ts";
6
+ import { BrrrShutdownSymbol, BrrrTaskDoneEventSymbol } from "./symbol.ts";
7
+ import { PendingReturn, ScheduleMessage, TaggedTuple } from "./tagged-tuple.ts";
8
+
9
+ export interface DeferredCall {
10
+ readonly topic: string | undefined;
11
+ readonly call: Call;
12
+ }
13
+
14
+ export class Defer {
15
+ public readonly calls: DeferredCall[];
16
+
17
+ public constructor(...calls: DeferredCall[]) {
18
+ this.calls = calls;
19
+ }
20
+ }
21
+
22
+ export interface Request {
23
+ readonly call: Call;
24
+ }
25
+
26
+ export interface Response {
27
+ readonly payload: Uint8Array;
28
+ }
29
+
30
+ type RequestHandler = (
31
+ request: Request,
32
+ connection: Connection,
33
+ ) => Promise<Response | Defer>;
34
+
35
+ export class Connection {
36
+ public readonly cache: Cache;
37
+ public readonly memory: Memory;
38
+ public readonly emitter: Publisher;
39
+ public readonly spawnLimit = 10_000;
40
+
41
+ public constructor(store: Store, cache: Cache, emitter: Publisher) {
42
+ this.cache = cache;
43
+ this.memory = new Memory(store);
44
+ this.emitter = emitter;
45
+ }
46
+
47
+ public async putJob(topic: string, job: ScheduleMessage): Promise<void> {
48
+ if ((await this.cache.incr(`brrr_count/${job.rootId}`)) > this.spawnLimit) {
49
+ throw new SpawnLimitError(this.spawnLimit, job.rootId, job.callHash);
50
+ }
51
+ await this.emitter.emit(topic, TaggedTuple.encodeToString(job));
52
+ }
53
+
54
+ public async scheduleRaw(topic: string, call: Call): Promise<void> {
55
+ if (await this.memory.hasValue(call.callHash)) {
56
+ return;
57
+ }
58
+ await this.memory.setCall(call);
59
+ const rootId = randomUUID().replaceAll("-", "");
60
+ await this.putJob(topic, new ScheduleMessage(rootId, call.callHash));
61
+ }
62
+
63
+ public async readRaw(callHash: string): Promise<Uint8Array | undefined> {
64
+ return this.memory.getValue(callHash);
65
+ }
66
+ }
67
+
68
+ export class Server extends Connection {
69
+ public constructor(store: Store, cache: Cache, emitter: Publisher) {
70
+ super(store, cache, emitter);
71
+ }
72
+
73
+ public async loop(
74
+ topic: string,
75
+ handler: RequestHandler,
76
+ getMessage: () => Promise<string | typeof BrrrShutdownSymbol | undefined>,
77
+ ) {
78
+ while (true) {
79
+ const message = await getMessage();
80
+ if (!message) {
81
+ continue;
82
+ }
83
+ if (message === BrrrShutdownSymbol) {
84
+ break;
85
+ }
86
+ const call = await this.handleMessage(handler, topic, message);
87
+ if (call) {
88
+ await this.emitter.emitEventSymbol?.(BrrrTaskDoneEventSymbol, call);
89
+ }
90
+ }
91
+ }
92
+
93
+ protected async handleMessage(
94
+ requestHandler: RequestHandler,
95
+ topic: string,
96
+ payload: string,
97
+ ): Promise<Call | undefined> {
98
+ const message = TaggedTuple.decodeFromString(ScheduleMessage, payload);
99
+ const call = await this.memory.getCall(message.callHash);
100
+ const handled = await requestHandler({ call }, this);
101
+ if (handled instanceof Defer) {
102
+ await Promise.all(
103
+ handled.calls.map((child) => {
104
+ return this.scheduleCallNested(topic, child, message);
105
+ }),
106
+ );
107
+ return;
108
+ }
109
+ await this.memory.setValue(message.callHash, handled.payload);
110
+ let spawnLimitError: SpawnLimitError;
111
+ await this.memory.withPendingReturnsRemove(
112
+ message.callHash,
113
+ async (returns) => {
114
+ for (const pending of returns) {
115
+ try {
116
+ await this.scheduleReturnCall(pending);
117
+ } catch (err) {
118
+ if (err instanceof SpawnLimitError) {
119
+ spawnLimitError = err;
120
+ continue;
121
+ }
122
+ throw err;
123
+ }
124
+ }
125
+ if (spawnLimitError) {
126
+ throw spawnLimitError;
127
+ }
128
+ },
129
+ );
130
+ return call;
131
+ }
132
+
133
+ private async scheduleReturnCall(
134
+ pendingReturn: PendingReturn,
135
+ ): Promise<void> {
136
+ const job = new ScheduleMessage(
137
+ pendingReturn.rootId,
138
+ pendingReturn.callHash,
139
+ );
140
+ await this.putJob(pendingReturn.topic, job);
141
+ }
142
+
143
+ private async scheduleCallNested(
144
+ topic: string,
145
+ child: DeferredCall,
146
+ parent: ScheduleMessage,
147
+ ): Promise<void> {
148
+ await this.memory.setCall(child.call);
149
+ const callHash = child.call.callHash;
150
+ const pendingReturn = new PendingReturn(
151
+ parent.rootId,
152
+ parent.callHash,
153
+ topic,
154
+ );
155
+ const shouldSchedule = await this.memory.addPendingReturns(
156
+ callHash,
157
+ pendingReturn,
158
+ );
159
+ if (shouldSchedule) {
160
+ const job = new ScheduleMessage(parent.rootId, callHash);
161
+ await this.putJob(child.topic || topic, job);
162
+ }
163
+ }
164
+ }
165
+
166
+ export class SubscriberServer extends Server {
167
+ public override readonly emitter: Publisher & Subscriber;
168
+
169
+ public constructor(
170
+ store: Store,
171
+ cache: Cache,
172
+ emitter: Publisher & Subscriber,
173
+ ) {
174
+ super(store, cache, emitter);
175
+ this.emitter = emitter;
176
+ }
177
+
178
+ public listen(topic: string, handler: RequestHandler) {
179
+ this.emitter.on(topic, async (callId: string): Promise<void> => {
180
+ const result = await this.handleMessage(handler, topic, callId);
181
+ if (result) {
182
+ await this.emitter.emitEventSymbol?.(BrrrTaskDoneEventSymbol, result);
183
+ }
184
+ });
185
+ }
186
+ }
package/src/emitter.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { Call } from "./call.ts";
2
+ import { BrrrTaskDoneEventSymbol } from "./symbol.ts";
3
+
4
+ export interface Publisher {
5
+ emit(topic: string, callId: string): Promise<void>;
6
+
7
+ emitEventSymbol?(
8
+ event: typeof BrrrTaskDoneEventSymbol,
9
+ call: Call,
10
+ ): Promise<void>;
11
+ }
12
+
13
+ export interface Subscriber {
14
+ on(topic: string, listener: (callId: string) => void): void;
15
+
16
+ onEventSymbol?(
17
+ event: typeof BrrrTaskDoneEventSymbol,
18
+ listener: (call: Call) => void,
19
+ ): void;
20
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { MemKey } from "./store.ts";
2
+ import type { Tagged } from "./tagged-tuple.ts";
3
+
4
+ abstract class BrrrError extends Error {
5
+ protected constructor(message: string) {
6
+ super();
7
+ this.name = this.constructor.name;
8
+ this.message = message;
9
+ }
10
+ }
11
+
12
+ export class NotFoundError extends BrrrError {
13
+ public constructor(key: MemKey) {
14
+ super(`Not found: ${key.type}/${key.callHash}`);
15
+ }
16
+ }
17
+
18
+ export class CasRetryLimitReachedError extends BrrrError {
19
+ public constructor(retryLimit: number) {
20
+ super(`CAS retry limit reached (${retryLimit})`);
21
+ }
22
+ }
23
+
24
+ export class SpawnLimitError extends BrrrError {
25
+ public constructor(limit: number, rootId: string, callHash: string) {
26
+ super(
27
+ `Spawn limit of ${limit} reached for rootId ${rootId} and callHash ${callHash}`,
28
+ );
29
+ }
30
+ }
31
+
32
+ export class TaskNotFoundError extends BrrrError {
33
+ public constructor(taskName: string) {
34
+ super(`Task not found: ${taskName}`);
35
+ }
36
+ }
37
+
38
+ export class TagMismatchError extends BrrrError {
39
+ public constructor(clz: Tagged) {
40
+ super(`Tag mismatch for ${clz.name}: expected ${clz.tag}`);
41
+ }
42
+ }
43
+
44
+ export class MalformedTaggedTupleError extends BrrrError {
45
+ public constructor(clz: Tagged) {
46
+ super(
47
+ `Malformed tagged tuple for ${clz.name}, expected ${clz.length} elements`,
48
+ );
49
+ }
50
+ }
@@ -0,0 +1,28 @@
1
+ import bencode from "bencode";
2
+ import { Buffer } from "node:buffer";
3
+ import type { Encoding } from "node:crypto";
4
+ import { TextDecoder, TextEncoder } from "node:util";
5
+
6
+ /**
7
+ * Brrr uses UTF-8 for encoding
8
+ */
9
+ export const encoding = "utf-8" as const satisfies Encoding;
10
+
11
+ /**
12
+ * Bencode encoding and decoding utility.
13
+ */
14
+ export const bencoder = {
15
+ encode(data: unknown): Uint8Array {
16
+ return bencode.encode(data);
17
+ },
18
+ decode(data: Uint8Array, _encoding?: typeof encoding): unknown {
19
+ const buffer = Buffer.from(data);
20
+ return bencode.decode(buffer, _encoding);
21
+ },
22
+ } as const;
23
+
24
+ /**
25
+ * Exports TextEncoder and TextDecoder instances for UTF-8 encoding.
26
+ */
27
+ export const encoder = new TextEncoder();
28
+ export const decoder = new TextDecoder(encoding);
@@ -0,0 +1,93 @@
1
+ import { SubscriberServer } from "./connection.ts";
2
+ import {
3
+ AppWorker,
4
+ type Handlers,
5
+ type NoAppTask,
6
+ type StripLeadingActiveWorker,
7
+ type TaskIdentifier,
8
+ taskIdentifierToName,
9
+ } from "./app.ts";
10
+ import type { Codec } from "./codec.ts";
11
+ import {
12
+ InMemoryCache,
13
+ InMemoryEmitter,
14
+ InMemoryStore,
15
+ } from "./backends/in-memory.ts";
16
+ import { NotFoundError } from "./errors.ts";
17
+ import { BrrrTaskDoneEventSymbol } from "./symbol.ts";
18
+
19
+ export class LocalApp {
20
+ public readonly topic: string;
21
+ public readonly server: SubscriberServer;
22
+ public readonly app: AppWorker;
23
+
24
+ private hasRun = false;
25
+
26
+ public constructor(topic: string, server: SubscriberServer, app: AppWorker) {
27
+ this.topic = topic;
28
+ this.server = server;
29
+ this.app = app;
30
+ }
31
+
32
+ public schedule<A extends unknown[], R>(
33
+ handler: Parameters<typeof this.app.schedule<A, R>>[0],
34
+ ): NoAppTask<A, void> {
35
+ return this.app.schedule(handler, this.topic);
36
+ }
37
+
38
+ public read<A extends unknown[], R>(
39
+ ...args: Parameters<typeof this.app.read<A, R>>
40
+ ): NoAppTask<A, R> {
41
+ return this.app.read(...args);
42
+ }
43
+
44
+ public run(): void {
45
+ if (this.hasRun) {
46
+ throw new Error("LocalApp has already been run");
47
+ }
48
+ this.hasRun = true;
49
+ this.server.listen(this.topic, this.app.handle);
50
+ }
51
+ }
52
+
53
+ export class LocalBrrr {
54
+ private readonly topic: string;
55
+ private readonly handlers: Handlers;
56
+ private readonly codec: Codec;
57
+
58
+ public constructor(topic: string, handlers: Handlers, codec: Codec) {
59
+ this.topic = topic;
60
+ this.handlers = handlers;
61
+ this.codec = codec;
62
+ }
63
+
64
+ public run<A extends unknown[], R>(taskIdentifier: TaskIdentifier<A, R>) {
65
+ const store = new InMemoryStore();
66
+ const cache = new InMemoryCache();
67
+ const emitter = new InMemoryEmitter();
68
+ const server = new SubscriberServer(store, cache, emitter);
69
+ const worker = new AppWorker(this.codec, server, this.handlers);
70
+ const localApp = new LocalApp(this.topic, server, worker);
71
+ const taskName = taskIdentifierToName(taskIdentifier, this.handlers);
72
+ return async (...args: StripLeadingActiveWorker<A>): Promise<R> => {
73
+ localApp.run();
74
+ await localApp.schedule(taskName)(...args);
75
+ const call = await this.codec.encodeCall(taskName, args);
76
+ return new Promise((resolve) => {
77
+ emitter.onEventSymbol(BrrrTaskDoneEventSymbol, async ({ callHash }) => {
78
+ if (callHash === call.callHash) {
79
+ const payload = await server.readRaw(callHash);
80
+ if (!payload) {
81
+ throw new NotFoundError({
82
+ type: "value",
83
+ callHash,
84
+ });
85
+ }
86
+ const result = this.codec.decodeReturn(taskName, payload) as R;
87
+ resolve(result);
88
+ }
89
+ });
90
+ });
91
+ };
92
+ }
93
+ }
@@ -0,0 +1,9 @@
1
+ import { suite, test } from "node:test";
2
+ import { NaiveJsonCodec } from "./naive-json-codec.ts";
3
+ import { codecContractTest } from "./codec.test.ts";
4
+
5
+ await suite(import.meta.filename, async () => {
6
+ await test(NaiveJsonCodec.name, async () => {
7
+ await codecContractTest(new NaiveJsonCodec());
8
+ });
9
+ });
@@ -0,0 +1,72 @@
1
+ import { type BinaryToTextEncoding, createHash } from "node:crypto";
2
+ import type { Call } from "./call.ts";
3
+ import type { Codec } from "./codec.ts";
4
+ import { parse, stringify } from "superjson";
5
+ import { decoder, encoder } from "./internal-codecs.ts";
6
+
7
+ /**
8
+ * Naive JSON codec that uses `superjson` for serialization and deserialization.
9
+ *
10
+ * It tries its best to ensure that the serialized data is deterministic by
11
+ * sorting object keys recursively before serialization, but it's not
12
+ * reccommended for production use; the primary purpose of this codec is
13
+ * executable documentation.
14
+ */
15
+ export class NaiveJsonCodec implements Codec {
16
+ public static readonly algorithm = "sha256";
17
+ public static readonly binaryToTextEncoding =
18
+ "hex" satisfies BinaryToTextEncoding;
19
+
20
+ public async decodeReturn(_: string, payload: Uint8Array): Promise<unknown> {
21
+ const decoded = decoder.decode(payload);
22
+ return parse(decoded);
23
+ }
24
+
25
+ public async encodeCall<A extends unknown[]>(
26
+ taskName: string,
27
+ args: A,
28
+ ): Promise<Call> {
29
+ const sortedArgs = args.map(NaiveJsonCodec.sortObjectKeys);
30
+ const data = stringify(sortedArgs);
31
+ const payload = encoder.encode(data);
32
+ const callHash = await this.hashCall(taskName, sortedArgs);
33
+ return { taskName, payload, callHash };
34
+ }
35
+
36
+ public async invokeTask<A extends unknown[], R>(
37
+ call: Call,
38
+ task: (...args: A) => Promise<R>,
39
+ ): Promise<Uint8Array> {
40
+ const decoded = decoder.decode(call.payload);
41
+ const args = parse<A>(decoded);
42
+ const result = await task(...args);
43
+ const resultJson = stringify(result);
44
+ return encoder.encode(resultJson);
45
+ }
46
+
47
+ private async hashCall<A extends unknown>(
48
+ taskName: string,
49
+ args: A,
50
+ ): Promise<string> {
51
+ const data = stringify([taskName, args]);
52
+ return createHash(NaiveJsonCodec.algorithm)
53
+ .update(data)
54
+ .digest(NaiveJsonCodec.binaryToTextEncoding);
55
+ }
56
+
57
+ private static sortObjectKeys<T>(unordered: T): T {
58
+ if (!unordered || typeof unordered !== "object") {
59
+ return unordered;
60
+ }
61
+ if (Array.isArray(unordered)) {
62
+ return unordered.map(NaiveJsonCodec.sortObjectKeys) as T;
63
+ }
64
+ const entries = Object.keys(unordered)
65
+ .sort()
66
+ .map((key) => [
67
+ key,
68
+ NaiveJsonCodec.sortObjectKeys(unordered[key as keyof typeof unordered]),
69
+ ]);
70
+ return Object.fromEntries(entries);
71
+ }
72
+ }