@verdant-web/server 2.0.8 → 2.1.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/dist/cjs/ClientConnection.d.ts +2 -0
- package/dist/cjs/ClientConnection.js +33 -0
- package/dist/cjs/ClientConnection.js.map +1 -1
- package/dist/cjs/Server.d.ts +17 -1
- package/dist/cjs/Server.js +211 -103
- package/dist/cjs/Server.js.map +1 -1
- package/dist/esm/ClientConnection.d.ts +2 -0
- package/dist/esm/ClientConnection.js +33 -0
- package/dist/esm/ClientConnection.js.map +1 -1
- package/dist/esm/Server.d.ts +17 -1
- package/dist/esm/Server.js +211 -103
- package/dist/esm/Server.js.map +1 -1
- package/dist/tsconfig-cjs.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/ClientConnection.ts +57 -1
- package/src/Server.ts +283 -118
package/src/Server.ts
CHANGED
|
@@ -25,6 +25,12 @@ import internal, { Readable } from 'stream';
|
|
|
25
25
|
import { FileMetadata, FileMetadataConfig } from './files/FileMetadata.js';
|
|
26
26
|
import { ServerLibrary } from './ServerLibrary.js';
|
|
27
27
|
import { migrations } from './migrations.js';
|
|
28
|
+
import {
|
|
29
|
+
ReadableStream,
|
|
30
|
+
ReadableWritablePair,
|
|
31
|
+
TransformStream,
|
|
32
|
+
WritableStream,
|
|
33
|
+
} from 'node:stream/web';
|
|
28
34
|
|
|
29
35
|
export interface ServerOptions {
|
|
30
36
|
/**
|
|
@@ -229,11 +235,21 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
229
235
|
}
|
|
230
236
|
};
|
|
231
237
|
|
|
232
|
-
private authorizeRequest = (req: IncomingMessage) => {
|
|
238
|
+
private authorizeRequest = (req: IncomingMessage | Request) => {
|
|
233
239
|
return this.tokenVerifier.verifyToken(this.getRequestToken(req));
|
|
234
240
|
};
|
|
235
241
|
|
|
236
|
-
private getRequestToken = (req: IncomingMessage) => {
|
|
242
|
+
private getRequestToken = (req: IncomingMessage | Request) => {
|
|
243
|
+
if (isFetch(req)) {
|
|
244
|
+
const authHeader = req.headers.get('Authorization');
|
|
245
|
+
assert(authHeader, 'Token is required');
|
|
246
|
+
const [type, token] = authHeader.split(' ');
|
|
247
|
+
if (type === 'Bearer') {
|
|
248
|
+
return token;
|
|
249
|
+
}
|
|
250
|
+
return token;
|
|
251
|
+
}
|
|
252
|
+
|
|
237
253
|
if (req.headers.authorization) {
|
|
238
254
|
const [type, token] = req.headers.authorization.split(' ');
|
|
239
255
|
if (type === 'Bearer') {
|
|
@@ -310,33 +326,71 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
310
326
|
});
|
|
311
327
|
}));
|
|
312
328
|
|
|
313
|
-
|
|
314
|
-
for (const message of body.messages) {
|
|
315
|
-
await this.handleMessage(key, info, message);
|
|
316
|
-
}
|
|
317
|
-
} catch (e) {
|
|
318
|
-
this.emit('error', e);
|
|
319
|
-
res.writeHead(500);
|
|
320
|
-
res.end();
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// update our keepalive timers for presence management
|
|
325
|
-
const firstMessage = body.messages[0];
|
|
326
|
-
if (firstMessage) {
|
|
327
|
-
this.keepalives.refresh(info.libraryId, firstMessage.replicaId);
|
|
328
|
-
}
|
|
329
|
+
await this.handleRequestBody(key, info, body);
|
|
329
330
|
|
|
330
331
|
finish();
|
|
331
332
|
|
|
332
333
|
this.emit('request', info);
|
|
333
334
|
}
|
|
334
335
|
} catch (e) {
|
|
335
|
-
return this.
|
|
336
|
+
return this.writeErrorResponse(e, res);
|
|
336
337
|
}
|
|
337
338
|
};
|
|
338
339
|
|
|
339
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Handles a "fetch" style request. Complement to handleRequest, for servers
|
|
342
|
+
* that use Request/Response style handlers.
|
|
343
|
+
*/
|
|
344
|
+
handleFetch = async (req: Request): Promise<Response> => {
|
|
345
|
+
try {
|
|
346
|
+
const info = this.authorizeRequest(req);
|
|
347
|
+
const key = generateId();
|
|
348
|
+
|
|
349
|
+
const finish = this.clientConnections.addFetch(
|
|
350
|
+
info.libraryId,
|
|
351
|
+
key,
|
|
352
|
+
req,
|
|
353
|
+
info,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const body = (await req.json()) as
|
|
357
|
+
| { messages: ClientMessage[] }
|
|
358
|
+
| null
|
|
359
|
+
| undefined;
|
|
360
|
+
|
|
361
|
+
if (!body) {
|
|
362
|
+
throw new VerdantError(VerdantError.Code.BodyRequired);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
await this.handleRequestBody(key, info, body);
|
|
366
|
+
|
|
367
|
+
const res = finish();
|
|
368
|
+
|
|
369
|
+
this.emit('request', info);
|
|
370
|
+
|
|
371
|
+
return res;
|
|
372
|
+
} catch (e) {
|
|
373
|
+
return this.getErrorResponse(e);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
private handleRequestBody = async (
|
|
378
|
+
key: string,
|
|
379
|
+
info: TokenInfo,
|
|
380
|
+
body: { messages: ClientMessage[] },
|
|
381
|
+
) => {
|
|
382
|
+
for (const message of body.messages) {
|
|
383
|
+
await this.handleMessage(key, info, message);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// update our keepalive timers for presence management
|
|
387
|
+
const firstMessage = body.messages[0];
|
|
388
|
+
if (firstMessage) {
|
|
389
|
+
this.keepalives.refresh(info.libraryId, firstMessage.replicaId);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
private writeErrorResponse(e: unknown, res: ServerResponse) {
|
|
340
394
|
this.emit('error', e);
|
|
341
395
|
this.log('Error handling request', e);
|
|
342
396
|
|
|
@@ -355,131 +409,238 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
355
409
|
res.end();
|
|
356
410
|
}
|
|
357
411
|
|
|
412
|
+
// for fetch-style
|
|
413
|
+
private getErrorResponse(e: unknown) {
|
|
414
|
+
this.emit('error', e);
|
|
415
|
+
this.log('Error handling request', e);
|
|
416
|
+
|
|
417
|
+
if (e instanceof VerdantError) {
|
|
418
|
+
return new Response(JSON.stringify(e.toResponse()), {
|
|
419
|
+
status: e.httpStatus,
|
|
420
|
+
headers: {
|
|
421
|
+
'Content-Type': 'application/json',
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
} else {
|
|
425
|
+
return new Response(
|
|
426
|
+
JSON.stringify(
|
|
427
|
+
new VerdantError(VerdantError.Code.Unexpected).toResponse(),
|
|
428
|
+
),
|
|
429
|
+
{
|
|
430
|
+
status: 500,
|
|
431
|
+
headers: {
|
|
432
|
+
'Content-Type': 'application/json',
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
358
439
|
/**
|
|
359
440
|
* Handles a multipart upload of a file from a verdant client. The upload
|
|
360
441
|
* will include parameters for the file's ID, name, and type. The request
|
|
361
442
|
* must be authenticated with a token to tie it to a library.
|
|
362
443
|
*/
|
|
363
444
|
handleFileRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
this.emit(
|
|
367
|
-
'error',
|
|
368
|
-
new Error(
|
|
369
|
-
'No file storage configured, but a client attempted to upload a file.',
|
|
370
|
-
),
|
|
371
|
-
);
|
|
372
|
-
res.writeHead(500);
|
|
373
|
-
res.write('File storage is not configured');
|
|
374
|
-
res.end();
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
445
|
+
try {
|
|
446
|
+
const info = this.authorizeRequest(req);
|
|
377
447
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
'http://localhost',
|
|
383
|
-
);
|
|
448
|
+
const url = new URL(
|
|
449
|
+
(req as any).originalUrl || (req as any).baseUrl || req.url || '',
|
|
450
|
+
'http://localhost',
|
|
451
|
+
);
|
|
384
452
|
|
|
385
|
-
|
|
453
|
+
const id = url.pathname.split('/').pop();
|
|
386
454
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
'File ID is required to be in the URL path as the last parameter',
|
|
391
|
-
);
|
|
392
|
-
res.end();
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
455
|
+
if (!id || id === 'files') {
|
|
456
|
+
throw new VerdantError(VerdantError.Code.NotFound);
|
|
457
|
+
}
|
|
395
458
|
|
|
396
|
-
try {
|
|
397
459
|
if (req.method === 'POST') {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
// too many 'info's....
|
|
404
|
-
const lofiFileInfo: FileInfo = {
|
|
405
|
-
id,
|
|
406
|
-
libraryId: info.libraryId,
|
|
407
|
-
fileName: fileInfo.filename,
|
|
408
|
-
type: fileInfo.mimeType,
|
|
409
|
-
};
|
|
410
|
-
// write metadata to storage
|
|
411
|
-
try {
|
|
412
|
-
this.fileMetadata.put(info.libraryId, lofiFileInfo);
|
|
413
|
-
fs.put(stream, lofiFileInfo);
|
|
414
|
-
} catch (e) {
|
|
415
|
-
reject(e);
|
|
416
|
-
}
|
|
417
|
-
});
|
|
418
|
-
bb.on('field', (fieldName, value) => {
|
|
419
|
-
if (fieldName === 'file') {
|
|
420
|
-
if (this.__testMode) {
|
|
421
|
-
// this isn't right in the real world, but in testing it's
|
|
422
|
-
// the only way we get file data.
|
|
423
|
-
// we create a stream from the data and pass it as if it
|
|
424
|
-
// were a file stream
|
|
425
|
-
const stream = new Readable();
|
|
426
|
-
stream.push(value);
|
|
427
|
-
stream.push(null);
|
|
428
|
-
const fileInfo = {
|
|
429
|
-
filename: 'test.txt',
|
|
430
|
-
mimeType: 'text/plain',
|
|
431
|
-
};
|
|
432
|
-
bb.emit('file', fieldName, stream, fileInfo);
|
|
433
|
-
} else {
|
|
434
|
-
throw new Error('Invalid file upload');
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
req.pipe(bb);
|
|
440
|
-
bb.on('finish', resolve);
|
|
441
|
-
bb.on('error', reject);
|
|
460
|
+
await this.streamIncomingFile({
|
|
461
|
+
req,
|
|
462
|
+
info,
|
|
463
|
+
headers: req.headers,
|
|
464
|
+
id,
|
|
442
465
|
});
|
|
443
466
|
this.log('File upload complete');
|
|
444
467
|
res.writeHead(200);
|
|
445
468
|
res.write(JSON.stringify({ success: true }));
|
|
446
469
|
res.end();
|
|
447
470
|
} else if (req.method === 'GET') {
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
const fileInfo = this.fileMetadata.get(info.libraryId, id);
|
|
451
|
-
if (!fileInfo) {
|
|
452
|
-
res.writeHead(404);
|
|
453
|
-
res.end();
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const url = await fs.getUrl({
|
|
458
|
-
fileName: fileInfo.name,
|
|
459
|
-
id: fileInfo.fileId,
|
|
460
|
-
libraryId: info.libraryId,
|
|
461
|
-
type: fileInfo.type,
|
|
462
|
-
});
|
|
471
|
+
const data = await this.getFileData(info, id);
|
|
463
472
|
res.writeHead(200, {
|
|
464
473
|
'Content-Type': 'application/json',
|
|
465
474
|
});
|
|
466
|
-
// we need to augment that data with the URL from the file backend.
|
|
467
|
-
// and generally enforce the FileData interface here...
|
|
468
|
-
const data: FileData = {
|
|
469
|
-
id: fileInfo.fileId,
|
|
470
|
-
url,
|
|
471
|
-
remote: true,
|
|
472
|
-
name: fileInfo.name,
|
|
473
|
-
type: fileInfo.type,
|
|
474
|
-
};
|
|
475
475
|
res.write(JSON.stringify(data));
|
|
476
476
|
res.end();
|
|
477
477
|
}
|
|
478
478
|
} catch (e) {
|
|
479
|
-
return this.
|
|
479
|
+
return this.writeErrorResponse(e, res);
|
|
480
480
|
}
|
|
481
481
|
};
|
|
482
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Handles a "fetch" style file request. Complement to handleFileRequest,
|
|
485
|
+
* for servers that use Request/Response style handlers.
|
|
486
|
+
*/
|
|
487
|
+
handleFileFetch = async (req: Request): Promise<Response> => {
|
|
488
|
+
this.log('info', 'Handling file fetch', req.url, req.method);
|
|
489
|
+
try {
|
|
490
|
+
const info = this.authorizeRequest(req);
|
|
491
|
+
|
|
492
|
+
const url = new URL(req.url, 'http://localhost');
|
|
493
|
+
|
|
494
|
+
const id = url.pathname.split('/').pop();
|
|
495
|
+
|
|
496
|
+
if (!id || id === 'files') {
|
|
497
|
+
throw new VerdantError(VerdantError.Code.NotFound);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (req.method === 'POST') {
|
|
501
|
+
if (!req.body) {
|
|
502
|
+
throw new VerdantError(VerdantError.Code.InvalidRequest);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const headersAsRecord = Array.from(req.headers.entries()).reduce(
|
|
506
|
+
(acc, [key, value]) => {
|
|
507
|
+
acc[key] = value;
|
|
508
|
+
return acc;
|
|
509
|
+
},
|
|
510
|
+
{} as Record<string, string | string[] | undefined>,
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
// this is needed because Node's webstreams don't
|
|
514
|
+
// like itty's polyfill streams
|
|
515
|
+
const intermediate = new TransformStream();
|
|
516
|
+
req.body.pipeTo(intermediate.writable);
|
|
517
|
+
|
|
518
|
+
await this.streamIncomingFile({
|
|
519
|
+
req: Readable.fromWeb(intermediate.readable),
|
|
520
|
+
info,
|
|
521
|
+
headers: headersAsRecord,
|
|
522
|
+
id,
|
|
523
|
+
});
|
|
524
|
+
this.log('File upload complete');
|
|
525
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
526
|
+
status: 200,
|
|
527
|
+
headers: {
|
|
528
|
+
'Content-Type': 'application/json',
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
} else if (req.method === 'GET') {
|
|
532
|
+
const data = await this.getFileData(info, id);
|
|
533
|
+
return new Response(JSON.stringify(data), {
|
|
534
|
+
status: 200,
|
|
535
|
+
headers: {
|
|
536
|
+
'Content-Type': 'application/json',
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
} else {
|
|
540
|
+
throw new VerdantError(VerdantError.Code.InvalidRequest);
|
|
541
|
+
}
|
|
542
|
+
} catch (e) {
|
|
543
|
+
return this.getErrorResponse(e);
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
private getFileStorageOrThrow = () => {
|
|
548
|
+
if (!this.fileStorage) {
|
|
549
|
+
throw new VerdantError(VerdantError.Code.NoFileStorage);
|
|
550
|
+
}
|
|
551
|
+
return this.fileStorage;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
private streamIncomingFile = ({
|
|
555
|
+
id,
|
|
556
|
+
req,
|
|
557
|
+
headers,
|
|
558
|
+
info,
|
|
559
|
+
}: {
|
|
560
|
+
req: Readable;
|
|
561
|
+
headers: Record<string, string | string[] | undefined>;
|
|
562
|
+
id: string;
|
|
563
|
+
info: TokenInfo;
|
|
564
|
+
}) => {
|
|
565
|
+
const fs = this.getFileStorageOrThrow();
|
|
566
|
+
|
|
567
|
+
return new Promise((resolve, reject) => {
|
|
568
|
+
const bb = busboy({ headers });
|
|
569
|
+
|
|
570
|
+
bb.on('file', (fieldName, stream, fileInfo) => {
|
|
571
|
+
// too many 'info's....
|
|
572
|
+
const lofiFileInfo: FileInfo = {
|
|
573
|
+
id,
|
|
574
|
+
libraryId: info.libraryId,
|
|
575
|
+
fileName: fileInfo.filename,
|
|
576
|
+
type: fileInfo.mimeType,
|
|
577
|
+
};
|
|
578
|
+
// write metadata to storage
|
|
579
|
+
try {
|
|
580
|
+
this.fileMetadata.put(info.libraryId, lofiFileInfo);
|
|
581
|
+
fs.put(stream, lofiFileInfo);
|
|
582
|
+
} catch (e) {
|
|
583
|
+
reject(e);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
bb.on('field', (fieldName, value) => {
|
|
587
|
+
if (fieldName === 'file') {
|
|
588
|
+
if (this.__testMode) {
|
|
589
|
+
// this isn't right in the real world, but in testing it's
|
|
590
|
+
// the only way we get file data.
|
|
591
|
+
// we create a stream from the data and pass it as if it
|
|
592
|
+
// were a file stream
|
|
593
|
+
const stream = new Readable();
|
|
594
|
+
stream.push(value);
|
|
595
|
+
stream.push(null);
|
|
596
|
+
const fileInfo = {
|
|
597
|
+
filename: 'test.txt',
|
|
598
|
+
mimeType: 'text/plain',
|
|
599
|
+
};
|
|
600
|
+
bb.emit('file', fieldName, stream, fileInfo);
|
|
601
|
+
} else {
|
|
602
|
+
throw new Error('Invalid file upload');
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
req.pipe(bb);
|
|
608
|
+
bb.on('finish', resolve);
|
|
609
|
+
bb.on('error', reject);
|
|
610
|
+
});
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
private getFileData = async (
|
|
614
|
+
info: TokenInfo,
|
|
615
|
+
id: string,
|
|
616
|
+
): Promise<FileData> => {
|
|
617
|
+
const fs = this.getFileStorageOrThrow();
|
|
618
|
+
|
|
619
|
+
const fileInfo = this.fileMetadata.get(info.libraryId, id);
|
|
620
|
+
if (!fileInfo) {
|
|
621
|
+
throw new VerdantError(
|
|
622
|
+
VerdantError.Code.NotFound,
|
|
623
|
+
undefined,
|
|
624
|
+
`File ${id} not found`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const url = await fs.getUrl({
|
|
629
|
+
fileName: fileInfo.name,
|
|
630
|
+
id: fileInfo.fileId,
|
|
631
|
+
libraryId: info.libraryId,
|
|
632
|
+
type: fileInfo.type,
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
id: fileInfo.fileId,
|
|
637
|
+
url,
|
|
638
|
+
remote: true,
|
|
639
|
+
name: fileInfo.name,
|
|
640
|
+
type: fileInfo.type,
|
|
641
|
+
};
|
|
642
|
+
};
|
|
643
|
+
|
|
483
644
|
broadcast = (
|
|
484
645
|
libraryId: string,
|
|
485
646
|
message: ServerMessage,
|
|
@@ -629,3 +790,7 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
629
790
|
);
|
|
630
791
|
};
|
|
631
792
|
}
|
|
793
|
+
|
|
794
|
+
function isFetch(request: Request | IncomingMessage): request is Request {
|
|
795
|
+
return 'json' in request;
|
|
796
|
+
}
|