@arcote.tech/arc-host 0.7.26 → 0.7.28
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/dist/index.js +278 -3815
- package/dist/index.js.map +8 -84
- package/dist/src/context-handler.d.ts +31 -0
- package/dist/src/context-handler.d.ts.map +1 -1
- package/dist/src/create-server.d.ts +17 -0
- package/dist/src/create-server.d.ts.map +1 -1
- package/dist/src/event-auth.d.ts.map +1 -1
- package/dist/src/middleware/http.d.ts.map +1 -1
- package/dist/src/middleware/types.d.ts +1 -1
- package/dist/src/middleware/types.d.ts.map +1 -1
- package/dist/src/middleware/ws.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/context-handler.ts +160 -2
- package/src/create-server.ts +78 -42
- package/src/event-auth.ts +8 -1
- package/src/middleware/http.ts +34 -16
- package/src/middleware/types.ts +1 -1
- package/src/middleware/ws.ts +157 -42
package/src/middleware/http.ts
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
import type { ArcRouteAny } from "@arcote.tech/arc";
|
|
2
|
-
import { ScopedModel } from "@arcote.tech/arc";
|
|
2
|
+
import { ArcValidationError, arcErrorStatus, ScopedModel } from "@arcote.tech/arc";
|
|
3
3
|
import type { ConnectionManager } from "../connection-manager";
|
|
4
4
|
import type { ContextHandler } from "../context-handler";
|
|
5
5
|
import type { ArcHttpHandler, ArcRequestContext } from "./types";
|
|
6
6
|
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Error → HTTP mapping
|
|
9
|
+
//
|
|
10
|
+
// Typed Arc errors map to 400/401/403 with safe bodies. Anything else is an
|
|
11
|
+
// unexpected failure → 500 with a GENERIC message (never leak error.message
|
|
12
|
+
// or stack to the client).
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function errorBody(error: unknown): { status: number; body: Record<string, any> } {
|
|
16
|
+
const status = arcErrorStatus(error) ?? 500;
|
|
17
|
+
if (error instanceof ArcValidationError) {
|
|
18
|
+
return { status, body: { error: "Invalid parameters", fields: error.fields } };
|
|
19
|
+
}
|
|
20
|
+
if (status === 401) return { status, body: { error: "Unauthorized" } };
|
|
21
|
+
if (status === 403) return { status, body: { error: "Forbidden" } };
|
|
22
|
+
return { status: 500, body: { error: "Internal server error" } };
|
|
23
|
+
}
|
|
24
|
+
|
|
7
25
|
// ---------------------------------------------------------------------------
|
|
8
26
|
// Shared
|
|
9
27
|
// ---------------------------------------------------------------------------
|
|
@@ -76,11 +94,11 @@ export function commandHandler(ch: ContextHandler): ArcHttpHandler {
|
|
|
76
94
|
headers: ctx.corsHeaders,
|
|
77
95
|
});
|
|
78
96
|
} catch (error) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
{ error
|
|
82
|
-
|
|
83
|
-
);
|
|
97
|
+
const { status, body } = errorBody(error);
|
|
98
|
+
if (status === 500) {
|
|
99
|
+
console.error(`[ARC] Command '${commandName}' error:`, error);
|
|
100
|
+
}
|
|
101
|
+
return Response.json(body, { status, headers: ctx.corsHeaders });
|
|
84
102
|
}
|
|
85
103
|
};
|
|
86
104
|
}
|
|
@@ -119,10 +137,9 @@ export function queryHandler(ch: ContextHandler): ArcHttpHandler {
|
|
|
119
137
|
|
|
120
138
|
return Response.json(result, { headers: ctx.corsHeaders });
|
|
121
139
|
} catch (error) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
);
|
|
140
|
+
const { status, body } = errorBody(error);
|
|
141
|
+
if (status === 500) console.error(`[ARC] Query error:`, error);
|
|
142
|
+
return Response.json(body, { status, headers: ctx.corsHeaders });
|
|
126
143
|
}
|
|
127
144
|
};
|
|
128
145
|
}
|
|
@@ -153,9 +170,11 @@ export function eventSyncHandler(ch: ContextHandler): ArcHttpHandler {
|
|
|
153
170
|
{ headers: ctx.corsHeaders },
|
|
154
171
|
);
|
|
155
172
|
} catch (error) {
|
|
173
|
+
const { status, body } = errorBody(error);
|
|
174
|
+
if (status === 500) console.error(`[ARC] Event sync error:`, error);
|
|
156
175
|
return Response.json(
|
|
157
|
-
{ success: false,
|
|
158
|
-
{ status
|
|
176
|
+
{ success: false, ...body },
|
|
177
|
+
{ status, headers: ctx.corsHeaders },
|
|
159
178
|
);
|
|
160
179
|
}
|
|
161
180
|
};
|
|
@@ -269,10 +288,9 @@ export function routeHandler(ch: ContextHandler): ArcHttpHandler {
|
|
|
269
288
|
headers: newHeaders,
|
|
270
289
|
});
|
|
271
290
|
} catch (error) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
);
|
|
291
|
+
const { status, body: errBody } = errorBody(error);
|
|
292
|
+
if (status === 500) console.error(`[ARC] Route error:`, error);
|
|
293
|
+
return Response.json(errBody, { status, headers: ctx.corsHeaders });
|
|
276
294
|
}
|
|
277
295
|
};
|
|
278
296
|
}
|
package/src/middleware/types.ts
CHANGED
|
@@ -28,7 +28,7 @@ export type ArcHttpHandler = (
|
|
|
28
28
|
export interface ArcWsContext {
|
|
29
29
|
contextHandler: ContextHandler;
|
|
30
30
|
connectionManager: ConnectionManager;
|
|
31
|
-
verifyToken: (token: string) => TokenPayload | null
|
|
31
|
+
verifyToken: (token: string) => Promise<TokenPayload | null>;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
package/src/middleware/ws.ts
CHANGED
|
@@ -1,23 +1,73 @@
|
|
|
1
|
-
import { LiveQuery } from "@arcote.tech/arc";
|
|
1
|
+
import { applyQueryChanges, LiveQuery } from "@arcote.tech/arc";
|
|
2
2
|
import { filterEventsForTokens } from "../event-auth";
|
|
3
3
|
import type { ArcWsHandler } from "./types";
|
|
4
4
|
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
// Live query subscriptions
|
|
7
7
|
//
|
|
8
|
-
// One LiveQuery per (
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// One LiveQuery per (scope, descriptor, token) — clients subscribed to the
|
|
9
|
+
// SAME query with the SAME token share a single server-side LiveQuery, so a
|
|
10
|
+
// mutation triggers one re-execute + diff instead of one per subscriber
|
|
11
|
+
// (packages/benchmarks/FINDINGS.md, F-08). The token is part of the key
|
|
12
|
+
// because protectBy restrictions make results token-specific. ALL query
|
|
13
|
+
// logic — execution, token restrictions, change detection, delta
|
|
14
|
+
// computation — lives in core's LiveQuery (query layer); this module only
|
|
15
|
+
// fans its updates out over WS.
|
|
11
16
|
// ---------------------------------------------------------------------------
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
interface SharedQueryEntry {
|
|
19
|
+
live: LiveQuery;
|
|
20
|
+
/** clientId → subscriptionIds subscribed under this shared query. */
|
|
21
|
+
subscribers: Map<string, Set<string>>;
|
|
22
|
+
/**
|
|
23
|
+
* Mirror of the current result — late joiners get their snapshot from it
|
|
24
|
+
* without re-executing. Maintained by the same update stream the clients
|
|
25
|
+
* receive, so it can't diverge from what they hold.
|
|
26
|
+
*/
|
|
27
|
+
lastResult: unknown;
|
|
28
|
+
/** start() resolved and the initial snapshot is trustworthy. */
|
|
29
|
+
ready: boolean;
|
|
30
|
+
}
|
|
15
31
|
|
|
16
|
-
|
|
17
|
-
|
|
32
|
+
/** sharedKey → entry. */
|
|
33
|
+
const sharedQueries = new Map<string, SharedQueryEntry>();
|
|
34
|
+
/** clientId → subscriptionId → sharedKey (reverse index for cleanup). */
|
|
35
|
+
const clientSubKeys = new Map<string, Map<string, string>>();
|
|
36
|
+
|
|
37
|
+
function sharedKeyFor(
|
|
38
|
+
descriptor: unknown,
|
|
39
|
+
scope: string,
|
|
40
|
+
rawToken: string | null,
|
|
41
|
+
): string {
|
|
42
|
+
// JSON tablicy = jednoznaczny klucz bez założeń o separatorach.
|
|
43
|
+
return JSON.stringify([scope, rawToken, descriptor]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function detachSub(
|
|
47
|
+
clientId: string,
|
|
48
|
+
subscriptionId: string,
|
|
49
|
+
sharedKey: string,
|
|
50
|
+
): void {
|
|
51
|
+
const entry = sharedQueries.get(sharedKey);
|
|
52
|
+
if (!entry) return;
|
|
53
|
+
const subs = entry.subscribers.get(clientId);
|
|
18
54
|
if (subs) {
|
|
19
|
-
|
|
20
|
-
|
|
55
|
+
subs.delete(subscriptionId);
|
|
56
|
+
if (subs.size === 0) entry.subscribers.delete(clientId);
|
|
57
|
+
}
|
|
58
|
+
if (entry.subscribers.size === 0) {
|
|
59
|
+
entry.live.stop();
|
|
60
|
+
sharedQueries.delete(sharedKey);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function cleanupClientSubs(clientId: string): void {
|
|
65
|
+
const keys = clientSubKeys.get(clientId);
|
|
66
|
+
if (keys) {
|
|
67
|
+
for (const [subscriptionId, sharedKey] of keys) {
|
|
68
|
+
detachSub(clientId, subscriptionId, sharedKey);
|
|
69
|
+
}
|
|
70
|
+
clientSubKeys.delete(clientId);
|
|
21
71
|
}
|
|
22
72
|
}
|
|
23
73
|
|
|
@@ -28,7 +78,7 @@ export function cleanupClientSubs(clientId: string): void {
|
|
|
28
78
|
export function scopeAuthHandler(): ArcWsHandler {
|
|
29
79
|
return async (client, message, ctx) => {
|
|
30
80
|
if (message.type !== "scope:auth") return false;
|
|
31
|
-
const decoded = ctx.verifyToken(message.token);
|
|
81
|
+
const decoded = await ctx.verifyToken(message.token);
|
|
32
82
|
if (decoded) {
|
|
33
83
|
ctx.connectionManager.setScopeToken(
|
|
34
84
|
client.id,
|
|
@@ -187,48 +237,114 @@ export function querySubscriptionHandler(): ArcWsHandler {
|
|
|
187
237
|
}
|
|
188
238
|
|
|
189
239
|
// Re-subscribe with the same id (reconnect resend) — drop the old one
|
|
190
|
-
|
|
240
|
+
const prevKey = clientSubKeys.get(client.id)?.get(subscriptionId);
|
|
241
|
+
if (prevKey) detachSub(client.id, subscriptionId, prevKey);
|
|
242
|
+
|
|
243
|
+
const sharedKey = sharedKeyFor(descriptor, scope, rawToken);
|
|
244
|
+
const attach = (entry: SharedQueryEntry) => {
|
|
245
|
+
if (!entry.subscribers.has(client.id)) {
|
|
246
|
+
entry.subscribers.set(client.id, new Set());
|
|
247
|
+
}
|
|
248
|
+
entry.subscribers.get(client.id)!.add(subscriptionId);
|
|
249
|
+
if (!clientSubKeys.has(client.id)) {
|
|
250
|
+
clientSubKeys.set(client.id, new Map());
|
|
251
|
+
}
|
|
252
|
+
clientSubKeys.get(client.id)!.set(subscriptionId, sharedKey);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const existing = sharedQueries.get(sharedKey);
|
|
256
|
+
if (existing && existing.ready) {
|
|
257
|
+
// Late joiner — snapshot from the mirror, no re-execute.
|
|
258
|
+
attach(existing);
|
|
259
|
+
ctx.connectionManager.sendToClient(client.id, {
|
|
260
|
+
type: "query-snapshot",
|
|
261
|
+
subscriptionId,
|
|
262
|
+
result: existing.lastResult ?? null,
|
|
263
|
+
});
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
if (existing) {
|
|
267
|
+
// start() in flight (another subscriber won the race) — attach;
|
|
268
|
+
// the starter's post-start broadcast covers this subscription too.
|
|
269
|
+
attach(existing);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
191
272
|
|
|
192
|
-
const
|
|
273
|
+
const entry: SharedQueryEntry = {
|
|
274
|
+
live: null as unknown as LiveQuery,
|
|
275
|
+
subscribers: new Map(),
|
|
276
|
+
lastResult: null,
|
|
277
|
+
ready: false,
|
|
278
|
+
};
|
|
279
|
+
entry.live = new LiveQuery(
|
|
193
280
|
ctx.contextHandler.getModel(),
|
|
194
281
|
descriptor,
|
|
195
282
|
scope,
|
|
196
283
|
rawToken,
|
|
197
284
|
(update) => {
|
|
285
|
+
// Keep the mirror in the same sequence the clients apply.
|
|
198
286
|
if (update.type === "changes") {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
287
|
+
if (Array.isArray(entry.lastResult)) {
|
|
288
|
+
entry.lastResult = applyQueryChanges(
|
|
289
|
+
entry.lastResult as Array<{ _id: string }>,
|
|
290
|
+
update.changes,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
204
293
|
} else {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
294
|
+
entry.lastResult = update.result ?? null;
|
|
295
|
+
}
|
|
296
|
+
for (const [subClientId, subIds] of entry.subscribers) {
|
|
297
|
+
for (const subId of subIds) {
|
|
298
|
+
ctx.connectionManager.sendToClient(
|
|
299
|
+
subClientId,
|
|
300
|
+
update.type === "changes"
|
|
301
|
+
? {
|
|
302
|
+
type: "query-changes",
|
|
303
|
+
subscriptionId: subId,
|
|
304
|
+
changes: update.changes,
|
|
305
|
+
}
|
|
306
|
+
: {
|
|
307
|
+
type: "query-snapshot",
|
|
308
|
+
subscriptionId: subId,
|
|
309
|
+
result: update.result ?? null,
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
}
|
|
210
313
|
}
|
|
211
314
|
},
|
|
212
315
|
);
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
clientQuerySubs.set(client.id, new Map());
|
|
216
|
-
}
|
|
217
|
-
clientQuerySubs.get(client.id)!.set(subscriptionId, live);
|
|
316
|
+
sharedQueries.set(sharedKey, entry);
|
|
317
|
+
attach(entry);
|
|
218
318
|
|
|
219
319
|
try {
|
|
220
|
-
const result = await live.start();
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
320
|
+
const result = await entry.live.start();
|
|
321
|
+
entry.lastResult = result ?? null;
|
|
322
|
+
entry.ready = true;
|
|
323
|
+
// Initial snapshot for EVERY subscription attached so far — the
|
|
324
|
+
// starter and any joiners that raced into the start window.
|
|
325
|
+
for (const [subClientId, subIds] of entry.subscribers) {
|
|
326
|
+
for (const subId of subIds) {
|
|
327
|
+
ctx.connectionManager.sendToClient(subClientId, {
|
|
328
|
+
type: "query-snapshot",
|
|
329
|
+
subscriptionId: subId,
|
|
330
|
+
result: result ?? null,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
226
334
|
// Catch-up pass AFTER the snapshot went out — diffs out any commit
|
|
227
335
|
// that slipped into the initial-execute window.
|
|
228
|
-
live.flush();
|
|
336
|
+
entry.live.flush();
|
|
229
337
|
} catch (err) {
|
|
230
|
-
live.stop();
|
|
231
|
-
|
|
338
|
+
entry.live.stop();
|
|
339
|
+
sharedQueries.delete(sharedKey);
|
|
340
|
+
for (const subClientId of entry.subscribers.keys()) {
|
|
341
|
+
const keys = clientSubKeys.get(subClientId);
|
|
342
|
+
if (keys) {
|
|
343
|
+
for (const [subId, key] of keys) {
|
|
344
|
+
if (key === sharedKey) keys.delete(subId);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
232
348
|
console.error(
|
|
233
349
|
`[Arc] Query subscription error (${descriptor?.element}.${descriptor?.method}):`,
|
|
234
350
|
err,
|
|
@@ -242,11 +358,10 @@ export function querySubscriptionHandler(): ArcWsHandler {
|
|
|
242
358
|
}
|
|
243
359
|
|
|
244
360
|
if (message.type === "unsubscribe-query") {
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
subs!.delete(message.subscriptionId);
|
|
361
|
+
const key = clientSubKeys.get(client.id)?.get(message.subscriptionId);
|
|
362
|
+
if (key) {
|
|
363
|
+
detachSub(client.id, message.subscriptionId, key);
|
|
364
|
+
clientSubKeys.get(client.id)?.delete(message.subscriptionId);
|
|
250
365
|
}
|
|
251
366
|
return true;
|
|
252
367
|
}
|