@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/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 +39 -3
- package/dist/cjs/Server.js +269 -134
- 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 +39 -3
- package/dist/esm/Server.js +269 -134
- 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 +356 -155
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
227
|
-
req: IncomingMessage,
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
}
|
|
238
|
-
|
|
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
|
-
|
|
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.
|
|
336
|
+
return this.writeErrorResponse(e, res);
|
|
300
337
|
}
|
|
301
338
|
};
|
|
302
339
|
|
|
303
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
453
|
+
const id = url.pathname.split('/').pop();
|
|
350
454
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
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.
|
|
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
|
+
}
|