@bikefinder/api-client 0.0.0-stage → 0.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/README.md +109 -2
- package/dist/errors.d.ts +4 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +7 -0
- package/dist/errors.js.map +1 -0
- package/dist/generated/client/client.gen.d.ts +3 -0
- package/dist/generated/client/client.gen.d.ts.map +1 -0
- package/dist/generated/client/client.gen.js +218 -0
- package/dist/generated/client/client.gen.js.map +1 -0
- package/dist/generated/client/index.d.ts +11 -0
- package/dist/generated/client/index.d.ts.map +1 -0
- package/dist/generated/client/index.js +7 -0
- package/dist/generated/client/index.js.map +1 -0
- package/dist/generated/client/types.gen.d.ts +121 -0
- package/dist/generated/client/types.gen.d.ts.map +1 -0
- package/dist/generated/client/types.gen.js +3 -0
- package/dist/generated/client/types.gen.js.map +1 -0
- package/dist/generated/client/utils.gen.d.ts +38 -0
- package/dist/generated/client/utils.gen.d.ts.map +1 -0
- package/dist/generated/client/utils.gen.js +232 -0
- package/dist/generated/client/utils.gen.js.map +1 -0
- package/dist/generated/client.gen.d.ts +13 -0
- package/dist/generated/client.gen.d.ts.map +1 -0
- package/dist/generated/client.gen.js +4 -0
- package/dist/generated/client.gen.js.map +1 -0
- package/dist/generated/core/auth.gen.d.ts +26 -0
- package/dist/generated/core/auth.gen.d.ts.map +1 -0
- package/dist/generated/core/auth.gen.js +15 -0
- package/dist/generated/core/auth.gen.js.map +1 -0
- package/dist/generated/core/bodySerializer.gen.d.ts +26 -0
- package/dist/generated/core/bodySerializer.gen.d.ts.map +1 -0
- package/dist/generated/core/bodySerializer.gen.js +58 -0
- package/dist/generated/core/bodySerializer.gen.js.map +1 -0
- package/dist/generated/core/params.gen.d.ts +44 -0
- package/dist/generated/core/params.gen.d.ts.map +1 -0
- package/dist/generated/core/params.gen.js +113 -0
- package/dist/generated/core/params.gen.js.map +1 -0
- package/dist/generated/core/pathSerializer.gen.d.ts +34 -0
- package/dist/generated/core/pathSerializer.gen.d.ts.map +1 -0
- package/dist/generated/core/pathSerializer.gen.js +115 -0
- package/dist/generated/core/pathSerializer.gen.js.map +1 -0
- package/dist/generated/core/queryKeySerializer.gen.d.ts +19 -0
- package/dist/generated/core/queryKeySerializer.gen.d.ts.map +1 -0
- package/dist/generated/core/queryKeySerializer.gen.js +100 -0
- package/dist/generated/core/queryKeySerializer.gen.js.map +1 -0
- package/dist/generated/core/serverSentEvents.gen.d.ts +72 -0
- package/dist/generated/core/serverSentEvents.gen.d.ts.map +1 -0
- package/dist/generated/core/serverSentEvents.gen.js +137 -0
- package/dist/generated/core/serverSentEvents.gen.js.map +1 -0
- package/dist/generated/core/types.gen.d.ts +84 -0
- package/dist/generated/core/types.gen.d.ts.map +1 -0
- package/dist/generated/core/types.gen.js +3 -0
- package/dist/generated/core/types.gen.js.map +1 -0
- package/dist/generated/core/utils.gen.d.ts +20 -0
- package/dist/generated/core/utils.gen.d.ts.map +1 -0
- package/dist/generated/core/utils.gen.js +88 -0
- package/dist/generated/core/utils.gen.js.map +1 -0
- package/dist/generated/index.d.ts +3 -0
- package/dist/generated/index.d.ts.map +1 -0
- package/dist/generated/index.js +3 -0
- package/dist/generated/index.js.map +1 -0
- package/dist/generated/sdk.gen.d.ts +51 -0
- package/dist/generated/sdk.gen.d.ts.map +1 -0
- package/dist/generated/sdk.gen.js +73 -0
- package/dist/generated/sdk.gen.js.map +1 -0
- package/dist/generated/types.gen.d.ts +152 -0
- package/dist/generated/types.gen.d.ts.map +1 -0
- package/dist/generated/types.gen.js +3 -0
- package/dist/generated/types.gen.js.map +1 -0
- package/dist/generated/zod.gen.d.ts +102 -0
- package/dist/generated/zod.gen.d.ts.map +1 -0
- package/dist/generated/zod.gen.js +73 -0
- package/dist/generated/zod.gen.js.map +1 -0
- package/dist/http-client.d.ts +3 -0
- package/dist/http-client.d.ts.map +1 -0
- package/dist/http-client.js +29 -0
- package/dist/http-client.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/options.d.ts +13 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +40 -0
- package/dist/options.js.map +1 -0
- package/dist/realtime/api-stream-error.d.ts +8 -0
- package/dist/realtime/api-stream-error.d.ts.map +1 -0
- package/dist/realtime/api-stream-error.js +16 -0
- package/dist/realtime/api-stream-error.js.map +1 -0
- package/dist/realtime/watch-trackers.d.ts +10 -0
- package/dist/realtime/watch-trackers.d.ts.map +1 -0
- package/dist/realtime/watch-trackers.js +93 -0
- package/dist/realtime/watch-trackers.js.map +1 -0
- package/dist/transport.d.ts +4 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +84 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -4
- package/src/errors.ts +6 -0
- package/src/generated/client/client.gen.ts +300 -0
- package/src/generated/client/index.ts +27 -0
- package/src/generated/client/types.gen.ts +238 -0
- package/src/generated/client/utils.gen.ts +337 -0
- package/src/generated/client.gen.ts +24 -0
- package/src/generated/core/auth.gen.ts +49 -0
- package/src/generated/core/bodySerializer.gen.ts +96 -0
- package/src/generated/core/params.gen.ts +188 -0
- package/src/generated/core/pathSerializer.gen.ts +186 -0
- package/src/generated/core/queryKeySerializer.gen.ts +134 -0
- package/src/generated/core/serverSentEvents.gen.ts +265 -0
- package/src/generated/core/types.gen.ts +126 -0
- package/src/generated/core/utils.gen.ts +146 -0
- package/src/generated/index.ts +29 -0
- package/src/generated/sdk.gen.ts +165 -0
- package/src/generated/types.gen.ts +179 -0
- package/src/generated/zod.gen.ts +85 -0
- package/src/http-client.ts +31 -0
- package/src/index.ts +22 -0
- package/src/options.ts +61 -0
- package/src/realtime/api-stream-error.ts +20 -0
- package/src/realtime/watch-trackers.ts +114 -0
- package/src/transport.ts +99 -0
- package/src/types.ts +5 -0
package/src/options.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { AuthenticationError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
export interface ApiClientOptions {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
getToken: (forceRefresh?: boolean) => Promise<string>;
|
|
6
|
+
fetch?: typeof globalThis.fetch;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ResolvedClientOptions {
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
getToken: ApiClientOptions["getToken"];
|
|
12
|
+
fetch: typeof globalThis.fetch;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveClientOptions(
|
|
16
|
+
options: ApiClientOptions,
|
|
17
|
+
): ResolvedClientOptions {
|
|
18
|
+
let url: URL;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(options.baseUrl);
|
|
21
|
+
} catch {
|
|
22
|
+
throw new TypeError("baseUrl must be an absolute HTTP or HTTPS URL.");
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
!["http:", "https:"].includes(url.protocol) ||
|
|
26
|
+
url.username ||
|
|
27
|
+
url.password ||
|
|
28
|
+
url.search ||
|
|
29
|
+
url.hash
|
|
30
|
+
) {
|
|
31
|
+
throw new TypeError(
|
|
32
|
+
"baseUrl must be HTTP or HTTPS without credentials, query or fragment.",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
37
|
+
if (!fetch) {
|
|
38
|
+
throw new TypeError("Supply a Fetch API implementation for this runtime.");
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
baseUrl: url.href.replace(/\/+$/, ""),
|
|
42
|
+
getToken: options.getToken,
|
|
43
|
+
fetch,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function getAuthorization(
|
|
48
|
+
options: Pick<ApiClientOptions, "getToken">,
|
|
49
|
+
forceRefresh = false,
|
|
50
|
+
): Promise<string> {
|
|
51
|
+
try {
|
|
52
|
+
const token = await options.getToken(forceRefresh);
|
|
53
|
+
if (typeof token !== "string" || !token || /[^\x21-\x7e]/.test(token)) {
|
|
54
|
+
throw new AuthenticationError();
|
|
55
|
+
}
|
|
56
|
+
return `Bearer ${token}`;
|
|
57
|
+
} catch {
|
|
58
|
+
// Identity-provider errors can contain credentials. Expose no original cause.
|
|
59
|
+
throw new AuthenticationError();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type ApiStreamErrorCode = "http" | "protocol" | "network";
|
|
2
|
+
|
|
3
|
+
/** A safe stream error. Response bodies, bearer tokens and transport errors are omitted. */
|
|
4
|
+
export class ApiStreamError extends Error {
|
|
5
|
+
readonly code: ApiStreamErrorCode;
|
|
6
|
+
readonly status: number | undefined;
|
|
7
|
+
|
|
8
|
+
constructor(code: ApiStreamErrorCode, status?: number) {
|
|
9
|
+
super(
|
|
10
|
+
code === "http"
|
|
11
|
+
? `The tracker stream returned HTTP ${status}.`
|
|
12
|
+
: code === "protocol"
|
|
13
|
+
? "The tracker stream returned an invalid response."
|
|
14
|
+
: "The tracker stream connection was interrupted.",
|
|
15
|
+
);
|
|
16
|
+
this.name = "ApiStreamError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.status = status;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defer,
|
|
3
|
+
filter,
|
|
4
|
+
merge,
|
|
5
|
+
Observable,
|
|
6
|
+
ReplaySubject,
|
|
7
|
+
retry,
|
|
8
|
+
share,
|
|
9
|
+
Subject,
|
|
10
|
+
timer,
|
|
11
|
+
} from "rxjs";
|
|
12
|
+
import { ZodError } from "zod";
|
|
13
|
+
|
|
14
|
+
import { AuthenticationError } from "../errors.js";
|
|
15
|
+
import type { BikeFinderSdk } from "../generated/sdk.gen.js";
|
|
16
|
+
import { zTrackerPage } from "../generated/zod.gen.js";
|
|
17
|
+
import type { TrackerPage, TrackerPageQuery } from "../types.js";
|
|
18
|
+
import { ApiStreamError } from "./api-stream-error.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Observe complete snapshots of one tracker page, shared by this observable's consumers.
|
|
22
|
+
* Unsubscribe when leaving the view or changing accounts. Re-subscribe after an error
|
|
23
|
+
* to start a fresh retry budget. Reconnects never request historical event replay.
|
|
24
|
+
*/
|
|
25
|
+
export function watchTrackers(
|
|
26
|
+
requests: BikeFinderSdk,
|
|
27
|
+
query: TrackerPageQuery = {},
|
|
28
|
+
): Observable<TrackerPage> {
|
|
29
|
+
return defer(() => {
|
|
30
|
+
const disconnected = new Subject<undefined>();
|
|
31
|
+
const snapshots = new Observable<TrackerPage>((subscriber) => {
|
|
32
|
+
disconnected.next(undefined);
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
|
|
35
|
+
const consume = async () => {
|
|
36
|
+
try {
|
|
37
|
+
const { stream } = await requests.subscribeTrackers({
|
|
38
|
+
query,
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
headers: { Accept: "text/event-stream" },
|
|
41
|
+
// Each RxJS retry starts a new generated stream with fresh credentials.
|
|
42
|
+
// The generated transport must not reconnect with an old token itself.
|
|
43
|
+
sseMaxRetryAttempts: 1,
|
|
44
|
+
onSseError: (error) => {
|
|
45
|
+
throw error;
|
|
46
|
+
},
|
|
47
|
+
onSseEvent: ({ event, data }) => {
|
|
48
|
+
if (subscriber.closed || event === "keepalive") return;
|
|
49
|
+
// A comment-only frame is a transport heartbeat, not an observation.
|
|
50
|
+
if (event === undefined && data === undefined) return;
|
|
51
|
+
if (event !== "snapshot") throw new ApiStreamError("protocol");
|
|
52
|
+
// The generator permits non-JSON SSE data; validate every snapshot
|
|
53
|
+
// with its generated schema, including malformed JSON strings.
|
|
54
|
+
const result = zTrackerPage.safeParse(data);
|
|
55
|
+
if (!result.success) throw new ApiStreamError("protocol");
|
|
56
|
+
subscriber.next(result.data);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
// Event callbacks retain SSE event names; consuming drives the generated
|
|
60
|
+
// async iterator. There is no handwritten URL, query serializer or parser.
|
|
61
|
+
for await (const _ of stream) {
|
|
62
|
+
if (subscriber.closed) break;
|
|
63
|
+
}
|
|
64
|
+
if (!subscriber.closed) throw new ApiStreamError("network");
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (subscriber.closed) return;
|
|
67
|
+
disconnected.next(undefined);
|
|
68
|
+
subscriber.error(
|
|
69
|
+
error instanceof AuthenticationError ||
|
|
70
|
+
error instanceof ApiStreamError
|
|
71
|
+
? error
|
|
72
|
+
: new ApiStreamError(
|
|
73
|
+
error instanceof ZodError ? "protocol" : "network",
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
void consume();
|
|
79
|
+
return () => controller.abort();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return merge(
|
|
83
|
+
disconnected,
|
|
84
|
+
snapshots.pipe(
|
|
85
|
+
retry({
|
|
86
|
+
count: 8,
|
|
87
|
+
resetOnSuccess: true,
|
|
88
|
+
delay: (error: unknown, attempt) => {
|
|
89
|
+
const retryable =
|
|
90
|
+
error instanceof ApiStreamError &&
|
|
91
|
+
(error.code === "network" ||
|
|
92
|
+
(error.code === "http" &&
|
|
93
|
+
(error.status === 408 ||
|
|
94
|
+
error.status === 429 ||
|
|
95
|
+
(error.status ?? 0) >= 500)));
|
|
96
|
+
if (!retryable) throw error;
|
|
97
|
+
const maximum = Math.min(30_000, 1_000 * 2 ** (attempt - 1));
|
|
98
|
+
return timer(maximum * (0.5 + Math.random() * 0.5));
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}).pipe(
|
|
104
|
+
share({
|
|
105
|
+
connector: () => new ReplaySubject<TrackerPage | undefined>(1),
|
|
106
|
+
resetOnError: true,
|
|
107
|
+
resetOnComplete: true,
|
|
108
|
+
resetOnRefCountZero: true,
|
|
109
|
+
}),
|
|
110
|
+
// An internal disconnect marker replaces the cached page during backoff, so a
|
|
111
|
+
// new subscriber never receives an old snapshot as though it were current.
|
|
112
|
+
filter((page): page is TrackerPage => page !== undefined),
|
|
113
|
+
);
|
|
114
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { AuthenticationError } from "./errors.js";
|
|
2
|
+
import { getAuthorization, type ResolvedClientOptions } from "./options.js";
|
|
3
|
+
import { ApiStreamError } from "./realtime/api-stream-error.js";
|
|
4
|
+
|
|
5
|
+
/** Shared bearer authentication and transport policy for generated REST and SSE calls. */
|
|
6
|
+
export function createAuthenticatedFetch(
|
|
7
|
+
options: ResolvedClientOptions,
|
|
8
|
+
): typeof globalThis.fetch {
|
|
9
|
+
const origin = new URL(options.baseUrl).origin;
|
|
10
|
+
return async (input, init) => {
|
|
11
|
+
const request = new Request(input, {
|
|
12
|
+
...init,
|
|
13
|
+
redirect: "error",
|
|
14
|
+
credentials: "omit",
|
|
15
|
+
});
|
|
16
|
+
const streaming =
|
|
17
|
+
request.headers
|
|
18
|
+
.get("Accept")
|
|
19
|
+
?.split(",")
|
|
20
|
+
.some(
|
|
21
|
+
(value) =>
|
|
22
|
+
value.split(";")[0]?.trim().toLowerCase() === "text/event-stream",
|
|
23
|
+
) ?? false;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
throwIfAborted(request.signal);
|
|
27
|
+
// Generated clients allow per-call base URLs; credentials belong only to
|
|
28
|
+
// the API origin configured on the factory.
|
|
29
|
+
if (new URL(request.url).origin !== origin) {
|
|
30
|
+
throw new TypeError(
|
|
31
|
+
"Authenticated requests must target the configured API origin.",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
request.headers.set("Authorization", await getAuthorization(options));
|
|
35
|
+
throwIfAborted(request.signal);
|
|
36
|
+
const retry = request.clone();
|
|
37
|
+
// RN's Request polyfill drops redirect. Expo fetch accepts that Request,
|
|
38
|
+
// but needs the redirect policy explicitly in its second argument.
|
|
39
|
+
let response = await options.fetch(request, {
|
|
40
|
+
redirect: "error",
|
|
41
|
+
credentials: "omit",
|
|
42
|
+
signal: request.signal,
|
|
43
|
+
});
|
|
44
|
+
if (response.status === 401) {
|
|
45
|
+
throwIfAborted(request.signal);
|
|
46
|
+
await response.body?.cancel().catch(() => {});
|
|
47
|
+
retry.headers.set(
|
|
48
|
+
"Authorization",
|
|
49
|
+
await getAuthorization(options, true),
|
|
50
|
+
);
|
|
51
|
+
throwIfAborted(retry.signal);
|
|
52
|
+
response = await options.fetch(retry, {
|
|
53
|
+
redirect: "error",
|
|
54
|
+
credentials: "omit",
|
|
55
|
+
signal: retry.signal,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (!streaming) return response;
|
|
59
|
+
|
|
60
|
+
if (response.status !== 200) {
|
|
61
|
+
await response.body?.cancel().catch(() => {});
|
|
62
|
+
if (response.status === 401) throw new AuthenticationError();
|
|
63
|
+
throw new ApiStreamError("http", response.status);
|
|
64
|
+
}
|
|
65
|
+
const contentType = response.headers
|
|
66
|
+
.get("Content-Type")
|
|
67
|
+
?.split(";")[0]
|
|
68
|
+
?.trim()
|
|
69
|
+
.toLowerCase();
|
|
70
|
+
if (contentType !== "text/event-stream" || !response.body) {
|
|
71
|
+
await response.body?.cancel().catch(() => {});
|
|
72
|
+
throw new ApiStreamError("protocol");
|
|
73
|
+
}
|
|
74
|
+
return response;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (
|
|
77
|
+
!streaming ||
|
|
78
|
+
error instanceof AuthenticationError ||
|
|
79
|
+
error instanceof ApiStreamError
|
|
80
|
+
) {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
throwIfAborted(request.signal);
|
|
84
|
+
throw new ApiStreamError("network");
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
90
|
+
// Expo's React Native AbortSignal supports aborted, but not throwIfAborted().
|
|
91
|
+
if (signal?.aborted) {
|
|
92
|
+
throw (
|
|
93
|
+
signal.reason ??
|
|
94
|
+
Object.assign(new Error("The request was cancelled."), {
|
|
95
|
+
name: "AbortError",
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SubscribeTrackersData } from "./generated/types.gen.js";
|
|
2
|
+
|
|
3
|
+
export type * from "./generated/types.gen.js";
|
|
4
|
+
export type { ProblemDetails as ApiProblem } from "./generated/types.gen.js";
|
|
5
|
+
export type TrackerPageQuery = NonNullable<SubscribeTrackersData["query"]>;
|