@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/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
- try {
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.sendErrorResponse(e, res);
336
+ return this.writeErrorResponse(e, res);
336
337
  }
337
338
  };
338
339
 
339
- private sendErrorResponse(e: unknown, res: ServerResponse) {
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
- const fs = this.fileStorage;
365
- if (!fs) {
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
- // FIXME: rather than trying to support Express, I should
379
- // just expose regular methods you call from whatever HTTP handler...
380
- const url = new URL(
381
- (req as any).originalUrl || (req as any).baseUrl || req.url || '',
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
- const id = url.pathname.split('/').pop();
453
+ const id = url.pathname.split('/').pop();
386
454
 
387
- if (!id || id === 'files') {
388
- res.writeHead(400);
389
- res.write(
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
- const info = this.authorizeRequest(req);
399
- await new Promise((resolve, reject) => {
400
- const bb = busboy({ headers: req.headers });
401
-
402
- bb.on('file', (fieldName, stream, fileInfo) => {
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 info = this.authorizeRequest(req);
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.sendErrorResponse(e, res);
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
+ }