@lenso/realtime 0.0.0-stage → 0.2.0
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 +420 -2
- package/dist/config.d.ts +3 -0
- package/dist/contracts.d.ts +119 -0
- package/dist/index-6fg21p7s.js +563 -0
- package/dist/index-htkqerky.js +81 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +12 -0
- package/dist/memory.d.ts +5 -0
- package/dist/memory.js +62 -0
- package/dist/plugin.d.ts +30 -0
- package/dist/plugin.js +68 -0
- package/dist/redis.d.ts +11 -0
- package/dist/redis.js +275 -0
- package/dist/wire.d.ts +10 -0
- package/examples/notes.ts +65 -0
- package/examples/run-notes.ts +43 -0
- package/examples/tasks.ts +10 -0
- package/package.json +53 -4
package/dist/memory.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RealtimeError2,
|
|
3
|
+
serialize
|
|
4
|
+
} from "./index-htkqerky.js";
|
|
5
|
+
|
|
6
|
+
// src/memory.ts
|
|
7
|
+
function createMemoryProvider(options = {}) {
|
|
8
|
+
const maxTopics = options.maxTopics ?? 1e4;
|
|
9
|
+
const ttl = options.watermarkTtlMs ?? 600000;
|
|
10
|
+
if (!Number.isSafeInteger(maxTopics) || maxTopics < 1 || !Number.isSafeInteger(ttl) || ttl < 1)
|
|
11
|
+
throw new RealtimeError2("invalid-input");
|
|
12
|
+
const marks = new Map;
|
|
13
|
+
let deliver;
|
|
14
|
+
let closed = false;
|
|
15
|
+
const current = (topic) => {
|
|
16
|
+
if (!deliver || closed)
|
|
17
|
+
throw new RealtimeError2("closed");
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
const old = marks.get(topic);
|
|
20
|
+
if (old && old.expiresAt > now) {
|
|
21
|
+
old.expiresAt = now + ttl;
|
|
22
|
+
return { ...old.cursor };
|
|
23
|
+
}
|
|
24
|
+
if (marks.size >= maxTopics) {
|
|
25
|
+
for (const [key, mark] of marks)
|
|
26
|
+
if (mark.expiresAt <= now)
|
|
27
|
+
marks.delete(key);
|
|
28
|
+
if (marks.size >= maxTopics)
|
|
29
|
+
throw new RealtimeError2("limit");
|
|
30
|
+
}
|
|
31
|
+
const cursor = { generation: crypto.randomUUID(), sequence: 0 };
|
|
32
|
+
marks.set(topic, { cursor, expiresAt: now + ttl });
|
|
33
|
+
return { ...cursor };
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
kind: "memory",
|
|
37
|
+
async start(listener) {
|
|
38
|
+
if (deliver || closed)
|
|
39
|
+
throw new RealtimeError2("closed");
|
|
40
|
+
deliver = listener;
|
|
41
|
+
},
|
|
42
|
+
async current(topic) {
|
|
43
|
+
return current(topic);
|
|
44
|
+
},
|
|
45
|
+
async publish(topic, event) {
|
|
46
|
+
const copy = JSON.parse(serialize(event, 49152));
|
|
47
|
+
const previous = current(topic);
|
|
48
|
+
const cursor = { ...previous, sequence: previous.sequence + 1 };
|
|
49
|
+
marks.set(topic, { cursor, expiresAt: Date.now() + ttl });
|
|
50
|
+
deliver({ topic, event: copy, cursor });
|
|
51
|
+
return { ...cursor };
|
|
52
|
+
},
|
|
53
|
+
async close() {
|
|
54
|
+
closed = true;
|
|
55
|
+
deliver = undefined;
|
|
56
|
+
marks.clear();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export {
|
|
61
|
+
createMemoryProvider
|
|
62
|
+
};
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ConfigSource } from "@lenso/core/config";
|
|
2
|
+
import type { Plugin } from "@lenso/core/plugin";
|
|
3
|
+
import type { Realtime, RealtimeConfig, RealtimeOptions, RealtimeProvider } from "./contracts";
|
|
4
|
+
export declare const realtimeConfig: import("@lenso/core/config").ConfigContract<{
|
|
5
|
+
"~standard": {
|
|
6
|
+
version: 1;
|
|
7
|
+
vendor: string;
|
|
8
|
+
types: {
|
|
9
|
+
input: RealtimeConfig;
|
|
10
|
+
output: Required<RealtimeConfig>;
|
|
11
|
+
} | undefined;
|
|
12
|
+
validate(input: unknown): {
|
|
13
|
+
issues: {
|
|
14
|
+
message: string;
|
|
15
|
+
}[];
|
|
16
|
+
value?: undefined;
|
|
17
|
+
} | {
|
|
18
|
+
issues?: undefined;
|
|
19
|
+
value: Required<RealtimeConfig>;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
}>;
|
|
23
|
+
/** Provider factory executes in setup, never at trusted config import time. */
|
|
24
|
+
export declare function createRealtimePlugin<P>(options: {
|
|
25
|
+
id: string;
|
|
26
|
+
requires?: readonly Plugin<unknown>[];
|
|
27
|
+
config: RealtimeConfig | readonly ConfigSource[];
|
|
28
|
+
provider(): RealtimeProvider;
|
|
29
|
+
authorize: RealtimeOptions<P>["authorize"];
|
|
30
|
+
}): Plugin<Realtime<P>>;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defaults,
|
|
3
|
+
resolveRealtimeConfig2,
|
|
4
|
+
createRealtime2
|
|
5
|
+
} from "./index-6fg21p7s.js";
|
|
6
|
+
|
|
7
|
+
// src/plugin.ts
|
|
8
|
+
import { bindConfig, definePluginConfig } from "@lenso/core/config";
|
|
9
|
+
var schema = {
|
|
10
|
+
"~standard": {
|
|
11
|
+
version: 1,
|
|
12
|
+
vendor: "lenso-realtime",
|
|
13
|
+
types: undefined,
|
|
14
|
+
validate(input) {
|
|
15
|
+
try {
|
|
16
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
17
|
+
return { issues: [{ message: "Invalid realtime configuration" }] };
|
|
18
|
+
return { value: resolveRealtimeConfig2(input) };
|
|
19
|
+
} catch {
|
|
20
|
+
return { issues: [{ message: "Invalid realtime configuration" }] };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var realtimeConfig = definePluginConfig({
|
|
26
|
+
schema,
|
|
27
|
+
description: "Bounded resource subscriptions; no stream or publisher is automatically exposed.",
|
|
28
|
+
fields: Object.keys(defaults).map((name) => ({
|
|
29
|
+
path: [name],
|
|
30
|
+
description: `Realtime ${name} limit.`
|
|
31
|
+
})),
|
|
32
|
+
jsonSchema: () => ({
|
|
33
|
+
type: "object",
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
properties: Object.fromEntries(Object.keys(defaults).map((key) => [
|
|
36
|
+
key,
|
|
37
|
+
{
|
|
38
|
+
type: "integer",
|
|
39
|
+
minimum: key === "maxBufferedBytes" ? 512 : 1,
|
|
40
|
+
maximum: key === "authorizationLeaseMs" ? 30000 : key === "sweepMs" ? 1000 : key === "maxPayloadBytes" ? 32768 : 1e7
|
|
41
|
+
}
|
|
42
|
+
]))
|
|
43
|
+
})
|
|
44
|
+
});
|
|
45
|
+
function createRealtimePlugin(options) {
|
|
46
|
+
return bindConfig(realtimeConfig, options.config, {
|
|
47
|
+
id: options.id,
|
|
48
|
+
requires: options.requires,
|
|
49
|
+
async setup(context, config) {
|
|
50
|
+
const provider = options.provider();
|
|
51
|
+
context.onCleanup(() => provider.close());
|
|
52
|
+
const realtime = await createRealtime2({
|
|
53
|
+
config,
|
|
54
|
+
provider,
|
|
55
|
+
authorize: options.authorize,
|
|
56
|
+
onDiagnostic(event) {
|
|
57
|
+
context.logger?.warn({ category: event }, "Realtime lifecycle diagnostic");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
context.onCleanup(() => realtime.close());
|
|
61
|
+
return realtime;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export {
|
|
66
|
+
createRealtimePlugin,
|
|
67
|
+
realtimeConfig
|
|
68
|
+
};
|
package/dist/redis.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type RealtimeProvider } from "./contracts";
|
|
2
|
+
export interface RedisProviderOptions {
|
|
3
|
+
readonly url: string;
|
|
4
|
+
readonly namespace: string;
|
|
5
|
+
readonly watermarkTtlMs?: number;
|
|
6
|
+
readonly commandTimeoutMs?: number;
|
|
7
|
+
readonly connectionTimeoutMs?: number;
|
|
8
|
+
readonly maxPendingOperations?: number;
|
|
9
|
+
}
|
|
10
|
+
/** Standalone Redis only: two owned sockets, non-durable namespace Pub/Sub. */
|
|
11
|
+
export declare function createRedisProvider(options: RedisProviderOptions): RealtimeProvider;
|
package/dist/redis.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/redis.ts
|
|
3
|
+
var {RedisClient } = globalThis.Bun;
|
|
4
|
+
|
|
5
|
+
// src/contracts.ts
|
|
6
|
+
class RealtimeError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code) {
|
|
9
|
+
super(`Realtime ${code}`);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = "RealtimeError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/wire.ts
|
|
16
|
+
function serialize(value, maxBytes) {
|
|
17
|
+
const active = new Set;
|
|
18
|
+
let nodes = 0;
|
|
19
|
+
const visit = (input, depth) => {
|
|
20
|
+
if (++nodes > 1e4 || depth > 32)
|
|
21
|
+
throw new RealtimeError("payload");
|
|
22
|
+
if (input === null || typeof input === "string" || typeof input === "boolean")
|
|
23
|
+
return;
|
|
24
|
+
if (typeof input === "number" && Number.isFinite(input))
|
|
25
|
+
return;
|
|
26
|
+
if (typeof input !== "object" || input === null || active.has(input))
|
|
27
|
+
throw new RealtimeError("payload");
|
|
28
|
+
const proto = Object.getPrototypeOf(input);
|
|
29
|
+
if (Array.isArray(input) ? proto !== Array.prototype : proto !== Object.prototype && proto !== null)
|
|
30
|
+
throw new RealtimeError("payload");
|
|
31
|
+
active.add(input);
|
|
32
|
+
if (Object.getOwnPropertySymbols(input).length)
|
|
33
|
+
throw new RealtimeError("payload");
|
|
34
|
+
const keys = Object.keys(input);
|
|
35
|
+
const ownNames = Object.getOwnPropertyNames(input);
|
|
36
|
+
if (ownNames.length !== keys.length + (Array.isArray(input) ? 1 : 0))
|
|
37
|
+
throw new RealtimeError("payload");
|
|
38
|
+
if (Array.isArray(input) && (keys.length !== input.length || keys.some((key, i) => key !== String(i))))
|
|
39
|
+
throw new RealtimeError("payload");
|
|
40
|
+
for (const key of keys) {
|
|
41
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, key);
|
|
42
|
+
if (!("value" in descriptor))
|
|
43
|
+
throw new RealtimeError("payload");
|
|
44
|
+
visit(descriptor.value, depth + 1);
|
|
45
|
+
}
|
|
46
|
+
active.delete(input);
|
|
47
|
+
};
|
|
48
|
+
visit(value, 0);
|
|
49
|
+
const text = JSON.stringify(value);
|
|
50
|
+
if (new TextEncoder().encode(text).byteLength > maxBytes)
|
|
51
|
+
throw new RealtimeError("payload");
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/redis.ts
|
|
56
|
+
var MAX_WIRE_BYTES = 48 * 1024;
|
|
57
|
+
var encoder = new TextEncoder;
|
|
58
|
+
var WATERMARK = `
|
|
59
|
+
if redis.call('EXISTS', KEYS[1]) == 0 then
|
|
60
|
+
redis.call('HSET', KEYS[1], 'generation', ARGV[1], 'sequence', '0')
|
|
61
|
+
end
|
|
62
|
+
local generation = redis.call('HGET', KEYS[1], 'generation')
|
|
63
|
+
local sequence = redis.call('HGET', KEYS[1], 'sequence')
|
|
64
|
+
if ARGV[3] == 'publish' then
|
|
65
|
+
if tonumber(sequence) >= 9007199254740991 then
|
|
66
|
+
return redis.error_reply('sequence exhausted')
|
|
67
|
+
end
|
|
68
|
+
redis.call('HINCRBY', KEYS[1], 'sequence', 1)
|
|
69
|
+
sequence = redis.call('HGET', KEYS[1], 'sequence')
|
|
70
|
+
local delivery = '{"topic":' .. ARGV[4] .. ',"cursor":{"generation":"' ..
|
|
71
|
+
generation .. '","sequence":' .. sequence .. '},"event":' .. ARGV[5] .. '}'
|
|
72
|
+
redis.call('PUBLISH', ARGV[6], delivery)
|
|
73
|
+
end
|
|
74
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
|
75
|
+
return {generation, sequence}
|
|
76
|
+
`;
|
|
77
|
+
function validCursor(value) {
|
|
78
|
+
if (!value || typeof value !== "object")
|
|
79
|
+
return false;
|
|
80
|
+
const cursor = value;
|
|
81
|
+
return Object.keys(cursor).length === 2 && typeof cursor.generation === "string" && /^[A-Za-z0-9-]{1,64}$/.test(cursor.generation) && Number.isSafeInteger(cursor.sequence) && cursor.sequence >= 0;
|
|
82
|
+
}
|
|
83
|
+
function validEvent(value) {
|
|
84
|
+
if (!value || typeof value !== "object")
|
|
85
|
+
return false;
|
|
86
|
+
const event = value;
|
|
87
|
+
if (event.kind === "deleted" || event.kind === "revoke")
|
|
88
|
+
return Object.keys(event).length === 1;
|
|
89
|
+
return event.kind === "update" && typeof event.type === "string" && event.type.length > 0 && event.type.length <= 256 && Object.hasOwn(event, "data") && Object.keys(event).length === 3;
|
|
90
|
+
}
|
|
91
|
+
function validTopic(topic) {
|
|
92
|
+
return typeof topic === "string" && topic.length > 0 && topic.length <= 4096;
|
|
93
|
+
}
|
|
94
|
+
function createRedisProvider(options) {
|
|
95
|
+
if (!options || typeof options.url !== "string" || !options.url || typeof options.namespace !== "string" || !/^[A-Za-z0-9:_-]{1,128}$/.test(options.namespace))
|
|
96
|
+
throw new RealtimeError("invalid-input");
|
|
97
|
+
const ttl = options.watermarkTtlMs ?? 600000;
|
|
98
|
+
const timeout = options.commandTimeoutMs ?? 5000;
|
|
99
|
+
const connectionTimeout = options.connectionTimeoutMs ?? 2000;
|
|
100
|
+
const maxPending = options.maxPendingOperations ?? 64;
|
|
101
|
+
for (const value of [ttl, timeout, connectionTimeout, maxPending]) {
|
|
102
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 1e7)
|
|
103
|
+
throw new RealtimeError("invalid-input");
|
|
104
|
+
}
|
|
105
|
+
const url = options.url;
|
|
106
|
+
const namespace = options.namespace;
|
|
107
|
+
const channel = `${namespace}:events`;
|
|
108
|
+
const keyPrefix = `${namespace.length}:${namespace}:watermark:`;
|
|
109
|
+
let state = "new";
|
|
110
|
+
let command;
|
|
111
|
+
let subscription;
|
|
112
|
+
let deliver;
|
|
113
|
+
let fail;
|
|
114
|
+
let pending = 0;
|
|
115
|
+
const cancellations = new Set;
|
|
116
|
+
function release(error) {
|
|
117
|
+
deliver = undefined;
|
|
118
|
+
fail = undefined;
|
|
119
|
+
for (const cancel of cancellations)
|
|
120
|
+
cancel(error);
|
|
121
|
+
for (const client of [command, subscription]) {
|
|
122
|
+
if (!client)
|
|
123
|
+
continue;
|
|
124
|
+
client.onclose = () => {};
|
|
125
|
+
client.onconnect = () => {};
|
|
126
|
+
try {
|
|
127
|
+
client.close();
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
command = undefined;
|
|
131
|
+
subscription = undefined;
|
|
132
|
+
}
|
|
133
|
+
function terminate() {
|
|
134
|
+
if (state === "failed" || state === "closed")
|
|
135
|
+
return;
|
|
136
|
+
const notify = fail;
|
|
137
|
+
state = "failed";
|
|
138
|
+
release(new RealtimeError("provider"));
|
|
139
|
+
notify?.();
|
|
140
|
+
}
|
|
141
|
+
function deadline(run) {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
let settled = false;
|
|
144
|
+
const finish = (error, value) => {
|
|
145
|
+
if (settled)
|
|
146
|
+
return;
|
|
147
|
+
settled = true;
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
cancellations.delete(cancel);
|
|
150
|
+
if (error)
|
|
151
|
+
reject(error);
|
|
152
|
+
else
|
|
153
|
+
resolve(value);
|
|
154
|
+
};
|
|
155
|
+
const cancel = (error) => finish(error);
|
|
156
|
+
const timer = setTimeout(() => terminate(), timeout);
|
|
157
|
+
cancellations.add(cancel);
|
|
158
|
+
try {
|
|
159
|
+
run().then((value) => finish(undefined, value), () => {
|
|
160
|
+
terminate();
|
|
161
|
+
finish(new RealtimeError("provider"));
|
|
162
|
+
});
|
|
163
|
+
} catch {
|
|
164
|
+
terminate();
|
|
165
|
+
finish(new RealtimeError("provider"));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function receive(message) {
|
|
170
|
+
if (state !== "starting" && state !== "ready")
|
|
171
|
+
return;
|
|
172
|
+
try {
|
|
173
|
+
if (encoder.encode(message).byteLength > MAX_WIRE_BYTES)
|
|
174
|
+
throw new Error;
|
|
175
|
+
const value = JSON.parse(message);
|
|
176
|
+
if (!value || typeof value !== "object" || Object.keys(value).length !== 3 || !validTopic(value.topic) || !validCursor(value.cursor) || !validEvent(value.event) || value.cursor.sequence < 1)
|
|
177
|
+
throw new Error;
|
|
178
|
+
serialize(value.event, MAX_WIRE_BYTES);
|
|
179
|
+
deliver?.(value);
|
|
180
|
+
} catch {
|
|
181
|
+
terminate();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function watermark(topic, event) {
|
|
185
|
+
if (state !== "ready")
|
|
186
|
+
throw new RealtimeError(state === "failed" ? "provider" : "closed");
|
|
187
|
+
if (pending >= maxPending)
|
|
188
|
+
throw new RealtimeError("limit");
|
|
189
|
+
if (!validTopic(topic))
|
|
190
|
+
throw new RealtimeError("invalid-input");
|
|
191
|
+
const generation = crypto.randomUUID();
|
|
192
|
+
const topicJson = serialize(topic, MAX_WIRE_BYTES);
|
|
193
|
+
let eventJson = "";
|
|
194
|
+
if (event !== undefined) {
|
|
195
|
+
if (!validEvent(event))
|
|
196
|
+
throw new RealtimeError("payload");
|
|
197
|
+
eventJson = serialize(event, MAX_WIRE_BYTES);
|
|
198
|
+
const wire = JSON.stringify({
|
|
199
|
+
topic,
|
|
200
|
+
cursor: { generation, sequence: Number.MAX_SAFE_INTEGER },
|
|
201
|
+
event: JSON.parse(eventJson)
|
|
202
|
+
});
|
|
203
|
+
if (encoder.encode(wire).byteLength > MAX_WIRE_BYTES)
|
|
204
|
+
throw new RealtimeError("payload");
|
|
205
|
+
}
|
|
206
|
+
pending++;
|
|
207
|
+
try {
|
|
208
|
+
const result = await deadline(() => command.send("EVAL", [
|
|
209
|
+
WATERMARK,
|
|
210
|
+
"1",
|
|
211
|
+
`${keyPrefix}${topic}`,
|
|
212
|
+
generation,
|
|
213
|
+
String(ttl),
|
|
214
|
+
event === undefined ? "current" : "publish",
|
|
215
|
+
topicJson,
|
|
216
|
+
eventJson,
|
|
217
|
+
channel
|
|
218
|
+
]));
|
|
219
|
+
if (!Array.isArray(result) || result.length !== 2 || typeof result[1] !== "string" || !/^\d+$/.test(result[1])) {
|
|
220
|
+
terminate();
|
|
221
|
+
throw new RealtimeError("provider");
|
|
222
|
+
}
|
|
223
|
+
const cursor = { generation: result[0], sequence: Number(result[1]) };
|
|
224
|
+
if (!validCursor(cursor)) {
|
|
225
|
+
terminate();
|
|
226
|
+
throw new RealtimeError("provider");
|
|
227
|
+
}
|
|
228
|
+
return cursor;
|
|
229
|
+
} finally {
|
|
230
|
+
pending--;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
kind: "redis",
|
|
235
|
+
async start(onDelivery, onFailure) {
|
|
236
|
+
if (state !== "new")
|
|
237
|
+
throw new RealtimeError(state === "failed" ? "provider" : "closed");
|
|
238
|
+
state = "starting";
|
|
239
|
+
deliver = onDelivery;
|
|
240
|
+
fail = onFailure;
|
|
241
|
+
try {
|
|
242
|
+
const clientOptions = {
|
|
243
|
+
connectionTimeout,
|
|
244
|
+
autoReconnect: false,
|
|
245
|
+
maxRetries: 0,
|
|
246
|
+
enableOfflineQueue: false,
|
|
247
|
+
enableAutoPipelining: false
|
|
248
|
+
};
|
|
249
|
+
command = new RedisClient(url, clientOptions);
|
|
250
|
+
subscription = new RedisClient(url, clientOptions);
|
|
251
|
+
command.onclose = terminate;
|
|
252
|
+
subscription.onclose = terminate;
|
|
253
|
+
await deadline(() => Promise.all([command.connect(), subscription.connect()]));
|
|
254
|
+
await deadline(() => subscription.subscribe(channel, receive));
|
|
255
|
+
if (state !== "starting")
|
|
256
|
+
throw new RealtimeError("provider");
|
|
257
|
+
state = "ready";
|
|
258
|
+
} catch {
|
|
259
|
+
terminate();
|
|
260
|
+
throw new RealtimeError("provider");
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
current: (topic) => watermark(topic),
|
|
264
|
+
publish: (topic, event) => watermark(topic, event),
|
|
265
|
+
async close() {
|
|
266
|
+
if (state === "closed")
|
|
267
|
+
return;
|
|
268
|
+
state = "closed";
|
|
269
|
+
release(new RealtimeError("closed"));
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
export {
|
|
274
|
+
createRedisProvider
|
|
275
|
+
};
|
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Cursor, type Resource } from "./contracts";
|
|
2
|
+
export declare function part(value: string): string;
|
|
3
|
+
export declare function resourceTopic(resource: Resource): string;
|
|
4
|
+
export declare function cursorToken(cursor: Cursor, now?: number): string;
|
|
5
|
+
export declare function parseCursor(token: string): (Cursor & {
|
|
6
|
+
issuedAt: number;
|
|
7
|
+
}) | undefined;
|
|
8
|
+
export declare function sameCursor(a: Cursor, b: Cursor): boolean;
|
|
9
|
+
/** No toJSON, accessors, classes, sparse arrays, cycles, non-finite numbers or lossy values. */
|
|
10
|
+
export declare function serialize(value: unknown, maxBytes: number): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRealtime,
|
|
3
|
+
RealtimeError,
|
|
4
|
+
type Identity,
|
|
5
|
+
type RealtimeProvider,
|
|
6
|
+
} from "@lenso/realtime";
|
|
7
|
+
|
|
8
|
+
export interface Note {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
body: string;
|
|
12
|
+
revision: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Implement with the existing authorized Notes service, not a second HTTP policy. */
|
|
16
|
+
export interface NotesPort<P> {
|
|
17
|
+
read(identity: Identity<P>, id: string, signal: AbortSignal): Promise<Note | null>;
|
|
18
|
+
update(
|
|
19
|
+
identity: Identity<P>,
|
|
20
|
+
id: string,
|
|
21
|
+
input: { title: string; body: string },
|
|
22
|
+
): Promise<Note | null>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function createNotesRealtime<P>(notes: NotesPort<P>, provider: RealtimeProvider) {
|
|
26
|
+
const realtime = await createRealtime<P>({
|
|
27
|
+
provider,
|
|
28
|
+
async authorize(identity, resource, signal) {
|
|
29
|
+
if (resource.type !== "note") return false;
|
|
30
|
+
const note = await notes.read(identity, resource.id, signal);
|
|
31
|
+
return note ? { validUntil: identity.expiresAt } : false;
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
realtime,
|
|
36
|
+
async open(identity: Identity<P>, id: string, signal?: AbortSignal, cursor?: string) {
|
|
37
|
+
const connection = realtime.connect(identity, { signal });
|
|
38
|
+
try {
|
|
39
|
+
const subscription = await connection.subscribe(
|
|
40
|
+
{ scope: identity.scope, type: "note", id },
|
|
41
|
+
{ cursor },
|
|
42
|
+
);
|
|
43
|
+
const snapshot = await connection.snapshot(subscription, (snapshotSignal) =>
|
|
44
|
+
notes.read(identity, id, snapshotSignal),
|
|
45
|
+
);
|
|
46
|
+
if (!snapshot.value) throw new RealtimeError("denied");
|
|
47
|
+
return { connection, subscription, snapshot };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
connection.close();
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
async update(identity: Identity<P>, id: string, input: { title: string; body: string }) {
|
|
54
|
+
const note = await notes.update(identity, id, input);
|
|
55
|
+
if (note) {
|
|
56
|
+
// Publish invalidation after the durable write, never uncommitted note contents.
|
|
57
|
+
await realtime.publish({ scope: identity.scope, type: "note", id }, "note.updated", {
|
|
58
|
+
revision: note.revision,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return note;
|
|
62
|
+
},
|
|
63
|
+
close: () => realtime.close(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createMemoryProvider } from "@lenso/realtime/memory";
|
|
2
|
+
import { createNotesRealtime, type Note, type NotesPort } from "./notes";
|
|
3
|
+
import type { Identity } from "@lenso/realtime";
|
|
4
|
+
|
|
5
|
+
// Trusted in-process fixture, not a login endpoint or production authentication.
|
|
6
|
+
const principal = Object.freeze({});
|
|
7
|
+
const identity: Identity<object> = {
|
|
8
|
+
scope: "single-application",
|
|
9
|
+
subject: "note-owner",
|
|
10
|
+
principal,
|
|
11
|
+
expiresAt: Date.now() + 60000,
|
|
12
|
+
};
|
|
13
|
+
let stored: Note = { id: "welcome", title: "Notes", body: "Initial text", revision: 1 };
|
|
14
|
+
const notes: NotesPort<object> = {
|
|
15
|
+
async read(actor, id, signal) {
|
|
16
|
+
signal.throwIfAborted();
|
|
17
|
+
if (actor.principal !== principal || actor.scope !== identity.scope) throw new Error("Denied");
|
|
18
|
+
return id === stored.id ? { ...stored } : null;
|
|
19
|
+
},
|
|
20
|
+
async update(actor, id, input) {
|
|
21
|
+
if (actor.principal !== principal || actor.scope !== identity.scope) throw new Error("Denied");
|
|
22
|
+
if (id !== stored.id) return null;
|
|
23
|
+
stored = { ...stored, ...input, revision: stored.revision + 1 };
|
|
24
|
+
return { ...stored };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const bridge = await createNotesRealtime(notes, createMemoryProvider());
|
|
28
|
+
try {
|
|
29
|
+
const first = await bridge.open(identity, "welcome");
|
|
30
|
+
const second = await bridge.open(identity, "welcome");
|
|
31
|
+
const a = first.connection[Symbol.asyncIterator](),
|
|
32
|
+
b = second.connection[Symbol.asyncIterator]();
|
|
33
|
+
await a.next();
|
|
34
|
+
await b.next();
|
|
35
|
+
console.log(JSON.stringify({ snapshot: first.snapshot }));
|
|
36
|
+
await bridge.update(identity, "welcome", { title: "Notes", body: "Updated text" });
|
|
37
|
+
console.log(JSON.stringify({ first: (await a.next()).value, second: (await b.next()).value }));
|
|
38
|
+
first.subscription.unsubscribe();
|
|
39
|
+
first.connection.close();
|
|
40
|
+
second.connection.close();
|
|
41
|
+
} finally {
|
|
42
|
+
await bridge.close();
|
|
43
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Realtime } from "@lenso/realtime";
|
|
2
|
+
|
|
3
|
+
/** Call only after the existing Tasks owner commits a status transition. */
|
|
4
|
+
export async function taskStatusCommitted(
|
|
5
|
+
realtime: Realtime,
|
|
6
|
+
resource: { scope: string; id: string },
|
|
7
|
+
status: "queued" | "running" | "succeeded" | "failed",
|
|
8
|
+
) {
|
|
9
|
+
return realtime.publish({ ...resource, type: "task" }, "task.status", { status });
|
|
10
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenso/realtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/LioRael/lenso.git",
|
|
8
|
+
"directory": "packages/realtime"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"examples"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./memory": {
|
|
22
|
+
"types": "./dist/memory.d.ts",
|
|
23
|
+
"import": "./dist/memory.js"
|
|
24
|
+
},
|
|
25
|
+
"./redis": {
|
|
26
|
+
"types": "./dist/redis.d.ts",
|
|
27
|
+
"import": "./dist/redis.js"
|
|
28
|
+
},
|
|
29
|
+
"./plugin": {
|
|
30
|
+
"types": "./dist/plugin.d.ts",
|
|
31
|
+
"import": "./dist/plugin.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "bun -e \"await import('node:fs/promises').then(({rm}) => rm('dist', {recursive:true, force:true}))\" && bun build src/index.ts src/memory.ts src/plugin.ts --root src --outdir dist --target browser --packages external --splitting && bun build src/redis.ts --outdir dist --target bun --packages external && tsc -p tsconfig.build.json",
|
|
36
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
37
|
+
"test": "bun test test",
|
|
38
|
+
"test:redis": "bun test integration/redis.test.ts",
|
|
39
|
+
"measure": "BUN_CONFIG_MAX_HTTP_REQUESTS=1024 bun scripts/measure.ts"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@lenso/core": "^0.3.0",
|
|
43
|
+
"@lenso/web": "^0.3.1",
|
|
44
|
+
"@lenso/workers": "^0.2.1",
|
|
45
|
+
"@orpc/server": "2.0.0-beta.42"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@lenso/core": "^0.3.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"@lenso/core": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|