@hecks/client 2.7.0
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 +310 -0
- package/dist/accountToken.d.ts +56 -0
- package/dist/accountToken.js +112 -0
- package/dist/accountToken.js.map +1 -0
- package/dist/answer.d.ts +27 -0
- package/dist/answer.js +37 -0
- package/dist/answer.js.map +1 -0
- package/dist/client.d.ts +65 -0
- package/dist/client.js +107 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/paymentKeys.d.ts +25 -0
- package/dist/paymentKeys.js +36 -0
- package/dist/paymentKeys.js.map +1 -0
- package/dist/payments.d.ts +80 -0
- package/dist/payments.js +95 -0
- package/dist/payments.js.map +1 -0
- package/dist/resilientFetch.d.ts +61 -0
- package/dist/resilientFetch.js +91 -0
- package/dist/resilientFetch.js.map +1 -0
- package/package.json +41 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// A client for the body-shaped dispatch protocol a Hecks host answers. Two
|
|
2
|
+
// request shapes, both posted to `<url>/dispatch`:
|
|
3
|
+
//
|
|
4
|
+
// { "read": true }
|
|
5
|
+
// -> { instances: { "Domain::Aggregate#<id>": { ...state } }, refusals: [] }
|
|
6
|
+
// { "verb": "Domain::Aggregate.Verb", "to": "<id>", "with": { ... }, "role": "Role" }
|
|
7
|
+
// -> the same shape after the command ran
|
|
8
|
+
//
|
|
9
|
+
// The host answers HTTP 200 whether or not the domain refused, and its
|
|
10
|
+
// `refusals` list can carry entries replayed from history, so a command is
|
|
11
|
+
// judged by the state that comes back (see `HostClient#apply`), never by the
|
|
12
|
+
// list alone.
|
|
13
|
+
//
|
|
14
|
+
// The protocol carries no authentication. It is meant for server-to-server
|
|
15
|
+
// calls on a private network, never for a browser.
|
|
16
|
+
import { refusalOf, DomainUnavailable } from "./errors.js";
|
|
17
|
+
import { instancesOf } from "./answer.js";
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
19
|
+
const fromEnv = (name) => {
|
|
20
|
+
const value = typeof process === "undefined" ? undefined : process.env?.[name];
|
|
21
|
+
return value ? value : undefined;
|
|
22
|
+
};
|
|
23
|
+
/** Talks to one domain on one host. */
|
|
24
|
+
export class HostClient {
|
|
25
|
+
/** The domain's name, as configured. */
|
|
26
|
+
domain;
|
|
27
|
+
/** The host's base URL, without a trailing slash. */
|
|
28
|
+
url;
|
|
29
|
+
role;
|
|
30
|
+
timeoutMs;
|
|
31
|
+
fetchImpl;
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
const domain = options.domain ?? fromEnv("HECKS_DOMAIN");
|
|
34
|
+
const url = options.url ?? fromEnv("HECKS_SERVICE_URL");
|
|
35
|
+
if (!domain)
|
|
36
|
+
throw new TypeError("HostClient needs a domain: pass `domain` or set HECKS_DOMAIN");
|
|
37
|
+
if (!url)
|
|
38
|
+
throw new TypeError("HostClient needs a service URL: pass `url` or set HECKS_SERVICE_URL");
|
|
39
|
+
this.domain = domain;
|
|
40
|
+
this.url = url.replace(/\/+$/, "");
|
|
41
|
+
this.role = options.role;
|
|
42
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
43
|
+
this.fetchImpl = options.fetch;
|
|
44
|
+
}
|
|
45
|
+
/** A name in full: one that already carries `::` is left alone, any other is placed in this client's domain. */
|
|
46
|
+
qualify(name) {
|
|
47
|
+
return name.includes("::") ? name : `${this.domain}::${name}`;
|
|
48
|
+
}
|
|
49
|
+
/** The states of one aggregate in an answer, as `[id, state]` pairs. `aggregate` may be bare ("Event") or qualified. */
|
|
50
|
+
instancesOf(answer, aggregate) {
|
|
51
|
+
return instancesOf(answer, this.qualify(aggregate));
|
|
52
|
+
}
|
|
53
|
+
/** Everything the domain holds, as the protocol answers it. */
|
|
54
|
+
read() {
|
|
55
|
+
return this.post({ read: true });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Sends one command and returns the raw answer. Callers judge the outcome
|
|
59
|
+
* from the state in it and turn a miss into a refusal with `refusalOf`.
|
|
60
|
+
*/
|
|
61
|
+
dispatch(verb, args = {}, to, role) {
|
|
62
|
+
const asRole = role ?? this.role;
|
|
63
|
+
return this.post({ verb: this.qualify(verb), ...(to ? { to } : {}), with: args, ...(asRole ? { role: asRole } : {}) });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Runs one command and returns what `command.parse` reads from the answer.
|
|
67
|
+
* Throws `DomainRefusal` when that state does not show the change,
|
|
68
|
+
* carrying the domain's own words for why.
|
|
69
|
+
*/
|
|
70
|
+
async apply(command) {
|
|
71
|
+
const answer = await this.dispatch(command.verb, command.with, command.to, command.role);
|
|
72
|
+
const parsed = command.parse(answer);
|
|
73
|
+
if (command.confirm(parsed))
|
|
74
|
+
return parsed;
|
|
75
|
+
throw refusalOf(answer, command.verb);
|
|
76
|
+
}
|
|
77
|
+
async post(body) {
|
|
78
|
+
const send = this.fetchImpl ?? globalThis.fetch;
|
|
79
|
+
let res;
|
|
80
|
+
try {
|
|
81
|
+
res = await send(`${this.url}/dispatch`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
throw new DomainUnavailable(`could not reach the domain at ${this.url}: ${err}`);
|
|
90
|
+
}
|
|
91
|
+
if (!res.ok)
|
|
92
|
+
throw new DomainUnavailable(`the domain answered HTTP ${res.status}`);
|
|
93
|
+
let answer;
|
|
94
|
+
try {
|
|
95
|
+
answer = (await res.json());
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new DomainUnavailable("the domain did not answer with JSON");
|
|
99
|
+
}
|
|
100
|
+
if (answer.error)
|
|
101
|
+
throw new DomainUnavailable(answer.error);
|
|
102
|
+
return answer;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Shorthand for `new HostClient(options)`. */
|
|
106
|
+
export const createClient = (options = {}) => new HostClient(options);
|
|
107
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Answer } from "./answer.js";
|
|
2
|
+
/** The domain understood the command and said no (a `given` or an invariant failed). */
|
|
3
|
+
export declare class DomainRefusal extends Error {
|
|
4
|
+
readonly kind: string;
|
|
5
|
+
constructor(kind: string, message: string);
|
|
6
|
+
}
|
|
7
|
+
/** The domain could not be reached, or did not answer in the protocol. */
|
|
8
|
+
export declare class DomainUnavailable extends Error {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The domain's own words for why a command changed nothing: the last refusal
|
|
13
|
+
* the answer carries. Falls back to a generic `NotApplied` refusal when the
|
|
14
|
+
* answer names none.
|
|
15
|
+
*/
|
|
16
|
+
export declare function refusalOf(answer: Answer, verb: string): DomainRefusal;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** The domain understood the command and said no (a `given` or an invariant failed). */
|
|
2
|
+
export class DomainRefusal extends Error {
|
|
3
|
+
kind;
|
|
4
|
+
constructor(kind, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.kind = kind;
|
|
7
|
+
this.name = "DomainRefusal";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** The domain could not be reached, or did not answer in the protocol. */
|
|
11
|
+
export class DomainUnavailable extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "DomainUnavailable";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The domain's own words for why a command changed nothing: the last refusal
|
|
19
|
+
* the answer carries. Falls back to a generic `NotApplied` refusal when the
|
|
20
|
+
* answer names none.
|
|
21
|
+
*/
|
|
22
|
+
export function refusalOf(answer, verb) {
|
|
23
|
+
const refusal = (answer.refusals ?? []).at(-1);
|
|
24
|
+
return new DomainRefusal(refusal?.kind ?? "NotApplied", refusal?.error ?? `the domain did not apply ${verb}`);
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAEA,wFAAwF;AACxF,MAAM,OAAO,aAAc,SAAQ,KAAK;IAE3B;IADX,YACW,IAAY,EACrB,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAC;QAHN,SAAI,GAAJ,IAAI,CAAQ;QAIrB,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;IAC9B,CAAC;CACF;AAED,0EAA0E;AAC1E,MAAM,OAAO,iBAAkB,SAAQ,KAAK;IAC1C,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAC;IAClC,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,UAAU,SAAS,CAAC,MAAc,EAAE,IAAY;IACpD,MAAM,OAAO,GAAG,CAAC,MAAM,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IAC/C,OAAO,IAAI,aAAa,CAAC,OAAO,EAAE,IAAI,IAAI,YAAY,EAAE,OAAO,EAAE,KAAK,IAAI,4BAA4B,IAAI,EAAE,CAAC,CAAC;AAChH,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { HostClient, createClient } from "./client.js";
|
|
2
|
+
export type { ClientOptions, Command } from "./client.js";
|
|
3
|
+
export { instancesOf, text, whole, optionalWhole } from "./answer.js";
|
|
4
|
+
export type { Answer, Refusal } from "./answer.js";
|
|
5
|
+
export { DomainRefusal, DomainUnavailable, refusalOf } from "./errors.js";
|
|
6
|
+
export { createResilientFetch, ResilientFetchError } from "./resilientFetch.js";
|
|
7
|
+
export type { ResilientFetch, ResilientFetchConfig, ResilientRequest } from "./resilientFetch.js";
|
|
8
|
+
export { PaymentsConnection } from "./payments.js";
|
|
9
|
+
export type { PaymentConnectionState, PaymentMode, PaymentStatus, PaymentsConnectionOptions, PaymentsResult, } from "./payments.js";
|
|
10
|
+
export { pastedKeys, savedMessage } from "./paymentKeys.js";
|
|
11
|
+
export type { FormFields, PastedKeys } from "./paymentKeys.js";
|
|
12
|
+
export { DEFAULT_ACCOUNT_COOKIE, accountFromCookieHeader, accountToken, cookieValue, resolveAccountCookieName, verifyAccountToken, } from "./accountToken.js";
|
|
13
|
+
export type { AccountClaims, Clock, CookieVerifyOptions, VerifyOptions } from "./accountToken.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { HostClient, createClient } from "./client.js";
|
|
2
|
+
export { instancesOf, text, whole, optionalWhole } from "./answer.js";
|
|
3
|
+
export { DomainRefusal, DomainUnavailable, refusalOf } from "./errors.js";
|
|
4
|
+
export { createResilientFetch, ResilientFetchError } from "./resilientFetch.js";
|
|
5
|
+
export { PaymentsConnection } from "./payments.js";
|
|
6
|
+
export { pastedKeys, savedMessage } from "./paymentKeys.js";
|
|
7
|
+
export { DEFAULT_ACCOUNT_COOKIE, accountFromCookieHeader, accountToken, cookieValue, resolveAccountCookieName, verifyAccountToken, } from "./accountToken.js";
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEvD,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEtE,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC1E,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAEhF,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAQnD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAE5D,OAAO,EACL,sBAAsB,EACtB,uBAAuB,EACvB,YAAY,EACZ,WAAW,EACX,wBAAwB,EACxB,kBAAkB,GACnB,MAAM,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The two keys, under the field names the host's `/payments/connection/direct` route reads. */
|
|
2
|
+
export interface PastedKeys {
|
|
3
|
+
secret_key: string;
|
|
4
|
+
publishable_key: string;
|
|
5
|
+
}
|
|
6
|
+
/** Anything that reads a form field by name: a `FormData`, a `Map`, `URLSearchParams`. */
|
|
7
|
+
export interface FormFields {
|
|
8
|
+
get(name: string): unknown;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Reads the pasted `secret_key` and `publishable_key` fields, trimmed, and
|
|
12
|
+
* says in plain words which one is missing when either is. An error never
|
|
13
|
+
* repeats a pasted value.
|
|
14
|
+
*/
|
|
15
|
+
export declare function pastedKeys(form: FormFields): {
|
|
16
|
+
keys: PastedKeys;
|
|
17
|
+
} | {
|
|
18
|
+
error: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The confirmation shown after a save: the business's name and the mode, both
|
|
22
|
+
* public facts the host answers with. A missing name reads "your Stripe
|
|
23
|
+
* account"; any mode but `"live"` reads as test.
|
|
24
|
+
*/
|
|
25
|
+
export declare function savedMessage(displayName: string | null | undefined, mode: string | null | undefined): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// What a "paste your two keys and Save" form posts, cleaned up, and the
|
|
2
|
+
// sentence a person reads afterwards. Pure on purpose, so it needs no server.
|
|
3
|
+
// The keys go to the host once and are never stored, logged or shown again
|
|
4
|
+
// here; nothing this file returns (an error, a notice) ever contains what was
|
|
5
|
+
// pasted.
|
|
6
|
+
//
|
|
7
|
+
// Whether a key is well formed for its processor is the host's call: it
|
|
8
|
+
// checks the prefixes and that both keys are from the same mode, and answers
|
|
9
|
+
// 422 with a message that never quotes a key (see `PaymentsConnection`).
|
|
10
|
+
/**
|
|
11
|
+
* Reads the pasted `secret_key` and `publishable_key` fields, trimmed, and
|
|
12
|
+
* says in plain words which one is missing when either is. An error never
|
|
13
|
+
* repeats a pasted value.
|
|
14
|
+
*/
|
|
15
|
+
export function pastedKeys(form) {
|
|
16
|
+
const secret = String(form.get("secret_key") ?? "").trim();
|
|
17
|
+
const publishable = String(form.get("publishable_key") ?? "").trim();
|
|
18
|
+
if (!secret && !publishable)
|
|
19
|
+
return { error: "Paste both keys to save them." };
|
|
20
|
+
if (!secret)
|
|
21
|
+
return { error: "Paste the restricted key as well." };
|
|
22
|
+
if (!publishable)
|
|
23
|
+
return { error: "Paste the publishable key as well." };
|
|
24
|
+
return { keys: { secret_key: secret, publishable_key: publishable } };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The confirmation shown after a save: the business's name and the mode, both
|
|
28
|
+
* public facts the host answers with. A missing name reads "your Stripe
|
|
29
|
+
* account"; any mode but `"live"` reads as test.
|
|
30
|
+
*/
|
|
31
|
+
export function savedMessage(displayName, mode) {
|
|
32
|
+
const name = displayName && displayName.trim() ? displayName.trim() : "your Stripe account";
|
|
33
|
+
const label = mode === "live" ? "live" : "test";
|
|
34
|
+
return `Saved. Connected to ${name} in ${label} mode.`;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=paymentKeys.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paymentKeys.js","sourceRoot":"","sources":["../src/paymentKeys.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,8EAA8E;AAC9E,2EAA2E;AAC3E,8EAA8E;AAC9E,UAAU;AACV,EAAE;AACF,wEAAwE;AACxE,6EAA6E;AAC7E,yEAAyE;AAazE;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,IAAgB;IACzC,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAC3D,MAAM,WAAW,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,iBAAiB,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACrE,IAAI,CAAC,MAAM,IAAI,CAAC,WAAW;QAAE,OAAO,EAAE,KAAK,EAAE,+BAA+B,EAAE,CAAC;IAC/E,IAAI,CAAC,MAAM;QAAE,OAAO,EAAE,KAAK,EAAE,mCAAmC,EAAE,CAAC;IACnE,IAAI,CAAC,WAAW;QAAE,OAAO,EAAE,KAAK,EAAE,oCAAoC,EAAE,CAAC;IACzE,OAAO,EAAE,IAAI,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,eAAe,EAAE,WAAW,EAAE,EAAE,CAAC;AACxE,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,WAAsC,EAAE,IAA+B;IAClG,MAAM,IAAI,GAAG,WAAW,IAAI,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,qBAAqB,CAAC;IAC5F,MAAM,KAAK,GAAG,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;IAChD,OAAO,uBAAuB,IAAI,OAAO,KAAK,QAAQ,CAAC;AACzD,CAAC"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { PastedKeys } from "./paymentKeys.js";
|
|
2
|
+
export type PaymentMode = "test" | "live";
|
|
3
|
+
export type PaymentStatus = "not_connected" | "connected" | "enabled" | "paused" | "disconnected";
|
|
4
|
+
/** What the host says about the business's payment connection. It never carries a credential. */
|
|
5
|
+
export interface PaymentConnectionState {
|
|
6
|
+
status: PaymentStatus;
|
|
7
|
+
/** The processor's key, such as `"stripe"`; `null` while nothing is connected. */
|
|
8
|
+
processor: string | null;
|
|
9
|
+
/** The processor's display name, such as `"Stripe"`. */
|
|
10
|
+
label: string | null;
|
|
11
|
+
account_ref: string | null;
|
|
12
|
+
mode: PaymentMode | null;
|
|
13
|
+
/** The business's name at the processor, when it has one. */
|
|
14
|
+
display_name: string | null;
|
|
15
|
+
/** Modes in which the business's own account has keys on the host. */
|
|
16
|
+
direct_modes: PaymentMode[];
|
|
17
|
+
/** Whether the current connection uses the business's own account. */
|
|
18
|
+
direct: boolean;
|
|
19
|
+
/** Whether the host can keep keys pasted into a form (so the form is worth showing). */
|
|
20
|
+
can_save_keys: boolean;
|
|
21
|
+
/** Whether the caller may connect and disconnect the account. */
|
|
22
|
+
can_manage: boolean;
|
|
23
|
+
/** Whether the caller may turn real payments on and off. */
|
|
24
|
+
can_enable: boolean;
|
|
25
|
+
/** Always empty on this host; kept so a page that still reads the list renders. */
|
|
26
|
+
adapters?: unknown[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The outcome of one call. A `200` with a connection is `ok`. Anything else
|
|
30
|
+
* (`401` not logged in, `403` not permitted, `409` wrong state, `422` refused
|
|
31
|
+
* or malformed keys, ...) carries the host's own words in `error` when it sent
|
|
32
|
+
* JSON with one, and `undefined` when it did not, which is what an older host
|
|
33
|
+
* without these routes does by redirecting to an HTML login page.
|
|
34
|
+
*/
|
|
35
|
+
export type PaymentsResult = {
|
|
36
|
+
ok: true;
|
|
37
|
+
status: number;
|
|
38
|
+
connection: PaymentConnectionState;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
status: number;
|
|
42
|
+
error: string | undefined;
|
|
43
|
+
};
|
|
44
|
+
/** What a `PaymentsConnection` needs to reach a host. */
|
|
45
|
+
export interface PaymentsConnectionOptions {
|
|
46
|
+
/** Where the host listens ("http://127.0.0.1:4322"). Trailing slashes are trimmed. Required. */
|
|
47
|
+
url: string;
|
|
48
|
+
/** The name of the cookie the host reads the account token from. Defaults to `hecks_session`, the host's own default. */
|
|
49
|
+
cookieName?: string;
|
|
50
|
+
/** How long one request may take before it counts as unreachable. Defaults to 8000. */
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
/** The fetch to send requests with. Defaults to the global `fetch`, looked up on each call. */
|
|
53
|
+
fetch?: typeof fetch;
|
|
54
|
+
}
|
|
55
|
+
/** Talks to one host's payment-connection routes on behalf of one signed-in person per call. */
|
|
56
|
+
export declare class PaymentsConnection {
|
|
57
|
+
/** The host's base URL, without a trailing slash. */
|
|
58
|
+
readonly url: string;
|
|
59
|
+
/** The cookie name the account token is sent under. */
|
|
60
|
+
readonly cookieName: string;
|
|
61
|
+
private readonly timeoutMs;
|
|
62
|
+
private readonly fetchImpl;
|
|
63
|
+
constructor(options: PaymentsConnectionOptions);
|
|
64
|
+
/** The connection as the host describes it. Readable by an Owner or the operator. */
|
|
65
|
+
show(session: string): Promise<PaymentsResult>;
|
|
66
|
+
/**
|
|
67
|
+
* Saves two pasted keys and connects the business's own account. The host
|
|
68
|
+
* checks them with the processor, so this can take a few seconds. Owner only.
|
|
69
|
+
*/
|
|
70
|
+
saveKeys(session: string, keys: PastedKeys): Promise<PaymentsResult>;
|
|
71
|
+
/** Connects the business's own account using keys already set on the host, for `mode`. Owner only. */
|
|
72
|
+
useOwnAccount(session: string, mode: PaymentMode): Promise<PaymentsResult>;
|
|
73
|
+
/** Disconnects the account; once payments are enabled, pauses registrations instead. Owner only. */
|
|
74
|
+
disconnect(session: string): Promise<PaymentsResult>;
|
|
75
|
+
/** Turns real payments on for the connected account. Platform operator only. */
|
|
76
|
+
enable(session: string): Promise<PaymentsResult>;
|
|
77
|
+
/** Turns real payments off. Platform operator only. */
|
|
78
|
+
disable(session: string): Promise<PaymentsResult>;
|
|
79
|
+
private call;
|
|
80
|
+
}
|
package/dist/payments.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// A client for the payment-connection JSON API a Hecks host answers under
|
|
2
|
+
// `/payments/connection` (rust/host/src/payments.rs). The routes:
|
|
3
|
+
//
|
|
4
|
+
// GET /payments/connection the connection as `PaymentConnectionState`
|
|
5
|
+
// POST /payments/connection/direct connect the business's own account
|
|
6
|
+
// ({secret_key, publishable_key}, or {mode})
|
|
7
|
+
// POST /payments/connection/disconnect disconnect (or pause, once enabled)
|
|
8
|
+
// POST /payments/connection/enable turn real payments on (operator only)
|
|
9
|
+
// POST /payments/connection/disable turn real payments off (operator only)
|
|
10
|
+
//
|
|
11
|
+
// Every route acts as the person behind the account cookie, so each call
|
|
12
|
+
// forwards that person's own session: the host decides who may connect the
|
|
13
|
+
// account (an Owner) and who may turn real payments on (the platform
|
|
14
|
+
// operator). Nothing here stores or logs a key; the connection the host
|
|
15
|
+
// answers with holds only public facts about the account.
|
|
16
|
+
//
|
|
17
|
+
// Like the dispatch protocol, this is for server-to-server calls, never for a
|
|
18
|
+
// browser.
|
|
19
|
+
import { DEFAULT_ACCOUNT_COOKIE } from "./accountToken.js";
|
|
20
|
+
import { DomainUnavailable } from "./errors.js";
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
22
|
+
const CONNECTION_PATH = "/payments/connection";
|
|
23
|
+
/** Talks to one host's payment-connection routes on behalf of one signed-in person per call. */
|
|
24
|
+
export class PaymentsConnection {
|
|
25
|
+
/** The host's base URL, without a trailing slash. */
|
|
26
|
+
url;
|
|
27
|
+
/** The cookie name the account token is sent under. */
|
|
28
|
+
cookieName;
|
|
29
|
+
timeoutMs;
|
|
30
|
+
fetchImpl;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
if (!options?.url)
|
|
33
|
+
throw new TypeError("PaymentsConnection needs the host's `url`");
|
|
34
|
+
this.url = options.url.replace(/\/+$/, "");
|
|
35
|
+
this.cookieName = options.cookieName ?? DEFAULT_ACCOUNT_COOKIE;
|
|
36
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
37
|
+
this.fetchImpl = options.fetch;
|
|
38
|
+
}
|
|
39
|
+
/** The connection as the host describes it. Readable by an Owner or the operator. */
|
|
40
|
+
show(session) {
|
|
41
|
+
return this.call(session, "GET", "");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Saves two pasted keys and connects the business's own account. The host
|
|
45
|
+
* checks them with the processor, so this can take a few seconds. Owner only.
|
|
46
|
+
*/
|
|
47
|
+
saveKeys(session, keys) {
|
|
48
|
+
return this.call(session, "POST", "/direct", keys);
|
|
49
|
+
}
|
|
50
|
+
/** Connects the business's own account using keys already set on the host, for `mode`. Owner only. */
|
|
51
|
+
useOwnAccount(session, mode) {
|
|
52
|
+
return this.call(session, "POST", "/direct", { mode });
|
|
53
|
+
}
|
|
54
|
+
/** Disconnects the account; once payments are enabled, pauses registrations instead. Owner only. */
|
|
55
|
+
disconnect(session) {
|
|
56
|
+
return this.call(session, "POST", "/disconnect", {});
|
|
57
|
+
}
|
|
58
|
+
/** Turns real payments on for the connected account. Platform operator only. */
|
|
59
|
+
enable(session) {
|
|
60
|
+
return this.call(session, "POST", "/enable", {});
|
|
61
|
+
}
|
|
62
|
+
/** Turns real payments off. Platform operator only. */
|
|
63
|
+
disable(session) {
|
|
64
|
+
return this.call(session, "POST", "/disable", {});
|
|
65
|
+
}
|
|
66
|
+
async call(session, method, path, body) {
|
|
67
|
+
// A session is one cookie value; anything that could end it early or
|
|
68
|
+
// start another header is refused rather than sent.
|
|
69
|
+
if (!session || /[\s;,"\\]/.test(session))
|
|
70
|
+
throw new TypeError("PaymentsConnection needs the account cookie's value as `session`");
|
|
71
|
+
const send = this.fetchImpl ?? globalThis.fetch;
|
|
72
|
+
let res;
|
|
73
|
+
try {
|
|
74
|
+
res = await send(`${this.url}${CONNECTION_PATH}${path}`, {
|
|
75
|
+
method,
|
|
76
|
+
headers: { "Content-Type": "application/json", Cookie: `${this.cookieName}=${session}` },
|
|
77
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
78
|
+
// An older host has no such route and redirects to an HTML login page.
|
|
79
|
+
redirect: "manual",
|
|
80
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
throw new DomainUnavailable(`could not reach the host at ${this.url}: ${err}`);
|
|
85
|
+
}
|
|
86
|
+
const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
|
|
87
|
+
const parsed = isJson ? await res.json().catch(() => null) : null;
|
|
88
|
+
const fields = parsed && typeof parsed === "object" ? parsed : null;
|
|
89
|
+
if (res.status === 200 && fields && typeof fields.status === "string") {
|
|
90
|
+
return { ok: true, status: res.status, connection: fields };
|
|
91
|
+
}
|
|
92
|
+
return { ok: false, status: res.status, error: typeof fields?.error === "string" ? fields.error : undefined };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=payments.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"payments.js","sourceRoot":"","sources":["../src/payments.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** What `createResilientFetch` needs; every field is optional. */
|
|
2
|
+
export interface ResilientFetchConfig {
|
|
3
|
+
/** The fetch to send requests with. Defaults to the global `fetch`, looked up on each call. */
|
|
4
|
+
fetch?: typeof fetch;
|
|
5
|
+
/** Waits `ms` milliseconds between attempts. Defaults to a timer. */
|
|
6
|
+
sleep?: (ms: number) => Promise<void>;
|
|
7
|
+
/** Told when a remembered answer is served instead of a live one. Defaults to `console.warn`. */
|
|
8
|
+
warn?: (message: string) => void;
|
|
9
|
+
/** Waits before each retry, in milliseconds; its length is the number of retries. Defaults to `[150, 500]`. */
|
|
10
|
+
retryDelaysMs?: number[];
|
|
11
|
+
/** How long one attempt may take before it counts as a network failure. Defaults to 10000. */
|
|
12
|
+
attemptTimeoutMs?: number;
|
|
13
|
+
/** How many URLs' last good answers are kept; the least recently answered is dropped first. Defaults to 200. */
|
|
14
|
+
maxRemembered?: number;
|
|
15
|
+
}
|
|
16
|
+
/** What one `json` call needs to know about the request. */
|
|
17
|
+
export interface ResilientRequest {
|
|
18
|
+
/** A human description for error messages, such as `page "about"`. */
|
|
19
|
+
what: string;
|
|
20
|
+
/** The upstream's name or base URL, shown in error messages. */
|
|
21
|
+
source: string;
|
|
22
|
+
/** Answer from memory when the upstream is down. Defaults to true; turn it off where a stale answer would be acted on. */
|
|
23
|
+
allowStale?: boolean;
|
|
24
|
+
/** Resolve to `null` on a 404 instead of throwing. */
|
|
25
|
+
nullOn404?: boolean;
|
|
26
|
+
/** Replaces the configured retry delays for this call. */
|
|
27
|
+
retryDelaysMs?: number[];
|
|
28
|
+
/** Extra request headers, such as a viewer's `Cookie` to read a draft. */
|
|
29
|
+
headers?: Record<string, string>;
|
|
30
|
+
/**
|
|
31
|
+
* The response is for one viewer, read with their credentials: it is never
|
|
32
|
+
* remembered for later requests, and the answer remembered for the same URL
|
|
33
|
+
* (which is public) is never served in its place.
|
|
34
|
+
*/
|
|
35
|
+
private?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** A read that failed and could not be answered from memory. */
|
|
38
|
+
export declare class ResilientFetchError extends Error {
|
|
39
|
+
/** The last attempt's failure: a network error's message, or `<status> <statusText>`. */
|
|
40
|
+
readonly failure: string;
|
|
41
|
+
constructor(message: string,
|
|
42
|
+
/** The last attempt's failure: a network error's message, or `<status> <statusText>`. */
|
|
43
|
+
failure: string);
|
|
44
|
+
}
|
|
45
|
+
/** A JSON reader with its own memory of last good answers. */
|
|
46
|
+
export interface ResilientFetch {
|
|
47
|
+
/**
|
|
48
|
+
* Reads `url` as JSON, retrying transient failures and, when allowed,
|
|
49
|
+
* answering from the last good response for the same URL if the upstream
|
|
50
|
+
* stays down.
|
|
51
|
+
* @throws {ResilientFetchError} when the read fails and nothing may or can be served from memory
|
|
52
|
+
*/
|
|
53
|
+
json<T>(url: string, request: ResilientRequest): Promise<T | null>;
|
|
54
|
+
/** Forgets every remembered answer. */
|
|
55
|
+
clear(): void;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Builds a JSON reader that survives a briefly unavailable upstream. Each
|
|
59
|
+
* reader keeps its own memory, so two readers never share answers.
|
|
60
|
+
*/
|
|
61
|
+
export declare function createResilientFetch(config?: ResilientFetchConfig): ResilientFetch;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Reads JSON from an upstream service that may be briefly down or restarting,
|
|
2
|
+
// without turning that into a failure for every caller.
|
|
3
|
+
//
|
|
4
|
+
// Two layers, in order:
|
|
5
|
+
// 1. Retry transient failures (network error, timeout, 502/503/504) a
|
|
6
|
+
// couple of times with a short backoff, enough for a container that is
|
|
7
|
+
// mid-restart.
|
|
8
|
+
// 2. If the upstream is still failing (network error or any 5xx), answer
|
|
9
|
+
// with the last successful response for that exact URL from this
|
|
10
|
+
// process's memory, when the caller allows it. A cold process has
|
|
11
|
+
// nothing remembered and still throws, so a genuinely broken deploy is
|
|
12
|
+
// never masked.
|
|
13
|
+
// A 4xx is a real answer (bad slug, bad credentials) and is never retried or
|
|
14
|
+
// answered from memory.
|
|
15
|
+
const DEFAULT_RETRY_DELAYS_MS = [150, 500];
|
|
16
|
+
const DEFAULT_ATTEMPT_TIMEOUT_MS = 10_000;
|
|
17
|
+
// URLs built from visitor-controlled paths can succeed with an empty answer
|
|
18
|
+
// for any input, so the memory is bounded.
|
|
19
|
+
const DEFAULT_MAX_REMEMBERED = 200;
|
|
20
|
+
/** A read that failed and could not be answered from memory. */
|
|
21
|
+
export class ResilientFetchError extends Error {
|
|
22
|
+
failure;
|
|
23
|
+
constructor(message,
|
|
24
|
+
/** The last attempt's failure: a network error's message, or `<status> <statusText>`. */
|
|
25
|
+
failure) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.failure = failure;
|
|
28
|
+
this.name = "ResilientFetchError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const timer = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
|
+
/**
|
|
33
|
+
* Builds a JSON reader that survives a briefly unavailable upstream. Each
|
|
34
|
+
* reader keeps its own memory, so two readers never share answers.
|
|
35
|
+
*/
|
|
36
|
+
export function createResilientFetch(config = {}) {
|
|
37
|
+
const sleep = config.sleep ?? timer;
|
|
38
|
+
const warn = config.warn ?? ((message) => console.warn(message));
|
|
39
|
+
const attemptTimeoutMs = config.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS;
|
|
40
|
+
const maxRemembered = config.maxRemembered ?? DEFAULT_MAX_REMEMBERED;
|
|
41
|
+
const lastGood = new Map();
|
|
42
|
+
const remember = (url, value) => {
|
|
43
|
+
lastGood.delete(url);
|
|
44
|
+
lastGood.set(url, value);
|
|
45
|
+
if (lastGood.size > maxRemembered)
|
|
46
|
+
lastGood.delete(lastGood.keys().next().value);
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
async json(url, request) {
|
|
50
|
+
const { what, source, nullOn404 = false, headers } = request;
|
|
51
|
+
const delays = request.retryDelaysMs ?? config.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
52
|
+
const allowStale = (request.allowStale ?? true) && !request.private;
|
|
53
|
+
let failure = "";
|
|
54
|
+
let staleable = false;
|
|
55
|
+
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
|
56
|
+
if (attempt > 0)
|
|
57
|
+
await sleep(delays[attempt - 1]);
|
|
58
|
+
let res;
|
|
59
|
+
try {
|
|
60
|
+
const send = config.fetch ?? fetch;
|
|
61
|
+
res = await send(url, { headers, signal: AbortSignal.timeout(attemptTimeoutMs) });
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
65
|
+
staleable = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (res.ok) {
|
|
69
|
+
const data = (await res.json());
|
|
70
|
+
if (!request.private)
|
|
71
|
+
remember(url, data);
|
|
72
|
+
return data;
|
|
73
|
+
}
|
|
74
|
+
if (res.status === 404 && nullOn404)
|
|
75
|
+
return null;
|
|
76
|
+
failure = `${res.status} ${res.statusText}`;
|
|
77
|
+
if (res.status >= 500)
|
|
78
|
+
staleable = true;
|
|
79
|
+
if (![502, 503, 504].includes(res.status))
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
if (allowStale && staleable && lastGood.has(url)) {
|
|
83
|
+
warn(`${what}: ${source} unavailable (${failure}); serving last known good`);
|
|
84
|
+
return lastGood.get(url);
|
|
85
|
+
}
|
|
86
|
+
throw new ResilientFetchError(`Failed to fetch ${what} from ${source}: ${failure}`, failure);
|
|
87
|
+
},
|
|
88
|
+
clear: () => lastGood.clear(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=resilientFetch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resilientFetch.js","sourceRoot":"","sources":["../src/resilientFetch.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hecks/client",
|
|
3
|
+
"version": "2.7.0",
|
|
4
|
+
"description": "Client for the body-shaped dispatch protocol a Hecks host answers: read the domain, dispatch commands, read the answers.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/heckslabs/hecks.git",
|
|
27
|
+
"directory": "packages/hecks-client"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/heckslabs/hecks/tree/main/packages/hecks-client",
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.json",
|
|
32
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
33
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
34
|
+
"test:contract": "npm run build && node test/contract.mjs",
|
|
35
|
+
"prepack": "npm run build"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.10.0",
|
|
39
|
+
"typescript": "^5.7.3"
|
|
40
|
+
}
|
|
41
|
+
}
|