@tstdl/base 0.93.126 → 0.93.128
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/api/client/client.js +45 -9
- package/api/client/tests/api-client.test.d.ts +1 -0
- package/api/client/tests/api-client.test.js +194 -0
- package/api/types.d.ts +34 -2
- package/api/types.js +9 -2
- package/authentication/client/authentication.service.js +30 -11
- package/authentication/client/http-client.middleware.js +10 -3
- package/authentication/server/authentication.service.d.ts +12 -0
- package/authentication/server/authentication.service.js +14 -2
- package/authentication/tests/authentication.client-error-handling.test.js +23 -66
- package/authentication/tests/authentication.client-service-refresh.test.js +14 -14
- package/cancellation/token.d.ts +6 -0
- package/cancellation/token.js +8 -0
- package/http/client/adapters/undici.adapter.js +0 -2
- package/http/client/http-client-request.d.ts +2 -0
- package/http/client/http-client-request.js +4 -0
- package/http/client/http-client-response.d.ts +1 -1
- package/http/client/http-client-response.js +3 -2
- package/http/utils.d.ts +6 -0
- package/http/utils.js +71 -0
- package/injector/graph.js +27 -6
- package/injector/injector.js +2 -0
- package/mail/drizzle/0000_numerous_the_watchers.sql +8 -0
- package/mail/drizzle/meta/0000_snapshot.json +1 -32
- package/mail/drizzle/meta/_journal.json +2 -9
- package/object-storage/s3/tests/s3.object-storage.integration.test.js +22 -53
- package/orm/tests/repository-expiration.test.js +3 -3
- package/package.json +1 -1
- package/rate-limit/tests/postgres-rate-limiter.test.js +9 -7
- package/task-queue/tests/complex.test.js +22 -22
- package/task-queue/tests/dependencies.test.js +15 -13
- package/task-queue/tests/queue.test.js +13 -13
- package/task-queue/tests/worker.test.js +12 -12
- package/testing/integration-setup.d.ts +2 -0
- package/testing/integration-setup.js +13 -7
- package/utils/backoff.d.ts +27 -3
- package/utils/backoff.js +31 -9
- package/utils/index.d.ts +1 -0
- package/utils/index.js +1 -0
- package/utils/retry-with-backoff.d.ts +22 -0
- package/utils/retry-with-backoff.js +64 -0
- package/utils/tests/backoff.test.d.ts +1 -0
- package/utils/tests/backoff.test.js +41 -0
- package/utils/tests/retry-with-backoff.test.d.ts +1 -0
- package/utils/tests/retry-with-backoff.test.js +49 -0
- package/mail/drizzle/0000_previous_malcolm_colcord.sql +0 -13
- package/mail/drizzle/0001_flimsy_bloodscream.sql +0 -5
- package/mail/drizzle/meta/0001_snapshot.json +0 -69
package/api/client/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CancellationToken } from '../../cancellation/index.js';
|
|
1
2
|
import { HttpClient, HttpClientRequest } from '../../http/client/index.js';
|
|
2
3
|
import { bustCache, normalizeSingleHttpValue } from '../../http/index.js';
|
|
3
4
|
import { inject } from '../../injector/inject.js';
|
|
@@ -64,8 +65,25 @@ export function compileClient(definition, options = defaultOptions) {
|
|
|
64
65
|
let resource;
|
|
65
66
|
const hasGet = methods.includes('GET');
|
|
66
67
|
const fallbackMethod = methods.find((method) => method != 'GET') ?? 'GET';
|
|
68
|
+
const hasParameters = isDefined(endpoint.parameters);
|
|
69
|
+
const hasBody = isDefined(endpoint.body);
|
|
67
70
|
const apiEndpointFunction = {
|
|
68
|
-
async [name](
|
|
71
|
+
async [name](...args) {
|
|
72
|
+
let parameters;
|
|
73
|
+
let requestBody;
|
|
74
|
+
let requestOptions;
|
|
75
|
+
if (hasBody) {
|
|
76
|
+
parameters = args[0];
|
|
77
|
+
requestBody = args[1];
|
|
78
|
+
requestOptions = args[2];
|
|
79
|
+
}
|
|
80
|
+
else if (hasParameters) {
|
|
81
|
+
parameters = args[0];
|
|
82
|
+
requestOptions = args[1];
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
requestOptions = args[0];
|
|
86
|
+
}
|
|
69
87
|
resource ??= getFullApiEndpointResource({ api: definition, endpoint, defaultPrefix: options.prefix });
|
|
70
88
|
const context = { endpoint };
|
|
71
89
|
const method = (hasGet && isUndefined(parameters)) ? 'GET' : fallbackMethod;
|
|
@@ -73,15 +91,15 @@ export function compileClient(definition, options = defaultOptions) {
|
|
|
73
91
|
if (isDefined(requestBody)) {
|
|
74
92
|
throw new Error('Body not supported for Server Sent Events.');
|
|
75
93
|
}
|
|
76
|
-
return getServerSentEvents(this[httpClientSymbol].options.baseUrl, resource, endpoint, parameters);
|
|
94
|
+
return getServerSentEvents(this[httpClientSymbol].options.baseUrl, resource, endpoint, parameters, requestOptions);
|
|
77
95
|
}
|
|
78
96
|
if (endpoint.result == DataStream) {
|
|
79
97
|
if (isDefined(requestBody)) {
|
|
80
98
|
throw new Error('Body not supported for DataStream.');
|
|
81
99
|
}
|
|
82
|
-
return getDataStream(this[httpClientSymbol].options.baseUrl, resource, endpoint, parameters);
|
|
100
|
+
return getDataStream(this[httpClientSymbol].options.baseUrl, resource, endpoint, parameters, requestOptions);
|
|
83
101
|
}
|
|
84
|
-
if (context.endpoint.data?.[bustCache] == true) {
|
|
102
|
+
if (context.endpoint.data?.[bustCache] == true || requestOptions?.bustCache == true) {
|
|
85
103
|
context[bustCache] = true;
|
|
86
104
|
}
|
|
87
105
|
const request = new HttpClientRequest({
|
|
@@ -90,7 +108,12 @@ export function compileClient(definition, options = defaultOptions) {
|
|
|
90
108
|
parameters,
|
|
91
109
|
body: getRequestBody(requestBody),
|
|
92
110
|
credentials: (endpoint.credentials == true) ? 'include' : undefined,
|
|
93
|
-
context,
|
|
111
|
+
context: { ...context, ...requestOptions?.context },
|
|
112
|
+
abortSignal: getCancellationSignal(requestOptions?.abortSignal),
|
|
113
|
+
timeout: requestOptions?.timeout,
|
|
114
|
+
headers: requestOptions?.headers,
|
|
115
|
+
authorization: requestOptions?.authorization,
|
|
116
|
+
priority: requestOptions?.priority,
|
|
94
117
|
});
|
|
95
118
|
const response = await this[httpClientSymbol].rawRequest(request);
|
|
96
119
|
return await getResponseBody(response, endpoint.result);
|
|
@@ -133,11 +156,11 @@ async function getResponseBody(response, schema) {
|
|
|
133
156
|
: undefined;
|
|
134
157
|
return Schema.parse(schema, body, { mask: true });
|
|
135
158
|
}
|
|
136
|
-
function getDataStream(baseUrl, resource, endpoint, parameters) {
|
|
137
|
-
const sse = getServerSentEvents(baseUrl, resource, endpoint, parameters);
|
|
159
|
+
function getDataStream(baseUrl, resource, endpoint, parameters, options) {
|
|
160
|
+
const sse = getServerSentEvents(baseUrl, resource, endpoint, parameters, options);
|
|
138
161
|
return DataStream.parse(sse);
|
|
139
162
|
}
|
|
140
|
-
function getServerSentEvents(baseUrl, resource, endpoint, parameters) {
|
|
163
|
+
function getServerSentEvents(baseUrl, resource, endpoint, parameters, options) {
|
|
141
164
|
const { parsedUrl, parametersRest } = buildUrl(resource, parameters, { arraySeparator: ',' });
|
|
142
165
|
const url = new URL(parsedUrl, baseUrl);
|
|
143
166
|
for (const [parameter, value] of objectEntries(parametersRest)) {
|
|
@@ -148,7 +171,20 @@ function getServerSentEvents(baseUrl, resource, endpoint, parameters) {
|
|
|
148
171
|
url.searchParams.append(parameter, normalizeSingleHttpValue(val));
|
|
149
172
|
}
|
|
150
173
|
}
|
|
151
|
-
|
|
174
|
+
const sse = new ServerSentEvents(url.toString(), { withCredentials: endpoint.credentials });
|
|
175
|
+
if (isDefined(options?.abortSignal)) {
|
|
176
|
+
options.abortSignal.addEventListener('abort', () => sse.close(), { once: true });
|
|
177
|
+
}
|
|
178
|
+
return sse;
|
|
179
|
+
}
|
|
180
|
+
function getCancellationSignal(signal) {
|
|
181
|
+
if (isUndefined(signal)) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
if (signal instanceof AbortSignal) {
|
|
185
|
+
return CancellationToken.from(signal).signal;
|
|
186
|
+
}
|
|
187
|
+
return signal;
|
|
152
188
|
}
|
|
153
189
|
export function getHttpClientOfApiClient(apiClient) {
|
|
154
190
|
return apiClient[httpClientSymbol];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CancellationToken } from '../../../cancellation/index.js';
|
|
3
|
+
import { HttpClient } from '../../../http/client/index.js';
|
|
4
|
+
import { bustCache } from '../../../http/index.js';
|
|
5
|
+
import { object } from '../../../schema/index.js';
|
|
6
|
+
import { DataStream } from '../../../sse/data-stream.js';
|
|
7
|
+
import { ServerSentEvents } from '../../../sse/server-sent-events.js';
|
|
8
|
+
import { defineApi } from '../../types.js';
|
|
9
|
+
import { compileClient } from '../client.js';
|
|
10
|
+
describe('ApiClient', () => {
|
|
11
|
+
it('should pass abortSignal and handle positional arguments', async () => {
|
|
12
|
+
const apiDefinition = defineApi({
|
|
13
|
+
resource: 'test',
|
|
14
|
+
endpoints: {
|
|
15
|
+
noParams: {
|
|
16
|
+
method: 'GET',
|
|
17
|
+
resource: 'no-params',
|
|
18
|
+
},
|
|
19
|
+
onlyParams: {
|
|
20
|
+
method: 'GET',
|
|
21
|
+
resource: 'only-params',
|
|
22
|
+
parameters: object({
|
|
23
|
+
id: String,
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
paramsAndBody: {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
resource: 'params-and-body',
|
|
29
|
+
parameters: object({
|
|
30
|
+
id: String,
|
|
31
|
+
}),
|
|
32
|
+
body: object({
|
|
33
|
+
data: String,
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const mockHttpClient = Object.assign(Object.create(HttpClient.prototype), {
|
|
39
|
+
rawRequest: vi.fn().mockResolvedValue({
|
|
40
|
+
statusCode: 200,
|
|
41
|
+
hasBody: false,
|
|
42
|
+
close: vi.fn(),
|
|
43
|
+
}),
|
|
44
|
+
options: { baseUrl: 'http://localhost/' },
|
|
45
|
+
});
|
|
46
|
+
const Client = compileClient(apiDefinition);
|
|
47
|
+
const client = new Client(mockHttpClient);
|
|
48
|
+
const abortController = new AbortController();
|
|
49
|
+
const cancellationToken = new CancellationToken();
|
|
50
|
+
// 1. No params
|
|
51
|
+
await client.noParams({ abortSignal: abortController.signal });
|
|
52
|
+
expect(mockHttpClient.rawRequest).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
53
|
+
url: expect.stringContaining('no-params'),
|
|
54
|
+
}));
|
|
55
|
+
let lastRequest = mockHttpClient.rawRequest.mock.calls.at(-1)[0];
|
|
56
|
+
expect(lastRequest.abortSignal.isSet).toBe(false);
|
|
57
|
+
abortController.abort();
|
|
58
|
+
expect(lastRequest.abortSignal.isSet).toBe(true);
|
|
59
|
+
// 2. Only params
|
|
60
|
+
await client.onlyParams({ id: '123' }, { abortSignal: cancellationToken.abortSignal });
|
|
61
|
+
expect(mockHttpClient.rawRequest).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
62
|
+
url: expect.stringContaining('only-params'),
|
|
63
|
+
parameters: { id: '123' },
|
|
64
|
+
}));
|
|
65
|
+
lastRequest = mockHttpClient.rawRequest.mock.calls.at(-1)[0];
|
|
66
|
+
expect(lastRequest.abortSignal.isSet).toBe(false);
|
|
67
|
+
cancellationToken.set();
|
|
68
|
+
expect(lastRequest.abortSignal.isSet).toBe(true);
|
|
69
|
+
// 3. Params and body
|
|
70
|
+
const abortController3 = new AbortController();
|
|
71
|
+
await client.paramsAndBody({ id: '456' }, { data: 'val' }, { abortSignal: abortController3.signal });
|
|
72
|
+
expect(mockHttpClient.rawRequest).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
73
|
+
url: expect.stringContaining('params-and-body'),
|
|
74
|
+
parameters: { id: '456' },
|
|
75
|
+
body: { json: { data: 'val' } },
|
|
76
|
+
}));
|
|
77
|
+
lastRequest = mockHttpClient.rawRequest.mock.calls.at(-1)[0];
|
|
78
|
+
expect(lastRequest.abortSignal.isSet).toBe(false);
|
|
79
|
+
abortController3.abort();
|
|
80
|
+
expect(lastRequest.abortSignal.isSet).toBe(true);
|
|
81
|
+
// 4. Omitted requestOptions
|
|
82
|
+
await client.onlyParams({ id: '789' });
|
|
83
|
+
expect(mockHttpClient.rawRequest).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
84
|
+
url: expect.stringContaining('only-params'),
|
|
85
|
+
parameters: { id: '789' },
|
|
86
|
+
}));
|
|
87
|
+
lastRequest = mockHttpClient.rawRequest.mock.calls.at(-1)[0];
|
|
88
|
+
expect(lastRequest.abortSignal.isSet).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
it('should handle different HTTP methods', async () => {
|
|
91
|
+
const apiDefinition = defineApi({
|
|
92
|
+
resource: 'methods',
|
|
93
|
+
endpoints: {
|
|
94
|
+
get: { method: 'GET' },
|
|
95
|
+
post: { method: 'POST' },
|
|
96
|
+
put: { method: 'PUT' },
|
|
97
|
+
patch: { method: 'PATCH' },
|
|
98
|
+
delete: { method: 'DELETE' },
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
const mockHttpClient = Object.assign(Object.create(HttpClient.prototype), {
|
|
102
|
+
rawRequest: vi.fn().mockResolvedValue({ statusCode: 200, hasBody: false, close: vi.fn() }),
|
|
103
|
+
options: { baseUrl: 'http://localhost/' },
|
|
104
|
+
});
|
|
105
|
+
const Client = compileClient(apiDefinition);
|
|
106
|
+
const client = new Client(mockHttpClient);
|
|
107
|
+
await client.get();
|
|
108
|
+
expect(mockHttpClient.rawRequest.mock.calls.at(-1)[0].method).toBe('GET');
|
|
109
|
+
await client.post();
|
|
110
|
+
expect(mockHttpClient.rawRequest.mock.calls.at(-1)[0].method).toBe('POST');
|
|
111
|
+
await client.put();
|
|
112
|
+
expect(mockHttpClient.rawRequest.mock.calls.at(-1)[0].method).toBe('PUT');
|
|
113
|
+
await client.patch();
|
|
114
|
+
expect(mockHttpClient.rawRequest.mock.calls.at(-1)[0].method).toBe('PATCH');
|
|
115
|
+
await client.delete();
|
|
116
|
+
expect(mockHttpClient.rawRequest.mock.calls.at(-1)[0].method).toBe('DELETE');
|
|
117
|
+
});
|
|
118
|
+
it('should handle body types', async () => {
|
|
119
|
+
const apiDefinition = defineApi({
|
|
120
|
+
resource: 'body',
|
|
121
|
+
endpoints: {
|
|
122
|
+
json: { method: 'POST', body: object({ foo: String }) },
|
|
123
|
+
text: { method: 'POST', body: String },
|
|
124
|
+
binary: { method: 'POST', body: Uint8Array },
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const mockHttpClient = Object.assign(Object.create(HttpClient.prototype), {
|
|
128
|
+
rawRequest: vi.fn().mockResolvedValue({ statusCode: 200, hasBody: false, close: vi.fn() }),
|
|
129
|
+
options: { baseUrl: 'http://localhost/' },
|
|
130
|
+
});
|
|
131
|
+
const Client = compileClient(apiDefinition);
|
|
132
|
+
const client = new Client(mockHttpClient);
|
|
133
|
+
await client.json(undefined, { foo: 'bar' });
|
|
134
|
+
expect(mockHttpClient.rawRequest.mock.calls.at(-1)[0].body).toEqual({ json: { foo: 'bar' } });
|
|
135
|
+
await client.text(undefined, 'hello');
|
|
136
|
+
expect(mockHttpClient.rawRequest.mock.calls.at(-1)[0].body).toEqual({ text: 'hello' });
|
|
137
|
+
const buffer = new Uint8Array([1, 2, 3]);
|
|
138
|
+
await client.binary(undefined, buffer);
|
|
139
|
+
expect(mockHttpClient.rawRequest.mock.calls.at(-1)[0].body).toEqual({ binary: buffer });
|
|
140
|
+
});
|
|
141
|
+
it('should handle credentials, bustCache and resource methods', async () => {
|
|
142
|
+
const apiDefinition = defineApi({
|
|
143
|
+
resource: 'features',
|
|
144
|
+
endpoints: {
|
|
145
|
+
withCredentials: { method: 'GET', credentials: true },
|
|
146
|
+
withBustCache: { method: 'GET', data: { [bustCache]: true } },
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
const mockHttpClient = Object.assign(Object.create(HttpClient.prototype), {
|
|
150
|
+
rawRequest: vi.fn().mockResolvedValue({ statusCode: 200, hasBody: false, close: vi.fn() }),
|
|
151
|
+
options: { baseUrl: 'http://localhost/' },
|
|
152
|
+
});
|
|
153
|
+
const Client = compileClient(apiDefinition);
|
|
154
|
+
const client = new Client(mockHttpClient);
|
|
155
|
+
await client.withCredentials();
|
|
156
|
+
expect(mockHttpClient.rawRequest.mock.calls.at(-1)[0].credentials).toBe('include');
|
|
157
|
+
await client.withBustCache();
|
|
158
|
+
expect(mockHttpClient.rawRequest.mock.calls.at(-1)[0].context[bustCache]).toBe(true);
|
|
159
|
+
expect(Client.getEndpointResource('withCredentials')).toContain('with-credentials');
|
|
160
|
+
expect(Client.getEndpointUrl('withCredentials').href).toBe('http://baseurl/api/v1/features/with-credentials');
|
|
161
|
+
expect(client.getEndpointUrl('withCredentials').href).toBe('http://localhost/api/v1/features/with-credentials');
|
|
162
|
+
});
|
|
163
|
+
it('should handle Server Sent Events and DataStream', async () => {
|
|
164
|
+
const apiDefinition = defineApi({
|
|
165
|
+
resource: 'sse',
|
|
166
|
+
endpoints: {
|
|
167
|
+
events: { method: 'GET', result: ServerSentEvents },
|
|
168
|
+
stream: { method: 'GET', result: DataStream },
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
const mockEventSource = {
|
|
172
|
+
addEventListener: vi.fn(),
|
|
173
|
+
removeEventListener: vi.fn(),
|
|
174
|
+
close: vi.fn(),
|
|
175
|
+
readyState: 0,
|
|
176
|
+
};
|
|
177
|
+
const EventSourceMock = vi.fn().mockImplementation(function () {
|
|
178
|
+
return mockEventSource;
|
|
179
|
+
});
|
|
180
|
+
vi.stubGlobal('EventSource', EventSourceMock);
|
|
181
|
+
const mockHttpClient = Object.assign(Object.create(HttpClient.prototype), {
|
|
182
|
+
options: { baseUrl: 'http://localhost/' },
|
|
183
|
+
});
|
|
184
|
+
const Client = compileClient(apiDefinition);
|
|
185
|
+
const client = new Client(mockHttpClient);
|
|
186
|
+
const sse = await client.events();
|
|
187
|
+
expect(sse).toBeInstanceOf(ServerSentEvents);
|
|
188
|
+
expect(EventSourceMock).toHaveBeenCalledWith(expect.stringContaining('sse/events'), expect.any(Object));
|
|
189
|
+
const stream = await client.stream();
|
|
190
|
+
expect(stream).toBeInstanceOf(Object); // It's an Observable
|
|
191
|
+
expect(EventSourceMock).toHaveBeenCalledWith(expect.stringContaining('sse/stream'), expect.any(Object));
|
|
192
|
+
vi.unstubAllGlobals();
|
|
193
|
+
});
|
|
194
|
+
});
|
package/api/types.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Observable } from 'rxjs';
|
|
2
2
|
import type { Auditor } from '../audit/index.js';
|
|
3
3
|
import type { Token } from '../authentication/index.js';
|
|
4
|
+
import type { HttpHeaders, HttpHeadersObject, HttpRequestAuthorization } from '../http/index.js';
|
|
4
5
|
import type { HttpServerRequest, HttpServerResponse } from '../http/server/index.js';
|
|
5
6
|
import type { HttpMethod } from '../http/types.js';
|
|
6
7
|
import type { SchemaOutput, SchemaTestable } from '../schema/index.js';
|
|
@@ -118,7 +119,38 @@ export type ApiRequestContext<T extends ApiDefinition = ApiDefinition, K extends
|
|
|
118
119
|
getAuditor(): Promise<Auditor>;
|
|
119
120
|
};
|
|
120
121
|
export type ApiEndpointServerImplementation<T extends ApiDefinition = ApiDefinition, K extends ApiEndpointKeys<T> = ApiEndpointKeys<T>> = (context: ApiRequestContext<T, K>) => ApiServerResult<T, K> | Promise<ApiServerResult<T, K>>;
|
|
121
|
-
export type
|
|
122
|
+
export type ApiClientRequestOptions = {
|
|
123
|
+
/**
|
|
124
|
+
* AbortSignal to cancel the request.
|
|
125
|
+
*/
|
|
126
|
+
abortSignal?: AbortSignal;
|
|
127
|
+
/**
|
|
128
|
+
* Request timeout in milliseconds.
|
|
129
|
+
* @default 30000
|
|
130
|
+
*/
|
|
131
|
+
timeout?: number;
|
|
132
|
+
/**
|
|
133
|
+
* Additional headers to send with the request.
|
|
134
|
+
*/
|
|
135
|
+
headers?: HttpHeadersObject | HttpHeaders;
|
|
136
|
+
/**
|
|
137
|
+
* Can be used to store data for middleware etc.
|
|
138
|
+
*/
|
|
139
|
+
context?: Record;
|
|
140
|
+
/**
|
|
141
|
+
* If true, adds a cache-busting parameter to the request URL.
|
|
142
|
+
*/
|
|
143
|
+
bustCache?: boolean;
|
|
144
|
+
/**
|
|
145
|
+
* Authorization for the request.
|
|
146
|
+
*/
|
|
147
|
+
authorization?: HttpRequestAuthorization;
|
|
148
|
+
/**
|
|
149
|
+
* Fetch priority for the request.
|
|
150
|
+
*/
|
|
151
|
+
priority?: RequestPriority;
|
|
152
|
+
};
|
|
153
|
+
export type ApiEndpointClientImplementation<T extends ApiDefinition = ApiDefinition, K extends ApiEndpointKeys<T> = ApiEndpointKeys<T>> = ApiClientBody<T, K> extends never ? ApiParameters<T, K> extends never ? (options?: ApiClientRequestOptions) => Promise<ApiClientResult<T, K>> : (parameters: ApiParameters<T, K>, options?: ApiClientRequestOptions) => Promise<ApiClientResult<T, K>> : (parameters: ApiParameters<T, K> extends never ? undefined | Record<never, never> : ApiParameters<T, K>, body: ApiClientBody<T, K>, options?: ApiClientRequestOptions) => Promise<ApiClientResult<T, K>>;
|
|
122
154
|
export type ApiController<T extends ApiDefinition = any> = {
|
|
123
155
|
[P in ApiEndpointKeys<T>]: ApiEndpointServerImplementation<T, P>;
|
|
124
156
|
};
|
|
@@ -126,7 +158,7 @@ export type ApiClientImplementation<T extends ApiDefinition = any> = {
|
|
|
126
158
|
[P in ApiEndpointKeys<T>]: ApiEndpointClientImplementation<T, P>;
|
|
127
159
|
} & {
|
|
128
160
|
getEndpointResource<E extends ApiEndpointKeys<T>>(endpoint: E, parameters?: ApiParameters<T, E>): string;
|
|
129
|
-
getEndpointUrl<E extends ApiEndpointKeys<T>>(endpoint: E, parameters?: ApiParameters<T, E>):
|
|
161
|
+
getEndpointUrl<E extends ApiEndpointKeys<T>>(endpoint: E, parameters?: ApiParameters<T, E>): URL;
|
|
130
162
|
};
|
|
131
163
|
export declare function defineApi<T extends ApiDefinition>(definition: T): T;
|
|
132
164
|
export declare function resolveApiEndpointDataProvider<T>(request: HttpServerRequest, context: ApiGatewayMiddlewareContext, provider: ApiEndpointDataProvider<T>): Promise<T>;
|
package/api/types.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { objectEntries } from '../utils/object/object.js';
|
|
2
|
-
import {
|
|
2
|
+
import { hyphenate } from '../utils/string/index.js';
|
|
3
|
+
import { isFunction, isUndefined } from '../utils/type-guards.js';
|
|
3
4
|
import { resolveValueOrProvider } from '../utils/value-or-provider.js';
|
|
4
5
|
export function defineApi(definition) {
|
|
5
6
|
return definition;
|
|
@@ -15,5 +16,11 @@ export function normalizedApiDefinitionEndpoints(apiDefinitionEndpoints) {
|
|
|
15
16
|
return Object.fromEntries(entries);
|
|
16
17
|
}
|
|
17
18
|
export function normalizedApiDefinitionEndpointsEntries(apiDefinition) {
|
|
18
|
-
return objectEntries(apiDefinition).map(([key, def]) =>
|
|
19
|
+
return objectEntries(apiDefinition).map(([key, def]) => {
|
|
20
|
+
const endpoint = resolveValueOrProvider(def);
|
|
21
|
+
if (isUndefined(endpoint.resource)) {
|
|
22
|
+
endpoint.resource = hyphenate(key);
|
|
23
|
+
}
|
|
24
|
+
return [key, endpoint];
|
|
25
|
+
});
|
|
19
26
|
}
|
|
@@ -7,7 +7,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
7
7
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
8
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
9
|
};
|
|
10
|
-
import { Subject, filter, firstValueFrom, map, race, timer } from 'rxjs';
|
|
10
|
+
import { Subject, filter, firstValueFrom, map, race, skip, timer } from 'rxjs';
|
|
11
11
|
import { CancellationSignal, CancellationToken } from '../../cancellation/token.js';
|
|
12
12
|
import { BadRequestError } from '../../errors/bad-request.error.js';
|
|
13
13
|
import { ForbiddenError } from '../../errors/forbidden.error.js';
|
|
@@ -36,7 +36,6 @@ const impersonatorAuthenticationDataStorageKey = 'AuthenticationService:imperson
|
|
|
36
36
|
const tokenUpdateBusName = 'AuthenticationService:tokenUpdate';
|
|
37
37
|
const loggedOutBusName = 'AuthenticationService:loggedOut';
|
|
38
38
|
const refreshLockResource = 'AuthenticationService:refresh';
|
|
39
|
-
const refreshBufferSeconds = 15;
|
|
40
39
|
const maxRefreshDelay = 15 * millisecondsPerMinute;
|
|
41
40
|
const lockTimeout = 10000;
|
|
42
41
|
const logoutTimeout = 150;
|
|
@@ -377,8 +376,9 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
377
376
|
await this.syncClock();
|
|
378
377
|
}
|
|
379
378
|
while (this.disposeSignal.isUnset) {
|
|
379
|
+
const iterationToken = this.token();
|
|
380
380
|
try {
|
|
381
|
-
const token =
|
|
381
|
+
const token = iterationToken;
|
|
382
382
|
if (isUndefined(token)) {
|
|
383
383
|
// Wait for login or dispose.
|
|
384
384
|
// We ignore forceRefreshToken here because we can't refresh without a token.
|
|
@@ -387,6 +387,7 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
387
387
|
}
|
|
388
388
|
const now = this.estimatedServerTimestampSeconds();
|
|
389
389
|
const forceRefresh = this.forceRefreshToken.isSet;
|
|
390
|
+
const refreshBufferSeconds = calculateRefreshBufferSeconds(token);
|
|
390
391
|
const needsRefresh = forceRefresh || (now >= (token.exp - refreshBufferSeconds));
|
|
391
392
|
if (needsRefresh) {
|
|
392
393
|
let lockAcquired = false;
|
|
@@ -394,18 +395,24 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
394
395
|
lockAcquired = true;
|
|
395
396
|
const currentToken = this.token();
|
|
396
397
|
const currentNow = this.estimatedServerTimestampSeconds();
|
|
397
|
-
const
|
|
398
|
+
const currentRefreshBufferSeconds = isDefined(currentToken) ? calculateRefreshBufferSeconds(currentToken) : 0;
|
|
399
|
+
// Passive Sync: Check if another tab refreshed the token while we were waiting for the lock (or trying to get it)
|
|
400
|
+
const stillNeedsRefresh = isDefined(currentToken) && (forceRefresh || (currentNow >= (currentToken.exp - currentRefreshBufferSeconds)));
|
|
398
401
|
if (stillNeedsRefresh) {
|
|
399
402
|
await this.refresh();
|
|
403
|
+
if (forceRefresh && (this.token() == currentToken)) {
|
|
404
|
+
this.forceRefreshToken.unset();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
else if (forceRefresh && (this.token() != currentToken)) {
|
|
400
408
|
this.forceRefreshToken.unset();
|
|
401
409
|
}
|
|
402
410
|
});
|
|
403
411
|
if (!lockAcquired) {
|
|
404
|
-
// Lock held by another instance, wait 5 seconds or until
|
|
405
|
-
// We ignore forceRefreshToken here to avoid a busy loop if it is already set.
|
|
412
|
+
// Lock held by another instance, wait 5 seconds or until token changes (Passive Sync)
|
|
406
413
|
const changeReason = await firstValueFrom(race([
|
|
407
414
|
timer(5000).pipe(map(() => 'timer')),
|
|
408
|
-
this.token$.pipe(filter((t) => t
|
|
415
|
+
this.token$.pipe(filter((t) => t != token), map(() => 'token')),
|
|
409
416
|
this.disposeSignal,
|
|
410
417
|
]), { defaultValue: undefined });
|
|
411
418
|
if (changeReason == 'token') {
|
|
@@ -414,7 +421,8 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
414
421
|
continue;
|
|
415
422
|
}
|
|
416
423
|
}
|
|
417
|
-
const
|
|
424
|
+
const currentRefreshBufferSeconds = calculateRefreshBufferSeconds(token);
|
|
425
|
+
const delay = Math.min(maxRefreshDelay, ((this.token()?.exp ?? 0) - this.estimatedServerTimestampSeconds() - currentRefreshBufferSeconds) * millisecondsPerSecond);
|
|
418
426
|
const wakeUpSignals = [
|
|
419
427
|
this.disposeSignal,
|
|
420
428
|
this.token$.pipe(filter((t) => t != token)),
|
|
@@ -431,9 +439,15 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
431
439
|
}
|
|
432
440
|
catch (error) {
|
|
433
441
|
this.logger.error(error);
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
442
|
+
if (this.token() != iterationToken) {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
await firstValueFrom(race([
|
|
446
|
+
timer(2500),
|
|
447
|
+
this.disposeSignal.set$,
|
|
448
|
+
this.token$.pipe(filter((t) => t != iterationToken)),
|
|
449
|
+
this.forceRefreshToken.set$.pipe(skip(this.forceRefreshToken.isSet ? 1 : 0)),
|
|
450
|
+
]), { defaultValue: undefined });
|
|
437
451
|
}
|
|
438
452
|
}
|
|
439
453
|
}
|
|
@@ -516,3 +530,8 @@ AuthenticationClientService = __decorate([
|
|
|
516
530
|
__metadata("design:paramtypes", [])
|
|
517
531
|
], AuthenticationClientService);
|
|
518
532
|
export { AuthenticationClientService };
|
|
533
|
+
function calculateRefreshBufferSeconds(token) {
|
|
534
|
+
const iat = token.iat ?? (token.exp - 3600);
|
|
535
|
+
const lifetime = token.exp - iat;
|
|
536
|
+
return (lifetime * 0.1) + 5;
|
|
537
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { firstValueFrom, timeout } from 'rxjs';
|
|
1
|
+
import { firstValueFrom, race, timeout } from 'rxjs';
|
|
2
2
|
import { HttpError } from '../../http/index.js';
|
|
3
3
|
import { isDefined } from '../../utils/type-guards.js';
|
|
4
4
|
import { cacheValueOrAsyncProvider } from '../../utils/value-or-provider.js';
|
|
@@ -14,8 +14,15 @@ export function waitForAuthenticationCredentialsMiddleware(authenticationService
|
|
|
14
14
|
const endpoint = request.context?.endpoint;
|
|
15
15
|
if ((endpoint?.credentials == true) && (endpoint.data?.[dontWaitForValidToken] != true)) {
|
|
16
16
|
const authenticationService = await getAuthenticationService();
|
|
17
|
-
while (!authenticationService.hasValidToken) {
|
|
18
|
-
|
|
17
|
+
while (!authenticationService.hasValidToken && authenticationService.isLoggedIn()) {
|
|
18
|
+
const race$ = race([
|
|
19
|
+
authenticationService.validToken$,
|
|
20
|
+
request.abortSignal,
|
|
21
|
+
]);
|
|
22
|
+
await firstValueFrom(race$.pipe(timeout(30000))).catch(() => undefined);
|
|
23
|
+
if (request.abortSignal.isSet) {
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
19
26
|
}
|
|
20
27
|
}
|
|
21
28
|
await next();
|
|
@@ -65,6 +65,18 @@ export declare class AuthenticationServiceOptions {
|
|
|
65
65
|
* @default 10 minutes
|
|
66
66
|
*/
|
|
67
67
|
secretResetTokenTimeToLive?: number;
|
|
68
|
+
/**
|
|
69
|
+
* Number of iterations for password hashing.
|
|
70
|
+
*
|
|
71
|
+
* @default 250000
|
|
72
|
+
*/
|
|
73
|
+
hashIterations?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Number of iterations for signing secrets derivation.
|
|
76
|
+
*
|
|
77
|
+
* @default 500000
|
|
78
|
+
*/
|
|
79
|
+
signingSecretsDerivationIterations?: number;
|
|
68
80
|
}
|
|
69
81
|
/**
|
|
70
82
|
* Result of an authentication attempt.
|
|
@@ -63,6 +63,18 @@ export class AuthenticationServiceOptions {
|
|
|
63
63
|
* @default 10 minutes
|
|
64
64
|
*/
|
|
65
65
|
secretResetTokenTimeToLive;
|
|
66
|
+
/**
|
|
67
|
+
* Number of iterations for password hashing.
|
|
68
|
+
*
|
|
69
|
+
* @default 250000
|
|
70
|
+
*/
|
|
71
|
+
hashIterations;
|
|
72
|
+
/**
|
|
73
|
+
* Number of iterations for signing secrets derivation.
|
|
74
|
+
*
|
|
75
|
+
* @default 500000
|
|
76
|
+
*/
|
|
77
|
+
signingSecretsDerivationIterations;
|
|
66
78
|
}
|
|
67
79
|
const HASH_ITERATIONS = 250000;
|
|
68
80
|
const HASH_LENGTH_BITS = 512;
|
|
@@ -747,7 +759,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
|
|
|
747
759
|
const key = await importPbkdf2Key(secret);
|
|
748
760
|
const saltBase64 = await this.#keyValueStore.getOrSet('derivationSalt', encodeBase64(getRandomBytes(SALT_LENGTH)));
|
|
749
761
|
const salt = decodeBase64(saltBase64);
|
|
750
|
-
const algorithm = { name: 'PBKDF2', hash: 'SHA-512', iterations: SIGNING_SECRETS_DERIVATION_ITERATIONS, salt };
|
|
762
|
+
const algorithm = { name: 'PBKDF2', hash: 'SHA-512', iterations: this.#options.signingSecretsDerivationIterations ?? SIGNING_SECRETS_DERIVATION_ITERATIONS, salt };
|
|
751
763
|
const [derivedTokenSigningSecret, derivedRefreshTokenSigningSecret, derivedSecretResetTokenSigningSecret] = await deriveBytesMultiple(algorithm, key, 3, SIGNING_SECRETS_LENGTH);
|
|
752
764
|
this.derivedTokenSigningSecret = derivedTokenSigningSecret;
|
|
753
765
|
this.derivedRefreshTokenSigningSecret = derivedRefreshTokenSigningSecret;
|
|
@@ -755,7 +767,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
|
|
|
755
767
|
}
|
|
756
768
|
async getHash(secret, salt) {
|
|
757
769
|
const key = await importPbkdf2Key(secret);
|
|
758
|
-
const hash = await globalThis.crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-512', iterations: HASH_ITERATIONS, salt }, key, HASH_LENGTH_BITS);
|
|
770
|
+
const hash = await globalThis.crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-512', iterations: this.#options.hashIterations ?? HASH_ITERATIONS, salt }, key, HASH_LENGTH_BITS);
|
|
759
771
|
return new Uint8Array(hash);
|
|
760
772
|
}
|
|
761
773
|
};
|