@verdant-web/server 2.0.8 → 2.1.1

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
  /**
@@ -108,7 +114,7 @@ export declare interface Server {
108
114
  }
109
115
 
110
116
  export class Server extends EventEmitter implements MessageSender {
111
- private httpServer: HttpServer;
117
+ private httpServer!: HttpServer;
112
118
  private wss: WebSocketServer;
113
119
  private fileStorage?: FileStorage;
114
120
  private fileMetadata;
@@ -170,8 +176,12 @@ export class Server extends EventEmitter implements MessageSender {
170
176
 
171
177
  this.wss.on('connection', this.handleConnection);
172
178
 
173
- this.httpServer =
174
- options.httpServer || new HttpServer(this.createInternalRequestHandler());
179
+ if (options.httpServer) {
180
+ // backwards compat with old httpServer option - it didn't attach path handlers
181
+ this.attach(options.httpServer, { httpPath: false });
182
+ } else {
183
+ this.attach(new HttpServer(this.createInternalRequestHandler()));
184
+ }
175
185
 
176
186
  this.keepalives.subscribe('lost', this.library.remove);
177
187
  }
@@ -229,11 +239,21 @@ export class Server extends EventEmitter implements MessageSender {
229
239
  }
230
240
  };
231
241
 
232
- private authorizeRequest = (req: IncomingMessage) => {
242
+ private authorizeRequest = (req: IncomingMessage | Request) => {
233
243
  return this.tokenVerifier.verifyToken(this.getRequestToken(req));
234
244
  };
235
245
 
236
- private getRequestToken = (req: IncomingMessage) => {
246
+ private getRequestToken = (req: IncomingMessage | Request) => {
247
+ if (isFetch(req)) {
248
+ const authHeader = req.headers.get('Authorization');
249
+ assert(authHeader, 'Token is required');
250
+ const [type, token] = authHeader.split(' ');
251
+ if (type === 'Bearer') {
252
+ return token;
253
+ }
254
+ return token;
255
+ }
256
+
237
257
  if (req.headers.authorization) {
238
258
  const [type, token] = req.headers.authorization.split(' ');
239
259
  if (type === 'Bearer') {
@@ -310,33 +330,71 @@ export class Server extends EventEmitter implements MessageSender {
310
330
  });
311
331
  }));
312
332
 
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
- }
333
+ await this.handleRequestBody(key, info, body);
329
334
 
330
335
  finish();
331
336
 
332
337
  this.emit('request', info);
333
338
  }
334
339
  } catch (e) {
335
- return this.sendErrorResponse(e, res);
340
+ return this.writeErrorResponse(e, res);
341
+ }
342
+ };
343
+
344
+ /**
345
+ * Handles a "fetch" style request. Complement to handleRequest, for servers
346
+ * that use Request/Response style handlers.
347
+ */
348
+ handleFetch = async (req: Request): Promise<Response> => {
349
+ try {
350
+ const info = this.authorizeRequest(req);
351
+ const key = generateId();
352
+
353
+ const finish = this.clientConnections.addFetch(
354
+ info.libraryId,
355
+ key,
356
+ req,
357
+ info,
358
+ );
359
+
360
+ const body = (await req.json()) as
361
+ | { messages: ClientMessage[] }
362
+ | null
363
+ | undefined;
364
+
365
+ if (!body) {
366
+ throw new VerdantError(VerdantError.Code.BodyRequired);
367
+ }
368
+
369
+ await this.handleRequestBody(key, info, body);
370
+
371
+ const res = finish();
372
+
373
+ this.emit('request', info);
374
+
375
+ return res;
376
+ } catch (e) {
377
+ return this.getErrorResponse(e);
378
+ }
379
+ };
380
+
381
+ private handleRequestBody = async (
382
+ key: string,
383
+ info: TokenInfo,
384
+ body: { messages: ClientMessage[] },
385
+ ) => {
386
+ for (const message of body.messages) {
387
+ await this.handleMessage(key, info, message);
388
+ }
389
+
390
+ // update our keepalive timers for presence management
391
+ const firstMessage = body.messages[0];
392
+ if (firstMessage) {
393
+ this.keepalives.refresh(info.libraryId, firstMessage.replicaId);
336
394
  }
337
395
  };
338
396
 
339
- private sendErrorResponse(e: unknown, res: ServerResponse) {
397
+ private writeErrorResponse(e: unknown, res: ServerResponse) {
340
398
  this.emit('error', e);
341
399
  this.log('Error handling request', e);
342
400
 
@@ -355,131 +413,238 @@ export class Server extends EventEmitter implements MessageSender {
355
413
  res.end();
356
414
  }
357
415
 
416
+ // for fetch-style
417
+ private getErrorResponse(e: unknown) {
418
+ this.emit('error', e);
419
+ this.log('Error handling request', e);
420
+
421
+ if (e instanceof VerdantError) {
422
+ return new Response(JSON.stringify(e.toResponse()), {
423
+ status: e.httpStatus,
424
+ headers: {
425
+ 'Content-Type': 'application/json',
426
+ },
427
+ });
428
+ } else {
429
+ return new Response(
430
+ JSON.stringify(
431
+ new VerdantError(VerdantError.Code.Unexpected).toResponse(),
432
+ ),
433
+ {
434
+ status: 500,
435
+ headers: {
436
+ 'Content-Type': 'application/json',
437
+ },
438
+ },
439
+ );
440
+ }
441
+ }
442
+
358
443
  /**
359
444
  * Handles a multipart upload of a file from a verdant client. The upload
360
445
  * will include parameters for the file's ID, name, and type. The request
361
446
  * must be authenticated with a token to tie it to a library.
362
447
  */
363
448
  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
- }
449
+ try {
450
+ const info = this.authorizeRequest(req);
377
451
 
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
- );
452
+ const url = new URL(
453
+ (req as any).originalUrl || (req as any).baseUrl || req.url || '',
454
+ 'http://localhost',
455
+ );
384
456
 
385
- const id = url.pathname.split('/').pop();
457
+ const id = url.pathname.split('/').pop();
386
458
 
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
- }
459
+ if (!id || id === 'files') {
460
+ throw new VerdantError(VerdantError.Code.NotFound);
461
+ }
395
462
 
396
- try {
397
463
  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);
464
+ await this.streamIncomingFile({
465
+ req,
466
+ info,
467
+ headers: req.headers,
468
+ id,
442
469
  });
443
470
  this.log('File upload complete');
444
471
  res.writeHead(200);
445
472
  res.write(JSON.stringify({ success: true }));
446
473
  res.end();
447
474
  } 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
- });
475
+ const data = await this.getFileData(info, id);
463
476
  res.writeHead(200, {
464
477
  'Content-Type': 'application/json',
465
478
  });
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
479
  res.write(JSON.stringify(data));
476
480
  res.end();
477
481
  }
478
482
  } catch (e) {
479
- return this.sendErrorResponse(e, res);
483
+ return this.writeErrorResponse(e, res);
484
+ }
485
+ };
486
+
487
+ /**
488
+ * Handles a "fetch" style file request. Complement to handleFileRequest,
489
+ * for servers that use Request/Response style handlers.
490
+ */
491
+ handleFileFetch = async (req: Request): Promise<Response> => {
492
+ this.log('info', 'Handling file fetch', req.url, req.method);
493
+ try {
494
+ const info = this.authorizeRequest(req);
495
+
496
+ const url = new URL(req.url, 'http://localhost');
497
+
498
+ const id = url.pathname.split('/').pop();
499
+
500
+ if (!id || id === 'files') {
501
+ throw new VerdantError(VerdantError.Code.NotFound);
502
+ }
503
+
504
+ if (req.method === 'POST') {
505
+ if (!req.body) {
506
+ throw new VerdantError(VerdantError.Code.InvalidRequest);
507
+ }
508
+
509
+ const headersAsRecord = Array.from(req.headers.entries()).reduce(
510
+ (acc, [key, value]) => {
511
+ acc[key] = value;
512
+ return acc;
513
+ },
514
+ {} as Record<string, string | string[] | undefined>,
515
+ );
516
+
517
+ // this is needed because Node's webstreams don't
518
+ // like itty's polyfill streams
519
+ const intermediate = new TransformStream();
520
+ req.body.pipeTo(intermediate.writable);
521
+
522
+ await this.streamIncomingFile({
523
+ req: Readable.fromWeb(intermediate.readable),
524
+ info,
525
+ headers: headersAsRecord,
526
+ id,
527
+ });
528
+ this.log('File upload complete');
529
+ return new Response(JSON.stringify({ success: true }), {
530
+ status: 200,
531
+ headers: {
532
+ 'Content-Type': 'application/json',
533
+ },
534
+ });
535
+ } else if (req.method === 'GET') {
536
+ const data = await this.getFileData(info, id);
537
+ return new Response(JSON.stringify(data), {
538
+ status: 200,
539
+ headers: {
540
+ 'Content-Type': 'application/json',
541
+ },
542
+ });
543
+ } else {
544
+ throw new VerdantError(VerdantError.Code.InvalidRequest);
545
+ }
546
+ } catch (e) {
547
+ return this.getErrorResponse(e);
480
548
  }
481
549
  };
482
550
 
551
+ private getFileStorageOrThrow = () => {
552
+ if (!this.fileStorage) {
553
+ throw new VerdantError(VerdantError.Code.NoFileStorage);
554
+ }
555
+ return this.fileStorage;
556
+ };
557
+
558
+ private streamIncomingFile = ({
559
+ id,
560
+ req,
561
+ headers,
562
+ info,
563
+ }: {
564
+ req: Readable;
565
+ headers: Record<string, string | string[] | undefined>;
566
+ id: string;
567
+ info: TokenInfo;
568
+ }) => {
569
+ const fs = this.getFileStorageOrThrow();
570
+
571
+ return new Promise((resolve, reject) => {
572
+ const bb = busboy({ headers });
573
+
574
+ bb.on('file', (fieldName, stream, fileInfo) => {
575
+ // too many 'info's....
576
+ const lofiFileInfo: FileInfo = {
577
+ id,
578
+ libraryId: info.libraryId,
579
+ fileName: fileInfo.filename,
580
+ type: fileInfo.mimeType,
581
+ };
582
+ // write metadata to storage
583
+ try {
584
+ this.fileMetadata.put(info.libraryId, lofiFileInfo);
585
+ fs.put(stream, lofiFileInfo);
586
+ } catch (e) {
587
+ reject(e);
588
+ }
589
+ });
590
+ bb.on('field', (fieldName, value) => {
591
+ if (fieldName === 'file') {
592
+ if (this.__testMode) {
593
+ // this isn't right in the real world, but in testing it's
594
+ // the only way we get file data.
595
+ // we create a stream from the data and pass it as if it
596
+ // were a file stream
597
+ const stream = new Readable();
598
+ stream.push(value);
599
+ stream.push(null);
600
+ const fileInfo = {
601
+ filename: 'test.txt',
602
+ mimeType: 'text/plain',
603
+ };
604
+ bb.emit('file', fieldName, stream, fileInfo);
605
+ } else {
606
+ throw new Error('Invalid file upload');
607
+ }
608
+ }
609
+ });
610
+
611
+ req.pipe(bb);
612
+ bb.on('finish', resolve);
613
+ bb.on('error', reject);
614
+ });
615
+ };
616
+
617
+ private getFileData = async (
618
+ info: TokenInfo,
619
+ id: string,
620
+ ): Promise<FileData> => {
621
+ const fs = this.getFileStorageOrThrow();
622
+
623
+ const fileInfo = this.fileMetadata.get(info.libraryId, id);
624
+ if (!fileInfo) {
625
+ throw new VerdantError(
626
+ VerdantError.Code.NotFound,
627
+ undefined,
628
+ `File ${id} not found`,
629
+ );
630
+ }
631
+
632
+ const url = await fs.getUrl({
633
+ fileName: fileInfo.name,
634
+ id: fileInfo.fileId,
635
+ libraryId: info.libraryId,
636
+ type: fileInfo.type,
637
+ });
638
+
639
+ return {
640
+ id: fileInfo.fileId,
641
+ url,
642
+ remote: true,
643
+ name: fileInfo.name,
644
+ type: fileInfo.type,
645
+ };
646
+ };
647
+
483
648
  broadcast = (
484
649
  libraryId: string,
485
650
  message: ServerMessage,
@@ -629,3 +794,7 @@ export class Server extends EventEmitter implements MessageSender {
629
794
  );
630
795
  };
631
796
  }
797
+
798
+ function isFetch(request: Request | IncomingMessage): request is Request {
799
+ return 'json' in request;
800
+ }