@work-shape/catalyst-api-client 0.0.0-stage → 0.8.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +221 -0
- package/dist/index.js +403 -0
- package/package.json +40 -3
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Workshape
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ApiErrorCode, Operation, OperationPathParamName, OperationRequestHeader, apiOperations, ApiOperationName } from '@work-shape/catalyst-api-contract';
|
|
3
|
+
export * from '@work-shape/catalyst-api-contract';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The sign-in library's mount. It is not a product operation: the library owns its paths and
|
|
7
|
+
* payloads, and a session cookie is the only credential it accepts.
|
|
8
|
+
*/
|
|
9
|
+
declare const AUTH_MOUNT_PATH = "/api/auth";
|
|
10
|
+
declare const authSessionSchema: z.ZodNullable<z.ZodObject<{
|
|
11
|
+
session: z.ZodObject<{
|
|
12
|
+
id: z.ZodString;
|
|
13
|
+
userId: z.ZodString;
|
|
14
|
+
expiresAt: z.ZodString;
|
|
15
|
+
}, z.core.$strip>;
|
|
16
|
+
user: z.ZodObject<{
|
|
17
|
+
id: z.ZodString;
|
|
18
|
+
email: z.ZodString;
|
|
19
|
+
name: z.ZodString;
|
|
20
|
+
}, z.core.$strip>;
|
|
21
|
+
}, z.core.$strip>>;
|
|
22
|
+
type AuthSession = NonNullable<z.infer<typeof authSessionSchema>>;
|
|
23
|
+
interface AuthResult {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
message?: string;
|
|
26
|
+
}
|
|
27
|
+
declare function getAuthSession(apiBaseUrl: string): Promise<AuthSession | null>;
|
|
28
|
+
declare function signInWithEmail(input: {
|
|
29
|
+
apiBaseUrl: string;
|
|
30
|
+
email: string;
|
|
31
|
+
password: string;
|
|
32
|
+
}): Promise<AuthResult>;
|
|
33
|
+
/**
|
|
34
|
+
* For a tool outside a browser, which keeps no cookies: signs in and returns a fetch that
|
|
35
|
+
* carries the session cookie and the origin the instance trusts on every request.
|
|
36
|
+
*/
|
|
37
|
+
declare function signInForSessionFetch(input: {
|
|
38
|
+
apiBaseUrl: string;
|
|
39
|
+
origin: string;
|
|
40
|
+
email: string;
|
|
41
|
+
password: string;
|
|
42
|
+
fetchImpl?: typeof fetch;
|
|
43
|
+
}): Promise<typeof fetch>;
|
|
44
|
+
declare function signOut(apiBaseUrl: string): Promise<void>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every way a call can fail once its input was accepted. `status` is the HTTP status, or 0 when
|
|
48
|
+
* no response arrived. `payload` is what the server sent with a refusal; when the request never
|
|
49
|
+
* got an answer or the answer could not be read, it is the underlying failure.
|
|
50
|
+
*/
|
|
51
|
+
declare class ApiError extends Error {
|
|
52
|
+
readonly status: number;
|
|
53
|
+
readonly code?: ApiErrorCode;
|
|
54
|
+
readonly payload: unknown;
|
|
55
|
+
readonly correlationId?: string;
|
|
56
|
+
/** How long a caller over a rate limit has to wait, from a 429 `RATE_LIMITED` answer. */
|
|
57
|
+
readonly retryAfterSeconds?: number;
|
|
58
|
+
constructor(status: number, message: string, payload: unknown);
|
|
59
|
+
}
|
|
60
|
+
/** The request got no answer, or the answer broke off. */
|
|
61
|
+
declare function transportError(cause: unknown): ApiError;
|
|
62
|
+
/** The server refused the call. Its body is untrusted and need not be an error envelope. */
|
|
63
|
+
declare function refusalError(response: Response): Promise<ApiError>;
|
|
64
|
+
/** The server answered a success whose body cannot be read as JSON. */
|
|
65
|
+
declare function unreadableResponseError(status: number, cause: unknown): ApiError;
|
|
66
|
+
/**
|
|
67
|
+
* The server answered a success that the operation's schema refuses: usually a client of
|
|
68
|
+
* another release than the server, such as an enum value or an event type this client does not
|
|
69
|
+
* know. `paths` names where the answer differs and never what it held.
|
|
70
|
+
*/
|
|
71
|
+
declare class ApiResponseShapeError extends ApiError {
|
|
72
|
+
/** Dotted paths of the fields the schema refused, each once. */
|
|
73
|
+
readonly paths: readonly string[];
|
|
74
|
+
constructor(status: number, cause: z.ZodError);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ApiClientOptions {
|
|
78
|
+
baseUrl: string;
|
|
79
|
+
/**
|
|
80
|
+
* The bearer credential of every request. Without a token source the client relies on the
|
|
81
|
+
* browser's session cookie instead; with one, cookies are never sent.
|
|
82
|
+
*/
|
|
83
|
+
getToken?: () => string | undefined | Promise<string | undefined>;
|
|
84
|
+
fetchImpl?: typeof fetch;
|
|
85
|
+
/** Only available without a token source; browser navigation cannot send a bearer token. */
|
|
86
|
+
browserManagedDownloads?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Called when the server answers that a method and path is no operation of its catalog:
|
|
89
|
+
* this client was built for another release than the server now runs.
|
|
90
|
+
*/
|
|
91
|
+
onUnknownOperation?: () => void;
|
|
92
|
+
/**
|
|
93
|
+
* Called when a successful answer does not match the schema of its operation, which reads
|
|
94
|
+
* the same way: the server speaks another release's contract than this client.
|
|
95
|
+
*/
|
|
96
|
+
onResponseMismatch?: () => void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type Catalog = typeof apiOperations;
|
|
100
|
+
/** Operations by id, as `apiOperations` holds the release's. */
|
|
101
|
+
type OperationCatalog = Record<string, Operation>;
|
|
102
|
+
/** `{ key: Value }`, with the key optional when an empty value would do. */
|
|
103
|
+
type Part<Key extends string, Value> = Record<never, never> extends Value ? Partial<Record<Key, Value>> : Record<Key, Value>;
|
|
104
|
+
type ParamsPart<Path extends string> = [OperationPathParamName<Path>] extends [never] ? unknown : {
|
|
105
|
+
params: Record<OperationPathParamName<Path>, string>;
|
|
106
|
+
};
|
|
107
|
+
/** The keys a query schema accepts, with the types its values have once they are parsed. */
|
|
108
|
+
type QueryValues<Schema extends z.ZodType> = {
|
|
109
|
+
[Key in keyof z.input<Schema>]: Key extends keyof z.output<Schema> ? z.output<Schema>[Key] : never;
|
|
110
|
+
};
|
|
111
|
+
type QueryPart<Op> = Op extends {
|
|
112
|
+
query: infer Schema extends z.ZodType;
|
|
113
|
+
} ? Part<"query", QueryValues<Schema>> : unknown;
|
|
114
|
+
type BodyPart<Op> = Op extends {
|
|
115
|
+
multipart: true;
|
|
116
|
+
} ? {
|
|
117
|
+
file: File;
|
|
118
|
+
} : Op extends {
|
|
119
|
+
body: infer Schema extends z.ZodType;
|
|
120
|
+
} ? {
|
|
121
|
+
body: z.input<Schema>;
|
|
122
|
+
} : unknown;
|
|
123
|
+
type ControlPart<Op extends Operation> = {
|
|
124
|
+
signal?: AbortSignal;
|
|
125
|
+
} & (Op["response"] extends {
|
|
126
|
+
kind: "sse";
|
|
127
|
+
} ? {
|
|
128
|
+
/** Called when the server has nothing after the given position and sends no stream. */
|
|
129
|
+
onCaughtUp?: () => void;
|
|
130
|
+
} : unknown);
|
|
131
|
+
/**
|
|
132
|
+
* An operation that takes an `Idempotency-Key` gets a new one with every call. A caller that
|
|
133
|
+
* sends the same call again after an answer that never arrived passes the key of the first.
|
|
134
|
+
*/
|
|
135
|
+
type IdempotencyPart<Op> = Op extends {
|
|
136
|
+
headers: {
|
|
137
|
+
request: readonly (infer Header extends OperationRequestHeader)[];
|
|
138
|
+
};
|
|
139
|
+
} ? "Idempotency-Key" extends Header ? {
|
|
140
|
+
idempotencyKey?: string;
|
|
141
|
+
} : unknown : unknown;
|
|
142
|
+
type InputOf<Op extends Operation> = ParamsPart<Op["path"]> & QueryPart<Op> & BodyPart<Op> & IdempotencyPart<Op> & ControlPart<Op>;
|
|
143
|
+
/**
|
|
144
|
+
* How a call of an operation that may wait for an approval came out. A refusal and a failure
|
|
145
|
+
* are thrown as `ApiError`, as for every operation.
|
|
146
|
+
*/
|
|
147
|
+
type OperationOutcome<Output, Run = unknown> = {
|
|
148
|
+
/** The Operation Run that records the call. */
|
|
149
|
+
operationRunId: string | undefined;
|
|
150
|
+
/** The answer is the recorded one of an earlier call with the same `Idempotency-Key`. */
|
|
151
|
+
replayed: boolean;
|
|
152
|
+
} & ({
|
|
153
|
+
status: "done";
|
|
154
|
+
output: Output;
|
|
155
|
+
}
|
|
156
|
+
/** Nothing ran yet: somebody else decides. `location` is where the run is read again. */
|
|
157
|
+
| {
|
|
158
|
+
status: "pending_approval";
|
|
159
|
+
run: Run;
|
|
160
|
+
location: string | undefined;
|
|
161
|
+
});
|
|
162
|
+
/**
|
|
163
|
+
* The output of a call that ran. A call that waits for somebody else's approval changed
|
|
164
|
+
* nothing yet, so it throws with `heldMessage`: for a caller that has no way to show a call
|
|
165
|
+
* that waits.
|
|
166
|
+
*/
|
|
167
|
+
declare function completedOutput<Output>(outcome: OperationOutcome<Output>, heldMessage?: string): Output;
|
|
168
|
+
type OutputOf<Op extends Operation> = Op["response"] extends {
|
|
169
|
+
kind: "sse";
|
|
170
|
+
schema: infer Schema extends z.ZodType;
|
|
171
|
+
} ? AsyncIterable<z.output<Schema>> : Op["response"] extends {
|
|
172
|
+
schema: infer Schema extends z.ZodType;
|
|
173
|
+
} ? Op extends {
|
|
174
|
+
accepted: infer Accepted extends z.ZodType;
|
|
175
|
+
} ? Promise<OperationOutcome<z.output<Schema>, z.output<Accepted>>> : Op extends {
|
|
176
|
+
deferred: true;
|
|
177
|
+
} ? Promise<z.output<Schema> | undefined> : Promise<z.output<Schema>> : Promise<Blob>;
|
|
178
|
+
/** What the method of a catalog operation takes: `params`, `query`, `body` or `file`, `signal`. */
|
|
179
|
+
type OperationInput<Name extends ApiOperationName> = InputOf<Catalog[Name]>;
|
|
180
|
+
type UrlInput<Op extends Operation> = Pick<InputOf<Op>, Extract<keyof InputOf<Op>, "params" | "query">>;
|
|
181
|
+
type OperationMethod<Op extends Operation> = Record<never, never> extends InputOf<Op> ? (input?: InputOf<Op>) => OutputOf<Op> : (input: InputOf<Op>) => OutputOf<Op>;
|
|
182
|
+
type FirstSegment<Id extends string> = Id extends `${infer First}.${string}` ? First : Id;
|
|
183
|
+
type AfterSegment<Id extends string, First extends string> = Id extends `${First}.${infer Rest}` ? Rest : never;
|
|
184
|
+
/** One method per operation, nested by the segments of its id: `conversations.runs.start`. */
|
|
185
|
+
type MethodTree<Operations extends OperationCatalog, Ids extends string, Prefix extends string = ""> = {
|
|
186
|
+
[Segment in FirstSegment<Ids>]: `${Prefix}${Segment}` extends keyof Operations ? OperationMethod<Operations[`${Prefix}${Segment}`]> : MethodTree<Operations, AfterSegment<Ids, Segment>, `${Prefix}${Segment}.`>;
|
|
187
|
+
};
|
|
188
|
+
type OperationMethods<Operations extends OperationCatalog> = MethodTree<Operations, keyof Operations & string>;
|
|
189
|
+
type ApiOperationMethods = OperationMethods<Catalog>;
|
|
190
|
+
/** The client of the given operations: one method each, and what every client has. */
|
|
191
|
+
type ApiClientFor<Operations extends OperationCatalog> = OperationMethods<Operations> & {
|
|
192
|
+
/** Whether the browser itself may fetch a file by URL, which only a session cookie allows. */
|
|
193
|
+
browserManagedDownloads: boolean;
|
|
194
|
+
/** The absolute URL of an operation, for a link or an element that loads it itself. */
|
|
195
|
+
urlFor<Name extends keyof Operations & string>(name: Name, input: UrlInput<Operations[Name]>): string;
|
|
196
|
+
};
|
|
197
|
+
type ApiClient = ApiClientFor<Catalog>;
|
|
198
|
+
/**
|
|
199
|
+
* The first-party client. Its methods are not written down anywhere: each operation of the
|
|
200
|
+
* catalog becomes one method, and every method goes through the same call.
|
|
201
|
+
*/
|
|
202
|
+
declare function createApiClient(options: ApiClientOptions): ApiClient;
|
|
203
|
+
/**
|
|
204
|
+
* The same client for operations that are not the release's own catalog, such as the
|
|
205
|
+
* operations a capability registers on its instance.
|
|
206
|
+
*/
|
|
207
|
+
declare function createApiClientFor<const Operations extends OperationCatalog>(operations: Operations, options: ApiClientOptions): ApiClientFor<Operations>;
|
|
208
|
+
/**
|
|
209
|
+
* Reads a list operation to its end, one largest page after the other:
|
|
210
|
+
* `listAll((paging) => client.users.list({ query: paging }))`. One call of a list method answers
|
|
211
|
+
* a single page, and a screen that shows a whole list must not stop at the first.
|
|
212
|
+
*/
|
|
213
|
+
declare function listAll<Item>(readPage: (paging: {
|
|
214
|
+
limit: number;
|
|
215
|
+
cursor?: string;
|
|
216
|
+
}) => Promise<{
|
|
217
|
+
items: Item[];
|
|
218
|
+
nextCursor?: string;
|
|
219
|
+
}>): Promise<Item[]>;
|
|
220
|
+
|
|
221
|
+
export { AUTH_MOUNT_PATH, type ApiClient, type ApiClientFor, type ApiClientOptions, ApiError, type ApiOperationMethods, ApiResponseShapeError, type AuthResult, type AuthSession, type OperationInput, type OperationOutcome, completedOutput, createApiClient, createApiClientFor, getAuthSession, listAll, refusalError, signInForSessionFetch, signInWithEmail, signOut, transportError, unreadableResponseError };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// src/auth.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var AUTH_MOUNT_PATH = "/api/auth";
|
|
4
|
+
var authSessionSchema = z.object({
|
|
5
|
+
session: z.object({ id: z.string(), userId: z.string(), expiresAt: z.string() }),
|
|
6
|
+
user: z.object({ id: z.string(), email: z.string(), name: z.string() })
|
|
7
|
+
}).nullable();
|
|
8
|
+
var authFailureSchema = z.object({
|
|
9
|
+
message: z.string().optional(),
|
|
10
|
+
error: z.object({ message: z.string().optional() }).optional()
|
|
11
|
+
});
|
|
12
|
+
async function getAuthSession(apiBaseUrl) {
|
|
13
|
+
const response = await fetch(authUrl(apiBaseUrl, "/get-session"), {
|
|
14
|
+
credentials: "include"
|
|
15
|
+
});
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return authSessionSchema.parse(await response.json());
|
|
20
|
+
}
|
|
21
|
+
async function signInWithEmail(input) {
|
|
22
|
+
const response = await fetch(authUrl(input.apiBaseUrl, "/sign-in/email"), {
|
|
23
|
+
method: "POST",
|
|
24
|
+
credentials: "include",
|
|
25
|
+
headers: {
|
|
26
|
+
"content-type": "application/json"
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
email: input.email,
|
|
30
|
+
password: input.password,
|
|
31
|
+
rememberMe: true
|
|
32
|
+
})
|
|
33
|
+
});
|
|
34
|
+
if (response.ok) {
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
37
|
+
const failure = authFailureSchema.safeParse(await response.json().catch(() => void 0));
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
message: failure.data?.message ?? failure.data?.error?.message ?? "Sign in failed"
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function signInForSessionFetch(input) {
|
|
44
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
45
|
+
const response = await fetchImpl(authUrl(input.apiBaseUrl, "/sign-in/email"), {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "content-type": "application/json", origin: input.origin },
|
|
48
|
+
body: JSON.stringify({ email: input.email, password: input.password })
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(`Sign-in as '${input.email}' was refused with status ${response.status}`);
|
|
52
|
+
}
|
|
53
|
+
const cookie = response.headers.getSetCookie().map((value) => value.split(";", 1)[0]).join("; ");
|
|
54
|
+
return (resource, init) => {
|
|
55
|
+
const request = new Request(resource, init);
|
|
56
|
+
request.headers.set("cookie", cookie);
|
|
57
|
+
request.headers.set("origin", input.origin);
|
|
58
|
+
return fetchImpl(request);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async function signOut(apiBaseUrl) {
|
|
62
|
+
await fetch(authUrl(apiBaseUrl, "/sign-out"), {
|
|
63
|
+
method: "POST",
|
|
64
|
+
credentials: "include"
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function authUrl(apiBaseUrl, path) {
|
|
68
|
+
return `${apiBaseUrl.replace(/\/$/u, "")}${AUTH_MOUNT_PATH}${path}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/errors.ts
|
|
72
|
+
import {
|
|
73
|
+
appErrorCodeSchema,
|
|
74
|
+
rateLimitedDetailsSchema
|
|
75
|
+
} from "@work-shape/catalyst-api-contract";
|
|
76
|
+
import { z as z2 } from "zod";
|
|
77
|
+
var ApiError = class extends Error {
|
|
78
|
+
status;
|
|
79
|
+
code;
|
|
80
|
+
payload;
|
|
81
|
+
correlationId;
|
|
82
|
+
/** How long a caller over a rate limit has to wait, from a 429 `RATE_LIMITED` answer. */
|
|
83
|
+
retryAfterSeconds;
|
|
84
|
+
constructor(status, message, payload) {
|
|
85
|
+
super(message);
|
|
86
|
+
this.name = "ApiError";
|
|
87
|
+
this.status = status;
|
|
88
|
+
this.code = readApiErrorCode(payload);
|
|
89
|
+
this.payload = payload;
|
|
90
|
+
this.correlationId = readCorrelationId(payload);
|
|
91
|
+
this.retryAfterSeconds = readRetryAfterSeconds(payload);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
var REQUEST_FAILED = "API request failed";
|
|
95
|
+
function transportError(cause) {
|
|
96
|
+
return new ApiError(0, REQUEST_FAILED, cause);
|
|
97
|
+
}
|
|
98
|
+
async function refusalError(response) {
|
|
99
|
+
const text = await response.text().catch(() => "");
|
|
100
|
+
const payload = parseJsonOrText(text);
|
|
101
|
+
return new ApiError(response.status, readErrorMessage(payload) ?? REQUEST_FAILED, payload);
|
|
102
|
+
}
|
|
103
|
+
function unreadableResponseError(status, cause) {
|
|
104
|
+
return new ApiError(status, "API response is not valid JSON", cause);
|
|
105
|
+
}
|
|
106
|
+
var ApiResponseShapeError = class extends ApiError {
|
|
107
|
+
/** Dotted paths of the fields the schema refused, each once. */
|
|
108
|
+
paths;
|
|
109
|
+
constructor(status, cause) {
|
|
110
|
+
super(status, "API response does not match the contract", cause);
|
|
111
|
+
this.name = "ApiResponseShapeError";
|
|
112
|
+
this.paths = [...new Set(cause.issues.map((issue) => issue.path.join(".") || "(root)"))];
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
function parseJsonOrText(text) {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(text);
|
|
118
|
+
return parsed;
|
|
119
|
+
} catch {
|
|
120
|
+
return text;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function readErrorEnvelope(payload) {
|
|
124
|
+
if (!payload || typeof payload !== "object" || !("error" in payload)) return void 0;
|
|
125
|
+
const error = payload.error;
|
|
126
|
+
return error && typeof error === "object" ? error : void 0;
|
|
127
|
+
}
|
|
128
|
+
function readErrorMessage(payload) {
|
|
129
|
+
const error = readErrorEnvelope(payload);
|
|
130
|
+
return error && "message" in error && typeof error.message === "string" ? error.message : void 0;
|
|
131
|
+
}
|
|
132
|
+
function readApiErrorCode(payload) {
|
|
133
|
+
const error = readErrorEnvelope(payload);
|
|
134
|
+
if (!error || !("code" in error)) return void 0;
|
|
135
|
+
const parsed = appErrorCodeSchema.safeParse(error.code);
|
|
136
|
+
return parsed.success ? parsed.data : void 0;
|
|
137
|
+
}
|
|
138
|
+
function readCorrelationId(payload) {
|
|
139
|
+
const error = readErrorEnvelope(payload);
|
|
140
|
+
return error && "correlationId" in error && typeof error.correlationId === "string" ? error.correlationId : void 0;
|
|
141
|
+
}
|
|
142
|
+
function readRetryAfterSeconds(payload) {
|
|
143
|
+
const parsed = z2.object({ error: z2.object({ details: rateLimitedDetailsSchema }) }).safeParse(payload);
|
|
144
|
+
return parsed.success ? parsed.data.error.details.retryAfterSeconds : void 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/client.ts
|
|
148
|
+
import {
|
|
149
|
+
apiOperations
|
|
150
|
+
} from "@work-shape/catalyst-api-contract";
|
|
151
|
+
|
|
152
|
+
// src/server-sent-events.ts
|
|
153
|
+
async function* readServerSentEvents(body) {
|
|
154
|
+
const reader = body.getReader();
|
|
155
|
+
const decoder = new TextDecoder();
|
|
156
|
+
let buffer = "";
|
|
157
|
+
let finished = false;
|
|
158
|
+
try {
|
|
159
|
+
while (!finished) {
|
|
160
|
+
const { done, value } = await reader.read();
|
|
161
|
+
finished = done;
|
|
162
|
+
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
|
163
|
+
const blocks = buffer.replaceAll("\r\n", "\n").split("\n\n");
|
|
164
|
+
buffer = done ? "" : blocks.pop() ?? "";
|
|
165
|
+
for (const block of blocks) {
|
|
166
|
+
const data = readEventData(block);
|
|
167
|
+
if (data !== void 0) {
|
|
168
|
+
yield data;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
await reader.cancel().catch(() => void 0);
|
|
174
|
+
reader.releaseLock();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function readEventData(block) {
|
|
178
|
+
const data = block.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice("data:".length).replace(/^ /u, "")).join("\n");
|
|
179
|
+
return data === "" ? void 0 : data;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/transport.ts
|
|
183
|
+
import { isUnknownOperationResponse } from "@work-shape/catalyst-api-contract";
|
|
184
|
+
function createApiTransport(options) {
|
|
185
|
+
const baseUrl = options.baseUrl.replace(/\/$/u, "");
|
|
186
|
+
const cookieMode = options.getToken === void 0;
|
|
187
|
+
const urlFor = (operation, request) => `${baseUrl}${operation.buildPath({ params: request.params, query: request.query })}`;
|
|
188
|
+
return {
|
|
189
|
+
browserManagedDownloads: cookieMode && (options.browserManagedDownloads ?? true),
|
|
190
|
+
urlFor,
|
|
191
|
+
reportResponseMismatch: () => options.onResponseMismatch?.(),
|
|
192
|
+
async send(operation, request) {
|
|
193
|
+
const url = urlFor(operation, request);
|
|
194
|
+
const headers = new Headers();
|
|
195
|
+
if (operation.response.kind === "sse") {
|
|
196
|
+
headers.set("accept", "text/event-stream");
|
|
197
|
+
}
|
|
198
|
+
let body;
|
|
199
|
+
if (operation.multipart) {
|
|
200
|
+
if (!request.file) {
|
|
201
|
+
throw new Error(`Operation "${operation.id}" needs a file`);
|
|
202
|
+
}
|
|
203
|
+
body = new FormData();
|
|
204
|
+
body.append("file", request.file);
|
|
205
|
+
} else if (operation.body) {
|
|
206
|
+
headers.set("content-type", "application/json");
|
|
207
|
+
body = JSON.stringify(operation.body.parse(request.body));
|
|
208
|
+
}
|
|
209
|
+
if (operation.headers?.request?.includes("Idempotency-Key")) {
|
|
210
|
+
headers.set("idempotency-key", request.idempotencyKey ?? createIdempotencyKey());
|
|
211
|
+
}
|
|
212
|
+
const token = await options.getToken?.();
|
|
213
|
+
if (token) {
|
|
214
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
215
|
+
}
|
|
216
|
+
let response;
|
|
217
|
+
try {
|
|
218
|
+
response = await (options.fetchImpl ?? fetch)(url, {
|
|
219
|
+
method: operation.method,
|
|
220
|
+
headers,
|
|
221
|
+
body,
|
|
222
|
+
credentials: cookieMode ? "include" : "omit",
|
|
223
|
+
signal: request.signal
|
|
224
|
+
});
|
|
225
|
+
} catch (error) {
|
|
226
|
+
throw request.signal?.aborted ? error : transportError(error);
|
|
227
|
+
}
|
|
228
|
+
if (response.ok) {
|
|
229
|
+
return response;
|
|
230
|
+
}
|
|
231
|
+
const refusal = await refusalError(response);
|
|
232
|
+
if (response.status === 404 && isUnknownOperationResponse(refusal.payload)) {
|
|
233
|
+
options.onUnknownOperation?.();
|
|
234
|
+
}
|
|
235
|
+
throw refusal;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function createIdempotencyKey() {
|
|
240
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
241
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/client.ts
|
|
245
|
+
function completedOutput(outcome, heldMessage = "The call waits for an approval and has changed nothing yet") {
|
|
246
|
+
if (outcome.status === "done") return outcome.output;
|
|
247
|
+
throw new Error(heldMessage);
|
|
248
|
+
}
|
|
249
|
+
function createApiClient(options) {
|
|
250
|
+
return createApiClientFor(apiOperations, options);
|
|
251
|
+
}
|
|
252
|
+
function createApiClientFor(operations, options) {
|
|
253
|
+
const transport = createApiTransport(options);
|
|
254
|
+
const methods = buildMethodTree(
|
|
255
|
+
operations,
|
|
256
|
+
(operation, request) => callOperation(transport, operation, request)
|
|
257
|
+
);
|
|
258
|
+
if (!hasEveryOperation(operations, methods)) {
|
|
259
|
+
throw new Error("The client's methods do not cover the operation catalog");
|
|
260
|
+
}
|
|
261
|
+
return Object.assign(methods, {
|
|
262
|
+
browserManagedDownloads: transport.browserManagedDownloads,
|
|
263
|
+
urlFor: (name, input) => transport.urlFor(operationOf(operations, name), input)
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function operationOf(operations, name) {
|
|
267
|
+
const operation = operations[name];
|
|
268
|
+
if (!operation) throw new Error(`Unknown operation "${name}"`);
|
|
269
|
+
return operation;
|
|
270
|
+
}
|
|
271
|
+
var LARGEST_PAGE = 200;
|
|
272
|
+
async function listAll(readPage) {
|
|
273
|
+
const items = [];
|
|
274
|
+
let cursor;
|
|
275
|
+
do {
|
|
276
|
+
const page = await readPage({ limit: LARGEST_PAGE, ...cursor ? { cursor } : {} });
|
|
277
|
+
items.push(...page.items);
|
|
278
|
+
cursor = page.nextCursor;
|
|
279
|
+
} while (cursor);
|
|
280
|
+
return items;
|
|
281
|
+
}
|
|
282
|
+
function buildMethodTree(operations, call) {
|
|
283
|
+
const root = {};
|
|
284
|
+
for (const operation of Object.values(operations)) {
|
|
285
|
+
const segments = operation.id.split(".");
|
|
286
|
+
const name = segments.pop() ?? "";
|
|
287
|
+
let node = root;
|
|
288
|
+
for (const segment of segments) {
|
|
289
|
+
const next = node[segment] ??= {};
|
|
290
|
+
if (typeof next === "function") {
|
|
291
|
+
throw new Error(`Operation id "${operation.id}" continues the id of another operation`);
|
|
292
|
+
}
|
|
293
|
+
node = next;
|
|
294
|
+
}
|
|
295
|
+
if (node[name]) {
|
|
296
|
+
throw new Error(`Operation id "${operation.id}" is the start of another operation's id`);
|
|
297
|
+
}
|
|
298
|
+
node[name] = (request = {}) => call(operation, request);
|
|
299
|
+
}
|
|
300
|
+
return root;
|
|
301
|
+
}
|
|
302
|
+
function hasEveryOperation(operations, tree) {
|
|
303
|
+
return Object.keys(operations).every((id) => {
|
|
304
|
+
let node = tree;
|
|
305
|
+
for (const segment of id.split(".")) {
|
|
306
|
+
node = typeof node === "object" ? node[segment] : void 0;
|
|
307
|
+
}
|
|
308
|
+
return typeof node === "function";
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
function callOperation(transport, operation, request) {
|
|
312
|
+
const { response } = operation;
|
|
313
|
+
if (response.kind === "sse") {
|
|
314
|
+
return streamEvents(transport, operation, response.schema, request);
|
|
315
|
+
}
|
|
316
|
+
if (response.kind === "blob") {
|
|
317
|
+
return transport.send(operation, request).then((answer) => readBody(() => answer.blob(), request));
|
|
318
|
+
}
|
|
319
|
+
const { accepted } = operation;
|
|
320
|
+
return transport.send(operation, request).then(async (answer) => {
|
|
321
|
+
if (operation.deferred && answer.status === 202) {
|
|
322
|
+
return void 0;
|
|
323
|
+
}
|
|
324
|
+
const text = await readBody(() => answer.text(), request);
|
|
325
|
+
if (!accepted) {
|
|
326
|
+
return parseJson(transport, text, response.schema, answer.status);
|
|
327
|
+
}
|
|
328
|
+
const outcome = {
|
|
329
|
+
operationRunId: answer.headers.get("operation-run-id") ?? void 0,
|
|
330
|
+
replayed: answer.headers.get("idempotent-replayed") === "true"
|
|
331
|
+
};
|
|
332
|
+
return answer.status === 202 ? {
|
|
333
|
+
...outcome,
|
|
334
|
+
status: "pending_approval",
|
|
335
|
+
run: parseJson(transport, text, accepted, answer.status),
|
|
336
|
+
location: answer.headers.get("location") ?? void 0
|
|
337
|
+
} : {
|
|
338
|
+
...outcome,
|
|
339
|
+
status: "done",
|
|
340
|
+
output: parseJson(transport, text, response.schema, answer.status)
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
async function* streamEvents(transport, operation, schema, request) {
|
|
345
|
+
const answer = await transport.send(operation, request);
|
|
346
|
+
if (answer.status === 204) {
|
|
347
|
+
request.onCaughtUp?.();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (!answer.body) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
for await (const data of readServerSentEvents(answer.body)) {
|
|
355
|
+
yield parseJson(transport, data, schema, answer.status);
|
|
356
|
+
}
|
|
357
|
+
} catch (error) {
|
|
358
|
+
throw asCallFailure(error, request);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function readBody(read, request) {
|
|
362
|
+
try {
|
|
363
|
+
return await read();
|
|
364
|
+
} catch (error) {
|
|
365
|
+
throw asCallFailure(error, request);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function asCallFailure(error, request) {
|
|
369
|
+
return error instanceof ApiError || request.signal?.aborted ? error : transportError(error);
|
|
370
|
+
}
|
|
371
|
+
function parseJson(transport, text, schema, status) {
|
|
372
|
+
let value;
|
|
373
|
+
try {
|
|
374
|
+
value = JSON.parse(text);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
throw unreadableResponseError(status, error);
|
|
377
|
+
}
|
|
378
|
+
const parsed = schema.safeParse(value);
|
|
379
|
+
if (!parsed.success) {
|
|
380
|
+
transport.reportResponseMismatch();
|
|
381
|
+
throw new ApiResponseShapeError(status, parsed.error);
|
|
382
|
+
}
|
|
383
|
+
return parsed.data;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/index.ts
|
|
387
|
+
export * from "@work-shape/catalyst-api-contract";
|
|
388
|
+
export {
|
|
389
|
+
AUTH_MOUNT_PATH,
|
|
390
|
+
ApiError,
|
|
391
|
+
ApiResponseShapeError,
|
|
392
|
+
completedOutput,
|
|
393
|
+
createApiClient,
|
|
394
|
+
createApiClientFor,
|
|
395
|
+
getAuthSession,
|
|
396
|
+
listAll,
|
|
397
|
+
refusalError,
|
|
398
|
+
signInForSessionFetch,
|
|
399
|
+
signInWithEmail,
|
|
400
|
+
signOut,
|
|
401
|
+
transportError,
|
|
402
|
+
unreadableResponseError
|
|
403
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@work-shape/catalyst-api-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.8.0-rc.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/work-shape/catalyst",
|
|
8
|
+
"directory": "packages/api-client"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"registry": "https://registry.npmjs.org/"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"zod": "4.4.3",
|
|
32
|
+
"@work-shape/catalyst-api-contract": "0.8.0-rc.1"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^24.13.1",
|
|
36
|
+
"tsup": "^8.5.1",
|
|
37
|
+
"typescript": "^6.0.3"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsup src/index.ts --format esm --dts",
|
|
41
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
42
|
+
}
|
|
6
43
|
}
|
package/README.md
DELETED