@fepro/workhub-app-sdk 0.2.0 → 0.4.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/AGENTS.md +122 -5
- package/CHANGELOG.md +52 -0
- package/README.md +92 -3
- package/dist/index.d.ts +180 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +252 -14
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/index.ts +377 -19
package/src/index.ts
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* await workhub.storage.set('lastView', { page: 7 });
|
|
17
17
|
* await workhub.notifications.toast('Saved');
|
|
18
18
|
* workhub.events.on('theme:change', applyTheme);
|
|
19
|
+
* workhub.events.on('app:customer.created', (e) => refresh());
|
|
20
|
+
* const { tokens } = await workhub.theme.current();
|
|
21
|
+
* const locale = await workhub.locale.current();
|
|
19
22
|
*
|
|
20
23
|
* Capability scopes (declared in workhub.json `scopes`) are enforced by
|
|
21
24
|
* the host bridge, not here — the SDK fires the request, the host either
|
|
@@ -40,17 +43,90 @@ export interface StorageEntry {
|
|
|
40
43
|
updatedAt: string;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
/** Portal-level theme tokens — the portal forwards its active theme so apps
|
|
47
|
+
* can repaint instead of guessing colors. Light/dark mode plus the tenant's
|
|
48
|
+
* brand palette and a CSS-variable map ready for `<style>` injection. */
|
|
49
|
+
export interface ThemeTokens {
|
|
50
|
+
mode: 'light' | 'dark';
|
|
51
|
+
/** Hex strings, lowercase, `#rrggbb`. */
|
|
52
|
+
brand: { primary: string; secondary: string };
|
|
53
|
+
/** Map of CSS variable names (without the leading `--`) to values.
|
|
54
|
+
* Apply via:
|
|
55
|
+
* for (const [k, v] of Object.entries(tokens.cssVars))
|
|
56
|
+
* document.documentElement.style.setProperty(`--${k}`, v);
|
|
57
|
+
*/
|
|
58
|
+
cssVars: Record<string, string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LocaleInfo {
|
|
62
|
+
/** BCP 47 tag, e.g. `en-US`, `fr-CA`. */
|
|
63
|
+
tag: string;
|
|
64
|
+
/** Convenience — the language subtag, e.g. `en`. */
|
|
65
|
+
language: string;
|
|
66
|
+
/** IANA timezone, e.g. `America/Toronto`. */
|
|
67
|
+
timezone: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Organization unit (sub-tenant / company / department). Mirrors the backend
|
|
71
|
+
* SDK's `OrgUnit`. Resolves BMS ERP request #22 (frontend half). */
|
|
72
|
+
export interface OrgUnit {
|
|
73
|
+
id: string;
|
|
74
|
+
name: string;
|
|
75
|
+
parentId: string | null;
|
|
76
|
+
/** Materialized path from the root unit, or null. */
|
|
77
|
+
path: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface OrgContext {
|
|
81
|
+
/** The unit the user is currently acting as, or null. */
|
|
82
|
+
active: OrgUnit | null;
|
|
83
|
+
/** All units this user may access (includes the active one). */
|
|
84
|
+
units: OrgUnit[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Events ──────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/** Built-in portal-emitted events. */
|
|
90
|
+
export type WorkhubHostEventName =
|
|
44
91
|
| 'theme:change'
|
|
45
92
|
| 'route:change'
|
|
46
|
-
| 'tenant:switch'
|
|
93
|
+
| 'tenant:switch'
|
|
94
|
+
| 'locale:change'
|
|
95
|
+
| 'org-unit:switch';
|
|
96
|
+
|
|
97
|
+
export interface WorkhubHostEventPayload {
|
|
98
|
+
'theme:change': ThemeTokens;
|
|
99
|
+
'route:change': { path: string };
|
|
100
|
+
'tenant:switch': { tenantId: string };
|
|
101
|
+
'locale:change': LocaleInfo;
|
|
102
|
+
/** The user switched the active org unit in the portal. The platform also
|
|
103
|
+
* re-issues the identity token, so the next `workhub.backend.*` call is
|
|
104
|
+
* authorized for the new unit. Re-fetch unit-scoped data on this event. */
|
|
105
|
+
'org-unit:switch': { unit: OrgUnit };
|
|
106
|
+
}
|
|
47
107
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
108
|
+
/** App-domain events emitted by the workspace backend via `ctx.events.emit`.
|
|
109
|
+
* Subscribe with `workhub.events.on('app:<kind>', cb)` — the prefix
|
|
110
|
+
* separates host events from app events so the channel namespace stays
|
|
111
|
+
* clean as more events graduate to the platform-emitted set.
|
|
112
|
+
*
|
|
113
|
+
* Apps SHOULD declare a global module augmentation to type their event
|
|
114
|
+
* payloads, e.g.:
|
|
115
|
+
*
|
|
116
|
+
* declare module '@fepro/workhub-app-sdk' {
|
|
117
|
+
* interface WorkhubAppEventPayload {
|
|
118
|
+
* 'app:customer.created': { id: string; displayName: string };
|
|
119
|
+
* }
|
|
120
|
+
* }
|
|
121
|
+
*/
|
|
122
|
+
export interface WorkhubAppEventPayload {
|
|
123
|
+
// Intentionally empty — augmented per-app.
|
|
124
|
+
[key: `app:${string}`]: unknown;
|
|
52
125
|
}
|
|
53
126
|
|
|
127
|
+
export type WorkhubEventName = WorkhubHostEventName | `app:${string}`;
|
|
128
|
+
export type WorkhubEventPayload = WorkhubHostEventPayload & WorkhubAppEventPayload;
|
|
129
|
+
|
|
54
130
|
// ─── postMessage envelope ────────────────────────────────────────────────────
|
|
55
131
|
|
|
56
132
|
interface RpcRequest {
|
|
@@ -78,12 +154,33 @@ type IncomingMessage = RpcResponse | RpcEvent;
|
|
|
78
154
|
|
|
79
155
|
const RPC_TIMEOUT_MS = 10_000;
|
|
80
156
|
|
|
157
|
+
/** Typed error rethrown from RPC failures and backend.fetch() error envelopes.
|
|
158
|
+
* `code` matches the backend SDK's `AppError.code`, so apps can branch on
|
|
159
|
+
* string codes rather than http status. */
|
|
160
|
+
export class WorkhubError extends Error {
|
|
161
|
+
public readonly code: string;
|
|
162
|
+
public readonly status: number | undefined;
|
|
163
|
+
public readonly requestId: string | undefined;
|
|
164
|
+
public readonly details: Record<string, unknown> | undefined;
|
|
165
|
+
|
|
166
|
+
constructor(
|
|
167
|
+
code: string,
|
|
168
|
+
message: string,
|
|
169
|
+
extras?: { status?: number; requestId?: string; details?: Record<string, unknown> },
|
|
170
|
+
) {
|
|
171
|
+
super(message);
|
|
172
|
+
this.name = 'WorkhubError';
|
|
173
|
+
this.code = code;
|
|
174
|
+
this.status = extras?.status;
|
|
175
|
+
this.requestId = extras?.requestId;
|
|
176
|
+
this.details = extras?.details;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
81
180
|
class HostChannel {
|
|
82
181
|
private nextId = 1;
|
|
83
182
|
private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
84
|
-
// Listeners: each event name maps to a Set of callbacks.
|
|
85
183
|
private listeners = new Map<string, Set<(payload: unknown) => void>>();
|
|
86
|
-
// Lazy-init flag — we only attach the message listener on first use.
|
|
87
184
|
private wired = false;
|
|
88
185
|
|
|
89
186
|
private wire(): void {
|
|
@@ -101,22 +198,59 @@ class HostChannel {
|
|
|
101
198
|
const pending = this.pending.get(data.id);
|
|
102
199
|
if (!pending) return;
|
|
103
200
|
this.pending.delete(data.id);
|
|
104
|
-
if (data.ok)
|
|
105
|
-
|
|
201
|
+
if (data.ok) {
|
|
202
|
+
pending.resolve(data.result);
|
|
203
|
+
} else {
|
|
204
|
+
pending.reject(
|
|
205
|
+
new WorkhubError(
|
|
206
|
+
data.error?.code ?? 'rpc_failed',
|
|
207
|
+
data.error?.message ?? 'rpc failed',
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
106
211
|
} else if (data.__workhub === 'rpc-event') {
|
|
212
|
+
// Auto-navigate on host-driven route changes (deep-link, refresh
|
|
213
|
+
// restore, browser back/forward). Apps route via the URL hash, so
|
|
214
|
+
// setting location.hash drives their router with no app code change.
|
|
215
|
+
// Guarded to the differing case so it can't loop with the route
|
|
216
|
+
// reporter below.
|
|
217
|
+
if (data.name === 'route:change') {
|
|
218
|
+
const p = (data.payload as { path?: string } | undefined)?.path;
|
|
219
|
+
if (typeof p === 'string') {
|
|
220
|
+
const target = p === '' ? '/' : (p.startsWith('/') ? p : `/${p}`);
|
|
221
|
+
if (window.location.hash.replace(/^#/, '') !== target) {
|
|
222
|
+
window.location.hash = target;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
107
226
|
const set = this.listeners.get(data.name);
|
|
108
227
|
if (!set) return;
|
|
109
228
|
for (const cb of set) {
|
|
110
|
-
try { cb(data.payload); } catch { /* swallow listener errors */ }
|
|
229
|
+
try { cb(data.payload); } catch { /* swallow listener errors so one bad subscriber can't break the others */ }
|
|
111
230
|
}
|
|
112
231
|
}
|
|
113
232
|
});
|
|
233
|
+
|
|
234
|
+
// Mirror the app's route OUT to the host so it can reflect it in the browser
|
|
235
|
+
// URL — making full-page ("popped-out") apps deep-linkable and refresh-safe.
|
|
236
|
+
// Apps route via the URL hash; we report location.hash on every change + once
|
|
237
|
+
// now. The host decides whether to act on it (the full-page surface updates
|
|
238
|
+
// the address bar; the embedded portal view ignores it).
|
|
239
|
+
const reportRoute = (): void => {
|
|
240
|
+
if (window.parent === window) return;
|
|
241
|
+
const path = window.location.hash.replace(/^#/, '') || '/';
|
|
242
|
+
window.parent.postMessage({ __workhub: 'route-report', path }, '*');
|
|
243
|
+
};
|
|
244
|
+
window.addEventListener('hashchange', reportRoute);
|
|
245
|
+
reportRoute();
|
|
114
246
|
}
|
|
115
247
|
|
|
116
248
|
call<T>(verb: string, payload: unknown): Promise<T> {
|
|
117
249
|
this.wire();
|
|
118
250
|
if (typeof window === 'undefined' || window.parent === window) {
|
|
119
|
-
return Promise.reject(
|
|
251
|
+
return Promise.reject(
|
|
252
|
+
new WorkhubError('not_embedded', 'workhub SDK only works inside the portal iframe'),
|
|
253
|
+
);
|
|
120
254
|
}
|
|
121
255
|
const id = String(this.nextId++);
|
|
122
256
|
const msg: RpcRequest = { __workhub: 'rpc-request', id, verb, payload };
|
|
@@ -124,7 +258,7 @@ class HostChannel {
|
|
|
124
258
|
return new Promise<T>((resolve, reject) => {
|
|
125
259
|
const timeout = setTimeout(() => {
|
|
126
260
|
this.pending.delete(id);
|
|
127
|
-
reject(
|
|
261
|
+
reject(new WorkhubError('timeout', `rpc_timeout: ${verb}`));
|
|
128
262
|
}, RPC_TIMEOUT_MS);
|
|
129
263
|
this.pending.set(id, {
|
|
130
264
|
resolve: (v) => { clearTimeout(timeout); resolve(v as T); },
|
|
@@ -143,19 +277,174 @@ class HostChannel {
|
|
|
143
277
|
this.listeners.set(name, set);
|
|
144
278
|
}
|
|
145
279
|
set.add(cb);
|
|
146
|
-
|
|
280
|
+
// Tell the host bridge this iframe wants to receive `name`. The host
|
|
281
|
+
// tracks subscriptions per-iframe so it only forwards events the app
|
|
282
|
+
// is actually listening for, and so it knows which apps need server
|
|
283
|
+
// pushes for `app:*` events (the tenant-event bridge).
|
|
284
|
+
void this.call<void>('events.subscribe', { name }).catch(() => undefined);
|
|
285
|
+
const setRef = set;
|
|
286
|
+
return () => {
|
|
287
|
+
setRef.delete(cb);
|
|
288
|
+
if (setRef.size === 0) {
|
|
289
|
+
this.listeners.delete(name);
|
|
290
|
+
void this.call<void>('events.unsubscribe', { name }).catch(() => undefined);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
147
293
|
}
|
|
148
294
|
}
|
|
149
295
|
|
|
150
296
|
const channel = new HostChannel();
|
|
151
297
|
|
|
298
|
+
/** Tag set on the backend's error JSON payloads — the frontend looks for
|
|
299
|
+
* this so it can rethrow as `WorkhubError` instead of treating a 4xx body
|
|
300
|
+
* as a successful response. Must match `ERROR_ENVELOPE_TAG` in the
|
|
301
|
+
* backend SDK. */
|
|
302
|
+
const ERROR_ENVELOPE_TAG = '__workhub_error';
|
|
303
|
+
|
|
304
|
+
interface BackendErrorEnvelope {
|
|
305
|
+
__workhub_error: true;
|
|
306
|
+
code: string;
|
|
307
|
+
message: string;
|
|
308
|
+
requestId: string;
|
|
309
|
+
details?: Record<string, unknown>;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isBackendErrorEnvelope(value: unknown): value is BackendErrorEnvelope {
|
|
313
|
+
return (
|
|
314
|
+
typeof value === 'object'
|
|
315
|
+
&& value !== null
|
|
316
|
+
&& (value as Record<string, unknown>)[ERROR_ENVELOPE_TAG] === true
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
152
320
|
// ─── Surface ────────────────────────────────────────────────────────────────
|
|
153
321
|
|
|
322
|
+
// Per-session identity cache. Identity is immutable for the lifetime of an
|
|
323
|
+
// iframe (the bundle token is minted once at boot; user / tenant / installation
|
|
324
|
+
// cannot change without a reload), so the first successful response is the
|
|
325
|
+
// only one we'll ever need. Resolves BMS ERP request #31 — the second
|
|
326
|
+
// identity.current() call within a session was racing the host RPC bridge
|
|
327
|
+
// and surfacing `rpc_timeout: identity.current` even when the first call had
|
|
328
|
+
// just succeeded.
|
|
329
|
+
let identityCache: Identity | null = null;
|
|
330
|
+
let identityPending: Promise<Identity> | null = null;
|
|
331
|
+
|
|
154
332
|
export const workhub = {
|
|
155
333
|
/** Identity — read-only user / tenant / installation context. */
|
|
156
334
|
identity: {
|
|
157
335
|
current(): Promise<Identity> {
|
|
158
|
-
|
|
336
|
+
// Fast path — cached value from an earlier successful resolution.
|
|
337
|
+
if (identityCache) return Promise.resolve(identityCache);
|
|
338
|
+
// In-flight de-duplication — concurrent callers during cold start
|
|
339
|
+
// share a single RPC instead of issuing one each. Without this,
|
|
340
|
+
// `Promise.all([identity.current(), identity.current()])` would
|
|
341
|
+
// re-race the host bridge.
|
|
342
|
+
if (identityPending) return identityPending;
|
|
343
|
+
identityPending = channel.call<Identity>('identity.current', {})
|
|
344
|
+
.then((value) => {
|
|
345
|
+
identityCache = value;
|
|
346
|
+
identityPending = null;
|
|
347
|
+
return value;
|
|
348
|
+
})
|
|
349
|
+
.catch((err) => {
|
|
350
|
+
// On failure clear the pending lock so a retry actually retries
|
|
351
|
+
// (callers usually catch + retry on UI events).
|
|
352
|
+
identityPending = null;
|
|
353
|
+
throw err;
|
|
354
|
+
});
|
|
355
|
+
return identityPending;
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
/** Session. `logout()` ends the WorkHub session by navigating the HOST (top)
|
|
360
|
+
* window to the platform sign-out — the only exit for full-page / app-only
|
|
361
|
+
* users who have no portal chrome. Fire-and-forget: the page unloads, so
|
|
362
|
+
* there's no result to await. */
|
|
363
|
+
auth: {
|
|
364
|
+
logout(): void {
|
|
365
|
+
void channel.call('auth.logout', {}).catch(() => { /* host is navigating away */ });
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
/** Locale propagation — read the portal's active locale so the app can
|
|
370
|
+
* match it. Apps SHOULD also subscribe to `locale:change` to update
|
|
371
|
+
* live when the user switches languages. */
|
|
372
|
+
locale: {
|
|
373
|
+
current(): Promise<LocaleInfo> {
|
|
374
|
+
return channel.call<LocaleInfo>('locale.current', {});
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
/** Organization units (sub-tenants / companies). `units()` lists what the
|
|
379
|
+
* user may access plus the active one; pair with `events.on('org-unit:switch')`
|
|
380
|
+
* to keep an in-app company switcher in sync with the portal. The active
|
|
381
|
+
* unit is also a signed claim on the backend (`ctx.org`), so authorization
|
|
382
|
+
* rides the identity token — the frontend value is for display + switching.
|
|
383
|
+
* Resolves BMS ERP request #22 (frontend half). */
|
|
384
|
+
org: {
|
|
385
|
+
/** List accessible units + the active one. */
|
|
386
|
+
units(): Promise<OrgContext> {
|
|
387
|
+
return channel.call<OrgContext>('org.units', {});
|
|
388
|
+
},
|
|
389
|
+
/** Ask the portal to switch the active unit. The portal validates access,
|
|
390
|
+
* re-issues the identity token, and emits `org-unit:switch`. */
|
|
391
|
+
switch(unitId: string): Promise<void> {
|
|
392
|
+
return channel.call<void>('org.switch', { unitId });
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
/** Theme tokens — the portal pushes light/dark mode + the active tenant's
|
|
397
|
+
* brand palette so embedded apps repaint without each maintaining their
|
|
398
|
+
* own theme store. Combine with `events.on('theme:change')` for live
|
|
399
|
+
* updates when the user toggles dark mode. */
|
|
400
|
+
theme: {
|
|
401
|
+
current(): Promise<ThemeTokens> {
|
|
402
|
+
return channel.call<ThemeTokens>('theme.current', {});
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
/** Object / file storage (bytes) — distinct from `storage` (small JSON KV).
|
|
407
|
+
* Backed by the workspace's platform-provisioned bucket; the host bridge
|
|
408
|
+
* mints presigned URLs so the browser uploads/downloads directly without
|
|
409
|
+
* routing bytes through the portal. Persist the returned `key` (e.g. on a
|
|
410
|
+
* row) and resolve it back to a URL with `getUrl(key)` when rendering.
|
|
411
|
+
* Resolves BMS ERP request #25 (frontend half). */
|
|
412
|
+
files: {
|
|
413
|
+
/** Get a presigned PUT URL + the stable key to persist. Upload the file
|
|
414
|
+
* yourself with a plain `fetch(url, { method: 'PUT', body: file })`. */
|
|
415
|
+
presignUpload(
|
|
416
|
+
opts: { filename: string; contentType?: string; keyHint?: string },
|
|
417
|
+
): Promise<{ url: string; key: string; headers?: Record<string, string> }> {
|
|
418
|
+
return channel.call('files.presignUpload', opts);
|
|
419
|
+
},
|
|
420
|
+
/** Convenience: presign + PUT in one call. Returns the stable key. */
|
|
421
|
+
async upload(file: Blob, opts?: { filename?: string; keyHint?: string }): Promise<{ key: string }> {
|
|
422
|
+
const filename = opts?.filename ?? (file as File).name ?? 'upload.bin';
|
|
423
|
+
const contentType = file.type || 'application/octet-stream';
|
|
424
|
+
const presign = await workhub.files.presignUpload({
|
|
425
|
+
filename,
|
|
426
|
+
contentType,
|
|
427
|
+
...(opts?.keyHint ? { keyHint: opts.keyHint } : {}),
|
|
428
|
+
});
|
|
429
|
+
const put = await fetch(presign.url, {
|
|
430
|
+
method: 'PUT',
|
|
431
|
+
headers: { 'content-type': contentType, ...(presign.headers ?? {}) },
|
|
432
|
+
body: file,
|
|
433
|
+
});
|
|
434
|
+
if (!put.ok) {
|
|
435
|
+
throw new WorkhubError('upload_failed', `upload PUT failed: ${put.status} ${put.statusText}`, { status: put.status });
|
|
436
|
+
}
|
|
437
|
+
return { key: presign.key };
|
|
438
|
+
},
|
|
439
|
+
/** Presigned, time-limited GET URL for a stored key. */
|
|
440
|
+
getUrl(key: string, opts?: { expiresInSeconds?: number }): Promise<{ url: string }> {
|
|
441
|
+
return channel.call('files.getUrl', { key, expiresInSeconds: opts?.expiresInSeconds });
|
|
442
|
+
},
|
|
443
|
+
delete(key: string): Promise<void> {
|
|
444
|
+
return channel.call<void>('files.delete', { key });
|
|
445
|
+
},
|
|
446
|
+
list(prefix?: string): Promise<Array<{ key: string; size: number; lastModified: string | null }>> {
|
|
447
|
+
return channel.call('files.list', { prefix });
|
|
159
448
|
},
|
|
160
449
|
},
|
|
161
450
|
|
|
@@ -184,8 +473,17 @@ export const workhub = {
|
|
|
184
473
|
},
|
|
185
474
|
},
|
|
186
475
|
|
|
187
|
-
/** Pub/sub
|
|
188
|
-
*
|
|
476
|
+
/** Pub/sub.
|
|
477
|
+
*
|
|
478
|
+
* Host events (`theme:change`, `route:change`, `tenant:switch`,
|
|
479
|
+
* `locale:change`) come from the portal directly.
|
|
480
|
+
*
|
|
481
|
+
* App events (`app:<kind>`) are emitted by the workspace backend via
|
|
482
|
+
* `ctx.events.emit('customer.created', ...)`; the platform's tenant-event
|
|
483
|
+
* bridge forwards them to subscribed iframes. Replaces the polling shim
|
|
484
|
+
* documented in BMS ERP request #1.
|
|
485
|
+
*
|
|
486
|
+
* Returns an unsubscribe function. */
|
|
189
487
|
events: {
|
|
190
488
|
on: channel.on.bind(channel),
|
|
191
489
|
},
|
|
@@ -215,12 +513,18 @@ export const workhub = {
|
|
|
215
513
|
* and uses same-origin credentialed fetch — no extra auth wiring. */
|
|
216
514
|
async fetch(path: string, init?: RequestInit): Promise<Response> {
|
|
217
515
|
if (!path.startsWith('/')) {
|
|
218
|
-
throw new
|
|
516
|
+
throw new WorkhubError(
|
|
517
|
+
'bad_request',
|
|
518
|
+
`workhub.backend.fetch: path must start with "/" — got "${path}"`,
|
|
519
|
+
);
|
|
219
520
|
}
|
|
220
521
|
const identity = await channel.call<Identity>('identity.current', {});
|
|
221
522
|
const installationId = identity.installation?.id;
|
|
222
523
|
if (!installationId) {
|
|
223
|
-
throw new
|
|
524
|
+
throw new WorkhubError(
|
|
525
|
+
'not_installed',
|
|
526
|
+
'workhub.backend.fetch: no installation id on identity — is this app installed under a workspace?',
|
|
527
|
+
);
|
|
224
528
|
}
|
|
225
529
|
const url = `/v1/apps/runtime/${encodeURIComponent(installationId)}/api${path}`;
|
|
226
530
|
return fetch(url, {
|
|
@@ -228,8 +532,62 @@ export const workhub = {
|
|
|
228
532
|
credentials: 'include',
|
|
229
533
|
});
|
|
230
534
|
},
|
|
535
|
+
|
|
536
|
+
/** Convenience wrapper — JSON in, JSON out, with automatic error
|
|
537
|
+
* envelope detection. A 2xx response is parsed and returned. A 4xx/5xx
|
|
538
|
+
* carrying a `__workhub_error: true` payload is rethrown as
|
|
539
|
+
* `WorkhubError` so apps can `try/catch` by code instead of inspecting
|
|
540
|
+
* the response object. Pass `idempotencyKey` to deduplicate retries on
|
|
541
|
+
* idempotent backend routes. */
|
|
542
|
+
async call<T = unknown>(
|
|
543
|
+
path: string,
|
|
544
|
+
opts: {
|
|
545
|
+
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
546
|
+
body?: unknown;
|
|
547
|
+
headers?: Record<string, string>;
|
|
548
|
+
idempotencyKey?: string;
|
|
549
|
+
} = {},
|
|
550
|
+
): Promise<T> {
|
|
551
|
+
const init: RequestInit = {
|
|
552
|
+
method: opts.method ?? (opts.body !== undefined ? 'POST' : 'GET'),
|
|
553
|
+
headers: {
|
|
554
|
+
accept: 'application/json',
|
|
555
|
+
...(opts.body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
556
|
+
...(opts.idempotencyKey ? { 'idempotency-key': opts.idempotencyKey } : {}),
|
|
557
|
+
...(opts.headers ?? {}),
|
|
558
|
+
},
|
|
559
|
+
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
|
|
560
|
+
};
|
|
561
|
+
const res = await workhub.backend.fetch(path, init);
|
|
562
|
+
const text = await res.text();
|
|
563
|
+
const parsed = text.length > 0 ? safeParseJson(text) : null;
|
|
564
|
+
if (!res.ok) {
|
|
565
|
+
if (isBackendErrorEnvelope(parsed)) {
|
|
566
|
+
throw new WorkhubError(parsed.code, parsed.message, {
|
|
567
|
+
status: res.status,
|
|
568
|
+
requestId: parsed.requestId,
|
|
569
|
+
...(parsed.details ? { details: parsed.details } : {}),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
const requestId = res.headers.get('x-request-id');
|
|
573
|
+
throw new WorkhubError(
|
|
574
|
+
'http_error',
|
|
575
|
+
`backend ${res.status} ${res.statusText}`,
|
|
576
|
+
{
|
|
577
|
+
status: res.status,
|
|
578
|
+
...(requestId ? { requestId } : {}),
|
|
579
|
+
details: { body: typeof parsed === 'string' ? parsed : (parsed as Record<string, unknown> | null) ?? undefined },
|
|
580
|
+
},
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
return parsed as T;
|
|
584
|
+
},
|
|
231
585
|
},
|
|
232
586
|
} as const;
|
|
233
587
|
|
|
588
|
+
function safeParseJson(text: string): unknown {
|
|
589
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
590
|
+
}
|
|
591
|
+
|
|
234
592
|
// Default export for ergonomic single-import usage.
|
|
235
593
|
export default workhub;
|