@comms-id/website-logo 0.0.0-stage → 0.3.2

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.
Files changed (47) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +43 -2
  3. package/dist/browser.d.ts +5 -0
  4. package/dist/browser.js +6 -0
  5. package/dist/controller.d.ts +13 -0
  6. package/dist/controller.js +9 -0
  7. package/dist/descriptor.d.ts +11 -0
  8. package/dist/descriptor.js +15 -0
  9. package/dist/fixtures.d.ts +5 -0
  10. package/dist/fixtures.js +10 -0
  11. package/dist/generated/client.d.ts +8 -0
  12. package/dist/generated/client.js +6 -0
  13. package/dist/generated/fixtures.d.ts +2 -0
  14. package/dist/generated/fixtures.js +27 -0
  15. package/dist/generated/operations.d.ts +6 -0
  16. package/dist/generated/operations.js +214 -0
  17. package/dist/generated/types.d.ts +29 -0
  18. package/dist/generated/types.js +2 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +4 -0
  21. package/dist/local/profile.d.ts +12 -0
  22. package/dist/local/profile.js +77 -0
  23. package/dist/runtime/browser-core.d.ts +23 -0
  24. package/dist/runtime/browser-core.js +41 -0
  25. package/dist/runtime/environment.d.ts +5 -0
  26. package/dist/runtime/environment.js +30 -0
  27. package/dist/runtime/errors.d.ts +29 -0
  28. package/dist/runtime/errors.js +40 -0
  29. package/dist/runtime/fixture-core.d.ts +7 -0
  30. package/dist/runtime/fixture-core.js +26 -0
  31. package/dist/runtime/http.d.ts +34 -0
  32. package/dist/runtime/http.js +172 -0
  33. package/dist/runtime/image-controller.d.ts +7 -0
  34. package/dist/runtime/image-controller.js +184 -0
  35. package/dist/runtime/image-types.d.ts +112 -0
  36. package/dist/runtime/image-types.js +8 -0
  37. package/dist/runtime/public-core.d.ts +12 -0
  38. package/dist/runtime/public-core.js +16 -0
  39. package/dist/runtime/server-core.d.ts +26 -0
  40. package/dist/runtime/server-core.js +39 -0
  41. package/dist/runtime/sign.d.ts +24 -0
  42. package/dist/runtime/sign.js +43 -0
  43. package/dist/runtime/validate.d.ts +57 -0
  44. package/dist/runtime/validate.js +175 -0
  45. package/dist/server.d.ts +9 -0
  46. package/dist/server.js +8 -0
  47. package/package.json +63 -3
@@ -0,0 +1,30 @@
1
+ // Runtime template: copied into every generated client package as src/runtime/environment.ts.
2
+ // The two environments of the hosted API (platform #515, T4). They differ in the base URL and in
3
+ // the keys the service knows: TEST answers with synthetic replies for apps registered in the
4
+ // development directory; LIVE answers with real data for apps registered in production. The API,
5
+ // the errors and the authentication are the same, so going live changes only the environment and
6
+ // the key.
7
+ import { CommsIdError } from "./errors.js";
8
+ export const BASE_URLS = {
9
+ live: "https://api.comms.id",
10
+ test: "https://api-test.comms.id",
11
+ };
12
+ export const isEnvironment = (value) => value === "live" || value === "test";
13
+ /** The base URL: an explicit one wins, then the environment (live by default). */
14
+ export const resolveBaseUrl = (environment, baseUrl) => {
15
+ if (baseUrl !== undefined) {
16
+ return baseUrl;
17
+ }
18
+ if (environment === undefined) {
19
+ return BASE_URLS.live;
20
+ }
21
+ if (!isEnvironment(environment)) {
22
+ throw new CommsIdError({
23
+ code: "NOT_CONFIGURED",
24
+ message: 'environment must be "test" or "live".',
25
+ status: 0,
26
+ retryable: false,
27
+ });
28
+ }
29
+ return BASE_URLS[environment];
30
+ };
@@ -0,0 +1,29 @@
1
+ /** The three results a lookup can have besides a match. */
2
+ export type Outcome = "no-match" | "source-unavailable" | "failed";
3
+ export interface ErrorInit {
4
+ /** The reply's own body, when the service answered an error status with a structured reply instead of an error body. */
5
+ readonly body?: unknown;
6
+ readonly code: string;
7
+ readonly message: string;
8
+ readonly requestId?: string | undefined;
9
+ readonly retryable: boolean;
10
+ /** The HTTP status, or 0 when no reply was received or the call never left the client. */
11
+ readonly status: number;
12
+ }
13
+ /**
14
+ * Every failure of a call. The service sends `code`, `message`, `retryable` and `requestId`.
15
+ * The client adds the codes INVALID_REQUEST (refused before any request), NETWORK, TIMEOUT,
16
+ * ABORTED and INVALID_RESPONSE.
17
+ */
18
+ export declare class CommsIdError extends Error {
19
+ readonly code: string;
20
+ readonly status: number;
21
+ readonly retryable: boolean;
22
+ readonly requestId: string | undefined;
23
+ /** The structured reply of a status such as 503 whose body is an outcome, not an error body (code HTTP_<status>). */
24
+ readonly body: unknown;
25
+ constructor(init: ErrorInit);
26
+ }
27
+ export declare const isCommsIdError: (value: unknown) => value is CommsIdError;
28
+ /** Maps an error to the outcome that the reply convention names. A match never reaches this. */
29
+ export declare const outcomeOf: (error: unknown) => Outcome;
@@ -0,0 +1,40 @@
1
+ // Runtime template: copied into every generated client package as src/runtime/errors.ts.
2
+ // It has no imports, so the copy is identical to this file.
3
+ /**
4
+ * Every failure of a call. The service sends `code`, `message`, `retryable` and `requestId`.
5
+ * The client adds the codes INVALID_REQUEST (refused before any request), NETWORK, TIMEOUT,
6
+ * ABORTED and INVALID_RESPONSE.
7
+ */
8
+ export class CommsIdError extends Error {
9
+ code;
10
+ status;
11
+ retryable;
12
+ requestId;
13
+ /** The structured reply of a status such as 503 whose body is an outcome, not an error body (code HTTP_<status>). */
14
+ body;
15
+ constructor(init) {
16
+ super(init.message);
17
+ this.name = "CommsIdError";
18
+ this.code = init.code;
19
+ this.status = init.status;
20
+ this.retryable = init.retryable;
21
+ this.requestId = init.requestId;
22
+ this.body = init.body;
23
+ }
24
+ }
25
+ export const isCommsIdError = (value) => value instanceof CommsIdError;
26
+ /** HTTP statuses that say the source or the path to it is down: 502, 503 and 504. The client's own failures have status 0. */
27
+ const GATEWAY_STATUSES = new Set([502, 503, 504]);
28
+ /** Maps an error to the outcome that the reply convention names. A match never reaches this. */
29
+ export const outcomeOf = (error) => {
30
+ if (!isCommsIdError(error)) {
31
+ return "failed";
32
+ }
33
+ if (error.code === "NOT_FOUND") {
34
+ return "no-match";
35
+ }
36
+ if (error.code === "UNAVAILABLE" || GATEWAY_STATUSES.has(error.status)) {
37
+ return "source-unavailable";
38
+ }
39
+ return "failed";
40
+ };
@@ -0,0 +1,7 @@
1
+ import type { TransportConfig } from "./http.js";
2
+ /** Every fixture reply carries this header, and every fixture client exposes it. */
3
+ export declare const FIXTURE_HEADER = "x-comms-id-fixture";
4
+ export declare const FIXTURE_NOTICE = "LOCAL FIXTURE: fake data, no network call was made.";
5
+ /** A fetch that answers each operation path with its canned reply. */
6
+ export declare const createFixtureFetch: (replies: Readonly<Record<string, unknown>>) => typeof fetch;
7
+ export declare const createFixtureTransport: (replies: Readonly<Record<string, unknown>>) => TransportConfig;
@@ -0,0 +1,26 @@
1
+ // Runtime template: copied into every generated client package as src/runtime/fixture-core.ts.
2
+ // LOCAL FIXTURES: no network. The client runs the same request and reply checks as a real call.
3
+ /** Every fixture reply carries this header, and every fixture client exposes it. */
4
+ export const FIXTURE_HEADER = "x-comms-id-fixture";
5
+ export const FIXTURE_NOTICE = "LOCAL FIXTURE: fake data, no network call was made.";
6
+ /** A fetch that answers each operation path with its canned reply. */
7
+ export const createFixtureFetch = (replies) => (input) => {
8
+ const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url, "http://fixture.invalid");
9
+ const reply = replies[url.pathname];
10
+ if (reply === undefined) {
11
+ return Promise.resolve(new Response(JSON.stringify({ code: "NOT_FOUND", message: "No fixture for this operation.", retryable: false }), {
12
+ status: 404,
13
+ headers: { "content-type": "application/json", [FIXTURE_HEADER]: FIXTURE_NOTICE },
14
+ }));
15
+ }
16
+ return Promise.resolve(new Response(JSON.stringify(reply), {
17
+ status: 200,
18
+ headers: { "content-type": "application/json", [FIXTURE_HEADER]: FIXTURE_NOTICE },
19
+ }));
20
+ };
21
+ export const createFixtureTransport = (replies) => ({
22
+ urlFor: (operation) => operation.path,
23
+ headers: () => Promise.resolve({}),
24
+ fetch: createFixtureFetch(replies),
25
+ maxRetries: 0,
26
+ });
@@ -0,0 +1,34 @@
1
+ import { type Schema } from "./validate.js";
2
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
3
+ /** A hand-written check that a schema cannot state. It returns the reason to refuse, or undefined. */
4
+ export type Guard = (input: unknown) => string | undefined;
5
+ /** One hosted operation, as the generator writes it from the OpenAPI document. */
6
+ export interface Operation {
7
+ /** Run after the schema check and before any request (for example an ABN check digit). */
8
+ readonly guard?: Guard | undefined;
9
+ readonly method: HttpMethod;
10
+ readonly name: string;
11
+ readonly path: string;
12
+ readonly request: Schema | undefined;
13
+ readonly response: Schema;
14
+ /** From x-comms-id-safe-to-retry: only such an operation is ever sent twice. */
15
+ readonly safeToRetry: boolean;
16
+ }
17
+ export interface CallOptions {
18
+ /** Overrides the deadline for this call, in milliseconds. */
19
+ readonly deadlineMs?: number;
20
+ /** Cancels the call. The error code is ABORTED. */
21
+ readonly signal?: AbortSignal;
22
+ }
23
+ export interface TransportConfig {
24
+ /** The deadline for the whole call, retries included. 10 seconds by default. */
25
+ readonly deadlineMs?: number;
26
+ readonly fetch?: typeof fetch;
27
+ readonly headers: () => Promise<Readonly<Record<string, string>>>;
28
+ /** Extra tries of a safe operation after a retryable failure. 2 by default. */
29
+ readonly maxRetries?: number;
30
+ readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
31
+ readonly urlFor: (operation: Operation) => string;
32
+ }
33
+ /** Sends one operation and returns its decoded reply, or throws a CommsIdError. */
34
+ export declare const call: <Reply>(transport: TransportConfig, operation: Operation, input: unknown, options?: CallOptions) => Promise<Reply>;
@@ -0,0 +1,172 @@
1
+ // Runtime template: copied into every generated client package as src/runtime/http.ts.
2
+ // One call: validate the request, send it with a deadline and a cancel signal, retry only an
3
+ // operation that is safe to repeat, validate the reply. No dependencies, no Comms.ID logic
4
+ // beyond the reply convention.
5
+ import { CommsIdError } from "./errors.js";
6
+ import { validate } from "./validate.js";
7
+ const DEFAULT_DEADLINE_MS = 10_000;
8
+ const DEFAULT_MAX_RETRIES = 2;
9
+ const BASE_DELAY_MS = 200;
10
+ const MAX_DELAY_MS = 2000;
11
+ const fail = (code, message, status, retryable, requestId, body) => new CommsIdError({ code, message, status, retryable, requestId, body });
12
+ const summarize = (issues) => issues
13
+ .slice(0, 3)
14
+ .map((issue) => `${issue.path} ${issue.message}`)
15
+ .join("; ");
16
+ const defaultSleep = (ms, signal) => new Promise((resolve, reject) => {
17
+ const timer = setTimeout(resolve, ms);
18
+ signal.addEventListener("abort", () => {
19
+ clearTimeout(timer);
20
+ reject(signal.reason);
21
+ }, { once: true });
22
+ });
23
+ const abortError = (user) => user?.aborted === true
24
+ ? fail("ABORTED", "The call was cancelled.", 0, false)
25
+ : fail("TIMEOUT", "The call passed its deadline.", 0, true);
26
+ const retryAfterMs = (response) => {
27
+ const header = response.headers.get("retry-after");
28
+ const seconds = header === null ? Number.NaN : Number(header);
29
+ return Number.isInteger(seconds) && seconds >= 0 ? seconds * 1000 : undefined;
30
+ };
31
+ const readFailure = async (response) => {
32
+ const headerId = response.headers.get("x-request-id") ?? undefined;
33
+ let body;
34
+ try {
35
+ body = await response.json();
36
+ }
37
+ catch {
38
+ body = undefined;
39
+ }
40
+ const record = typeof body === "object" && body !== null ? body : {};
41
+ if (typeof record.code === "string" && typeof record.message === "string" && typeof record.retryable === "boolean") {
42
+ const requestId = typeof record.requestId === "string" ? record.requestId : headerId;
43
+ return fail(record.code, record.message, response.status, record.retryable, requestId);
44
+ }
45
+ const gateway = response.status >= 502 && response.status <= 504;
46
+ return fail(`HTTP_${response.status}`, `The service answered ${response.status}.`, response.status, gateway, headerId,
47
+ // A structured reply with an error status (an unavailable or partial outcome) keeps its body.
48
+ Object.keys(record).length > 0 ? record : undefined);
49
+ };
50
+ const readSuccess = async (operation, response) => {
51
+ const requestId = response.headers.get("x-request-id") ?? undefined;
52
+ let value;
53
+ try {
54
+ value = await response.json();
55
+ }
56
+ catch {
57
+ return {
58
+ ok: false,
59
+ error: fail("INVALID_RESPONSE", "The reply is not JSON.", response.status, false, requestId),
60
+ retryAfterMs: undefined,
61
+ };
62
+ }
63
+ const issues = validate(operation.response, value, "response");
64
+ if (issues.length > 0) {
65
+ return {
66
+ ok: false,
67
+ error: fail("INVALID_RESPONSE", `The reply does not match the contract: ${summarize(issues)}`, response.status, false, requestId),
68
+ retryAfterMs: undefined,
69
+ };
70
+ }
71
+ return { ok: true, value };
72
+ };
73
+ /**
74
+ * Builds the credential headers. A failure here is not a network failure: a CommsIdError (for
75
+ * example a refused public key) is passed on as it is, and anything else is a configuration
76
+ * error. Neither is sent anywhere.
77
+ */
78
+ const credentialHeaders = async (transport) => {
79
+ try {
80
+ return { ok: true, headers: await transport.headers() };
81
+ }
82
+ catch (cause) {
83
+ if (cause instanceof CommsIdError) {
84
+ return { ok: false, error: cause };
85
+ }
86
+ const reason = cause instanceof Error ? cause.message : "unknown error";
87
+ return { ok: false, error: fail("NOT_CONFIGURED", `Could not prepare the credentials: ${reason}`, 0, false) };
88
+ }
89
+ };
90
+ const attemptOnce = async (transport, operation, body, signal, user) => {
91
+ if (signal.aborted) {
92
+ throw abortError(user);
93
+ }
94
+ const credentials = await credentialHeaders(transport);
95
+ // A cancel that arrives while the credentials are built must not reach fetch.
96
+ if (signal.aborted) {
97
+ throw abortError(user);
98
+ }
99
+ if (!credentials.ok) {
100
+ return { ok: false, error: credentials.error, retryAfterMs: undefined };
101
+ }
102
+ const headers = {
103
+ accept: "application/json",
104
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
105
+ ...credentials.headers,
106
+ };
107
+ let response;
108
+ try {
109
+ response = await (transport.fetch ?? fetch)(transport.urlFor(operation), {
110
+ method: operation.method,
111
+ headers,
112
+ body,
113
+ signal,
114
+ });
115
+ }
116
+ catch (cause) {
117
+ if (signal.aborted) {
118
+ throw abortError(user);
119
+ }
120
+ const reason = cause instanceof Error ? cause.message : "The request failed.";
121
+ return { ok: false, error: fail("NETWORK", reason, 0, true), retryAfterMs: undefined };
122
+ }
123
+ if (response.ok) {
124
+ return await readSuccess(operation, response);
125
+ }
126
+ return { ok: false, error: await readFailure(response), retryAfterMs: retryAfterMs(response) };
127
+ };
128
+ const prepare = (operation, input) => {
129
+ if (operation.request === undefined) {
130
+ return undefined;
131
+ }
132
+ const issues = validate(operation.request, input, "request");
133
+ if (issues.length > 0) {
134
+ throw fail("INVALID_REQUEST", `The request is not valid: ${summarize(issues)}`, 0, false);
135
+ }
136
+ const refusal = operation.guard?.(input);
137
+ if (refusal !== undefined) {
138
+ throw fail("INVALID_REQUEST", `The request is not valid: ${refusal}`, 0, false);
139
+ }
140
+ return JSON.stringify(input);
141
+ };
142
+ /** Sends one operation and returns its decoded reply, or throws a CommsIdError. */
143
+ export const call = async (transport, operation, input, options = {}) => {
144
+ const body = prepare(operation, input);
145
+ const deadlineMs = options.deadlineMs ?? transport.deadlineMs ?? DEFAULT_DEADLINE_MS;
146
+ const startedAt = performance.now();
147
+ const deadline = AbortSignal.timeout(deadlineMs);
148
+ const signal = options.signal === undefined ? deadline : AbortSignal.any([options.signal, deadline]);
149
+ const maxRetries = operation.safeToRetry ? (transport.maxRetries ?? DEFAULT_MAX_RETRIES) : 0;
150
+ const sleep = transport.sleep ?? defaultSleep;
151
+ for (let attempt = 0;; attempt += 1) {
152
+ const result = await attemptOnce(transport, operation, body, signal, options.signal);
153
+ if (result.ok) {
154
+ return result.value;
155
+ }
156
+ if (attempt >= maxRetries || !result.error.retryable) {
157
+ throw result.error;
158
+ }
159
+ const delay = result.retryAfterMs ?? Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
160
+ // A retry that cannot start within this call's budget must not replace a real
161
+ // service failure (notably 429) with a later timeout or violate Retry-After.
162
+ if (delay >= deadlineMs - (performance.now() - startedAt)) {
163
+ throw result.error;
164
+ }
165
+ try {
166
+ await sleep(delay, signal);
167
+ }
168
+ catch {
169
+ throw abortError(options.signal);
170
+ }
171
+ }
172
+ };
@@ -0,0 +1,7 @@
1
+ import type { ImageController, ImageControllerOptions, ImageProfile, ImageState } from "./image-types.js";
2
+ /** True when the URL is https and on one of the allowed origins. Anything else is never loaded. */
3
+ export declare const isAllowedImage: (url: string, origins: readonly string[]) => boolean;
4
+ /** The state of an image with no site. */
5
+ export declare const initialImageState: <R>(profile: ImageProfile<never, R>, site?: string) => ImageState<R>;
6
+ /** Creates the controller. Nothing is called until a site is set. */
7
+ export declare const createImageController: <C, R>(options: ImageControllerOptions<C, R>) => ImageController<R>;
@@ -0,0 +1,184 @@
1
+ // Runtime template: copied into a generated client package as src/runtime/image-controller.ts.
2
+ // The headless controller of an image: it asks the service about a site and decides what to show,
3
+ // with no framework and no markup. The React package and the web element both run on it.
4
+ //
5
+ // An image is shown only when the service named one on an https URL of an allowed origin; anything
6
+ // else, a missing image, a blocked address, a failed load, a failed call, shows the letter in the
7
+ // same box. The product-specific part is the profile (src/local/profile.ts).
8
+ import { isCommsIdError, outcomeOf } from "./errors.js";
9
+ const errorMessage = (service, code, outcome) => {
10
+ if (code === "FAIR_USE_CEILING") {
11
+ return `The ${service.toLowerCase()} limit is reached. Try again later.`;
12
+ }
13
+ if (outcome === "source-unavailable") {
14
+ return `${service} is unavailable. Try again later.`;
15
+ }
16
+ return `${service} failed. Try again.`;
17
+ };
18
+ const toError = (service, cause) => {
19
+ const outcome = outcomeOf(cause);
20
+ const base = isCommsIdError(cause)
21
+ ? { code: cause.code, detail: cause.message, outcome, retryable: cause.retryable, requestId: cause.requestId }
22
+ : {
23
+ code: "UNKNOWN",
24
+ detail: cause instanceof Error ? cause.message : String(cause),
25
+ outcome,
26
+ retryable: false,
27
+ requestId: undefined,
28
+ };
29
+ return { ...base, message: errorMessage(service, base.code, outcome) };
30
+ };
31
+ const isOurAbort = (cause) => isCommsIdError(cause) && cause.code === "ABORTED";
32
+ /** True when the URL is https and on one of the allowed origins. Anything else is never loaded. */
33
+ export const isAllowedImage = (url, origins) => {
34
+ try {
35
+ const parsed = new URL(url);
36
+ return parsed.protocol === "https:" && origins.includes(parsed.origin);
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ };
42
+ /** The state of an image with no site. */
43
+ export const initialImageState = (profile, site = "") => ({
44
+ site,
45
+ status: "idle",
46
+ url: undefined,
47
+ reason: undefined,
48
+ error: undefined,
49
+ record: undefined,
50
+ label: profile.label(site),
51
+ monogram: profile.monogram(site),
52
+ });
53
+ /** Creates the controller. Nothing is called until a site is set. */
54
+ export const createImageController = (options) => {
55
+ const { client, profile } = options;
56
+ const origins = [...profile.allowedOrigins, ...(options.extraOrigins ?? [])];
57
+ let state = initialImageState(profile);
58
+ const listeners = new Set();
59
+ let sequence = 0;
60
+ let inFlight;
61
+ let retryAction;
62
+ let disposed = false;
63
+ const notify = () => {
64
+ for (const listener of [...listeners]) {
65
+ listener();
66
+ }
67
+ };
68
+ const set = (patch) => {
69
+ state = { ...state, ...patch };
70
+ notify();
71
+ };
72
+ /** Starts a new generation: a call still in flight becomes stale. */
73
+ const supersede = () => {
74
+ sequence += 1;
75
+ inFlight?.abort();
76
+ inFlight = undefined;
77
+ return sequence;
78
+ };
79
+ /** A consumer's callback must not corrupt the state: its error goes to the page. */
80
+ const tell = (callback) => {
81
+ try {
82
+ callback();
83
+ }
84
+ catch (thrown) {
85
+ queueMicrotask(() => {
86
+ throw thrown;
87
+ });
88
+ }
89
+ };
90
+ const fallBack = (reason) => {
91
+ set({ status: "fallback", reason, url: undefined });
92
+ tell(() => options.onFallback?.(reason));
93
+ };
94
+ const answer = (resolved) => {
95
+ if (resolved.url !== undefined && isAllowedImage(resolved.url, origins)) {
96
+ set({ status: "ready", url: resolved.url, reason: undefined, error: undefined, record: resolved.record });
97
+ }
98
+ else {
99
+ state = { ...state, record: resolved.record, error: undefined };
100
+ fallBack(resolved.url === undefined ? "missing" : "blocked");
101
+ }
102
+ tell(() => options.onResult?.(resolved.record));
103
+ };
104
+ const call = async (generation, run) => {
105
+ const controller = new AbortController();
106
+ inFlight = controller;
107
+ retryAction = () => {
108
+ const next = supersede();
109
+ set({ status: "loading", error: undefined });
110
+ call(next, run).catch(() => undefined);
111
+ };
112
+ let resolved;
113
+ try {
114
+ resolved = await run(client, { signal: controller.signal });
115
+ }
116
+ catch (cause) {
117
+ if (generation !== sequence || disposed || isOurAbort(cause)) {
118
+ return;
119
+ }
120
+ const error = toError(profile.service, cause);
121
+ set({ status: "error", error, url: undefined, reason: undefined });
122
+ tell(() => options.onError?.(error));
123
+ return;
124
+ }
125
+ if (generation !== sequence || disposed) {
126
+ return;
127
+ }
128
+ answer(resolved);
129
+ };
130
+ const setSite = (site) => {
131
+ if (disposed || (site === state.site && state.status !== "idle")) {
132
+ return;
133
+ }
134
+ const generation = supersede();
135
+ const reading = profile.read(site);
136
+ const base = initialImageState(profile, site);
137
+ if (reading.kind === "idle") {
138
+ state = base;
139
+ notify();
140
+ return;
141
+ }
142
+ if (reading.kind === "invalid") {
143
+ state = { ...base, status: "invalid" };
144
+ notify();
145
+ return;
146
+ }
147
+ state = { ...base, status: "loading" };
148
+ notify();
149
+ call(generation, reading.run).catch(() => undefined);
150
+ };
151
+ const imageFailed = () => {
152
+ if (disposed || state.status !== "ready") {
153
+ return;
154
+ }
155
+ fallBack("failed");
156
+ };
157
+ const retry = () => {
158
+ if (disposed || state.status !== "error") {
159
+ return;
160
+ }
161
+ retryAction?.();
162
+ };
163
+ const dispose = () => {
164
+ disposed = true;
165
+ supersede();
166
+ listeners.clear();
167
+ };
168
+ if (options.site !== undefined) {
169
+ setSite(options.site);
170
+ }
171
+ return {
172
+ getState: () => state,
173
+ subscribe: (listener) => {
174
+ listeners.add(listener);
175
+ return () => {
176
+ listeners.delete(listener);
177
+ };
178
+ },
179
+ setSite,
180
+ imageFailed,
181
+ retry,
182
+ dispose,
183
+ };
184
+ };
@@ -0,0 +1,112 @@
1
+ import type { Outcome } from "./errors.js";
2
+ import type { CallOptions } from "./http.js";
3
+ /** A call the profile prepared: it runs against the client as soon as the site is known. */
4
+ export type Run<C, T> = (client: C, options: CallOptions) => Promise<T>;
5
+ /** What the service answered. */
6
+ export interface Resolved<R> {
7
+ /** What the consumer receives. */
8
+ readonly record: R;
9
+ /** The image to show, or undefined when the service has none. Only a Comms.ID URL is ever shown. */
10
+ readonly url: string | undefined;
11
+ }
12
+ /** What the profile makes of the site the page gave. */
13
+ export type ImageReading<C, R> =
14
+ /** Nothing to show yet. */
15
+ {
16
+ readonly kind: "idle";
17
+ }
18
+ /** The site is not valid. Nothing is sent. */
19
+ | {
20
+ readonly kind: "invalid";
21
+ readonly message: string;
22
+ } | {
23
+ readonly kind: "resolve";
24
+ readonly run: Run<C, Resolved<R>>;
25
+ };
26
+ /** The only product-specific part of an image: written by hand in the package (src/local/profile.ts). */
27
+ export interface ImageProfile<C, R> {
28
+ /** Origins an image may be loaded from, for example "https://api.comms.id". Nothing else is ever shown. */
29
+ readonly allowedOrigins: readonly string[];
30
+ /** The accessible name of the image for a site: "Stripe logo". */
31
+ readonly label: (site: string) => string;
32
+ /** The letter or two shown when there is no image. */
33
+ readonly monogram: (site: string) => string;
34
+ readonly read: (site: string) => ImageReading<C, R>;
35
+ /** The service in a failure message: "Logo lookup". */
36
+ readonly service: string;
37
+ }
38
+ export type ImageStatus =
39
+ /** No site. */
40
+ "idle"
41
+ /** The service is being asked. */
42
+ | "loading"
43
+ /** There is an image to show. It may still fail to load: see `imageFailed`. */
44
+ | "ready"
45
+ /** There is no image: the letter is shown. `reason` says why. */
46
+ | "fallback"
47
+ /** The site is not valid. The letter is shown. */
48
+ | "invalid"
49
+ /** The call failed; `error` says how. The letter is shown. */
50
+ | "error";
51
+ /** Why the letter is shown instead of an image. */
52
+ export type FallbackReason =
53
+ /** The image failed to load in the browser. */
54
+ "failed"
55
+ /** The service has no image for the site. */
56
+ | "missing"
57
+ /** The service named an image outside the hosts the profile allows: it was never loaded. */
58
+ | "blocked";
59
+ export interface ImageError {
60
+ readonly code: string;
61
+ /** What the technical error said, for a developer: never shown to the end user. */
62
+ readonly detail: string;
63
+ readonly message: string;
64
+ /** A failed call is never shown as "no image". */
65
+ readonly outcome: Outcome;
66
+ readonly requestId: string | undefined;
67
+ readonly retryable: boolean;
68
+ }
69
+ export interface ImageState<R> {
70
+ readonly error: ImageError | undefined;
71
+ /** The accessible name of the image. */
72
+ readonly label: string;
73
+ /** The letter shown when there is no image. */
74
+ readonly monogram: string;
75
+ /** Set with the status "fallback". */
76
+ readonly reason: FallbackReason | undefined;
77
+ /** The answer, once the service has given one. */
78
+ readonly record: R | undefined;
79
+ readonly site: string;
80
+ readonly status: ImageStatus;
81
+ /** The image to load, set with the status "ready". Always on an allowed origin. */
82
+ readonly url: string | undefined;
83
+ }
84
+ export interface ImageControllerOptions<C, R> {
85
+ /** Any client of the product's API: server, browser (relay) or fixtures. */
86
+ readonly client: C;
87
+ /** Origins to allow besides the profile's, for a development host. */
88
+ readonly extraOrigins?: readonly string[];
89
+ /** Called when the call failed, after the state is updated. */
90
+ readonly onError?: (error: ImageError) => void;
91
+ /** Called when the letter is shown instead of an image, after the state is updated. */
92
+ readonly onFallback?: (reason: FallbackReason) => void;
93
+ /** Called once when the service has answered, after the state is updated. */
94
+ readonly onResult?: (record: R) => void;
95
+ readonly profile: ImageProfile<C, R>;
96
+ /** The site to start with. */
97
+ readonly site?: string;
98
+ }
99
+ export interface ImageController<R> {
100
+ /** Stops every call. The controller does nothing afterwards. */
101
+ dispose: () => void;
102
+ /** The current state. The same object until the state changes. */
103
+ getState: () => ImageState<R>;
104
+ /** Tells the controller that the browser could not load the image: the letter is shown. */
105
+ imageFailed: () => void;
106
+ /** Runs again the call that failed. */
107
+ retry: () => void;
108
+ /** The site the image is for. The service is asked at once; a late answer for an earlier site is dropped. */
109
+ setSite: (site: string) => void;
110
+ /** Calls the listener after every change. Returns the function that stops it. */
111
+ subscribe: (listener: () => void) => () => void;
112
+ }
@@ -0,0 +1,8 @@
1
+ // Runtime template: copied into a generated client package as src/runtime/image-types.ts, with
2
+ // image-controller.ts, when the product has an image element (generate.config.json, "ui", kind
3
+ // "image"). The types of the headless image controller and of the per-product profile.
4
+ //
5
+ // An image element shows a picture of a website (a logo) that the service resolved. The browser only
6
+ // ever loads an image from a Comms.ID host the profile names, so a visitor's address never reaches a
7
+ // third party, and it always has something to show: a letter in the same box when there is no image.
8
+ export {};
@@ -0,0 +1,12 @@
1
+ import { type Environment } from "./environment.js";
2
+ import type { TransportConfig } from "./http.js";
3
+ export interface PublicOptions {
4
+ /** Another host. It wins over `environment`. */
5
+ readonly baseUrl?: string;
6
+ readonly deadlineMs?: number;
7
+ /** "live" (the default) or "test". */
8
+ readonly environment?: Environment;
9
+ readonly fetch?: typeof fetch;
10
+ readonly maxRetries?: number;
11
+ }
12
+ export declare const createPublicTransport: (options?: PublicOptions) => TransportConfig;