@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/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
- export type WorkhubEventName =
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
- export interface WorkhubEventPayload {
49
- 'theme:change': { mode: 'light' | 'dark' };
50
- 'route:change': { path: string };
51
- 'tenant:switch': { tenantId: string };
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) pending.resolve(data.result);
105
- else pending.reject(Object.assign(new Error(data.error?.message ?? 'rpc_failed'), { code: data.error?.code ?? 'unknown' }));
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(new Error('not_embedded: workhub SDK only works inside the portal iframe'));
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(Object.assign(new Error(`rpc_timeout: ${verb}`), { code: 'timeout' }));
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
- return () => set!.delete(cb);
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
- return channel.call<Identity>('identity.current', {});
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 for host → app events. The host emits when relevant
188
- * (theme switch, route change, tenant switch). */
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 Error(`workhub.backend.fetch: path must start with "/" — got "${path}"`);
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 Error('workhub.backend.fetch: no installation id on identity — is this app installed under a workspace?');
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;