@coworkee/client 0.0.0-stage → 0.0.1024

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 CHANGED
@@ -1,3 +1,21 @@
1
- # Temporary Holding Version
1
+ # @coworkee/client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Typed TypeScript client for the API of apps built on [Coworkee](https://fgilde.github.io/CoworkeeLib/): types generated from the OpenAPI description of the library endpoints, `createCoworkeeClient()` (openapi-fetch), `apiRequest`, `ApiError` with problem details, the realtime hub (`createRealtime()`, SignalR), streamed answers (`postNdjson`), and helpers for the BFF session, theme, texts and OData.
4
+
5
+ ```bash
6
+ npm install @coworkee/client
7
+ ```
8
+
9
+ ```ts
10
+ import { createCoworkeeClient, createRealtime, unwrap } from '@coworkee/client';
11
+
12
+ const api = createCoworkeeClient(); // in a page served by the app's BFF: session cookie + CSRF header
13
+ const me = unwrap(await api.GET('/api/v1/identity/me'));
14
+ const stop = createRealtime().subscribe(`user:${userId}`, ({ type, payload }) => console.log(type, payload));
15
+ ```
16
+
17
+ - Every Coworkee release publishes it in the version of the release (`a.b.c.d` becomes `a.b.(c*1000+d)`, npm knows no fourth part).
18
+ - `npm run openapi -- <url of /openapi/v1.json> [more documents]` refreshes `openapi.json`, `npm run generate` the types.
19
+ - Documentation: [SDKs](https://fgilde.github.io/CoworkeeLib/web/sdk/)
20
+
21
+ License: MIT
package/dist/bff.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { type CoworkeeClientOptions } from './client.ts';
2
+ /** The signed-in user as the BFF of a Coworkee app reports it (GET /bff/user). */
3
+ export type BffUser = {
4
+ isAuthenticated: boolean;
5
+ name?: string | null;
6
+ email?: string | null;
7
+ userId?: string | null;
8
+ tenantId?: string | null;
9
+ roles: string[];
10
+ manageUrl?: string | null;
11
+ systemTenant?: boolean | null;
12
+ };
13
+ export declare const getUser: (options?: CoworkeeClientOptions) => Promise<BffUser>;
14
+ /** The address that starts the sign-in at the auth server and comes back to returnUrl (a local path). */
15
+ export declare function loginUrl(returnUrl?: string, loginHint?: string): string;
16
+ /** Ends the session and returns where the browser goes next (the end-session page of the auth server). */
17
+ export declare function logout(options?: CoworkeeClientOptions): Promise<string>;
package/dist/bff.js ADDED
@@ -0,0 +1,14 @@
1
+ import { apiRequest } from "./client.js";
2
+ export const getUser = (options) => apiRequest('/bff/user', {}, options);
3
+ /** The address that starts the sign-in at the auth server and comes back to returnUrl (a local path). */
4
+ export function loginUrl(returnUrl = '/', loginHint) {
5
+ const query = new URLSearchParams({ returnUrl: returnUrl.startsWith('/') && !returnUrl.startsWith('//') ? returnUrl : '/' });
6
+ if (loginHint)
7
+ query.set('loginHint', loginHint);
8
+ return `/bff/login?${query}`;
9
+ }
10
+ /** Ends the session and returns where the browser goes next (the end-session page of the auth server). */
11
+ export async function logout(options) {
12
+ const result = await apiRequest('/bff/logout', { method: 'POST' }, options);
13
+ return result?.redirect || '/';
14
+ }
@@ -0,0 +1,22 @@
1
+ import { type Client } from 'openapi-fetch';
2
+ import type { paths } from './schema.ts';
3
+ export type CoworkeeClient = Client<paths>;
4
+ export type CoworkeeClientOptions = {
5
+ /** Address of the app (its BFF) or the API; empty for the page's own origin. */
6
+ baseUrl?: string;
7
+ /** A bearer token of the API (an API key or an access token); without one the browser's session cookie and the CSRF header of the BFF are used. */
8
+ token?: () => string | Promise<string>;
9
+ fetch?: typeof fetch;
10
+ };
11
+ /** Header the BFF demands on every unsafe request, so other sites cannot send them with the session cookie. */
12
+ export declare const csrfHeader: {
13
+ readonly name: "X-CSRF";
14
+ readonly value: "1";
15
+ };
16
+ /** Typed client over the OpenAPI document of the library: paths, parameters, bodies and answers are checked by the compiler. */
17
+ export declare function createCoworkeeClient(options?: CoworkeeClientOptions): CoworkeeClient;
18
+ /**
19
+ * Untyped request for any endpoint, the app's own included: JSON in and out, text for other answers, nothing for 204.
20
+ * Bodies that are strings go as JSON, others (Blob, File, FormData) as they are. Failures throw an ApiError.
21
+ */
22
+ export declare function apiRequest<T = unknown>(path: string, init?: RequestInit, options?: CoworkeeClientOptions): Promise<T>;
package/dist/client.js ADDED
@@ -0,0 +1,37 @@
1
+ import createClient, {} from 'openapi-fetch';
2
+ import { ApiError, problemOf } from "./errors.js";
3
+ /** Header the BFF demands on every unsafe request, so other sites cannot send them with the session cookie. */
4
+ export const csrfHeader = { name: 'X-CSRF', value: '1' };
5
+ async function authorize(headers, options) {
6
+ if (options.token)
7
+ headers.set('Authorization', `Bearer ${await options.token()}`);
8
+ else
9
+ headers.set(csrfHeader.name, csrfHeader.value);
10
+ }
11
+ /** Typed client over the OpenAPI document of the library: paths, parameters, bodies and answers are checked by the compiler. */
12
+ export function createCoworkeeClient(options = {}) {
13
+ const client = createClient({ baseUrl: options.baseUrl ?? '', fetch: options.fetch, credentials: 'same-origin' });
14
+ client.use({
15
+ async onRequest({ request }) {
16
+ await authorize(request.headers, options);
17
+ return request;
18
+ },
19
+ });
20
+ return client;
21
+ }
22
+ /**
23
+ * Untyped request for any endpoint, the app's own included: JSON in and out, text for other answers, nothing for 204.
24
+ * Bodies that are strings go as JSON, others (Blob, File, FormData) as they are. Failures throw an ApiError.
25
+ */
26
+ export async function apiRequest(path, init = {}, options = {}) {
27
+ const headers = new Headers(init.headers);
28
+ await authorize(headers, options);
29
+ if (typeof init.body === 'string' && !headers.has('Content-Type'))
30
+ headers.set('Content-Type', 'application/json');
31
+ const response = await (options.fetch ?? fetch)(`${options.baseUrl ?? ''}${path}`, { credentials: 'same-origin', ...init, headers });
32
+ if (!response.ok)
33
+ throw new ApiError(response.status, await problemOf(response));
34
+ if (response.status === 204)
35
+ return undefined;
36
+ return ((response.headers.get('content-type') ?? '').includes('json') ? await response.json() : await response.text());
37
+ }
@@ -0,0 +1,25 @@
1
+ /** The problem details a Coworkee API answers with: title, the error code of the result and validation messages per field. */
2
+ export type Problem = {
3
+ title?: string;
4
+ detail?: string;
5
+ status?: number;
6
+ code?: string;
7
+ errors?: Record<string, string[]>;
8
+ };
9
+ /** A failed API call. */
10
+ export declare class ApiError extends Error {
11
+ readonly status: number;
12
+ readonly problem?: Problem;
13
+ constructor(status: number, problem?: Problem);
14
+ get code(): string | undefined;
15
+ }
16
+ /** One line for the user: the validation messages, else the detail or title, else the status. */
17
+ export declare function problemMessage(status: number, problem?: Problem): string;
18
+ /** Reads the problem of a failed response; bodies that are no JSON give none. */
19
+ export declare function problemOf(response: Response): Promise<Problem | undefined>;
20
+ /** The data of an openapi-fetch result, or an ApiError. */
21
+ export declare function unwrap<T>(result: {
22
+ data?: T;
23
+ error?: unknown;
24
+ response: Response;
25
+ }): T;
package/dist/errors.js ADDED
@@ -0,0 +1,35 @@
1
+ /** A failed API call. */
2
+ export class ApiError extends Error {
3
+ status;
4
+ problem;
5
+ constructor(status, problem) {
6
+ super(problemMessage(status, problem));
7
+ this.name = 'ApiError';
8
+ this.status = status;
9
+ this.problem = problem;
10
+ }
11
+ get code() { return this.problem?.code; }
12
+ }
13
+ /** One line for the user: the validation messages, else the detail or title, else the status. */
14
+ export function problemMessage(status, problem) {
15
+ const validation = Object.values(problem?.errors ?? {}).flat().filter(Boolean);
16
+ if (validation.length > 0)
17
+ return validation.join(' ');
18
+ return problem?.detail || problem?.title || `The API answered ${status}.`;
19
+ }
20
+ /** Reads the problem of a failed response; bodies that are no JSON give none. */
21
+ export async function problemOf(response) {
22
+ try {
23
+ const text = await response.text();
24
+ return text ? JSON.parse(text) : undefined;
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ /** The data of an openapi-fetch result, or an ApiError. */
31
+ export function unwrap(result) {
32
+ if (result.response.ok)
33
+ return result.data;
34
+ throw new ApiError(result.response.status, typeof result.error === 'object' && result.error ? result.error : undefined);
35
+ }
@@ -0,0 +1,11 @@
1
+ import type { components } from './schema.ts';
2
+ export type { paths, components, operations } from './schema.ts';
3
+ export type Schemas = components['schemas'];
4
+ export { apiRequest, createCoworkeeClient, csrfHeader, type CoworkeeClient, type CoworkeeClientOptions } from './client.ts';
5
+ export { ApiError, problemMessage, problemOf, unwrap, type Problem } from './errors.ts';
6
+ export { getUser, loginUrl, logout, type BffUser } from './bff.ts';
7
+ export { applyTheme, cssVariables, getCurrentTheme, logoUrl, palette, resolveMode, type ColorMode, type Theme } from './theme.ts';
8
+ export { format, getTexts, pickCulture, translator, type Texts, type Translate } from './texts.ts';
9
+ export { createRealtime, realtimeEvents, realtimeTopics, type EntityChanged, type Realtime, type RealtimeEnvelope, type RealtimeHandler } from './realtime.ts';
10
+ export { lineSplitter, postNdjson } from './stream.ts';
11
+ export { entitySets, literal, odataPage, odataQuery, searchFilter, type EntitySetInfo, type ODataPage, type ODataQuery } from './odata.ts';
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { apiRequest, createCoworkeeClient, csrfHeader } from "./client.js";
2
+ export { ApiError, problemMessage, problemOf, unwrap } from "./errors.js";
3
+ export { getUser, loginUrl, logout } from "./bff.js";
4
+ export { applyTheme, cssVariables, getCurrentTheme, logoUrl, palette, resolveMode } from "./theme.js";
5
+ export { format, getTexts, pickCulture, translator } from "./texts.js";
6
+ export { createRealtime, realtimeEvents, realtimeTopics } from "./realtime.js";
7
+ export { lineSplitter, postNdjson } from "./stream.js";
8
+ export { entitySets, literal, odataPage, odataQuery, searchFilter } from "./odata.js";
@@ -0,0 +1,26 @@
1
+ import { type CoworkeeClientOptions } from './client.ts';
2
+ export type ODataQuery = {
3
+ filter?: string;
4
+ orderBy?: string;
5
+ top?: number;
6
+ skip?: number;
7
+ select?: readonly string[];
8
+ expand?: string;
9
+ count?: boolean;
10
+ };
11
+ export type ODataPage<T> = {
12
+ items: T[];
13
+ count?: number;
14
+ };
15
+ export type EntitySetInfo = {
16
+ name: string;
17
+ url: string;
18
+ };
19
+ export declare function odataQuery(query: ODataQuery): string;
20
+ /** An OData string literal: single quotes doubled. */
21
+ export declare const literal: (text: string) => string;
22
+ /** Rows whose text fields contain the words, case-insensitive: every word in one of the fields. */
23
+ export declare function searchFilter(fields: readonly string[], text: string): string | undefined;
24
+ export declare function odataPage<T = Record<string, unknown>>(entitySet: string, query?: ODataQuery, options?: CoworkeeClientOptions): Promise<ODataPage<T>>;
25
+ /** The entity sets the OData service document lists. */
26
+ export declare function entitySets(options?: CoworkeeClientOptions): Promise<EntitySetInfo[]>;
package/dist/odata.js ADDED
@@ -0,0 +1,27 @@
1
+ import { apiRequest } from "./client.js";
2
+ export function odataQuery(query) {
3
+ const parts = [
4
+ ['$filter', query.filter], ['$orderby', query.orderBy], ['$select', query.select?.join(',')], ['$expand', query.expand],
5
+ ['$top', query.top?.toString()], ['$skip', query.skip ? query.skip.toString() : undefined], ['$count', query.count ? 'true' : undefined],
6
+ ];
7
+ const text = parts.filter(([, value]) => value).map(([name, value]) => `${name}=${encodeURIComponent(value)}`).join('&');
8
+ return text ? `?${text}` : '';
9
+ }
10
+ /** An OData string literal: single quotes doubled. */
11
+ export const literal = (text) => `'${text.replace(/'/g, "''")}'`;
12
+ /** Rows whose text fields contain the words, case-insensitive: every word in one of the fields. */
13
+ export function searchFilter(fields, text) {
14
+ const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean);
15
+ if (fields.length === 0 || words.length === 0)
16
+ return undefined;
17
+ return words.map((word) => `(${fields.map((field) => `contains(tolower(${field}),${literal(word)})`).join(' or ')})`).join(' and ');
18
+ }
19
+ export async function odataPage(entitySet, query = {}, options) {
20
+ const page = await apiRequest(`/odata/${encodeURIComponent(entitySet)}${odataQuery(query)}`, {}, options);
21
+ return { items: page?.value ?? [], count: page?.['@odata.count'] };
22
+ }
23
+ /** The entity sets the OData service document lists. */
24
+ export async function entitySets(options) {
25
+ const document = await apiRequest('/odata', {}, options);
26
+ return (document?.value ?? []).filter((item) => (item.kind ?? 'EntitySet') === 'EntitySet').map(({ name, url }) => ({ name, url }));
27
+ }
@@ -0,0 +1,43 @@
1
+ import { type HubConnection } from '@microsoft/signalr';
2
+ /** What the realtime hub of a Coworkee app sends: an event of a type on a topic (user:{id}, tenant:presence, type:{Entity}, ...). */
3
+ export type RealtimeEnvelope<T = unknown> = {
4
+ topic: string;
5
+ type: string;
6
+ payload: T;
7
+ };
8
+ export type EntityChanged = {
9
+ entityType: string;
10
+ entityId: string;
11
+ action: 'Created' | 'Updated' | 'Deleted';
12
+ changedProperties: string[];
13
+ };
14
+ export type RealtimeHandler = (envelope: RealtimeEnvelope) => void;
15
+ export type Realtime = {
16
+ /** Follows a topic; the returned function stops it. Topics the server refuses are left out silently. */
17
+ subscribe: (topic: string, handler: RealtimeHandler) => () => void;
18
+ stop: () => Promise<void>;
19
+ };
20
+ export declare const realtimeTopics: {
21
+ readonly hub: "/hubs/realtime";
22
+ readonly user: (userId: string) => string;
23
+ readonly type: (entityType: string) => string;
24
+ readonly entity: (entityType: string, id: string) => string;
25
+ readonly presence: "tenant:presence";
26
+ readonly tenantPermissions: "tenant:permissions";
27
+ readonly globalPermissions: "global:permissions";
28
+ };
29
+ export declare const realtimeEvents: {
30
+ readonly entityChanged: "EntityChanged";
31
+ readonly sessionRevoked: "SessionRevoked";
32
+ readonly presenceChanged: "presence.changed";
33
+ readonly chatMessage: "chat.message";
34
+ readonly chatRead: "chat.read";
35
+ };
36
+ /**
37
+ * The realtime hub of the app (SignalR through the BFF, which adds the token). Connects on the first subscription and subscribes every
38
+ * followed topic again after a reconnect.
39
+ */
40
+ export declare function createRealtime(options?: {
41
+ baseUrl?: string;
42
+ connection?: HubConnection;
43
+ }): Realtime;
@@ -0,0 +1,64 @@
1
+ import { HubConnectionBuilder, HubConnectionState } from '@microsoft/signalr';
2
+ export const realtimeTopics = {
3
+ hub: '/hubs/realtime',
4
+ user: (userId) => `user:${userId}`,
5
+ type: (entityType) => `type:${entityType}`,
6
+ entity: (entityType, id) => `entity:${entityType}:${id}`,
7
+ presence: 'tenant:presence',
8
+ tenantPermissions: 'tenant:permissions',
9
+ globalPermissions: 'global:permissions',
10
+ };
11
+ export const realtimeEvents = { entityChanged: 'EntityChanged', sessionRevoked: 'SessionRevoked', presenceChanged: 'presence.changed', chatMessage: 'chat.message', chatRead: 'chat.read' };
12
+ /** Every retry after 0, 2, 10, then 30 seconds, forever - like the Blazor client. */
13
+ const delays = [0, 2000, 10000, 30000];
14
+ /**
15
+ * The realtime hub of the app (SignalR through the BFF, which adds the token). Connects on the first subscription and subscribes every
16
+ * followed topic again after a reconnect.
17
+ */
18
+ export function createRealtime(options = {}) {
19
+ const handlers = new Map();
20
+ let connection = options.connection;
21
+ let started;
22
+ let wired = false;
23
+ const connect = () => {
24
+ connection ??= new HubConnectionBuilder()
25
+ .withUrl(`${options.baseUrl ?? ''}${realtimeTopics.hub}`, { withCredentials: true })
26
+ .withAutomaticReconnect({ nextRetryDelayInMilliseconds: (context) => delays[Math.min(context.previousRetryCount, delays.length - 1)] })
27
+ .build();
28
+ if (!wired) {
29
+ wired = true;
30
+ connection.on('OnEvent', (envelope) => handlers.get(envelope.topic)?.forEach((handler) => handler(envelope)));
31
+ connection.onreconnected(() => { for (const topic of handlers.keys())
32
+ void invoke('Subscribe', topic); });
33
+ }
34
+ started = connection.state === HubConnectionState.Disconnected
35
+ ? connection.start().then(() => true, () => { started = undefined; return false; })
36
+ : started ?? Promise.resolve(true);
37
+ return started;
38
+ };
39
+ const invoke = async (method, topic) => {
40
+ try {
41
+ await connection?.invoke(method, topic);
42
+ }
43
+ catch { /* not allowed or not connected: the topic stays quiet */ }
44
+ };
45
+ return {
46
+ subscribe(topic, handler) {
47
+ let set = handlers.get(topic);
48
+ const first = !set;
49
+ if (!set)
50
+ handlers.set(topic, set = new Set());
51
+ set.add(handler);
52
+ if (first)
53
+ void connect().then((ok) => ok && handlers.has(topic) ? invoke('Subscribe', topic) : undefined);
54
+ return () => {
55
+ if (!set.delete(handler) || set.size > 0)
56
+ return;
57
+ handlers.delete(topic);
58
+ if (connection?.state === HubConnectionState.Connected)
59
+ void invoke('Unsubscribe', topic);
60
+ };
61
+ },
62
+ stop: async () => { handlers.clear(); started = undefined; await connection?.stop(); },
63
+ };
64
+ }