@verdant-web/server 2.0.7 → 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
@@ -21,10 +21,16 @@ import { ReplicaKeepaliveTimers } from './ReplicaKeepaliveTimers.js';
21
21
  import { TokenInfo, TokenVerifier } from './TokenVerifier.js';
22
22
  import busboy from 'busboy';
23
23
  import { FileInfo, FileStorage } from './files/FileStorage.js';
24
- import { Readable } from 'stream';
24
+ 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
- readonly httpServer: HttpServer;
117
+ private httpServer: HttpServer;
112
118
  private wss: WebSocketServer;
113
119
  private fileStorage?: FileStorage;
114
120
  private fileMetadata;
@@ -171,36 +177,79 @@ export class Server extends EventEmitter implements MessageSender {
171
177
  this.wss.on('connection', this.handleConnection);
172
178
 
173
179
  this.httpServer =
174
- options.httpServer || new HttpServer(this.internalServerHandleRequest);
175
-
176
- this.httpServer.on('upgrade', async (req, socket, head) => {
177
- try {
178
- const info = this.authorizeRequest(req);
179
- this.wss.handleUpgrade(req, socket, head, (ws) => {
180
- this.wss.emit('connection', ws, req, info);
181
- });
182
- } catch (e) {
183
- this.emit('error', e);
184
- if (e instanceof VerdantError && e.httpStatus === 401) {
185
- socket.write(
186
- 'HTTP/1.1 401 Unauthorized\r\n' +
187
- 'Connection: close\r\n' +
188
- 'Content-Length: 0\r\n' +
189
- '\r\n',
190
- );
191
- }
192
- socket.destroy();
193
- }
194
- });
180
+ options.httpServer || new HttpServer(this.createInternalRequestHandler());
195
181
 
196
182
  this.keepalives.subscribe('lost', this.library.remove);
197
183
  }
198
184
 
199
- private authorizeRequest = (req: IncomingMessage) => {
185
+ /**
186
+ * Attaches the Verdant server to an HttpServer instance, which
187
+ * allows it to handle requests and websockets required for the
188
+ * Verdant protocol.
189
+ *
190
+ * You can pass httpPath = false if you want to handle HTTP requests
191
+ * yourself, and this will only attach the websocket handling.
192
+ */
193
+ attach = (
194
+ server: HttpServer,
195
+ options: { httpPath?: string | false } = {},
196
+ ) => {
197
+ if (this.httpServer) {
198
+ this.httpServer.off('upgrade', this.handleUpgrade);
199
+ }
200
+ this.httpServer = server;
201
+ this.httpServer.on('upgrade', this.handleUpgrade);
202
+
203
+ if (options.httpPath !== false) {
204
+ this.httpServer.on(
205
+ 'request',
206
+ this.createInternalRequestHandler(options.httpPath),
207
+ );
208
+ }
209
+ };
210
+
211
+ /**
212
+ * Handles an HTTP upgrade request to a websocket connection.
213
+ */
214
+ handleUpgrade = async (
215
+ req: IncomingMessage,
216
+ socket: internal.Duplex,
217
+ head: Buffer,
218
+ ) => {
219
+ try {
220
+ const info = this.authorizeRequest(req);
221
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
222
+ this.wss.emit('connection', ws, req, info);
223
+ });
224
+ } catch (e) {
225
+ this.emit('error', e);
226
+ if (e instanceof VerdantError && e.httpStatus === 401) {
227
+ socket.write(
228
+ 'HTTP/1.1 401 Unauthorized\r\n' +
229
+ 'Connection: close\r\n' +
230
+ 'Content-Length: 0\r\n' +
231
+ '\r\n',
232
+ );
233
+ }
234
+ socket.destroy();
235
+ }
236
+ };
237
+
238
+ private authorizeRequest = (req: IncomingMessage | Request) => {
200
239
  return this.tokenVerifier.verifyToken(this.getRequestToken(req));
201
240
  };
202
241
 
203
- 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
+
204
253
  if (req.headers.authorization) {
205
254
  const [type, token] = req.headers.authorization.split(' ');
206
255
  if (type === 'Bearer') {
@@ -223,23 +272,26 @@ export class Server extends EventEmitter implements MessageSender {
223
272
  return token;
224
273
  };
225
274
 
226
- private internalServerHandleRequest = (
227
- req: IncomingMessage,
228
- res: ServerResponse,
229
- ) => {
230
- const url = new URL(req.url || '', 'http://localhost');
231
- if (url.pathname.startsWith('lofi')) {
232
- if (url.pathname === 'lofi') {
233
- return this.handleRequest(req, res);
234
- } else if (url.pathname.startsWith('/lofi/files/')) {
235
- return this.handleFileRequest(req, res);
275
+ private createInternalRequestHandler = (pathPrefix = '/sync') => {
276
+ const handleRequest = (req: IncomingMessage, res: ServerResponse) => {
277
+ const url = new URL(req.url || '', 'http://localhost');
278
+ if (url.pathname.startsWith('sync')) {
279
+ if (url.pathname === 'sync') {
280
+ return this.handleRequest(req, res);
281
+ } else if (url.pathname.startsWith('/sync/files/')) {
282
+ return this.handleFileRequest(req, res);
283
+ }
284
+ } else {
285
+ res.writeHead(404);
286
+ res.end();
236
287
  }
237
- } else {
238
- res.writeHead(404);
239
- res.end();
240
- }
288
+ };
289
+ return handleRequest;
241
290
  };
242
291
 
292
+ /**
293
+ * Handles an HTTP request from a verdant client.
294
+ */
243
295
  handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
244
296
  try {
245
297
  if (req.method === 'POST') {
@@ -274,33 +326,71 @@ export class Server extends EventEmitter implements MessageSender {
274
326
  });
275
327
  }));
276
328
 
277
- try {
278
- for (const message of body.messages) {
279
- await this.handleMessage(key, info, message);
280
- }
281
- } catch (e) {
282
- this.emit('error', e);
283
- res.writeHead(500);
284
- res.end();
285
- return;
286
- }
287
-
288
- // update our keepalive timers for presence management
289
- const firstMessage = body.messages[0];
290
- if (firstMessage) {
291
- this.keepalives.refresh(info.libraryId, firstMessage.replicaId);
292
- }
329
+ await this.handleRequestBody(key, info, body);
293
330
 
294
331
  finish();
295
332
 
296
333
  this.emit('request', info);
297
334
  }
298
335
  } catch (e) {
299
- return this.sendErrorResponse(e, res);
336
+ return this.writeErrorResponse(e, res);
300
337
  }
301
338
  };
302
339
 
303
- 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) {
304
394
  this.emit('error', e);
305
395
  this.log('Error handling request', e);
306
396
 
@@ -319,129 +409,236 @@ export class Server extends EventEmitter implements MessageSender {
319
409
  res.end();
320
410
  }
321
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
+
322
439
  /**
323
440
  * Handles a multipart upload of a file from a verdant client. The upload
324
441
  * will include parameters for the file's ID, name, and type. The request
325
442
  * must be authenticated with a token to tie it to a library.
326
443
  */
327
444
  handleFileRequest = async (req: IncomingMessage, res: ServerResponse) => {
328
- const fs = this.fileStorage;
329
- if (!fs) {
330
- this.emit(
331
- 'error',
332
- new Error(
333
- 'No file storage configured, but a client attempted to upload a file.',
334
- ),
335
- );
336
- res.writeHead(500);
337
- res.write('File storage is not configured');
338
- res.end();
339
- return;
340
- }
445
+ try {
446
+ const info = this.authorizeRequest(req);
341
447
 
342
- // FIXME: rather than trying to support Express, I should
343
- // just expose regular methods you call from whatever HTTP handler...
344
- const url = new URL(
345
- (req as any).originalUrl || (req as any).baseUrl || req.url || '',
346
- 'http://localhost',
347
- );
448
+ const url = new URL(
449
+ (req as any).originalUrl || (req as any).baseUrl || req.url || '',
450
+ 'http://localhost',
451
+ );
348
452
 
349
- const id = url.pathname.split('/').pop();
453
+ const id = url.pathname.split('/').pop();
350
454
 
351
- if (!id || id === 'files') {
352
- res.writeHead(400);
353
- res.write(
354
- 'File ID is required to be in the URL path as the last parameter',
355
- );
356
- res.end();
357
- return;
358
- }
455
+ if (!id || id === 'files') {
456
+ throw new VerdantError(VerdantError.Code.NotFound);
457
+ }
359
458
 
360
- try {
361
459
  if (req.method === 'POST') {
362
- const info = this.authorizeRequest(req);
363
- await new Promise((resolve, reject) => {
364
- const bb = busboy({ headers: req.headers });
365
-
366
- bb.on('file', (fieldName, stream, fileInfo) => {
367
- // too many 'info's....
368
- const lofiFileInfo: FileInfo = {
369
- id,
370
- libraryId: info.libraryId,
371
- fileName: fileInfo.filename,
372
- type: fileInfo.mimeType,
373
- };
374
- // write metadata to storage
375
- try {
376
- this.fileMetadata.put(info.libraryId, lofiFileInfo);
377
- fs.put(stream, lofiFileInfo);
378
- } catch (e) {
379
- reject(e);
380
- }
381
- });
382
- bb.on('field', (fieldName, value) => {
383
- if (fieldName === 'file') {
384
- if (this.__testMode) {
385
- // this isn't right in the real world, but in testing it's
386
- // the only way we get file data.
387
- // we create a stream from the data and pass it as if it
388
- // were a file stream
389
- const stream = new Readable();
390
- stream.push(value);
391
- stream.push(null);
392
- const fileInfo = {
393
- filename: 'test.txt',
394
- mimeType: 'text/plain',
395
- };
396
- bb.emit('file', fieldName, stream, fileInfo);
397
- } else {
398
- throw new Error('Invalid file upload');
399
- }
400
- }
401
- });
402
-
403
- req.pipe(bb);
404
- bb.on('finish', resolve);
405
- bb.on('error', reject);
460
+ await this.streamIncomingFile({
461
+ req,
462
+ info,
463
+ headers: req.headers,
464
+ id,
406
465
  });
407
466
  this.log('File upload complete');
408
467
  res.writeHead(200);
409
468
  res.write(JSON.stringify({ success: true }));
410
469
  res.end();
411
470
  } else if (req.method === 'GET') {
412
- const info = this.authorizeRequest(req);
413
-
414
- const fileInfo = this.fileMetadata.get(info.libraryId, id);
415
- if (!fileInfo) {
416
- res.writeHead(404);
417
- res.end();
418
- return;
419
- }
420
-
421
- const url = await fs.getUrl({
422
- fileName: fileInfo.name,
423
- id: fileInfo.fileId,
424
- libraryId: info.libraryId,
425
- type: fileInfo.type,
426
- });
471
+ const data = await this.getFileData(info, id);
427
472
  res.writeHead(200, {
428
473
  'Content-Type': 'application/json',
429
474
  });
430
- // we need to augment that data with the URL from the file backend.
431
- // and generally enforce the FileData interface here...
432
- const data: FileData = {
433
- id: fileInfo.fileId,
434
- url,
435
- remote: true,
436
- name: fileInfo.name,
437
- type: fileInfo.type,
438
- };
439
475
  res.write(JSON.stringify(data));
440
476
  res.end();
441
477
  }
442
478
  } catch (e) {
443
- return this.sendErrorResponse(e, res);
479
+ return this.writeErrorResponse(e, res);
480
+ }
481
+ };
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);
444
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
+ };
445
642
  };
446
643
 
447
644
  broadcast = (
@@ -593,3 +790,7 @@ export class Server extends EventEmitter implements MessageSender {
593
790
  );
594
791
  };
595
792
  }
793
+
794
+ function isFetch(request: Request | IncomingMessage): request is Request {
795
+ return 'json' in request;
796
+ }