@arcote.tech/arc-host 0.1.10 → 0.3.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/host.ts DELETED
@@ -1,510 +0,0 @@
1
- import {
2
- MasterDataStorage,
3
- Model,
4
- QueryBuilderContext,
5
- QueryCache,
6
- type ArcContextAny,
7
- type AuthContext,
8
- type DatabaseAdapter,
9
- type DataStorageChanges,
10
- type MessageClientToHost,
11
- type MessageHostToClient,
12
- type RealTimeCommunicationAdapter,
13
- } from "@arcote.tech/arc";
14
- import type { Server, ServerWebSocket } from "bun";
15
- import { verifyToken, type TokenPayload } from "./auth/jwt";
16
-
17
- class RTCHost implements RealTimeCommunicationAdapter {
18
- private server!: Server;
19
- private dataStore: MasterDataStorage;
20
- private model: Model<ArcContextAny>;
21
-
22
- constructor(
23
- private context: ArcContextAny,
24
- dbAdapter: Promise<DatabaseAdapter>,
25
- ) {
26
- this.dataStore = new MasterDataStorage(dbAdapter, () => this, context);
27
- this.model = new Model(context, this.dataStore, (error) =>
28
- console.error("Command error:", error),
29
- );
30
- this.setupServer();
31
- }
32
-
33
- commitChanges(changes: DataStorageChanges[]): void {
34
- // throw new Error("Method not implemented.");
35
- }
36
-
37
- async sync(
38
- progressCallback: ({
39
- store,
40
- size,
41
- }: {
42
- store: string;
43
- size: number;
44
- }) => void,
45
- ): Promise<void> {
46
- // throw new Error("Method not implemented.");
47
- }
48
-
49
- private async verifyAuthToken(token: string) {
50
- try {
51
- const payload = verifyToken(token);
52
- return payload;
53
- } catch (error) {
54
- return null;
55
- }
56
- }
57
-
58
- /**
59
- * Convert JWT payload to AuthContext
60
- */
61
- private tokenToAuthContext(
62
- payload: TokenPayload,
63
- ipAddress?: string,
64
- ): AuthContext {
65
- return {
66
- userId: payload.userId,
67
- ipAddress,
68
- } as AuthContext;
69
- }
70
-
71
- /**
72
- * Extract client IP address from request headers
73
- */
74
- private getClientIpAddress(req: Request): string | undefined {
75
- // Check common headers for client IP
76
- const xForwardedFor = req.headers.get("x-forwarded-for");
77
- const xRealIp = req.headers.get("x-real-ip");
78
- const cfConnectingIp = req.headers.get("cf-connecting-ip");
79
-
80
- if (xForwardedFor) {
81
- // x-forwarded-for can contain multiple IPs, take the first one
82
- return xForwardedFor.split(",")[0].trim();
83
- }
84
-
85
- if (xRealIp) {
86
- return xRealIp;
87
- }
88
-
89
- if (cfConnectingIp) {
90
- return cfConnectingIp;
91
- }
92
-
93
- // Fallback - this might not work in all environments
94
- return undefined;
95
- }
96
-
97
- /**
98
- * Get default auth context for anonymous users
99
- */
100
- private getDefaultAuthContext(ipAddress?: string): AuthContext {
101
- return {
102
- ipAddress,
103
- };
104
- }
105
-
106
- /**
107
- * Parse FormData back to an object structure
108
- */
109
- private async parseFormDataToObject(formData: FormData): Promise<any> {
110
- const obj: any = {};
111
-
112
- // Use forEach instead of entries() for better TypeScript compatibility
113
- formData.forEach((value, key) => {
114
- this.setNestedProperty(obj, key, value);
115
- });
116
-
117
- return obj;
118
- }
119
-
120
- /**
121
- * Helper method to set nested properties from FormData keys like "user[profile][avatar]"
122
- */
123
- private setNestedProperty(obj: any, path: string, value: any): void {
124
- // Handle array notation like "items[0]" or nested object notation like "user[profile][name]"
125
- const keys = path.split(/[\[\]]+/).filter((key) => key !== "");
126
- let current = obj;
127
-
128
- for (let i = 0; i < keys.length - 1; i++) {
129
- const key = keys[i];
130
- const nextKey = keys[i + 1];
131
-
132
- // Check if next key is a number (array index)
133
- if (!isNaN(Number(nextKey))) {
134
- if (!current[key]) {
135
- current[key] = [];
136
- }
137
- if (!current[key][Number(nextKey)]) {
138
- current[key][Number(nextKey)] = {};
139
- }
140
- current = current[key][Number(nextKey)];
141
- i++; // Skip the array index
142
- } else {
143
- if (!current[key]) {
144
- current[key] = {};
145
- }
146
- current = current[key];
147
- }
148
- }
149
-
150
- const finalKey = keys[keys.length - 1];
151
- if (!isNaN(Number(finalKey))) {
152
- // This is an array index at the root level
153
- const parentKey = keys[keys.length - 2];
154
- if (!current[parentKey]) {
155
- current[parentKey] = [];
156
- }
157
- current[parentKey][Number(finalKey)] = value;
158
- } else {
159
- current[finalKey] = value;
160
- }
161
- }
162
-
163
- private async handleCommand(req: Request) {
164
- const url = new URL(req.url);
165
- const commandName = url.pathname.split("/command/")[1];
166
-
167
- if (!commandName) {
168
- return new Response("Command not specified", { status: 400 });
169
- }
170
-
171
- try {
172
- // Extract token from Authorization header
173
- const authHeader = req.headers.get("Authorization");
174
- const token = authHeader?.replace("Bearer ", "");
175
-
176
- const clientIp = this.getClientIpAddress(req);
177
- let authContext: AuthContext;
178
- if (token && !this.isPublicEndpoint(url.pathname)) {
179
- const payload = await this.verifyAuthToken(token);
180
- if (!payload) {
181
- return new Response("Invalid or expired token", { status: 401 });
182
- }
183
- authContext = this.tokenToAuthContext(payload, clientIp);
184
- } else {
185
- authContext = this.getDefaultAuthContext(clientIp);
186
- }
187
-
188
- let argument: any;
189
- const contentType = req.headers.get("Content-Type");
190
-
191
- // Handle different content types
192
- if (contentType && contentType.includes("multipart/form-data")) {
193
- // Parse FormData
194
- const formData = await req.formData();
195
- argument = await this.parseFormDataToObject(formData);
196
- } else {
197
- // Parse JSON (default behavior)
198
- argument = await req.json();
199
- }
200
-
201
- // Use the model's commands() method with auth context
202
- const commands = this.model.commands(authContext);
203
-
204
- const result = await (commands as any)[commandName](argument);
205
- return new Response(JSON.stringify(result), {
206
- headers: { "Content-Type": "application/json" },
207
- status: 200,
208
- });
209
- } catch (error) {
210
- console.error(`Error executing command ${commandName}:`, error);
211
- return new Response("Internal Server Error", { status: 500 });
212
- }
213
- }
214
-
215
- private async handleQuery(req: Request) {
216
- try {
217
- // Extract token from Authorization header
218
- const authHeader = req.headers.get("Authorization");
219
- const token = authHeader?.replace("Bearer ", "");
220
-
221
- const clientIp = this.getClientIpAddress(req);
222
- let authContext: AuthContext;
223
- if (token) {
224
- const payload = await this.verifyAuthToken(token);
225
- if (!payload) {
226
- return new Response("Invalid or expired token", { status: 401 });
227
- }
228
- authContext = this.tokenToAuthContext(payload, clientIp);
229
- } else {
230
- return new Response("Authorization token required", { status: 401 });
231
- }
232
-
233
- const body = await req.json();
234
- const { query } = body;
235
-
236
- if (!query || !query.element || !query.queryType) {
237
- return new Response("Invalid query format", { status: 400 });
238
- }
239
-
240
- const { element, queryType, params } = query;
241
-
242
- // Access the model and run the query with auth context
243
- const queryBuilder = this.context.queryBuilder(
244
- new QueryBuilderContext(new QueryCache(), this.dataStore),
245
- authContext,
246
- ) as any;
247
-
248
- // Get the appropriate element from the queryBuilder
249
- const elementBuilder = queryBuilder[element];
250
- if (!elementBuilder) {
251
- return new Response(`Element '${element}' not found`, { status: 400 });
252
- }
253
-
254
- // Get the query method from the element
255
- const queryMethod = elementBuilder[queryType];
256
- if (!queryMethod || typeof queryMethod !== "function") {
257
- return new Response(
258
- `Query type '${queryType}' not found on element '${element}'`,
259
- { status: 400 },
260
- );
261
- }
262
-
263
- // Build and execute the query
264
- const queryParams = params || [];
265
- const queryObj = queryMethod.apply(elementBuilder, queryParams).toQuery();
266
- const result = await queryObj.run(this.dataStore);
267
-
268
- return new Response(JSON.stringify(result), {
269
- headers: { "Content-Type": "application/json" },
270
- status: 200,
271
- });
272
- } catch (error) {
273
- console.error("Error executing query:", error);
274
- return new Response(`Internal Server Error: ${error}`, {
275
- status: 500,
276
- });
277
- }
278
- }
279
-
280
- private async handleRoute(req: Request) {
281
- const url = new URL(req.url);
282
- const method = req.method;
283
-
284
- // Find matching route
285
- let matchedRoute: any = null;
286
- let routeParams: Record<string, string> = {};
287
-
288
- for (const element of this.context.elements) {
289
- // Check if element has matchesRoutePath method (ArcRoute)
290
- if (typeof (element as any).matchesRoutePath === "function") {
291
- const { matches, params } = (element as any).matchesRoutePath(
292
- url.pathname,
293
- );
294
- if (matches) {
295
- matchedRoute = element;
296
- routeParams = params || {};
297
- break;
298
- }
299
- }
300
- }
301
-
302
- if (!matchedRoute) {
303
- return new Response("Route not found", { status: 404 });
304
- }
305
-
306
- const handler = matchedRoute.getHandler(method);
307
- if (!handler) {
308
- return new Response(`Method ${method} not allowed`, { status: 405 });
309
- }
310
-
311
- try {
312
- // Extract token from Authorization header
313
- const authHeader = req.headers.get("Authorization");
314
- const token = authHeader?.replace("Bearer ", "");
315
-
316
- const clientIp = this.getClientIpAddress(req);
317
- let authContext: AuthContext;
318
-
319
- if (token && !matchedRoute.isPublic) {
320
- const payload = await this.verifyAuthToken(token);
321
- if (!payload) {
322
- return new Response("Invalid or expired token", { status: 401 });
323
- }
324
- authContext = this.tokenToAuthContext(payload, clientIp);
325
- } else {
326
- authContext = this.getDefaultAuthContext(clientIp);
327
- }
328
-
329
- // Use the model's routes method to properly handle event publishing
330
- const routes = this.model.routes(authContext);
331
- const response = await routes[matchedRoute.name](
332
- method,
333
- req,
334
- routeParams,
335
- url,
336
- );
337
- return response;
338
- } catch (error) {
339
- console.error(`Error executing route ${matchedRoute.name}:`, error);
340
- return new Response("Internal Server Error", { status: 500 });
341
- }
342
- }
343
-
344
- private setupServer() {
345
- this.server = Bun.serve({
346
- fetch: async (req, server) => {
347
- const url = new URL(req.url);
348
-
349
- // Extract token from Authorization header or query parameter
350
- const authHeader = req.headers.get("Authorization");
351
- const token =
352
- authHeader?.replace("Bearer ", "") || url.searchParams.get("token");
353
-
354
- // Verify token for protected endpoints
355
- let payload = null;
356
- if (token) {
357
- payload = await this.verifyAuthToken(token);
358
-
359
- // For protected endpoints, require valid token
360
- if (!payload && !this.isPublicEndpoint(url.pathname)) {
361
- return new Response("Invalid or expired token", { status: 401 });
362
- }
363
- } else if (!this.isPublicEndpoint(url.pathname)) {
364
- return new Response("Authorization token required", { status: 401 });
365
- }
366
-
367
- // Handle different endpoints
368
- if (
369
- url.pathname === "/ws" &&
370
- req.headers.get("Upgrade") === "websocket"
371
- ) {
372
- if (server.upgrade(req, { data: { user: payload } })) {
373
- return;
374
- }
375
- return new Response("Upgrade failed", { status: 500 });
376
- }
377
-
378
- if (url.pathname === "/sync" && req.method === "GET") {
379
- return await this.handleSync(url.searchParams.get("lastSync"));
380
- }
381
-
382
- if (url.pathname.startsWith("/command/") && req.method === "POST") {
383
- return await this.handleCommand(req);
384
- }
385
-
386
- if (url.pathname === "/query" && req.method === "POST") {
387
- return await this.handleQuery(req);
388
- }
389
-
390
- // Try to handle as a route
391
- const routeResponse = await this.handleRoute(req);
392
- if (routeResponse.status !== 404) {
393
- return routeResponse;
394
- }
395
-
396
- return new Response("Not Found", { status: 404 });
397
- },
398
- websocket: {
399
- message: this.onMessage.bind(this),
400
- open(ws) {
401
- ws.subscribe("sync");
402
- },
403
- close(ws, code, message) {},
404
- perMessageDeflate: true,
405
- backpressureLimit: 16 * 1024 * 1024,
406
- },
407
- port: 5005,
408
- idleTimeout: 30,
409
- });
410
- }
411
-
412
- private isPublicEndpoint(pathname: string): boolean {
413
- // Iterate through all context elements and check if any match and are public
414
- for (const element of this.context.elements) {
415
- // Check if element has matchesCommandPath method (ArcCommand)
416
- if (typeof (element as any).matchesCommandPath === "function") {
417
- const { matches, isPublic } = (element as any).matchesCommandPath(
418
- pathname,
419
- );
420
- if (matches) {
421
- return isPublic;
422
- }
423
- }
424
-
425
- // Check if element has matchesRoutePath method (ArcRoute)
426
- if (typeof (element as any).matchesRoutePath === "function") {
427
- const { matches, isPublic } = (element as any).matchesRoutePath(
428
- pathname,
429
- );
430
- if (matches) {
431
- return isPublic;
432
- }
433
- }
434
- }
435
-
436
- // Default to non-public if no matching element found
437
- return false;
438
- }
439
-
440
- private async handleSync(lastDate: string | null) {
441
- const syncDate = new Date();
442
- const where = lastDate
443
- ? {
444
- lastUpdate: {
445
- $gt: new Date(lastDate),
446
- },
447
- }
448
- : {};
449
-
450
- // const client = await this.clientPromise;
451
- const syncResults: { store: string; items: any[] }[] = [];
452
-
453
- // for (const collection of this.context.elements
454
- // .filter(
455
- // (element) => element instanceof ArcCollection,
456
- // // || element instanceof ArcIndexedCollection,
457
- // )
458
- // .concat([{ name: "state" } as unknown as ArcCollectionAny])) {
459
- // // const result = await client
460
- // // .db(process.env.MONGODB_DB)
461
- // // .collection(collection.name)
462
- // // .find(where)
463
- // // .toArray();
464
- // // const prepareDeleted = result.map((item) => {
465
- // // if (item.deleted) return { _id: item._id, deleted: true };
466
- // // return item;
467
- // // });
468
- // // syncResults.push({
469
- // // store: collection.name,
470
- // // items: prepareDeleted,
471
- // // });
472
- // }
473
-
474
- return new Response(
475
- JSON.stringify({
476
- results: syncResults,
477
- syncDate: syncDate.toISOString(),
478
- }),
479
- {
480
- headers: {
481
- "Content-Type": "application/json",
482
- },
483
- },
484
- );
485
- }
486
-
487
- private async onMessage(ws: ServerWebSocket, messageAsString: string) {
488
- const message = JSON.parse(messageAsString) as MessageClientToHost;
489
- switch (message.type) {
490
- case "changes-executed":
491
- await this.dataStore.applyChanges(message.changes);
492
- this.publishMessage(ws, {
493
- type: "state-changes",
494
- changes: message.changes,
495
- });
496
- break;
497
- default:
498
- console.warn(`Message unsupported`, message);
499
- }
500
- }
501
-
502
- private publishMessage(ws: ServerWebSocket, message: MessageHostToClient) {
503
- ws.publish("sync", JSON.stringify(message));
504
- }
505
- }
506
-
507
- export const rtcHostFactory =
508
- (context: ArcContextAny, dbAdapter: Promise<DatabaseAdapter>) => () => {
509
- return new RTCHost(context, dbAdapter);
510
- };
@@ -1,50 +0,0 @@
1
- import {
2
- createPostgreSQLAdapterFactory,
3
- type ArcContextAny,
4
- type DBAdapterFactory,
5
- type PostgreSQLDatabase,
6
- } from "@arcote.tech/arc";
7
- import { SQL } from "bun";
8
-
9
- class BunPostgreSQLDatabase implements PostgreSQLDatabase {
10
- private connectionString: string;
11
- private sql: SQL;
12
- constructor(connectionString: string) {
13
- this.connectionString = connectionString;
14
- this.sql = new SQL(this.connectionString);
15
- }
16
-
17
- async exec(query: string, params?: any[]): Promise<any> {
18
- try {
19
- const result = await this.sql.unsafe(query, params);
20
- return result;
21
- } catch (error) {
22
- console.error("PostgreSQL error:", error, query, params);
23
- throw error;
24
- }
25
- }
26
-
27
- async execBatch(
28
- queries: Array<{ sql: string; params?: any[] }>,
29
- ): Promise<any> {
30
- try {
31
- return await this.sql.transaction(async (tx) => {
32
- for (const query of queries) {
33
- await tx.unsafe(query.sql, query.params);
34
- }
35
- });
36
- } catch (error) {
37
- console.error("PostgreSQL batch transaction error:", error);
38
- throw error;
39
- }
40
- }
41
- }
42
-
43
- export const postgreSQLAdapterFactory = (
44
- connectionString: string,
45
- ): DBAdapterFactory => {
46
- return async (context: ArcContextAny) => {
47
- const pgDb = new BunPostgreSQLDatabase(connectionString);
48
- return createPostgreSQLAdapterFactory(pgDb)(context);
49
- };
50
- };