@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/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 +218 -105
- 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 +218 -105
- 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 +290 -121
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
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
449
|
+
try {
|
|
450
|
+
const info = this.authorizeRequest(req);
|
|
377
451
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
457
|
+
const id = url.pathname.split('/').pop();
|
|
386
458
|
|
|
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
|
-
}
|
|
459
|
+
if (!id || id === 'files') {
|
|
460
|
+
throw new VerdantError(VerdantError.Code.NotFound);
|
|
461
|
+
}
|
|
395
462
|
|
|
396
|
-
try {
|
|
397
463
|
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);
|
|
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
|
|
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.
|
|
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
|
+
}
|