@kubun/hub 0.10.0 → 0.11.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/lib/create-relay.d.ts +6 -6
- package/lib/create-relay.js +214 -1
- package/lib/hub-store.d.ts +17 -6
- package/lib/hub-store.js +205 -1
- package/lib/index.d.ts +9 -9
- package/lib/index.js +42 -1
- package/lib/migrations.js +31 -1
- package/lib/observers.d.ts +13 -5
- package/lib/observers.js +71 -1
- package/package.json +23 -19
package/lib/create-relay.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { Identity } from '@enkaku/token';
|
|
1
|
+
import type { Identity } from '@kokuin/token';
|
|
3
2
|
import type { Adapter } from '@kubun/db-adapter';
|
|
4
3
|
import { type AdapterInput } from '@kubun/db-provider';
|
|
5
4
|
import { Kysely } from 'kysely';
|
|
6
|
-
import { type HubDatabase } from './hub-store.js';
|
|
5
|
+
import { type HubDatabase, type KubunHubStore } from './hub-store.js';
|
|
7
6
|
import { type RelayObservers } from './observers.js';
|
|
8
7
|
export type HubAuthConfig = {
|
|
9
8
|
mode: 'open';
|
|
@@ -15,10 +14,11 @@ export type HubAuthConfig = {
|
|
|
15
14
|
export type RelayRetentionConfig = {
|
|
16
15
|
olderThanSeconds: number;
|
|
17
16
|
intervalSeconds?: number;
|
|
17
|
+
subscriptionOlderThanSeconds?: number;
|
|
18
18
|
};
|
|
19
19
|
export type CreateRelayOptions = {
|
|
20
20
|
db: AdapterInput;
|
|
21
|
-
identity
|
|
21
|
+
identity: Identity;
|
|
22
22
|
auth?: HubAuthConfig;
|
|
23
23
|
allowedOrigin?: string | Array<string>;
|
|
24
24
|
retention?: RelayRetentionConfig;
|
|
@@ -26,14 +26,14 @@ export type CreateRelayOptions = {
|
|
|
26
26
|
};
|
|
27
27
|
export type Relay = {
|
|
28
28
|
fetch: (request: Request) => Promise<Response>;
|
|
29
|
-
hubStore:
|
|
29
|
+
hubStore: KubunHubStore;
|
|
30
30
|
pendingDeliveries: () => Promise<number>;
|
|
31
31
|
dispose: () => Promise<void>;
|
|
32
32
|
};
|
|
33
33
|
export type RelayInternalOptions = {
|
|
34
34
|
kysely: Kysely<HubDatabase>;
|
|
35
35
|
adapter: Adapter;
|
|
36
|
-
identity
|
|
36
|
+
identity: Identity;
|
|
37
37
|
auth?: HubAuthConfig;
|
|
38
38
|
allowedOrigin?: string | Array<string>;
|
|
39
39
|
retention?: RelayRetentionConfig;
|
package/lib/create-relay.js
CHANGED
|
@@ -1 +1,214 @@
|
|
|
1
|
-
import{ServerTransport
|
|
1
|
+
import { ServerTransport } from '@enkaku/http-serve';
|
|
2
|
+
import { Server } from '@enkaku/server';
|
|
3
|
+
import { resolveAdapter } from '@kubun/db-provider';
|
|
4
|
+
import { resolveAllowedOrigin } from '@kubun/http-util';
|
|
5
|
+
import { createHandlers as createHubHandlers, HubClientRegistry } from '@kumiai/hub-server';
|
|
6
|
+
import { Kysely } from 'kysely';
|
|
7
|
+
import { Migrator } from 'kysely/migration';
|
|
8
|
+
import { createHubStore } from './hub-store.js';
|
|
9
|
+
import { createHubMigrations } from './migrations.js';
|
|
10
|
+
import { decorateHubStore } from './observers.js';
|
|
11
|
+
const DEFAULT_RETENTION_INTERVAL_SECONDS = 3600;
|
|
12
|
+
const DEFAULT_SUBSCRIPTION_RETENTION_SECONDS = 90 * 24 * 60 * 60;
|
|
13
|
+
function resolveAccessRules(auth) {
|
|
14
|
+
if (auth == null) return {};
|
|
15
|
+
switch(auth.mode){
|
|
16
|
+
case 'open':
|
|
17
|
+
return {
|
|
18
|
+
'*': {
|
|
19
|
+
allow: true
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
case 'allowlist':
|
|
23
|
+
{
|
|
24
|
+
const { allowedDIDs, isAllowed } = auth;
|
|
25
|
+
if (isAllowed != null) {
|
|
26
|
+
const wrapped = async (ctx)=>{
|
|
27
|
+
const did = ctx.payload.iss;
|
|
28
|
+
if (allowedDIDs?.includes(did)) return true;
|
|
29
|
+
return await isAllowed(did);
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
'*': {
|
|
33
|
+
allow: wrapped
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
'*': {
|
|
39
|
+
allow: allowedDIDs ?? []
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function createRelayInternal(options) {
|
|
46
|
+
const { kysely, adapter, observers, ownsKysely } = options;
|
|
47
|
+
const migrationCtx = {
|
|
48
|
+
types: adapter.types,
|
|
49
|
+
functions: adapter.functions
|
|
50
|
+
};
|
|
51
|
+
const migrations = createHubMigrations(migrationCtx);
|
|
52
|
+
const migrationProvider = {
|
|
53
|
+
getMigrations: async ()=>migrations
|
|
54
|
+
};
|
|
55
|
+
const migrator = new Migrator({
|
|
56
|
+
db: kysely,
|
|
57
|
+
provider: migrationProvider,
|
|
58
|
+
migrationTableName: 'kubun_hub_migration',
|
|
59
|
+
migrationLockTableName: 'kubun_hub_migration_lock'
|
|
60
|
+
});
|
|
61
|
+
const { error: migrationError } = await migrator.migrateToLatest();
|
|
62
|
+
if (migrationError != null) {
|
|
63
|
+
throw migrationError;
|
|
64
|
+
}
|
|
65
|
+
const registry = new HubClientRegistry();
|
|
66
|
+
const rawStore = createHubStore({
|
|
67
|
+
kysely,
|
|
68
|
+
adapter
|
|
69
|
+
});
|
|
70
|
+
const hubStore = decorateHubStore(rawStore, observers);
|
|
71
|
+
let unsubscribePurge;
|
|
72
|
+
if (observers?.onPurge != null) {
|
|
73
|
+
const onPurge = observers.onPurge;
|
|
74
|
+
unsubscribePurge = rawStore.events.on('purge', (info)=>{
|
|
75
|
+
try {
|
|
76
|
+
onPurge({
|
|
77
|
+
sequenceCount: info.sequenceIDs.length
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const hubHandlers = createHubHandlers({
|
|
85
|
+
registry,
|
|
86
|
+
store: hubStore
|
|
87
|
+
});
|
|
88
|
+
const allowedOrigin = resolveAllowedOrigin(options.allowedOrigin);
|
|
89
|
+
const transport = new ServerTransport({
|
|
90
|
+
allowedOrigin
|
|
91
|
+
});
|
|
92
|
+
const accessRules = resolveAccessRules(options.auth);
|
|
93
|
+
const serverParams = {
|
|
94
|
+
identity: options.identity,
|
|
95
|
+
accessRules,
|
|
96
|
+
handlers: hubHandlers,
|
|
97
|
+
transports: [
|
|
98
|
+
transport
|
|
99
|
+
]
|
|
100
|
+
};
|
|
101
|
+
const enkakuServer = new Server(serverParams);
|
|
102
|
+
if (observers?.onAuthRejected != null) {
|
|
103
|
+
const onAuthRejected = observers.onAuthRejected;
|
|
104
|
+
enkakuServer.events.on('handlerError', (info)=>{
|
|
105
|
+
if (info.category !== 'auth') return;
|
|
106
|
+
try {
|
|
107
|
+
onAuthRejected({
|
|
108
|
+
reason: info.error.message
|
|
109
|
+
});
|
|
110
|
+
} catch {
|
|
111
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
let retentionTimer;
|
|
116
|
+
if (options.retention != null) {
|
|
117
|
+
const { olderThanSeconds } = options.retention;
|
|
118
|
+
const intervalSeconds = options.retention.intervalSeconds ?? DEFAULT_RETENTION_INTERVAL_SECONDS;
|
|
119
|
+
const intervalMs = Math.max(1, Math.floor(intervalSeconds * 1000));
|
|
120
|
+
// A subscriber must never be reaped while a message it would still receive
|
|
121
|
+
// is retained, so the subscription window is clamped up to the message
|
|
122
|
+
// window.
|
|
123
|
+
const configuredSubscription = options.retention.subscriptionOlderThanSeconds ?? DEFAULT_SUBSCRIPTION_RETENTION_SECONDS;
|
|
124
|
+
const subscriptionOlderThanSeconds = Math.max(configuredSubscription, olderThanSeconds);
|
|
125
|
+
if (configuredSubscription < olderThanSeconds && observers?.onRetentionConfigClamped != null) {
|
|
126
|
+
try {
|
|
127
|
+
observers.onRetentionConfigClamped({
|
|
128
|
+
configuredSubscriptionOlderThanSeconds: configuredSubscription,
|
|
129
|
+
clampedToSeconds: subscriptionOlderThanSeconds
|
|
130
|
+
});
|
|
131
|
+
} catch {
|
|
132
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
retentionTimer = setInterval(()=>{
|
|
136
|
+
void hubStore.purge({
|
|
137
|
+
olderThan: olderThanSeconds
|
|
138
|
+
}).catch((err)=>{
|
|
139
|
+
if (observers?.onError != null) {
|
|
140
|
+
try {
|
|
141
|
+
observers.onError({
|
|
142
|
+
stage: 'purge',
|
|
143
|
+
error: err
|
|
144
|
+
});
|
|
145
|
+
} catch {
|
|
146
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
void hubStore.purgeSubscribers({
|
|
151
|
+
olderThan: subscriptionOlderThanSeconds
|
|
152
|
+
}).then((purged)=>{
|
|
153
|
+
if (observers?.onSubscriptionPurge != null) {
|
|
154
|
+
try {
|
|
155
|
+
observers.onSubscriptionPurge({
|
|
156
|
+
subscriptionCount: purged.length
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}).catch((err)=>{
|
|
163
|
+
if (observers?.onError != null) {
|
|
164
|
+
try {
|
|
165
|
+
observers.onError({
|
|
166
|
+
stage: 'subscription-purge',
|
|
167
|
+
error: err
|
|
168
|
+
});
|
|
169
|
+
} catch {
|
|
170
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}, intervalMs);
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
fetch: (request)=>transport.fetch(request),
|
|
178
|
+
hubStore,
|
|
179
|
+
pendingDeliveries: async ()=>{
|
|
180
|
+
const row = await kysely.selectFrom('kubun_hub_deliveries').select(({ fn })=>fn.countAll().as('count')).executeTakeFirstOrThrow();
|
|
181
|
+
return Number(row.count);
|
|
182
|
+
},
|
|
183
|
+
dispose: async ()=>{
|
|
184
|
+
if (retentionTimer != null) {
|
|
185
|
+
clearInterval(retentionTimer);
|
|
186
|
+
retentionTimer = undefined;
|
|
187
|
+
}
|
|
188
|
+
if (unsubscribePurge != null) {
|
|
189
|
+
unsubscribePurge();
|
|
190
|
+
unsubscribePurge = undefined;
|
|
191
|
+
}
|
|
192
|
+
await enkakuServer.dispose();
|
|
193
|
+
if (ownsKysely) {
|
|
194
|
+
await kysely.destroy();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
export async function createRelay(options) {
|
|
200
|
+
const adapter = resolveAdapter(options.db);
|
|
201
|
+
const kysely = new Kysely({
|
|
202
|
+
dialect: adapter.dialect
|
|
203
|
+
});
|
|
204
|
+
return await createRelayInternal({
|
|
205
|
+
kysely,
|
|
206
|
+
adapter,
|
|
207
|
+
identity: options.identity,
|
|
208
|
+
auth: options.auth,
|
|
209
|
+
allowedOrigin: options.allowedOrigin,
|
|
210
|
+
retention: options.retention,
|
|
211
|
+
observers: options.observers,
|
|
212
|
+
ownsKysely: true
|
|
213
|
+
});
|
|
214
|
+
}
|
package/lib/hub-store.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { HubStore } from '@enkaku/hub-protocol';
|
|
2
1
|
import type { Adapter } from '@kubun/db-adapter';
|
|
2
|
+
import type { HubStore } from '@kumiai/hub-protocol';
|
|
3
3
|
import type { ColumnType, Kysely } from 'kysely';
|
|
4
4
|
export type HubDatabase = {
|
|
5
5
|
kubun_hub_messages: {
|
|
6
6
|
sequence_id: string;
|
|
7
7
|
sender_did: string;
|
|
8
|
-
|
|
8
|
+
topic_id: string;
|
|
9
9
|
payload: Uint8Array;
|
|
10
10
|
stored_at: number;
|
|
11
11
|
};
|
|
@@ -18,13 +18,24 @@ export type HubDatabase = {
|
|
|
18
18
|
owner_did: string;
|
|
19
19
|
key_package: string;
|
|
20
20
|
};
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
kubun_hub_subscriptions: {
|
|
22
|
+
topic_id: string;
|
|
23
|
+
subscriber_did: string;
|
|
24
|
+
last_active_at: number;
|
|
24
25
|
};
|
|
25
26
|
};
|
|
26
27
|
export type CreateHubStoreParams = {
|
|
27
28
|
kysely: Kysely<HubDatabase> | Promise<Kysely<HubDatabase>>;
|
|
28
29
|
adapter: Adapter;
|
|
29
30
|
};
|
|
30
|
-
export
|
|
31
|
+
export type PurgeSubscriptionsParams = {
|
|
32
|
+
olderThan: number;
|
|
33
|
+
};
|
|
34
|
+
export type PurgedSubscription = {
|
|
35
|
+
topicID: string;
|
|
36
|
+
subscriberDID: string;
|
|
37
|
+
};
|
|
38
|
+
export type KubunHubStore = HubStore & {
|
|
39
|
+
purgeSubscribers(params: PurgeSubscriptionsParams): Promise<Array<PurgedSubscription>>;
|
|
40
|
+
};
|
|
41
|
+
export declare function createHubStore(params: CreateHubStoreParams): KubunHubStore;
|
package/lib/hub-store.js
CHANGED
|
@@ -1 +1,205 @@
|
|
|
1
|
-
import{EventEmitter
|
|
1
|
+
import { EventEmitter } from '@sozai/event';
|
|
2
|
+
export function createHubStore(params) {
|
|
3
|
+
const { adapter } = params;
|
|
4
|
+
// Lazy DB resolution -- accepts Kysely or Promise<Kysely> and caches the resolved instance
|
|
5
|
+
let resolvedDB;
|
|
6
|
+
async function db() {
|
|
7
|
+
if (resolvedDB == null) {
|
|
8
|
+
resolvedDB = await params.kysely;
|
|
9
|
+
}
|
|
10
|
+
return resolvedDB;
|
|
11
|
+
}
|
|
12
|
+
// Lazy sequence counter -- initialized from DB on first publish() call
|
|
13
|
+
let sequenceCounter;
|
|
14
|
+
async function nextSequenceID() {
|
|
15
|
+
const kysely = await db();
|
|
16
|
+
if (sequenceCounter == null) {
|
|
17
|
+
const maxRow = await kysely.selectFrom('kubun_hub_messages').select(kysely.fn.max('sequence_id').as('max_id')).executeTakeFirst();
|
|
18
|
+
sequenceCounter = maxRow?.max_id != null ? Number.parseInt(maxRow.max_id, 10) : 0;
|
|
19
|
+
}
|
|
20
|
+
sequenceCounter++;
|
|
21
|
+
return String(sequenceCounter).padStart(12, '0');
|
|
22
|
+
}
|
|
23
|
+
const events = new EventEmitter();
|
|
24
|
+
async function publish(publishParams) {
|
|
25
|
+
const sequenceID = await nextSequenceID();
|
|
26
|
+
const kysely = await db();
|
|
27
|
+
// Recipients = current subscribers minus the sender. Zero recipients (no
|
|
28
|
+
// subscribers, or only the sender) means there is nothing to deliver, so the
|
|
29
|
+
// sequence is consumed but no message or delivery rows are stored.
|
|
30
|
+
const subscribers = await getSubscribers(publishParams.topicID);
|
|
31
|
+
const recipients = subscribers.filter((did)=>did !== publishParams.senderDID);
|
|
32
|
+
if (recipients.length === 0) {
|
|
33
|
+
return sequenceID;
|
|
34
|
+
}
|
|
35
|
+
const storedAt = Date.now();
|
|
36
|
+
// The message row and its delivery rows are written in one transaction: a
|
|
37
|
+
// partial write (message stored, deliveries failed) would orphan a message
|
|
38
|
+
// with zero deliveries — never fetched, never acked, never GC'd by `ack`,
|
|
39
|
+
// reclaimed only by the age-based purge.
|
|
40
|
+
await kysely.transaction().execute(async (trx)=>{
|
|
41
|
+
await trx.insertInto('kubun_hub_messages').values({
|
|
42
|
+
sequence_id: sequenceID,
|
|
43
|
+
sender_did: publishParams.senderDID,
|
|
44
|
+
topic_id: publishParams.topicID,
|
|
45
|
+
payload: adapter.encodeBinary(publishParams.payload),
|
|
46
|
+
stored_at: storedAt
|
|
47
|
+
}).execute();
|
|
48
|
+
await trx.insertInto('kubun_hub_deliveries').values(recipients.map((did)=>({
|
|
49
|
+
sequence_id: sequenceID,
|
|
50
|
+
recipient_did: did
|
|
51
|
+
}))).execute();
|
|
52
|
+
});
|
|
53
|
+
return sequenceID;
|
|
54
|
+
}
|
|
55
|
+
async function fetch(fetchParams) {
|
|
56
|
+
// Process acks first if provided
|
|
57
|
+
if (fetchParams.ack != null && fetchParams.ack.length > 0) {
|
|
58
|
+
await ack({
|
|
59
|
+
recipientDID: fetchParams.recipientDID,
|
|
60
|
+
sequenceIDs: fetchParams.ack
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const kysely = await db();
|
|
64
|
+
let query = kysely.selectFrom('kubun_hub_deliveries').innerJoin('kubun_hub_messages', 'kubun_hub_messages.sequence_id', 'kubun_hub_deliveries.sequence_id').select([
|
|
65
|
+
'kubun_hub_messages.sequence_id',
|
|
66
|
+
'kubun_hub_messages.sender_did',
|
|
67
|
+
'kubun_hub_messages.topic_id',
|
|
68
|
+
'kubun_hub_messages.payload'
|
|
69
|
+
]).where('kubun_hub_deliveries.recipient_did', '=', fetchParams.recipientDID).orderBy('kubun_hub_messages.sequence_id', 'asc');
|
|
70
|
+
if (fetchParams.after != null) {
|
|
71
|
+
query = query.where('kubun_hub_messages.sequence_id', '>', fetchParams.after);
|
|
72
|
+
}
|
|
73
|
+
const limit = fetchParams.limit;
|
|
74
|
+
if (limit != null) {
|
|
75
|
+
query = query.limit(limit + 1); // fetch one extra to determine hasMore
|
|
76
|
+
}
|
|
77
|
+
const rows = await query.execute();
|
|
78
|
+
let hasMore = false;
|
|
79
|
+
let resultRows = rows;
|
|
80
|
+
if (limit != null && rows.length > limit) {
|
|
81
|
+
hasMore = true;
|
|
82
|
+
resultRows = rows.slice(0, limit);
|
|
83
|
+
}
|
|
84
|
+
const messages = resultRows.map((row)=>({
|
|
85
|
+
sequenceID: row.sequence_id,
|
|
86
|
+
senderDID: row.sender_did,
|
|
87
|
+
topicID: row.topic_id,
|
|
88
|
+
payload: row.payload
|
|
89
|
+
}));
|
|
90
|
+
const cursor = messages.length > 0 ? messages[messages.length - 1].sequenceID : null;
|
|
91
|
+
return {
|
|
92
|
+
messages,
|
|
93
|
+
cursor,
|
|
94
|
+
hasMore
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function ack(ackParams) {
|
|
98
|
+
if (ackParams.sequenceIDs.length === 0) return;
|
|
99
|
+
const kysely = await db();
|
|
100
|
+
// Remove delivery entries for this recipient
|
|
101
|
+
await kysely.deleteFrom('kubun_hub_deliveries').where('recipient_did', '=', ackParams.recipientDID).where('sequence_id', 'in', ackParams.sequenceIDs).execute();
|
|
102
|
+
// Clean up messages with no remaining deliveries (all recipients acked)
|
|
103
|
+
const withDeliveries = await kysely.selectFrom('kubun_hub_deliveries').select('sequence_id').where('sequence_id', 'in', ackParams.sequenceIDs).execute();
|
|
104
|
+
const stillDelivered = new Set(withDeliveries.map((r)=>r.sequence_id));
|
|
105
|
+
const fullyAcked = ackParams.sequenceIDs.filter((id)=>!stillDelivered.has(id));
|
|
106
|
+
if (fullyAcked.length > 0) {
|
|
107
|
+
await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', fullyAcked).execute();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function purge(purgeParams) {
|
|
111
|
+
const kysely = await db();
|
|
112
|
+
const threshold = Date.now() - purgeParams.olderThan * 1000;
|
|
113
|
+
const oldMessages = await kysely.selectFrom('kubun_hub_messages').select('sequence_id').where('stored_at', '<=', threshold).execute();
|
|
114
|
+
const sequenceIDs = oldMessages.map((m)=>m.sequence_id);
|
|
115
|
+
if (sequenceIDs.length === 0) return [];
|
|
116
|
+
await kysely.deleteFrom('kubun_hub_deliveries').where('sequence_id', 'in', sequenceIDs).execute();
|
|
117
|
+
await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', sequenceIDs).execute();
|
|
118
|
+
await events.emit('purge', {
|
|
119
|
+
sequenceIDs
|
|
120
|
+
});
|
|
121
|
+
return sequenceIDs;
|
|
122
|
+
}
|
|
123
|
+
async function storeKeyPackage(ownerDID, keyPackage) {
|
|
124
|
+
const kysely = await db();
|
|
125
|
+
await kysely.insertInto('kubun_hub_key_packages').values({
|
|
126
|
+
owner_did: ownerDID,
|
|
127
|
+
key_package: keyPackage
|
|
128
|
+
}).execute();
|
|
129
|
+
}
|
|
130
|
+
async function fetchKeyPackages(ownerDID, count) {
|
|
131
|
+
const kysely = await db();
|
|
132
|
+
const limit = count ?? 1;
|
|
133
|
+
// Atomic delete-returning: a non-atomic select+delete pair would let two
|
|
134
|
+
// concurrent fetches return the same key package twice.
|
|
135
|
+
const rows = await kysely.deleteFrom('kubun_hub_key_packages').where('id', 'in', kysely.selectFrom('kubun_hub_key_packages').select('id').where('owner_did', '=', ownerDID).orderBy('id', 'asc').limit(limit)).returning('key_package').execute();
|
|
136
|
+
return rows.map((r)=>r.key_package);
|
|
137
|
+
}
|
|
138
|
+
async function subscribe(subscriberDID, topicID) {
|
|
139
|
+
const kysely = await db();
|
|
140
|
+
// Upsert on the (topic_id, subscriber_did) primary key so re-subscribing an
|
|
141
|
+
// existing subscriber refreshes its activity timestamp rather than failing
|
|
142
|
+
// or duplicating.
|
|
143
|
+
await kysely.insertInto('kubun_hub_subscriptions').values({
|
|
144
|
+
topic_id: topicID,
|
|
145
|
+
subscriber_did: subscriberDID,
|
|
146
|
+
last_active_at: Date.now()
|
|
147
|
+
}).onConflict((oc)=>oc.columns([
|
|
148
|
+
'topic_id',
|
|
149
|
+
'subscriber_did'
|
|
150
|
+
]).doUpdateSet({
|
|
151
|
+
last_active_at: (eb)=>eb.ref('excluded.last_active_at')
|
|
152
|
+
})).execute();
|
|
153
|
+
}
|
|
154
|
+
async function unsubscribe(subscriberDID, topicID) {
|
|
155
|
+
const kysely = await db();
|
|
156
|
+
await kysely.deleteFrom('kubun_hub_subscriptions').where('topic_id', '=', topicID).where('subscriber_did', '=', subscriberDID).execute();
|
|
157
|
+
// Drop this subscriber's pending deliveries for the topic, then GC any
|
|
158
|
+
// message left without a recipient (refcount → 0), mirroring ack cleanup.
|
|
159
|
+
const pending = await kysely.selectFrom('kubun_hub_deliveries').innerJoin('kubun_hub_messages', 'kubun_hub_messages.sequence_id', 'kubun_hub_deliveries.sequence_id').select('kubun_hub_deliveries.sequence_id').where('kubun_hub_deliveries.recipient_did', '=', subscriberDID).where('kubun_hub_messages.topic_id', '=', topicID).execute();
|
|
160
|
+
if (pending.length === 0) return;
|
|
161
|
+
const sequenceIDs = pending.map((r)=>r.sequence_id);
|
|
162
|
+
await kysely.deleteFrom('kubun_hub_deliveries').where('recipient_did', '=', subscriberDID).where('sequence_id', 'in', sequenceIDs).execute();
|
|
163
|
+
const remaining = await kysely.selectFrom('kubun_hub_deliveries').select('sequence_id').where('sequence_id', 'in', sequenceIDs).execute();
|
|
164
|
+
const stillDelivered = new Set(remaining.map((r)=>r.sequence_id));
|
|
165
|
+
const orphaned = sequenceIDs.filter((id)=>!stillDelivered.has(id));
|
|
166
|
+
if (orphaned.length > 0) {
|
|
167
|
+
await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', orphaned).execute();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function getSubscribers(topicID) {
|
|
171
|
+
const kysely = await db();
|
|
172
|
+
const rows = await kysely.selectFrom('kubun_hub_subscriptions').select('subscriber_did').where('topic_id', '=', topicID).execute();
|
|
173
|
+
return rows.map((r)=>r.subscriber_did);
|
|
174
|
+
}
|
|
175
|
+
async function purgeSubscribers(purgeParams) {
|
|
176
|
+
const kysely = await db();
|
|
177
|
+
// Strict `<`: a row refreshed exactly at the boundary survives. Subscription
|
|
178
|
+
// retention runs on a much longer window than message purge.
|
|
179
|
+
const threshold = Date.now() - purgeParams.olderThan * 1000;
|
|
180
|
+
// Atomic delete-and-return so the reported set exactly matches the rows
|
|
181
|
+
// actually removed — a subscriber who rejoins between evaluating and deleting
|
|
182
|
+
// is neither reaped nor over-reported. Mirrors `fetchKeyPackages`.
|
|
183
|
+
const deletedRows = await kysely.deleteFrom('kubun_hub_subscriptions').where('last_active_at', '<', threshold).returning([
|
|
184
|
+
'topic_id',
|
|
185
|
+
'subscriber_did'
|
|
186
|
+
]).execute();
|
|
187
|
+
return deletedRows.map((row)=>({
|
|
188
|
+
topicID: row.topic_id,
|
|
189
|
+
subscriberDID: row.subscriber_did
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
events,
|
|
194
|
+
publish,
|
|
195
|
+
fetch,
|
|
196
|
+
ack,
|
|
197
|
+
purge,
|
|
198
|
+
purgeSubscribers,
|
|
199
|
+
storeKeyPackage,
|
|
200
|
+
fetchKeyPackages,
|
|
201
|
+
subscribe,
|
|
202
|
+
unsubscribe,
|
|
203
|
+
getSubscribers
|
|
204
|
+
};
|
|
205
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { Identity } from '@
|
|
1
|
+
import type { Identity } from '@kokuin/token';
|
|
2
2
|
import type { Adapter } from '@kubun/db-adapter';
|
|
3
3
|
import type { Kysely } from 'kysely';
|
|
4
4
|
import { type HubAuthConfig, type RelayRetentionConfig } from './create-relay.js';
|
|
5
5
|
import type { HubDatabase } from './hub-store.js';
|
|
6
6
|
import type { RelayObservers } from './observers.js';
|
|
7
7
|
export { type CreateRelayOptions, createRelay, createRelayInternal, type HubAuthConfig, type Relay, type RelayRetentionConfig, } from './create-relay.js';
|
|
8
|
-
export { type CreateHubStoreParams, createHubStore, type HubDatabase } from './hub-store.js';
|
|
8
|
+
export { type CreateHubStoreParams, createHubStore, type HubDatabase, type KubunHubStore, type PurgedSubscription, type PurgeSubscriptionsParams, } from './hub-store.js';
|
|
9
9
|
export { createHubMigrations, type MigrationContext } from './migrations.js';
|
|
10
|
-
export { decorateHubStore, type RelayObserverAckInfo, type RelayObserverAuthRejectedInfo, type RelayObserverErrorInfo, type RelayObserverErrorStage, type RelayObserverFetchInfo, type RelayObserverPurgeInfo, type RelayObserverStoreInfo, type RelayObservers, } from './observers.js';
|
|
10
|
+
export { decorateHubStore, type RelayObserverAckInfo, type RelayObserverAuthRejectedInfo, type RelayObserverErrorInfo, type RelayObserverErrorStage, type RelayObserverFetchInfo, type RelayObserverPurgeInfo, type RelayObserverRetentionConfigClampedInfo, type RelayObserverStoreInfo, type RelayObserverSubscriptionPurgeInfo, type RelayObservers, } from './observers.js';
|
|
11
11
|
export type HubOptions = {
|
|
12
12
|
/** Kysely instance for the hub database. */
|
|
13
13
|
kysely: Kysely<HubDatabase>;
|
|
@@ -18,14 +18,14 @@ export type HubOptions = {
|
|
|
18
18
|
/** Allowed CORS origin(s). Default: ['*']. */
|
|
19
19
|
allowedOrigin?: string | Array<string>;
|
|
20
20
|
/**
|
|
21
|
-
* Hub server identity. Required
|
|
22
|
-
*
|
|
21
|
+
* Hub server identity. Required — the server signs response tokens, derives
|
|
22
|
+
* the sender DID from each verified request, and clients pin its DID for
|
|
23
|
+
* TOFU verification.
|
|
23
24
|
*/
|
|
24
|
-
identity
|
|
25
|
+
identity: Identity;
|
|
25
26
|
/**
|
|
26
|
-
* Authentication configuration
|
|
27
|
-
*
|
|
28
|
-
* provided.
|
|
27
|
+
* Authentication configuration layered over the required identity. When
|
|
28
|
+
* omitted, no access rules are configured beyond signature verification.
|
|
29
29
|
*/
|
|
30
30
|
auth?: HubAuthConfig;
|
|
31
31
|
/** Optional retention scheduler. */
|
package/lib/index.js
CHANGED
|
@@ -1 +1,42 @@
|
|
|
1
|
-
import{serve
|
|
1
|
+
import { serve } from '@hono/node-server';
|
|
2
|
+
import getPort from 'get-port';
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { createRelayInternal } from './create-relay.js';
|
|
5
|
+
export { createRelay, createRelayInternal } from './create-relay.js';
|
|
6
|
+
export { createHubStore } from './hub-store.js';
|
|
7
|
+
export { createHubMigrations } from './migrations.js';
|
|
8
|
+
export { decorateHubStore } from './observers.js';
|
|
9
|
+
export async function createHub(options) {
|
|
10
|
+
const relay = await createRelayInternal({
|
|
11
|
+
kysely: options.kysely,
|
|
12
|
+
adapter: options.adapter,
|
|
13
|
+
identity: options.identity,
|
|
14
|
+
auth: options.auth,
|
|
15
|
+
allowedOrigin: options.allowedOrigin,
|
|
16
|
+
retention: options.retention,
|
|
17
|
+
observers: options.observers,
|
|
18
|
+
ownsKysely: false
|
|
19
|
+
});
|
|
20
|
+
const app = new Hono();
|
|
21
|
+
app.get('/health', (c)=>c.json({
|
|
22
|
+
status: 'ok'
|
|
23
|
+
}));
|
|
24
|
+
app.post('*', async (c)=>relay.fetch(c.req.raw));
|
|
25
|
+
app.options('*', async (c)=>relay.fetch(c.req.raw));
|
|
26
|
+
const port = options.port ?? await getPort();
|
|
27
|
+
const url = `http://localhost:${port}`;
|
|
28
|
+
const httpServer = await new Promise((resolve)=>{
|
|
29
|
+
const server = serve({
|
|
30
|
+
fetch: app.fetch,
|
|
31
|
+
port
|
|
32
|
+
}, ()=>resolve(server));
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
port,
|
|
36
|
+
url,
|
|
37
|
+
dispose: async ()=>{
|
|
38
|
+
await relay.dispose();
|
|
39
|
+
httpServer.close();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
package/lib/migrations.js
CHANGED
|
@@ -1 +1,31 @@
|
|
|
1
|
-
export function createHubMigrations(
|
|
1
|
+
export function createHubMigrations(ctx) {
|
|
2
|
+
const t = ctx.types;
|
|
3
|
+
return {
|
|
4
|
+
'001_create_hub_tables': {
|
|
5
|
+
async up (db) {
|
|
6
|
+
// Hub messages
|
|
7
|
+
await db.schema.createTable('kubun_hub_messages').ifNotExists().addColumn('sequence_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('sender_did', t.text, (col)=>col.notNull()).addColumn('topic_id', t.text, (col)=>col.notNull()).addColumn('payload', t.binary, (col)=>col.notNull()).addColumn('stored_at', t.bigint, (col)=>col.notNull()).execute();
|
|
8
|
+
// Hub deliveries
|
|
9
|
+
await db.schema.createTable('kubun_hub_deliveries').ifNotExists().addColumn('sequence_id', t.text, (col)=>col.notNull()).addColumn('recipient_did', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_hub_deliveries', [
|
|
10
|
+
'sequence_id',
|
|
11
|
+
'recipient_did'
|
|
12
|
+
]).execute();
|
|
13
|
+
await db.schema.createIndex('idx_hub_deliveries_recipient').ifNotExists().on('kubun_hub_deliveries').column('recipient_did').execute();
|
|
14
|
+
// Hub key packages
|
|
15
|
+
await db.schema.createTable('kubun_hub_key_packages').ifNotExists().addColumn('id', t.serial, (col)=>col.primaryKey()).addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('key_package', t.text, (col)=>col.notNull()).execute();
|
|
16
|
+
await db.schema.createIndex('idx_hub_key_packages_owner').ifNotExists().on('kubun_hub_key_packages').column('owner_did').execute();
|
|
17
|
+
// Hub subscriptions
|
|
18
|
+
await db.schema.createTable('kubun_hub_subscriptions').ifNotExists().addColumn('topic_id', t.text, (col)=>col.notNull()).addColumn('subscriber_did', t.text, (col)=>col.notNull()).addColumn('last_active_at', t.bigint, (col)=>col.notNull().defaultTo(0)).addPrimaryKeyConstraint('pk_hub_subscriptions', [
|
|
19
|
+
'topic_id',
|
|
20
|
+
'subscriber_did'
|
|
21
|
+
]).execute();
|
|
22
|
+
},
|
|
23
|
+
async down (db) {
|
|
24
|
+
await db.schema.dropTable('kubun_hub_subscriptions').ifExists().execute();
|
|
25
|
+
await db.schema.dropTable('kubun_hub_key_packages').ifExists().execute();
|
|
26
|
+
await db.schema.dropTable('kubun_hub_deliveries').ifExists().execute();
|
|
27
|
+
await db.schema.dropTable('kubun_hub_messages').ifExists().execute();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
package/lib/observers.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { KubunHubStore } from './hub-store.js';
|
|
2
2
|
export type RelayObserverStoreInfo = {
|
|
3
3
|
senderDID: string;
|
|
4
|
-
|
|
5
|
-
recipientCount: number;
|
|
4
|
+
topicID: string;
|
|
6
5
|
sequenceID: string;
|
|
7
6
|
};
|
|
8
7
|
export type RelayObserverFetchInfo = {
|
|
@@ -16,10 +15,17 @@ export type RelayObserverAckInfo = {
|
|
|
16
15
|
export type RelayObserverPurgeInfo = {
|
|
17
16
|
sequenceCount: number;
|
|
18
17
|
};
|
|
18
|
+
export type RelayObserverSubscriptionPurgeInfo = {
|
|
19
|
+
subscriptionCount: number;
|
|
20
|
+
};
|
|
19
21
|
export type RelayObserverAuthRejectedInfo = {
|
|
20
22
|
reason: string;
|
|
21
23
|
};
|
|
22
|
-
export type
|
|
24
|
+
export type RelayObserverRetentionConfigClampedInfo = {
|
|
25
|
+
configuredSubscriptionOlderThanSeconds: number;
|
|
26
|
+
clampedToSeconds: number;
|
|
27
|
+
};
|
|
28
|
+
export type RelayObserverErrorStage = 'store' | 'fetch' | 'ack' | 'purge' | 'subscription-purge';
|
|
23
29
|
export type RelayObserverErrorInfo = {
|
|
24
30
|
stage: RelayObserverErrorStage;
|
|
25
31
|
error: unknown;
|
|
@@ -29,7 +35,9 @@ export type RelayObservers = {
|
|
|
29
35
|
onFetch?: (info: RelayObserverFetchInfo) => void;
|
|
30
36
|
onAck?: (info: RelayObserverAckInfo) => void;
|
|
31
37
|
onPurge?: (info: RelayObserverPurgeInfo) => void;
|
|
38
|
+
onSubscriptionPurge?: (info: RelayObserverSubscriptionPurgeInfo) => void;
|
|
32
39
|
onAuthRejected?: (info: RelayObserverAuthRejectedInfo) => void;
|
|
40
|
+
onRetentionConfigClamped?: (info: RelayObserverRetentionConfigClampedInfo) => void;
|
|
33
41
|
onError?: (info: RelayObserverErrorInfo) => void;
|
|
34
42
|
};
|
|
35
|
-
export declare function decorateHubStore(store:
|
|
43
|
+
export declare function decorateHubStore(store: KubunHubStore, observers?: RelayObservers): KubunHubStore;
|
package/lib/observers.js
CHANGED
|
@@ -1 +1,71 @@
|
|
|
1
|
-
function
|
|
1
|
+
function safeCall(callback, info) {
|
|
2
|
+
if (callback == null) return;
|
|
3
|
+
try {
|
|
4
|
+
callback(info);
|
|
5
|
+
} catch {
|
|
6
|
+
// Observer callbacks are diagnostics — never let them break the relay.
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function decorateHubStore(store, observers) {
|
|
10
|
+
if (observers == null) return store;
|
|
11
|
+
const { onStore, onFetch, onAck, onError } = observers;
|
|
12
|
+
const wrappedStore = {
|
|
13
|
+
events: store.events,
|
|
14
|
+
storeKeyPackage: store.storeKeyPackage.bind(store),
|
|
15
|
+
fetchKeyPackages: store.fetchKeyPackages.bind(store),
|
|
16
|
+
subscribe: store.subscribe.bind(store),
|
|
17
|
+
unsubscribe: store.unsubscribe.bind(store),
|
|
18
|
+
getSubscribers: store.getSubscribers.bind(store),
|
|
19
|
+
purgeSubscribers: store.purgeSubscribers.bind(store),
|
|
20
|
+
purge: store.purge.bind(store),
|
|
21
|
+
async publish (params) {
|
|
22
|
+
try {
|
|
23
|
+
const sequenceID = await store.publish(params);
|
|
24
|
+
safeCall(onStore, {
|
|
25
|
+
senderDID: params.senderDID,
|
|
26
|
+
topicID: params.topicID,
|
|
27
|
+
sequenceID
|
|
28
|
+
});
|
|
29
|
+
return sequenceID;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
safeCall(onError, {
|
|
32
|
+
stage: 'store',
|
|
33
|
+
error
|
|
34
|
+
});
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
async fetch (params) {
|
|
39
|
+
try {
|
|
40
|
+
const result = await store.fetch(params);
|
|
41
|
+
safeCall(onFetch, {
|
|
42
|
+
recipientDID: params.recipientDID,
|
|
43
|
+
messageCount: result.messages.length
|
|
44
|
+
});
|
|
45
|
+
return result;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
safeCall(onError, {
|
|
48
|
+
stage: 'fetch',
|
|
49
|
+
error
|
|
50
|
+
});
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
async ack (params) {
|
|
55
|
+
try {
|
|
56
|
+
await store.ack(params);
|
|
57
|
+
safeCall(onAck, {
|
|
58
|
+
recipientDID: params.recipientDID,
|
|
59
|
+
sequenceCount: params.sequenceIDs.length
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
safeCall(onError, {
|
|
63
|
+
stage: 'ack',
|
|
64
|
+
error
|
|
65
|
+
});
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
return wrappedStore;
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -14,31 +14,35 @@
|
|
|
14
14
|
],
|
|
15
15
|
"sideEffects": false,
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@
|
|
18
|
-
"@enkaku/http-
|
|
19
|
-
"@
|
|
20
|
-
"@
|
|
21
|
-
"@enkaku/server": "^0.
|
|
22
|
-
"@
|
|
23
|
-
"@hono/node-server": "^2.0.
|
|
17
|
+
"@sozai/event": "^0.1.0",
|
|
18
|
+
"@enkaku/http-serve": "^0.18.1",
|
|
19
|
+
"@kumiai/hub-protocol": "^0.1.0",
|
|
20
|
+
"@kumiai/hub-server": "^0.1.0",
|
|
21
|
+
"@enkaku/server": "^0.18.1",
|
|
22
|
+
"@kokuin/token": "^0.1.1",
|
|
23
|
+
"@hono/node-server": "^2.0.8",
|
|
24
24
|
"get-port": "^7.2.0",
|
|
25
|
-
"hono": "^4.12.
|
|
25
|
+
"hono": "^4.12.27",
|
|
26
26
|
"kysely": "^0.29.2",
|
|
27
|
-
"@kubun/db-adapter": "^0.
|
|
28
|
-
"@kubun/
|
|
27
|
+
"@kubun/db-adapter": "^0.11.0",
|
|
28
|
+
"@kubun/http-util": "^0.11.0",
|
|
29
|
+
"@kubun/db-provider": "^0.11.0"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
|
-
"@
|
|
32
|
-
"@enkaku/client": "^0.
|
|
33
|
-
"@
|
|
34
|
-
"@enkaku/
|
|
35
|
-
"@enkaku/
|
|
36
|
-
"@
|
|
37
|
-
"@
|
|
32
|
+
"@sozai/async": "^0.1.0",
|
|
33
|
+
"@enkaku/client": "^0.18.1",
|
|
34
|
+
"@kumiai/mls": "^0.1.0",
|
|
35
|
+
"@enkaku/http-fetch": "^0.18.1",
|
|
36
|
+
"@enkaku/protocol": "^0.18.1",
|
|
37
|
+
"@enkaku/transport": "^0.18.1",
|
|
38
|
+
"@testcontainers/postgresql": "^12.0.4",
|
|
39
|
+
"@kubun/db-better-sqlite": "^0.11.0",
|
|
40
|
+
"@kubun/db-postgres": "^0.11.0",
|
|
41
|
+
"@kubun/db-node-sqlite": "^0.11.0"
|
|
38
42
|
},
|
|
39
43
|
"scripts": {
|
|
40
44
|
"build:clean": "del lib",
|
|
41
|
-
"build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
|
|
45
|
+
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
42
46
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
43
47
|
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types"
|
|
44
48
|
}
|