typedrest 0.4.0 → 0.5.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/README.md +2 -0
- package/endpoints/Endpoint.js +14 -27
- package/endpoints/Endpoint.ts +5 -5
- package/endpoints/EntryEndpoint.js +3 -14
- package/endpoints/generic/ETagEndpointBase.js +27 -42
- package/endpoints/generic/ElementEndpoint.js +39 -60
- package/endpoints/generic/GenericCollectionEndpoint.js +70 -44
- package/endpoints/generic/GenericCollectionEndpoint.test.ts +123 -0
- package/endpoints/generic/GenericCollectionEndpoint.ts +54 -1
- package/endpoints/generic/PartialResponse.js +33 -0
- package/endpoints/generic/PartialResponse.ts +28 -0
- package/endpoints/generic/index.js +2 -1
- package/endpoints/generic/index.ts +1 -0
- package/endpoints/index.js +3 -2
- package/endpoints/index.ts +1 -0
- package/endpoints/raw/BlobEndpoint.js +10 -27
- package/endpoints/raw/UploadEndpoint.js +11 -23
- package/endpoints/raw/UploadEndpoint.test.ts +1 -4
- package/endpoints/raw/UploadEndpoint.ts +1 -2
- package/endpoints/reactive/Delay.js +27 -0
- package/endpoints/reactive/Delay.ts +26 -0
- package/endpoints/reactive/GenericStreamingCollectionEndpoint.js +79 -0
- package/endpoints/reactive/GenericStreamingCollectionEndpoint.ts +73 -0
- package/endpoints/reactive/PollingEndpoint.js +67 -0
- package/endpoints/reactive/PollingEndpoint.test.ts +84 -0
- package/endpoints/reactive/PollingEndpoint.ts +66 -0
- package/endpoints/reactive/SseStreamingEndpoint.js +120 -0
- package/endpoints/reactive/SseStreamingEndpoint.test.ts +169 -0
- package/endpoints/reactive/SseStreamingEndpoint.ts +115 -0
- package/endpoints/reactive/StreamingCollectionEndpoint.js +23 -0
- package/endpoints/reactive/StreamingCollectionEndpoint.test.ts +110 -0
- package/endpoints/reactive/StreamingCollectionEndpoint.ts +20 -0
- package/endpoints/reactive/StreamingEndpoint.js +56 -0
- package/endpoints/reactive/StreamingEndpoint.test.ts +130 -0
- package/endpoints/reactive/StreamingEndpoint.ts +50 -0
- package/endpoints/reactive/index.js +22 -0
- package/endpoints/reactive/index.ts +5 -0
- package/endpoints/rpc/ActionEndpoint.js +3 -14
- package/endpoints/rpc/ConsumerEndpoint.js +5 -16
- package/endpoints/rpc/FunctionEndpoint.js +6 -17
- package/endpoints/rpc/ProducerEndpoint.js +4 -15
- package/endpoints/rpc/RpcEndpointBase.js +3 -14
- package/errors/DefaultErrorHandler.js +20 -22
- package/errors/DefaultErrorHandler.ts +12 -4
- package/errors/Errors.js +8 -2
- package/errors/Errors.ts +5 -0
- package/http/EntityStream.js +42 -0
- package/http/EntityStream.ts +40 -0
- package/http/HttpContentRange.js +39 -0
- package/http/HttpContentRange.ts +41 -0
- package/http/HttpHeader.js +6 -1
- package/http/HttpHeader.ts +6 -1
- package/http/HttpStatusCode.js +4 -1
- package/http/HttpStatusCode.ts +4 -1
- package/http/ResponseCache.js +4 -15
- package/http/RetryAfter.js +24 -0
- package/http/RetryAfter.ts +19 -0
- package/http/ServerSentEvents.js +140 -0
- package/http/ServerSentEvents.ts +145 -0
- package/http/index.js +5 -1
- package/http/index.ts +4 -0
- package/jest.config.js +1 -1
- package/links/AggregateLinkExtractor.js +7 -18
- package/links/HalLinkExtractor.js +9 -20
- package/links/HeaderLinkExtractor.js +5 -16
- package/links/HeaderLinkExtractor.ts +1 -1
- package/package.json +9 -9
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOTE: The jsdom environment does not expose response bodies as streams, so these tests need the node environment.
|
|
3
|
+
* @jest-environment node
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fetchMock from 'jest-fetch-mock';
|
|
7
|
+
import { SseStreamingEndpoint } from '.';
|
|
8
|
+
import { EntryEndpoint } from '..';
|
|
9
|
+
import { ConflictError } from '../../errors';
|
|
10
|
+
import { HttpHeader, HttpStatusCode } from '../../http';
|
|
11
|
+
|
|
12
|
+
class MockEntity {
|
|
13
|
+
constructor(public id: number, public name: string) { }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
fetchMock.enableMocks();
|
|
17
|
+
let entryEndpoint: EntryEndpoint;
|
|
18
|
+
let endpoint: SseStreamingEndpoint<MockEntity>;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
fetchMock.resetMocks();
|
|
22
|
+
entryEndpoint = new EntryEndpoint('http://localhost/');
|
|
23
|
+
endpoint = new SseStreamingEndpoint<MockEntity>(entryEndpoint, 'endpoint');
|
|
24
|
+
endpoint.autoReconnect = false;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function sseEvent(data: string, type?: string, id?: string) {
|
|
28
|
+
return (type ? `event: ${type}\n` : '')
|
|
29
|
+
+ (id ? `id: ${id}\n` : '')
|
|
30
|
+
+ `data: ${data}\n\n`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Mocks a response that ends the stream for good.<br>
|
|
35
|
+
* NOTE: Needs a `null` body because a 204 response must not have one.
|
|
36
|
+
*/
|
|
37
|
+
function mockNoContentOnce() {
|
|
38
|
+
fetchMock.mockImplementationOnce(async () => new Response(null, { status: HttpStatusCode.NoContent }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function requestHeader(index: number, header: HttpHeader) {
|
|
42
|
+
return new Headers(fetchMock.mock.calls[index][1]?.headers).get(header);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function collect<T>(stream: AsyncIterable<T>) {
|
|
46
|
+
const result: T[] = [];
|
|
47
|
+
for await (const element of stream) result.push(element);
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('stream', async () => {
|
|
52
|
+
fetchMock.mockResponseOnce(
|
|
53
|
+
sseEvent('{"id":5,"name":"test1"}')
|
|
54
|
+
+ sseEvent('{"id":6,"name":"test2"}')
|
|
55
|
+
+ sseEvent('{"id":7,"name":"test3"}'));
|
|
56
|
+
|
|
57
|
+
expect(await collect(endpoint.stream())).toEqual([
|
|
58
|
+
new MockEntity(5, 'test1'),
|
|
59
|
+
new MockEntity(6, 'test2'),
|
|
60
|
+
new MockEntity(7, 'test3')
|
|
61
|
+
]);
|
|
62
|
+
expect(requestHeader(0, HttpHeader.Accept)).toBe('text/event-stream');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('streamAsyncIterator', async () => {
|
|
66
|
+
fetchMock.mockResponseOnce(sseEvent('{"id":5,"name":"test1"}'));
|
|
67
|
+
|
|
68
|
+
expect(await collect(endpoint)).toEqual([new MockEntity(5, 'test1')]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('streamEventTypeFilter', async () => {
|
|
72
|
+
endpoint = new SseStreamingEndpoint<MockEntity>(entryEndpoint, 'endpoint', 'update');
|
|
73
|
+
endpoint.autoReconnect = false;
|
|
74
|
+
|
|
75
|
+
fetchMock.mockResponseOnce(
|
|
76
|
+
sseEvent('{"id":1,"name":"skip"}', 'ignored')
|
|
77
|
+
+ sseEvent('{"id":5,"name":"test1"}', 'update')
|
|
78
|
+
+ sseEvent('{"id":99,"name":"default"}')
|
|
79
|
+
+ sseEvent('{"id":6,"name":"test2"}', 'update'));
|
|
80
|
+
|
|
81
|
+
expect(await collect(endpoint.stream())).toEqual([
|
|
82
|
+
new MockEntity(5, 'test1'),
|
|
83
|
+
new MockEntity(6, 'test2')
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('streamComments', async () => {
|
|
88
|
+
fetchMock.mockResponseOnce(': keep-alive\n\n' + sseEvent('{"id":5,"name":"test1"}'));
|
|
89
|
+
|
|
90
|
+
expect(await collect(endpoint.stream())).toEqual([new MockEntity(5, 'test1')]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('streamMultiLineData', async () => {
|
|
94
|
+
fetchMock.mockResponseOnce('data: {"id":5,\ndata: "name":"test1"}\n\n');
|
|
95
|
+
|
|
96
|
+
expect(await collect(endpoint.stream())).toEqual([new MockEntity(5, 'test1')]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('streamCarriageReturns', async () => {
|
|
100
|
+
fetchMock.mockResponseOnce('data: {"id":5,"name":"test1"}\r\n\r\ndata: {"id":6,"name":"test2"}\r\r');
|
|
101
|
+
|
|
102
|
+
expect(await collect(endpoint.stream())).toEqual([
|
|
103
|
+
new MockEntity(5, 'test1'),
|
|
104
|
+
new MockEntity(6, 'test2')
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('streamError', async () => {
|
|
109
|
+
fetchMock.mockResponseOnce('{"message":"my message"}', {
|
|
110
|
+
status: HttpStatusCode.Conflict,
|
|
111
|
+
headers: { [HttpHeader.ContentType]: 'application/json' }
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await expect(collect(endpoint.stream())).rejects.toThrow(new ConflictError('my message', HttpStatusCode.Conflict));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('streamNoContentCompletes', async () => {
|
|
118
|
+
endpoint.autoReconnect = true;
|
|
119
|
+
mockNoContentOnce();
|
|
120
|
+
|
|
121
|
+
expect(await collect(endpoint.stream())).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('streamReconnectsWithLastEventId', async () => {
|
|
125
|
+
endpoint.autoReconnect = true;
|
|
126
|
+
endpoint.defaultReconnectionInterval = 0;
|
|
127
|
+
fetchMock.mockResponseOnce(sseEvent('{"id":5,"name":"test1"}', undefined, '42'));
|
|
128
|
+
mockNoContentOnce();
|
|
129
|
+
|
|
130
|
+
expect(await collect(endpoint.stream())).toEqual([new MockEntity(5, 'test1')]);
|
|
131
|
+
expect(requestHeader(0, HttpHeader.LastEventId)).toBeNull();
|
|
132
|
+
expect(requestHeader(1, HttpHeader.LastEventId)).toBe('42');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('streamReconnectsOnServerError', async () => {
|
|
136
|
+
endpoint.autoReconnect = true;
|
|
137
|
+
endpoint.defaultReconnectionInterval = 0;
|
|
138
|
+
fetchMock.mockResponseOnce('', { status: 503 });
|
|
139
|
+
fetchMock.mockResponseOnce(sseEvent('{"id":5,"name":"test1"}'));
|
|
140
|
+
mockNoContentOnce();
|
|
141
|
+
|
|
142
|
+
expect(await collect(endpoint.stream())).toEqual([new MockEntity(5, 'test1')]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('streamHonorsRetryField', async () => {
|
|
146
|
+
endpoint.autoReconnect = true;
|
|
147
|
+
endpoint.defaultReconnectionInterval = 0;
|
|
148
|
+
fetchMock.mockResponseOnce('retry: 42\n\n' + sseEvent('{"id":5,"name":"test1"}'));
|
|
149
|
+
mockNoContentOnce();
|
|
150
|
+
|
|
151
|
+
const start = Date.now();
|
|
152
|
+
expect(await collect(endpoint.stream())).toEqual([new MockEntity(5, 'test1')]);
|
|
153
|
+
expect(Date.now() - start).toBeGreaterThanOrEqual(42);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('streamAbort', async () => {
|
|
157
|
+
endpoint.autoReconnect = true;
|
|
158
|
+
endpoint.defaultReconnectionInterval = 0;
|
|
159
|
+
fetchMock.mockResponse(sseEvent('{"id":5,"name":"test1"}'));
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
|
|
162
|
+
const result: MockEntity[] = [];
|
|
163
|
+
for await (const element of endpoint.stream(controller.signal)) {
|
|
164
|
+
result.push(element);
|
|
165
|
+
controller.abort();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
expect(result).toEqual([new MockEntity(5, 'test1')]);
|
|
169
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Endpoint } from "../Endpoint";
|
|
2
|
+
import { delay } from "./Delay";
|
|
3
|
+
import { HttpMethod, HttpHeader, HttpStatusCode, SseReader } from "../../http";
|
|
4
|
+
|
|
5
|
+
const eventStreamMediaType = "text/event-stream";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Endpoint for a stream of `TEntity`s using Server-Sent Events (SSE).<br>
|
|
9
|
+
* Sends `Accept: text/event-stream`. By default, transparently reconnects on connection drops or transient errors,
|
|
10
|
+
* honoring the server-supplied `retry:` interval and resuming via the {@link http!HttpHeader.LastEventId} header.
|
|
11
|
+
* @typeParam TEntity The type of individual elements in the stream.
|
|
12
|
+
*/
|
|
13
|
+
export class SseStreamingEndpoint<TEntity> extends Endpoint {
|
|
14
|
+
/**
|
|
15
|
+
* Creates a new SSE streaming endpoint.
|
|
16
|
+
* @param referrer The endpoint used to navigate to this one.
|
|
17
|
+
* @param relativeUri The URI of this endpoint relative to the `referrer`'s. Add a `./` prefix here to imply a trailing slash in the `referrer`'s URI.
|
|
18
|
+
* @param eventType If set, only events with this `event:` type are provided; others are ignored. Leave unspecified to provide all events.
|
|
19
|
+
*/
|
|
20
|
+
constructor(referrer: Endpoint, relativeUri: URL | string, private readonly eventType?: string) {
|
|
21
|
+
super(referrer, relativeUri);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether to transparently reconnect on connection drops, transient transport errors and 5xx responses.
|
|
26
|
+
*/
|
|
27
|
+
autoReconnect = true;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The reconnection interval in milliseconds used when the server has not (yet) supplied one via the SSE `retry:` field.
|
|
31
|
+
*/
|
|
32
|
+
defaultReconnectionInterval = 3000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Provides a stream of `TEntity`s, reconnecting as needed unless {@link autoReconnect} is disabled.
|
|
36
|
+
* @param signal Used to close the connection.
|
|
37
|
+
* @throws {@link errors!AuthenticationError}: {@link http!HttpStatusCode.Unauthorized}
|
|
38
|
+
* @throws {@link errors!AuthorizationError}: {@link http!HttpStatusCode.Forbidden}
|
|
39
|
+
* @throws {@link errors!NotFoundError}: {@link http!HttpStatusCode.NotFound} or {@link http!HttpStatusCode.Gone}
|
|
40
|
+
* @throws {@link errors!HttpError}: Other non-success status code
|
|
41
|
+
*/
|
|
42
|
+
async *stream(signal?: AbortSignal): AsyncIterableIterator<TEntity> {
|
|
43
|
+
const state = { lastEventId: undefined as string | undefined, reconnectionInterval: this.defaultReconnectionInterval };
|
|
44
|
+
|
|
45
|
+
while (!signal?.aborted) {
|
|
46
|
+
let reconnect: boolean;
|
|
47
|
+
try {
|
|
48
|
+
reconnect = yield* this.consumeOnce(state, signal);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// Aborting is a regular way of ending the stream, not an error
|
|
51
|
+
if (signal?.aborted) return;
|
|
52
|
+
|
|
53
|
+
// Transport-level failures are the ones worth retrying; anything else is the server telling us no
|
|
54
|
+
if (!this.autoReconnect || !SseStreamingEndpoint.isTransient(err)) throw err;
|
|
55
|
+
reconnect = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!reconnect) return;
|
|
59
|
+
if (!await delay(state.reconnectionInterval, signal)) return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Provides a stream of `TEntity`s using {@link stream}.
|
|
65
|
+
*/
|
|
66
|
+
[Symbol.asyncIterator]() { return this.stream(); }
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Opens a connection and provides events until the stream ends.
|
|
70
|
+
* @param state Carries the last event id and the reconnection interval over to the next attempt.
|
|
71
|
+
* @param signal Used to close the connection.
|
|
72
|
+
* @returns `true` if another connection attempt should be made; `false` if the stream has ended for good.
|
|
73
|
+
*/
|
|
74
|
+
private async *consumeOnce(state: { lastEventId?: string; reconnectionInterval: number; }, signal?: AbortSignal): AsyncGenerator<TEntity, boolean> {
|
|
75
|
+
// NOTE: Abort the request when the consumer stops iterating, not just when the caller's signal fires.
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const onAbort = () => controller.abort();
|
|
78
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
79
|
+
if (signal?.aborted) controller.abort();
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const headers = new Headers({ [HttpHeader.Accept]: eventStreamMediaType });
|
|
83
|
+
if (state.lastEventId) headers.set(HttpHeader.LastEventId, state.lastEventId);
|
|
84
|
+
|
|
85
|
+
const response = await this.httpClient.send(this.uri, HttpMethod.Get, controller.signal, headers);
|
|
86
|
+
if (response.status === HttpStatusCode.NoContent) return false;
|
|
87
|
+
if (this.autoReconnect && response.status >= 500) return true;
|
|
88
|
+
await this.handle(response);
|
|
89
|
+
|
|
90
|
+
const reader = new SseReader(response);
|
|
91
|
+
try {
|
|
92
|
+
for await (const event of reader.events()) {
|
|
93
|
+
if (this.eventType !== undefined && event.type !== this.eventType) continue;
|
|
94
|
+
yield this.serializer.deserialize<TEntity>(event.data);
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
state.lastEventId = reader.lastEventId ?? state.lastEventId;
|
|
98
|
+
if (reader.reconnectionInterval !== undefined) state.reconnectionInterval = reader.reconnectionInterval;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return this.autoReconnect;
|
|
102
|
+
} finally {
|
|
103
|
+
signal?.removeEventListener("abort", onAbort);
|
|
104
|
+
controller.abort();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Determines whether an error is a transport-level failure that may succeed on a retry.
|
|
110
|
+
* The Fetch API reports these as `TypeError`s, while responses the server actually sent become {@link errors!HttpError}s.
|
|
111
|
+
*/
|
|
112
|
+
private static isTransient(err: unknown) {
|
|
113
|
+
return err instanceof TypeError;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StreamingCollectionEndpoint = void 0;
|
|
4
|
+
const GenericStreamingCollectionEndpoint_1 = require("./GenericStreamingCollectionEndpoint");
|
|
5
|
+
const ElementEndpoint_1 = require("../generic/ElementEndpoint");
|
|
6
|
+
/**
|
|
7
|
+
* Endpoint for a collection of `TEntity`s observable as an append-only stream using long-polling,
|
|
8
|
+
* addressable as {@link ElementEndpoint}s.<br>
|
|
9
|
+
* Use {@link GenericStreamingCollectionEndpoint} instead if you wish to customize the element endpoint type.
|
|
10
|
+
* @typeParam TEntity The type of individual elements in the collection.
|
|
11
|
+
*/
|
|
12
|
+
class StreamingCollectionEndpoint extends GenericStreamingCollectionEndpoint_1.GenericStreamingCollectionEndpoint {
|
|
13
|
+
/**
|
|
14
|
+
* Creates a new streaming collection endpoint.
|
|
15
|
+
* @param referrer The endpoint used to navigate to this one.
|
|
16
|
+
* @param relativeUri The URI of this endpoint relative to the `referrer`'s. Add a `./` prefix here to imply a trailing slash in the `referrer`'s URI.
|
|
17
|
+
*/
|
|
18
|
+
constructor(referrer, relativeUri) {
|
|
19
|
+
super(referrer, relativeUri, ElementEndpoint_1.ElementEndpoint);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
exports.StreamingCollectionEndpoint = StreamingCollectionEndpoint;
|
|
23
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiU3RyZWFtaW5nQ29sbGVjdGlvbkVuZHBvaW50LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiU3RyZWFtaW5nQ29sbGVjdGlvbkVuZHBvaW50LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7OztBQUFBLDZGQUEwRjtBQUMxRixnRUFBNkQ7QUFHN0Q7Ozs7O0dBS0c7QUFDSCxNQUFhLDJCQUFxQyxTQUFRLHVFQUFxRTtJQUMzSDs7OztPQUlHO0lBQ0gsWUFBWSxRQUFrQixFQUFFLFdBQXlCO1FBQ3JELEtBQUssQ0FBQyxRQUFRLEVBQUUsV0FBVyxFQUFFLGlDQUFlLENBQUMsQ0FBQztJQUNsRCxDQUFDO0NBQ0o7QUFURCxrRUFTQyJ9
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOTE: The jsdom environment strips the "Range" request header, so these tests need the node environment.
|
|
3
|
+
* @jest-environment node
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fetchMock from 'jest-fetch-mock';
|
|
7
|
+
import { StreamingCollectionEndpoint } from '.';
|
|
8
|
+
import { EntryEndpoint } from '..';
|
|
9
|
+
import { HttpHeader, HttpStatusCode } from '../../http';
|
|
10
|
+
|
|
11
|
+
class MockEntity {
|
|
12
|
+
constructor(public id: number, public name: string) { }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
fetchMock.enableMocks();
|
|
16
|
+
let endpoint: StreamingCollectionEndpoint<MockEntity>;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
fetchMock.resetMocks();
|
|
20
|
+
endpoint = new StreamingCollectionEndpoint<MockEntity>(new EntryEndpoint('http://localhost/'), 'endpoint');
|
|
21
|
+
endpoint.pollingInterval = 0;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function mockRangeOnce(body: string, contentRange?: string, retryAfterHeader?: string) {
|
|
25
|
+
const headers: { [key: string]: string; } = {};
|
|
26
|
+
if (contentRange) headers[HttpHeader.ContentRange] = contentRange;
|
|
27
|
+
if (retryAfterHeader) headers[HttpHeader.RetryAfter] = retryAfterHeader;
|
|
28
|
+
|
|
29
|
+
fetchMock.mockResponseOnce(body, { status: HttpStatusCode.PartialContent, headers });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requestHeader(index: number, header: HttpHeader) {
|
|
33
|
+
return new Headers(fetchMock.mock.calls[index][1]?.headers).get(header);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function collect<T>(stream: AsyncIterable<T>) {
|
|
37
|
+
const result: T[] = [];
|
|
38
|
+
for await (const element of stream) result.push(element);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('stream', async () => {
|
|
43
|
+
mockRangeOnce('[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]', 'elements 0-1/*');
|
|
44
|
+
mockRangeOnce('[{"id":7,"name":"test3"}]', 'elements 2-2/3', '42');
|
|
45
|
+
|
|
46
|
+
expect(await collect(endpoint.stream())).toEqual([
|
|
47
|
+
new MockEntity(5, 'test1'),
|
|
48
|
+
new MockEntity(6, 'test2'),
|
|
49
|
+
new MockEntity(7, 'test3')
|
|
50
|
+
]);
|
|
51
|
+
expect(requestHeader(0, HttpHeader.Range)).toBe('elements=0-');
|
|
52
|
+
expect(requestHeader(1, HttpHeader.Range)).toBe('elements=2-');
|
|
53
|
+
expect(endpoint.pollingInterval).toBe(42000);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('streamOffset', async () => {
|
|
57
|
+
mockRangeOnce('[{"id":7,"name":"test3"}]', 'elements 2-2/3');
|
|
58
|
+
|
|
59
|
+
expect(await collect(endpoint.stream(2))).toEqual([new MockEntity(7, 'test3')]);
|
|
60
|
+
expect(requestHeader(0, HttpHeader.Range)).toBe('elements=2-');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('streamTail', async () => {
|
|
64
|
+
mockRangeOnce('[{"id":7,"name":"test3"}]', 'elements 2-2/3');
|
|
65
|
+
|
|
66
|
+
expect(await collect(endpoint.stream(-1))).toEqual([new MockEntity(7, 'test3')]);
|
|
67
|
+
expect(requestHeader(0, HttpHeader.Range)).toBe('elements=-1');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('streamAsyncIterator', async () => {
|
|
71
|
+
mockRangeOnce('[{"id":5,"name":"test1"}]', 'elements 0-0/1');
|
|
72
|
+
|
|
73
|
+
expect(await collect(endpoint)).toEqual([new MockEntity(5, 'test1')]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('streamKeepsPollingWhileRangeNotSatisfiable', async () => {
|
|
77
|
+
mockRangeOnce('[{"id":5,"name":"test1"}]', 'elements 0-0/*');
|
|
78
|
+
fetchMock.mockResponseOnce('', { status: HttpStatusCode.RangeNotSatisfiable });
|
|
79
|
+
mockRangeOnce('[{"id":6,"name":"test2"}]', 'elements 1-1/2');
|
|
80
|
+
|
|
81
|
+
expect(await collect(endpoint.stream())).toEqual([
|
|
82
|
+
new MockEntity(5, 'test1'),
|
|
83
|
+
new MockEntity(6, 'test2')
|
|
84
|
+
]);
|
|
85
|
+
expect(requestHeader(1, HttpHeader.Range)).toBe('elements=1-');
|
|
86
|
+
expect(requestHeader(2, HttpHeader.Range)).toBe('elements=1-');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('streamEndsWithoutContentRange', async () => {
|
|
90
|
+
fetchMock.mockResponseOnce('[{"id":5,"name":"test1"}]');
|
|
91
|
+
|
|
92
|
+
expect(await collect(endpoint.stream())).toEqual([new MockEntity(5, 'test1')]);
|
|
93
|
+
expect(fetchMock.mock.calls.length).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('streamAbort', async () => {
|
|
97
|
+
fetchMock.mockResponse('[{"id":5,"name":"test1"}]', {
|
|
98
|
+
status: HttpStatusCode.PartialContent,
|
|
99
|
+
headers: { [HttpHeader.ContentRange]: 'elements 0-0/*' }
|
|
100
|
+
});
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
|
|
103
|
+
const result: MockEntity[] = [];
|
|
104
|
+
for await (const element of endpoint.stream(0, controller.signal)) {
|
|
105
|
+
result.push(element);
|
|
106
|
+
controller.abort();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
expect(result).toEqual([new MockEntity(5, 'test1')]);
|
|
110
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { GenericStreamingCollectionEndpoint } from "./GenericStreamingCollectionEndpoint";
|
|
2
|
+
import { ElementEndpoint } from "../generic/ElementEndpoint";
|
|
3
|
+
import { Endpoint } from "../Endpoint";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Endpoint for a collection of `TEntity`s observable as an append-only stream using long-polling,
|
|
7
|
+
* addressable as {@link ElementEndpoint}s.<br>
|
|
8
|
+
* Use {@link GenericStreamingCollectionEndpoint} instead if you wish to customize the element endpoint type.
|
|
9
|
+
* @typeParam TEntity The type of individual elements in the collection.
|
|
10
|
+
*/
|
|
11
|
+
export class StreamingCollectionEndpoint<TEntity> extends GenericStreamingCollectionEndpoint<TEntity, ElementEndpoint<TEntity>> {
|
|
12
|
+
/**
|
|
13
|
+
* Creates a new streaming collection endpoint.
|
|
14
|
+
* @param referrer The endpoint used to navigate to this one.
|
|
15
|
+
* @param relativeUri The URI of this endpoint relative to the `referrer`'s. Add a `./` prefix here to imply a trailing slash in the `referrer`'s URI.
|
|
16
|
+
*/
|
|
17
|
+
constructor(referrer: Endpoint, relativeUri: URL | string) {
|
|
18
|
+
super(referrer, relativeUri, ElementEndpoint);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StreamingEndpoint = void 0;
|
|
4
|
+
const Endpoint_1 = require("../Endpoint");
|
|
5
|
+
const http_1 = require("../../http");
|
|
6
|
+
/**
|
|
7
|
+
* Endpoint for a stream of `TEntity`s using a persistent HTTP connection.
|
|
8
|
+
* @typeParam TEntity The type of individual elements in the stream.
|
|
9
|
+
*/
|
|
10
|
+
class StreamingEndpoint extends Endpoint_1.Endpoint {
|
|
11
|
+
/**
|
|
12
|
+
* Creates a new streaming endpoint.
|
|
13
|
+
* @param referrer The endpoint used to navigate to this one.
|
|
14
|
+
* @param relativeUri The URI of this endpoint relative to the `referrer`'s. Add a `./` prefix here to imply a trailing slash in the `referrer`'s URI.
|
|
15
|
+
* @param separator The character sequence used to detect that a new element starts in the HTTP stream.
|
|
16
|
+
*/
|
|
17
|
+
constructor(referrer, relativeUri, separator = "\n") {
|
|
18
|
+
super(referrer, relativeUri);
|
|
19
|
+
this.separator = separator;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Provides a stream of `TEntity`s, keeping the HTTP connection open until the server closes it.
|
|
23
|
+
* @param signal Used to close the connection.
|
|
24
|
+
* @throws {@link errors!AuthenticationError}: {@link http!HttpStatusCode.Unauthorized}
|
|
25
|
+
* @throws {@link errors!AuthorizationError}: {@link http!HttpStatusCode.Forbidden}
|
|
26
|
+
* @throws {@link errors!NotFoundError}: {@link http!HttpStatusCode.NotFound} or {@link http!HttpStatusCode.Gone}
|
|
27
|
+
* @throws {@link errors!HttpError}: Other non-success status code
|
|
28
|
+
*/
|
|
29
|
+
async *stream(signal) {
|
|
30
|
+
// NOTE: Abort the request when the consumer stops iterating, not just when the caller's signal fires.
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
const onAbort = () => controller.abort();
|
|
33
|
+
signal === null || signal === void 0 ? void 0 : signal.addEventListener("abort", onAbort, { once: true });
|
|
34
|
+
if (signal === null || signal === void 0 ? void 0 : signal.aborted)
|
|
35
|
+
controller.abort();
|
|
36
|
+
try {
|
|
37
|
+
const response = await this.send(http_1.HttpMethod.Get, controller.signal);
|
|
38
|
+
yield* (0, http_1.entityStream)(response, this.serializer, this.separator);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
// Aborting is a regular way of ending the stream, not an error
|
|
42
|
+
if (!(signal === null || signal === void 0 ? void 0 : signal.aborted))
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
signal === null || signal === void 0 ? void 0 : signal.removeEventListener("abort", onAbort);
|
|
47
|
+
controller.abort();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Provides a stream of `TEntity`s using {@link stream}.
|
|
52
|
+
*/
|
|
53
|
+
[Symbol.asyncIterator]() { return this.stream(); }
|
|
54
|
+
}
|
|
55
|
+
exports.StreamingEndpoint = StreamingEndpoint;
|
|
56
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiU3RyZWFtaW5nRW5kcG9pbnQuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJTdHJlYW1pbmdFbmRwb2ludC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7QUFBQSwwQ0FBdUM7QUFDdkMscUNBQXNEO0FBRXREOzs7R0FHRztBQUNILE1BQWEsaUJBQTJCLFNBQVEsbUJBQVE7SUFDcEQ7Ozs7O09BS0c7SUFDSCxZQUFZLFFBQWtCLEVBQUUsV0FBeUIsRUFBbUIsWUFBb0IsSUFBSTtRQUNoRyxLQUFLLENBQUMsUUFBUSxFQUFFLFdBQVcsQ0FBQyxDQUFDO1FBRDJDLGNBQVMsR0FBVCxTQUFTLENBQWU7SUFFcEcsQ0FBQztJQUVEOzs7Ozs7O09BT0c7SUFDSCxLQUFLLENBQUMsQ0FBQyxNQUFNLENBQUMsTUFBb0I7UUFDOUIsc0dBQXNHO1FBQ3RHLE1BQU0sVUFBVSxHQUFHLElBQUksZUFBZSxFQUFFLENBQUM7UUFDekMsTUFBTSxPQUFPLEdBQUcsR0FBRyxFQUFFLENBQUMsVUFBVSxDQUFDLEtBQUssRUFBRSxDQUFDO1FBQ3pDLE1BQU0sYUFBTixNQUFNLHVCQUFOLE1BQU0sQ0FBRSxnQkFBZ0IsQ0FBQyxPQUFPLEVBQUUsT0FBTyxFQUFFLEVBQUUsSUFBSSxFQUFFLElBQUksRUFBRSxDQUFDLENBQUM7UUFDM0QsSUFBSSxNQUFNLGFBQU4sTUFBTSx1QkFBTixNQUFNLENBQUUsT0FBTztZQUFFLFVBQVUsQ0FBQyxLQUFLLEVBQUUsQ0FBQztRQUV4QyxJQUFJLENBQUM7WUFDRCxNQUFNLFFBQVEsR0FBRyxNQUFNLElBQUksQ0FBQyxJQUFJLENBQUMsaUJBQVUsQ0FBQyxHQUFHLEVBQUUsVUFBVSxDQUFDLE1BQU0sQ0FBQyxDQUFDO1lBQ3BFLEtBQUssQ0FBQyxDQUFDLElBQUEsbUJBQVksRUFBVSxRQUFRLEVBQUUsSUFBSSxDQUFDLFVBQVUsRUFBRSxJQUFJLENBQUMsU0FBUyxDQUFDLENBQUM7UUFDNUUsQ0FBQztRQUFDLE9BQU8sR0FBRyxFQUFFLENBQUM7WUFDWCwrREFBK0Q7WUFDL0QsSUFBSSxDQUFDLENBQUEsTUFBTSxhQUFOLE1BQU0sdUJBQU4sTUFBTSxDQUFFLE9BQU8sQ0FBQTtnQkFBRSxNQUFNLEdBQUcsQ0FBQztRQUNwQyxDQUFDO2dCQUFTLENBQUM7WUFDUCxNQUFNLGFBQU4sTUFBTSx1QkFBTixNQUFNLENBQUUsbUJBQW1CLENBQUMsT0FBTyxFQUFFLE9BQU8sQ0FBQyxDQUFDO1lBQzlDLFVBQVUsQ0FBQyxLQUFLLEVBQUUsQ0FBQztRQUN2QixDQUFDO0lBQ0wsQ0FBQztJQUVEOztPQUVHO0lBQ0gsQ0FBQyxNQUFNLENBQUMsYUFBYSxDQUFDLEtBQUssT0FBTyxJQUFJLENBQUMsTUFBTSxFQUFFLENBQUMsQ0FBQyxDQUFDO0NBQ3JEO0FBMUNELDhDQTBDQyJ9
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOTE: The jsdom environment does not expose response bodies as streams, so these tests need the node environment.
|
|
3
|
+
* @jest-environment node
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fetchMock from 'jest-fetch-mock';
|
|
7
|
+
import { StreamingEndpoint } from '.';
|
|
8
|
+
import { EntryEndpoint } from '..';
|
|
9
|
+
import { NotFoundError } from '../../errors';
|
|
10
|
+
import { HttpStatusCode } from '../../http';
|
|
11
|
+
|
|
12
|
+
class MockEntity {
|
|
13
|
+
constructor(public id: number, public name: string) { }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
fetchMock.enableMocks();
|
|
17
|
+
let entryEndpoint: EntryEndpoint;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
fetchMock.resetMocks();
|
|
21
|
+
entryEndpoint = new EntryEndpoint('http://localhost/');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Mocks a response whose body is streamed in multiple chunks.
|
|
26
|
+
* @param chunks The chunks to send, in order.
|
|
27
|
+
* @param close Whether to close the connection after the last chunk.
|
|
28
|
+
* @returns A callback reporting whether the client has cancelled the connection.
|
|
29
|
+
*/
|
|
30
|
+
function mockStream(chunks: string[], close: boolean = true) {
|
|
31
|
+
let cancelled = false;
|
|
32
|
+
|
|
33
|
+
fetchMock.mockImplementation(async (_input, init) => {
|
|
34
|
+
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
35
|
+
const body = new ReadableStream<Uint8Array>({
|
|
36
|
+
start(controller) {
|
|
37
|
+
streamController = controller;
|
|
38
|
+
const encoder = new TextEncoder();
|
|
39
|
+
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
|
|
40
|
+
if (close) controller.close();
|
|
41
|
+
},
|
|
42
|
+
cancel() { cancelled = true; }
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Emulate how aborting a real request fails the response body
|
|
46
|
+
init?.signal?.addEventListener('abort', () => {
|
|
47
|
+
try { streamController?.error(new Error('aborted')); } catch { }
|
|
48
|
+
}, { once: true });
|
|
49
|
+
|
|
50
|
+
return new Response(body);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return () => cancelled;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function collect<T>(stream: AsyncIterable<T>) {
|
|
57
|
+
const result: T[] = [];
|
|
58
|
+
for await (const element of stream) result.push(element);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test('stream', async () => {
|
|
63
|
+
mockStream(['{"id":5,"name":"test1"}\n', '{"id":6,"name":"test2"}\n']);
|
|
64
|
+
const endpoint = new StreamingEndpoint<MockEntity>(entryEndpoint, 'endpoint');
|
|
65
|
+
|
|
66
|
+
expect(await collect(endpoint.stream())).toEqual([new MockEntity(5, 'test1'), new MockEntity(6, 'test2')]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('streamSplitAcrossChunks', async () => {
|
|
70
|
+
mockStream(['{"id":5,"na', 'me":"test1"}\n{"id":6,', '"name":"test2"}\n']);
|
|
71
|
+
const endpoint = new StreamingEndpoint<MockEntity>(entryEndpoint, 'endpoint');
|
|
72
|
+
|
|
73
|
+
expect(await collect(endpoint.stream())).toEqual([new MockEntity(5, 'test1'), new MockEntity(6, 'test2')]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('streamWithoutTrailingSeparator', async () => {
|
|
77
|
+
mockStream(['{"id":5,"name":"test1"}\n{"id":6,"name":"test2"}']);
|
|
78
|
+
const endpoint = new StreamingEndpoint<MockEntity>(entryEndpoint, 'endpoint');
|
|
79
|
+
|
|
80
|
+
expect(await collect(endpoint.stream())).toEqual([new MockEntity(5, 'test1'), new MockEntity(6, 'test2')]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('streamCustomSeparator', async () => {
|
|
84
|
+
mockStream(['{"id":5,"name":"test1"}|{"id":6,"name":"test2"}']);
|
|
85
|
+
const endpoint = new StreamingEndpoint<MockEntity>(entryEndpoint, 'endpoint', '|');
|
|
86
|
+
|
|
87
|
+
expect(await collect(endpoint.stream())).toEqual([new MockEntity(5, 'test1'), new MockEntity(6, 'test2')]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('streamAsyncIterator', async () => {
|
|
91
|
+
mockStream(['{"id":5,"name":"test1"}\n']);
|
|
92
|
+
const endpoint = new StreamingEndpoint<MockEntity>(entryEndpoint, 'endpoint');
|
|
93
|
+
|
|
94
|
+
expect(await collect(endpoint)).toEqual([new MockEntity(5, 'test1')]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('streamBreak', async () => {
|
|
98
|
+
const cancelled = mockStream(['{"id":5,"name":"test1"}\n', '{"id":6,"name":"test2"}\n'], false);
|
|
99
|
+
const endpoint = new StreamingEndpoint<MockEntity>(entryEndpoint, 'endpoint');
|
|
100
|
+
|
|
101
|
+
const result: MockEntity[] = [];
|
|
102
|
+
for await (const element of endpoint.stream()) {
|
|
103
|
+
result.push(element);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
expect(result).toEqual([new MockEntity(5, 'test1')]);
|
|
108
|
+
expect(cancelled()).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('streamAbort', async () => {
|
|
112
|
+
mockStream(['{"id":5,"name":"test1"}\n', '{"id":6,"name":"test2"}\n'], false);
|
|
113
|
+
const endpoint = new StreamingEndpoint<MockEntity>(entryEndpoint, 'endpoint');
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
|
|
116
|
+
const result: MockEntity[] = [];
|
|
117
|
+
for await (const element of endpoint.stream(controller.signal)) {
|
|
118
|
+
result.push(element);
|
|
119
|
+
if (result.length === 2) controller.abort();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
expect(result).toEqual([new MockEntity(5, 'test1'), new MockEntity(6, 'test2')]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('streamError', async () => {
|
|
126
|
+
fetchMock.mockResponseOnce('', { status: HttpStatusCode.NotFound });
|
|
127
|
+
const endpoint = new StreamingEndpoint<MockEntity>(entryEndpoint, 'endpoint');
|
|
128
|
+
|
|
129
|
+
await expect(collect(endpoint.stream())).rejects.toThrow(NotFoundError);
|
|
130
|
+
});
|