@foir/sdk 0.10.0 → 0.12.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/CHANGELOG.md +353 -2
- package/README.md +10 -2
- package/dist/app/client.d.ts +371 -0
- package/dist/app/contract/index.d.ts +23 -0
- package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
- package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
- package/dist/app/dispatch.d.ts +64 -0
- package/dist/app/errors.d.ts +50 -0
- package/dist/app/index.d.ts +40 -0
- package/dist/app/manifest.d.ts +56 -0
- package/dist/app/naming.d.ts +31 -0
- package/dist/app/record-key.d.ts +17 -0
- package/dist/app/ticket.d.ts +37 -0
- package/dist/app/vocabulary.generated.d.ts +20 -0
- package/dist/app-contract.js +124 -0
- package/dist/app.js +998 -0
- package/dist/auth/auth.d.ts +24 -6
- package/dist/auth/index.d.ts +2 -1
- package/dist/auth/ops.d.ts +8 -0
- package/dist/auth.js +230 -60
- package/dist/client.js +80 -15
- package/dist/codegen/preset.d.ts +3 -2
- package/dist/data/builtins.d.ts +17 -1
- package/dist/data/client.d.ts +10 -8
- package/dist/data/document.d.ts +13 -0
- package/dist/data/index.d.ts +15 -5
- package/dist/data/manifest.d.ts +31 -3
- package/dist/data/schema.d.ts +16 -9
- package/dist/data/selection.d.ts +15 -0
- package/dist/data/transport.d.ts +277 -23
- package/dist/data/ws-transport.d.ts +8 -2
- package/dist/data.js +313 -72
- package/dist/graphql/client.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/index.js +0 -9
- package/dist/live/drift.d.ts +9 -10
- package/dist/live.js +1 -1
- package/dist/next.js +158 -37
- package/dist/placement/index.d.ts +129 -0
- package/dist/placement.js +144 -0
- package/dist/push.js +212 -50
- package/dist/react.js +158 -37
- package/dist/server/callback-client.d.ts +30 -9
- package/dist/server/continue-run.d.ts +56 -0
- package/dist/server/index.d.ts +6 -3
- package/dist/server/operation-types.d.ts +114 -65
- package/dist/server/read-dispatch.d.ts +53 -0
- package/dist/server/require-scoped-token.d.ts +71 -7
- package/dist/server/verify-scoped-token.d.ts +109 -3
- package/dist/server.js +566 -138
- package/dist/transport/foir-http.d.ts +103 -5
- package/package.json +24 -6
package/dist/react.js
CHANGED
|
@@ -81,7 +81,12 @@ var FoirRequestError = class extends Error {
|
|
|
81
81
|
* failed on the first attempt for an hour.
|
|
82
82
|
*/
|
|
83
83
|
get retryable() {
|
|
84
|
-
if (this.status === void 0)
|
|
84
|
+
if (this.status === void 0) {
|
|
85
|
+
if (this.graphQLErrors?.length || this.prunedSelections?.length) {
|
|
86
|
+
return this.code === "RATE_LIMITED" || this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
85
90
|
if (this.status === 429 || this.code === "RATE_LIMITED") return true;
|
|
86
91
|
if (this.status >= 500) return true;
|
|
87
92
|
return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
|
|
@@ -95,19 +100,50 @@ var FoirRequestError = class extends Error {
|
|
|
95
100
|
return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
|
|
96
101
|
}
|
|
97
102
|
};
|
|
103
|
+
var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
104
|
+
"x-api-key",
|
|
105
|
+
"authorization",
|
|
106
|
+
"x-foir-token",
|
|
107
|
+
"x-foir-context"
|
|
108
|
+
]);
|
|
109
|
+
function refuseCredentialHeaders(extra) {
|
|
110
|
+
if (!extra) return;
|
|
111
|
+
for (const name of Object.keys(extra)) {
|
|
112
|
+
if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
|
|
113
|
+
throw new FoirRequestError(
|
|
114
|
+
`[foir] a client built from a dispatch token cannot send "${name}" in \`headers\`. The dispatch token is the credential, and an api key or a bearer beside it would make the request two actors at once. Build a separate client for the other credential.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
|
|
98
120
|
function buildFoirHeaders(input) {
|
|
99
121
|
const headers = {
|
|
100
|
-
"Content-Type": "application/json"
|
|
101
|
-
"x-api-key": input.apiKey
|
|
122
|
+
"Content-Type": "application/json"
|
|
102
123
|
};
|
|
103
|
-
if (input.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
headers["
|
|
124
|
+
if (input.dispatchToken !== void 0) {
|
|
125
|
+
refuseCredentialHeaders(input.extra);
|
|
126
|
+
headers["X-Foir-Token"] = input.dispatchToken;
|
|
127
|
+
if (input.dispatchContext) {
|
|
128
|
+
headers["X-Foir-Context"] = input.dispatchContext;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
headers["x-api-key"] = input.apiKey;
|
|
132
|
+
if (input.draftChannel) {
|
|
133
|
+
headers["X-Foir-Schema-Channel"] = "draft";
|
|
134
|
+
}
|
|
135
|
+
if (input.bearerToken) {
|
|
136
|
+
headers["Authorization"] = `Bearer ${input.bearerToken}`;
|
|
137
|
+
}
|
|
138
|
+
if (input.orgId) {
|
|
139
|
+
headers["X-Org-Id"] = input.orgId;
|
|
140
|
+
}
|
|
141
|
+
if (input.orgUrl) {
|
|
142
|
+
headers["X-Org-Url"] = input.orgUrl;
|
|
143
|
+
}
|
|
144
|
+
if (input.visitorAddress) {
|
|
145
|
+
headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
|
|
146
|
+
}
|
|
111
147
|
}
|
|
112
148
|
if (input.extra) {
|
|
113
149
|
for (const [key, value] of Object.entries(input.extra)) {
|
|
@@ -123,6 +159,26 @@ function foirApiEndpoint(apiUrl, path) {
|
|
|
123
159
|
}
|
|
124
160
|
return base + path;
|
|
125
161
|
}
|
|
162
|
+
var FoirAuthDowngradedError = class extends FoirRequestError {
|
|
163
|
+
downgrade;
|
|
164
|
+
constructor(downgrade) {
|
|
165
|
+
super(
|
|
166
|
+
`[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}), so the request was served as ${downgrade.servedAs}`,
|
|
167
|
+
{ graphQLErrors: [{ message: "customer token rejected", extensions: { code: "AUTH_DOWNGRADED" } }] }
|
|
168
|
+
);
|
|
169
|
+
this.name = "FoirAuthDowngradedError";
|
|
170
|
+
this.downgrade = downgrade;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
var FoirSessionExpiredError = class extends FoirRequestError {
|
|
174
|
+
constructor(options) {
|
|
175
|
+
super("[foir] the customer session has expired and could not be refreshed: sign in again", {
|
|
176
|
+
cause: options?.cause,
|
|
177
|
+
graphQLErrors: [{ message: "session expired", extensions: { code: "SESSION_EXPIRED" } }]
|
|
178
|
+
});
|
|
179
|
+
this.name = "FoirSessionExpiredError";
|
|
180
|
+
}
|
|
181
|
+
};
|
|
126
182
|
var warnedDowngrade = null;
|
|
127
183
|
function reportAuthDowngrade(envelope) {
|
|
128
184
|
const downgrade = envelope.extensions?.auth;
|
|
@@ -151,12 +207,18 @@ function refusePrunedDocument(json, options) {
|
|
|
151
207
|
`[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
|
|
152
208
|
);
|
|
153
209
|
}
|
|
210
|
+
function answeredAnything(data) {
|
|
211
|
+
return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
|
|
212
|
+
}
|
|
154
213
|
function readFoirEnvelope(json, options) {
|
|
214
|
+
if (options?.rejectDowngrade && json.extensions?.auth) {
|
|
215
|
+
throw new FoirAuthDowngradedError(json.extensions.auth);
|
|
216
|
+
}
|
|
155
217
|
reportAuthDowngrade(json);
|
|
156
218
|
refusePrunedDocument(json, options);
|
|
157
219
|
if (json.errors?.length) {
|
|
158
220
|
const msg = json.errors.map((e) => e.message).join("; ");
|
|
159
|
-
if (json.data && !options?.strict) {
|
|
221
|
+
if (answeredAnything(json.data) && !options?.strict) {
|
|
160
222
|
console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
|
|
161
223
|
} else {
|
|
162
224
|
throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
|
|
@@ -670,15 +732,54 @@ function operationName(scope, op) {
|
|
|
670
732
|
function foirDataEndpoint(apiUrl, path) {
|
|
671
733
|
return foirApiEndpoint(apiUrl, path);
|
|
672
734
|
}
|
|
735
|
+
async function resolveVisitorAddress(config) {
|
|
736
|
+
const source = config.visitor;
|
|
737
|
+
const value = typeof source === "function" ? await source() : source;
|
|
738
|
+
const address = value?.trim();
|
|
739
|
+
return address ? address : void 0;
|
|
740
|
+
}
|
|
673
741
|
async function resolveBearerToken(config) {
|
|
674
742
|
const source = config.bearerToken;
|
|
675
743
|
if (typeof source === "function") return await source() ?? void 0;
|
|
676
744
|
return source ?? void 0;
|
|
677
745
|
}
|
|
746
|
+
async function resolveDispatchToken(dispatch) {
|
|
747
|
+
const token = (await dispatch.token())?.trim();
|
|
748
|
+
if (!token) {
|
|
749
|
+
throw new FoirRequestError(
|
|
750
|
+
"[foir] the dispatch token provider returned no token. A client built from a dispatch calls Foir as that operation and holds no other credential. Build it from the token the endpoint was handed: `(await requireScopedToken(request)).dispatch`."
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
return token;
|
|
754
|
+
}
|
|
755
|
+
async function resolveFoirAuth(config) {
|
|
756
|
+
if (config.dispatch !== void 0) {
|
|
757
|
+
return {
|
|
758
|
+
headers: buildFoirHeaders({
|
|
759
|
+
dispatchToken: await resolveDispatchToken(config.dispatch),
|
|
760
|
+
dispatchContext: config.dispatch.context ?? void 0,
|
|
761
|
+
extra: config.headers
|
|
762
|
+
})
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
const bearerToken = await resolveBearerToken(config);
|
|
766
|
+
return {
|
|
767
|
+
headers: buildFoirHeaders({
|
|
768
|
+
apiKey: config.apiKey,
|
|
769
|
+
draftChannel: config.channel === "draft",
|
|
770
|
+
bearerToken,
|
|
771
|
+
orgId: config.orgId,
|
|
772
|
+
orgUrl: config.orgUrl,
|
|
773
|
+
visitorAddress: await resolveVisitorAddress(config),
|
|
774
|
+
extra: config.headers
|
|
775
|
+
}),
|
|
776
|
+
key: { apiKey: config.apiKey, bearerToken }
|
|
777
|
+
};
|
|
778
|
+
}
|
|
678
779
|
function foirRoute(input) {
|
|
679
780
|
if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
|
|
680
781
|
if (input.bearerToken) return { lane: "origin", reason: "bearer" };
|
|
681
|
-
if (input.orgId) return { lane: "origin", reason: "org" };
|
|
782
|
+
if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
|
|
682
783
|
if (input.mutation) return { lane: "origin", reason: "operation" };
|
|
683
784
|
if (input.writeToken) return { lane: "origin", reason: "floor" };
|
|
684
785
|
if (input.channel === "draft") return { lane: "origin", reason: "channel" };
|
|
@@ -693,25 +794,45 @@ async function executeFoirDocument(config, plan) {
|
|
|
693
794
|
}
|
|
694
795
|
return data[plan.rootField];
|
|
695
796
|
}
|
|
797
|
+
var renewing = /* @__PURE__ */ new WeakMap();
|
|
798
|
+
function renewBearerOnce(refresh) {
|
|
799
|
+
const running = renewing.get(refresh);
|
|
800
|
+
if (running) return running;
|
|
801
|
+
const next = refresh().finally(() => renewing.delete(refresh));
|
|
802
|
+
renewing.set(refresh, next);
|
|
803
|
+
return next;
|
|
804
|
+
}
|
|
696
805
|
async function runFoirDocument(config, document2, mutation, variables, options) {
|
|
806
|
+
try {
|
|
807
|
+
return await sendFoirDocument(config, document2, mutation, variables, options);
|
|
808
|
+
} catch (err) {
|
|
809
|
+
const refresh = config.refreshBearer;
|
|
810
|
+
if (!(err instanceof FoirAuthDowngradedError) || !refresh || err.downgrade.reason === "wrong_project") {
|
|
811
|
+
throw err;
|
|
812
|
+
}
|
|
813
|
+
const token = err.downgrade.reason === "expired" ? await renewBearerOnce(refresh) : null;
|
|
814
|
+
if (!token) throw new FoirSessionExpiredError({ cause: err });
|
|
815
|
+
try {
|
|
816
|
+
return await sendFoirDocument({ ...config, bearerToken: token }, document2, mutation, variables, options);
|
|
817
|
+
} catch (again) {
|
|
818
|
+
if (again instanceof FoirAuthDowngradedError) throw new FoirSessionExpiredError({ cause: again });
|
|
819
|
+
throw again;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
async function sendFoirDocument(config, document2, mutation, variables, options) {
|
|
697
824
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
698
825
|
const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
|
|
699
|
-
const
|
|
700
|
-
const headers = buildFoirHeaders({
|
|
701
|
-
apiKey: config.apiKey,
|
|
702
|
-
draftChannel: config.channel === "draft",
|
|
703
|
-
bearerToken,
|
|
704
|
-
orgId: config.orgId,
|
|
705
|
-
extra: config.headers
|
|
706
|
-
});
|
|
826
|
+
const { headers, key } = await resolveFoirAuth(config);
|
|
707
827
|
const hash = await documentHash(document2);
|
|
708
|
-
const
|
|
709
|
-
apiKey:
|
|
710
|
-
bearerToken,
|
|
828
|
+
const sharedKey = key !== void 0 && foirRoute({
|
|
829
|
+
apiKey: key.apiKey,
|
|
830
|
+
bearerToken: key.bearerToken,
|
|
711
831
|
orgId: config.orgId,
|
|
832
|
+
orgUrl: config.orgUrl,
|
|
712
833
|
mutation,
|
|
713
834
|
channel: config.channel
|
|
714
|
-
});
|
|
835
|
+
}).lane === "shared" ? key.apiKey : void 0;
|
|
715
836
|
const post = (body) => doFetch(endpoint, {
|
|
716
837
|
method: "POST",
|
|
717
838
|
headers,
|
|
@@ -721,8 +842,8 @@ async function runFoirDocument(config, document2, mutation, variables, options)
|
|
|
721
842
|
const extensions = persistedQueryExtension(hash);
|
|
722
843
|
let res;
|
|
723
844
|
try {
|
|
724
|
-
if (
|
|
725
|
-
res = await doFetch(buildCdnUrl(endpoint,
|
|
845
|
+
if (sharedKey !== void 0) {
|
|
846
|
+
res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
|
|
726
847
|
method: "GET",
|
|
727
848
|
headers,
|
|
728
849
|
...config.requestInit
|
|
@@ -748,7 +869,7 @@ async function runFoirDocument(config, document2, mutation, variables, options)
|
|
|
748
869
|
});
|
|
749
870
|
}
|
|
750
871
|
const json = await res.json();
|
|
751
|
-
return readFoirEnvelope(json, options);
|
|
872
|
+
return readFoirEnvelope(json, { ...options, rejectDowngrade: Boolean(key?.bearerToken) });
|
|
752
873
|
}
|
|
753
874
|
async function readErrorBody(res) {
|
|
754
875
|
try {
|
|
@@ -760,20 +881,16 @@ async function readErrorBody(res) {
|
|
|
760
881
|
}
|
|
761
882
|
|
|
762
883
|
// src/data/manifest.ts
|
|
763
|
-
async function fetchFoirManifest(config) {
|
|
764
|
-
const
|
|
884
|
+
async function fetchFoirManifest(config, options = {}) {
|
|
885
|
+
const base = foirDataEndpoint(config.apiUrl, "/v1/manifest");
|
|
886
|
+
const url = options.operation === void 0 ? base : `${base}?operation=${encodeURIComponent(options.operation)}`;
|
|
765
887
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
888
|
+
const { headers } = await resolveFoirAuth(config);
|
|
766
889
|
let res;
|
|
767
890
|
try {
|
|
768
891
|
res = await doFetch(url, {
|
|
769
892
|
method: "GET",
|
|
770
|
-
headers
|
|
771
|
-
apiKey: config.apiKey,
|
|
772
|
-
draftChannel: config.channel === "draft",
|
|
773
|
-
bearerToken: await resolveBearerToken(config),
|
|
774
|
-
orgId: config.orgId,
|
|
775
|
-
extra: config.headers
|
|
776
|
-
}),
|
|
893
|
+
headers,
|
|
777
894
|
...config.requestInit
|
|
778
895
|
});
|
|
779
896
|
} catch (err) {
|
|
@@ -845,6 +962,7 @@ function authClientConfig(env, options) {
|
|
|
845
962
|
channel: isFoirPreview(env) ? "draft" : "published",
|
|
846
963
|
bearerToken: options?.bearerToken,
|
|
847
964
|
orgId: options?.orgId,
|
|
965
|
+
orgUrl: options?.orgUrl,
|
|
848
966
|
headers: options?.headers,
|
|
849
967
|
fetch: options?.fetch
|
|
850
968
|
};
|
|
@@ -917,6 +1035,9 @@ async function getAuthProviders(env) {
|
|
|
917
1035
|
async function saveSession(adapter, result) {
|
|
918
1036
|
await adapter.set(TOKEN_KEY, result.token);
|
|
919
1037
|
await adapter.set(REFRESH_KEY, result.refreshToken);
|
|
1038
|
+
if (!result.customerId) {
|
|
1039
|
+
return await getSessionCustomer(adapter) ?? { id: "", email: "" };
|
|
1040
|
+
}
|
|
920
1041
|
await adapter.set(CUSTOMER_ID_KEY, result.customerId);
|
|
921
1042
|
await adapter.set(EMAIL_KEY, result.email);
|
|
922
1043
|
return { id: result.customerId, email: result.email };
|
|
@@ -17,14 +17,21 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Each call carries:
|
|
19
19
|
* - `Authorization: Bearer <callback.token>` — Ed25519 scoped token.
|
|
20
|
-
* - `X-Foir-Signature: sha256=<hex>` — HMAC of the raw request body
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* - `X-Foir-Signature: sha256=<hex>` — HMAC of the raw request body, keyed
|
|
21
|
+
* on the run's own key (`callback.signingKey`), which the platform makes
|
|
22
|
+
* for each run and sends with it. An endpoint that still holds the
|
|
23
|
+
* project's signing secret may pass it as `signingSecret` instead; the
|
|
24
|
+
* platform accepts either.
|
|
25
|
+
*
|
|
26
|
+
* A call that fails before the platform answers, or that the platform answers
|
|
27
|
+
* with a 5xx or a 429, is tried again: a report that is lost leaves the run to
|
|
28
|
+
* be closed by the platform's watchdog as timed out, and every one of these
|
|
29
|
+
* calls is safe to repeat.
|
|
24
30
|
*
|
|
25
31
|
* Both response body shape and content-type follow the GraphQL spec —
|
|
26
32
|
* extensions importing this client receive typed result data.
|
|
27
33
|
*/
|
|
34
|
+
import { type FoirRetryPolicy } from '../transport/retry';
|
|
28
35
|
import type { OperationCallback } from './operation-types.js';
|
|
29
36
|
/** Shape of a successful response from any callback mutation. */
|
|
30
37
|
export interface CallbackResult {
|
|
@@ -78,15 +85,29 @@ export interface CallbackClient<TResult = unknown> {
|
|
|
78
85
|
* `callback`; pass them together so the SDK doesn't have to re-parse the
|
|
79
86
|
* payload structure on every call.
|
|
80
87
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
88
|
+
* The signature is keyed on `callback.signingKey`, the run's own key, which
|
|
89
|
+
* the platform sends with every ASYNC dispatch. `signingSecret` is for an
|
|
90
|
+
* endpoint that holds the project's signing secret instead (`foir push`
|
|
91
|
+
* writes it to `FOIR_SIGNING_SECRET`); when it is passed it is the key used.
|
|
92
|
+
* One of the two must be there.
|
|
84
93
|
*
|
|
85
94
|
* `fetch` is overridable for testability and to support runtimes (CF
|
|
86
|
-
* Workers, Node) where `fetch` may not be the global.
|
|
95
|
+
* Workers, Node) where `fetch` may not be the global. `retry` is how a call
|
|
96
|
+
* that got no answer, a 5xx or a 429 is tried again (three attempts unless
|
|
97
|
+
* told otherwise; `{ attempts: 1 }` turns it off).
|
|
98
|
+
*
|
|
99
|
+
* `apiUrl` is the Foir API origin the endpoint is configured with. The
|
|
100
|
+
* dispatch names the address to report to and that address is the one used; a
|
|
101
|
+
* platform with no public address of its own configured names none, and the
|
|
102
|
+
* run then reports to the data API at `apiUrl`.
|
|
87
103
|
*/
|
|
88
104
|
export declare function createCallbackClient<TResult = unknown>(callback: OperationCallback, opts: {
|
|
89
105
|
executionId: string;
|
|
90
|
-
signingSecret
|
|
106
|
+
signingSecret?: string;
|
|
107
|
+
/** The Foir API origin, used when the dispatch names no address to report to. */
|
|
108
|
+
apiUrl?: string;
|
|
91
109
|
fetch?: typeof fetch;
|
|
110
|
+
retry?: FoirRetryPolicy;
|
|
111
|
+
/** Aborts a call in flight, and the wait before a retry is not started. */
|
|
112
|
+
signal?: AbortSignal;
|
|
92
113
|
}): CallbackClient<TResult>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* continueRun: answer one slice of an ASYNC run and ask to be called again.
|
|
3
|
+
*
|
|
4
|
+
* A run that takes longer than one call is cut into slices. Each dispatch does
|
|
5
|
+
* a bounded amount of work and then either answers `202 Accepted` and reports
|
|
6
|
+
* the outcome through the callback, as an ASYNC run always has, or answers a
|
|
7
|
+
* continuation: the cursor the next slice resumes from. The platform queues the
|
|
8
|
+
* next call, at once or after `delayMs`, and hands the cursor back as
|
|
9
|
+
* `continuation` on the dispatch body (`readDispatch` reads it). Each slice
|
|
10
|
+
* gets a fresh callback, so the one that finishes reports through its own.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const payload = await readDispatch(request);
|
|
14
|
+
* const page = await syncOnePage(payload.continuation?.cursor);
|
|
15
|
+
* if (page.next) return continueRun({ cursor: page.next, progressPct: page.pct });
|
|
16
|
+
* await createCallbackClient(payload.callback!, { executionId, signingSecret }).complete(page.summary);
|
|
17
|
+
* return new Response(null, { status: 202 });
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Only an ASYNC run can continue: a caller waiting on a SYNC call cannot be
|
|
21
|
+
* answered by a later one, so the platform fails a SYNC call answered this
|
|
22
|
+
* way. A continuation is not a retry and does not spend the retry budget; a run
|
|
23
|
+
* takes at most 1000 slices, and one that asks for more is dead-lettered
|
|
24
|
+
* holding its last cursor, so a retry resumes from it.
|
|
25
|
+
*
|
|
26
|
+
* On the wire it is a 200 under {@link CONTINUATION_MEDIA_TYPE} with the
|
|
27
|
+
* continuation as the JSON body. The content type is what marks it, so a
|
|
28
|
+
* framework without fetch `Response` objects sends the same thing by setting
|
|
29
|
+
* that header and writing `JSON.stringify(continuation)`.
|
|
30
|
+
*/
|
|
31
|
+
/** The content type a continuation is answered under. */
|
|
32
|
+
export declare const CONTINUATION_MEDIA_TYPE = "application/vnd.foir.continuation+json";
|
|
33
|
+
/** The longest cursor the platform keeps, in bytes. */
|
|
34
|
+
export declare const MAX_CONTINUATION_CURSOR_BYTES: number;
|
|
35
|
+
/** The longest wait a continuation may ask for, in milliseconds. */
|
|
36
|
+
export declare const MAX_CONTINUATION_DELAY_MS: number;
|
|
37
|
+
/** What a slice answers when the run is not finished. */
|
|
38
|
+
export interface Continuation {
|
|
39
|
+
/** Where the next slice resumes. Opaque to the platform, never empty. */
|
|
40
|
+
cursor: string;
|
|
41
|
+
/**
|
|
42
|
+
* How long to wait before the next slice, for a slice waiting on a third
|
|
43
|
+
* party, such as an export that finishes later. At most 15 minutes.
|
|
44
|
+
*/
|
|
45
|
+
delayMs?: number;
|
|
46
|
+
/** Progress shown on the execution while it runs, 0 to 100. */
|
|
47
|
+
progressPct?: number;
|
|
48
|
+
/** A line shown beside the progress. */
|
|
49
|
+
progressMessage?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the continuation answer. Throws, before anything is sent, on a
|
|
53
|
+
* continuation the platform would refuse, so the mistake surfaces in the
|
|
54
|
+
* endpoint rather than as a failed run.
|
|
55
|
+
*/
|
|
56
|
+
export declare function continueRun(continuation: Continuation): Response;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
export { createServerClient, ServerClientError } from './server-client';
|
|
2
2
|
export { verifyWebhookSignature } from './webhook-helpers';
|
|
3
3
|
export { verifyScopedToken, parseContextHeader, parseSubject, recordsReadModels, recordsWriteModels, type ScopedTokenClaims, type ScopedTokenSubject, type VerifyScopedTokenOptions, } from './verify-scoped-token';
|
|
4
|
-
export {
|
|
4
|
+
export { createScopedTokenVerifier, ScopedTokenUnavailableError, type ScopedTokenVerifier, type ScopedTokenVerifierOptions, type ScopedTokenVerifierStats, } from './verify-scoped-token';
|
|
5
|
+
export { requireScopedToken, ScopedTokenError, FOIR_TOKEN_HEADER, FOIR_CONTEXT_HEADER, type RequireScopedTokenOptions, type ScopedTokenIngress, } from './require-scoped-token';
|
|
6
|
+
export { readDispatch, OperationPayloadError, type BodyCarrier, } from './read-dispatch';
|
|
5
7
|
export type { ServerClient, ServerClientOptions, GraphQLResponse, EditorPlacement, } from './types';
|
|
6
|
-
export type { OperationPayload, OperationTrigger, OperationRecordContext, OperationContext, OperationResult, OperationCallback, } from './operation-types';
|
|
8
|
+
export type { OperationPayload, OperationTrigger, OperationRecordContext, OperationContext, OperationResult, OperationCallback, OperationContinuationBlock, } from './operation-types';
|
|
9
|
+
export { continueRun, CONTINUATION_MEDIA_TYPE, MAX_CONTINUATION_CURSOR_BYTES, MAX_CONTINUATION_DELAY_MS, type Continuation, } from './continue-run';
|
|
7
10
|
export { createCallbackClient } from './callback-client';
|
|
8
11
|
export type { CallbackClient, CallbackResult } from './callback-client';
|
|
9
12
|
export { createEventsClient, FoirEventCursorExpiredError, isFoirEventCursorExpired, FOIR_EVENTS_PATH, FOIR_EVENTS_SCOPE, FOIR_EVENTS_RETENTION_DAYS, } from './events-client';
|
|
10
13
|
export type { FoirEvent, FoirEventActor, FoirEventPage, FoirEventRetention, FoirEventsClient, FoirEventsClientConfig, FoirEventsListParams, FoirEventsStreamParams, FoirEventsSubscribeParams, } from './events-client';
|
|
11
14
|
export type { FoirSubscription, FoirSubscriptionHandlers } from '../data/ws-transport';
|
|
12
|
-
export { FoirRequestError, isFoirForbidden, isFoirNotFound, isFoirRateLimited, isFoirUnauthenticated, } from '../transport/foir-http';
|
|
15
|
+
export { FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, isFoirForbidden, isFoirNotFound, isFoirRateLimited, isFoirUnauthenticated, } from '../transport/foir-http';
|
|
13
16
|
export type { FoirErrorCode, FoirErrorExtensions, FoirGraphQLErrorItem, FoirAuthDowngrade, } from '../transport/foir-http';
|
|
@@ -1,54 +1,98 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Type definitions for platform operations.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* `OperationPayload` is the body the platform POSTs to an operation endpoint,
|
|
5
|
+
* field for field. It is written against the worker that builds it
|
|
6
|
+
* (`buildOperationPayload` in the platform's operation processor), not against
|
|
7
|
+
* what an endpoint might like to receive: every field here is one the platform
|
|
8
|
+
* sends, present exactly when the platform sends it. A field the platform does
|
|
9
|
+
* not send is not here, because a type that promises one is how an endpoint
|
|
10
|
+
* comes to read `undefined` and refuse every call.
|
|
11
|
+
*
|
|
12
|
+
* Read it with `readDispatch(request)`, which parses and checks the body into
|
|
13
|
+
* this type.
|
|
5
14
|
*/
|
|
6
15
|
/**
|
|
7
|
-
*
|
|
16
|
+
* The body the platform sends an operation endpoint.
|
|
17
|
+
*
|
|
18
|
+
* The same shape however the run was started (a person in the console, an
|
|
19
|
+
* `execute<Op>` call, a lifecycle hook, a schedule) and whichever way it is
|
|
20
|
+
* delivered (to the registered endpoint, or down `foir operations dev` to one
|
|
21
|
+
* on your machine).
|
|
8
22
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
23
|
+
* `TInput` types `input`. It is an assertion, not a check: the platform
|
|
24
|
+
* validates `input` against the operation's input schema before dispatching,
|
|
25
|
+
* and this parameter lets the endpoint say what that schema is.
|
|
11
26
|
*/
|
|
12
|
-
export interface OperationPayload {
|
|
13
|
-
/**
|
|
14
|
-
executionId
|
|
15
|
-
/**
|
|
27
|
+
export interface OperationPayload<TInput extends object = Record<string, unknown>> {
|
|
28
|
+
/** The run. Always present; quote it in a callback and in your own logs. */
|
|
29
|
+
executionId: string;
|
|
30
|
+
/** The key of the operation being run, e.g. `send_quote`. */
|
|
16
31
|
operationKey: string;
|
|
17
|
-
/** How
|
|
32
|
+
/** How the run was started. */
|
|
18
33
|
trigger: OperationTrigger;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* What the caller passed as the operation's input. Always an object:
|
|
36
|
+
* `readDispatch` reads a run started with no input (a schedule that declares
|
|
37
|
+
* none, which the platform serialises as `null`) as `{}`.
|
|
38
|
+
*/
|
|
39
|
+
input: TInput;
|
|
40
|
+
/** The tenant, project and principal the run belongs to. */
|
|
26
41
|
context: OperationContext;
|
|
27
|
-
/** Extension credentials (auto-injected by platform) */
|
|
28
|
-
credentials?: Record<string, unknown>;
|
|
29
|
-
/** Extension config (auto-injected by platform) */
|
|
30
|
-
config?: Record<string, unknown>;
|
|
31
42
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
43
|
+
* The content block the caller attached when it started the run, forwarded
|
|
44
|
+
* as the caller wrote it. Absent when there was none, which is every run
|
|
45
|
+
* started by a hook, a schedule or `execute<Op>`.
|
|
46
|
+
*/
|
|
47
|
+
content?: Record<string, unknown>;
|
|
48
|
+
/**
|
|
49
|
+
* The record block the caller attached when it started the run, forwarded
|
|
50
|
+
* as the caller wrote it. Absent when there was none. For a record event,
|
|
51
|
+
* the record's id arrives in `input` (for example `input.recordId`), not
|
|
52
|
+
* here: read the record back as the operation when you need its content.
|
|
53
|
+
*/
|
|
54
|
+
record?: OperationRecordContext;
|
|
55
|
+
/**
|
|
56
|
+
* Caller-supplied context about where the run was started from, forwarded as
|
|
57
|
+
* written. Absent when there was none.
|
|
58
|
+
*/
|
|
59
|
+
triggerContext?: Record<string, unknown>;
|
|
60
|
+
/**
|
|
61
|
+
* Where and how to report back. Present only when the run was dispatched in
|
|
62
|
+
* ASYNC mode: the endpoint answers `202 Accepted` and reports the outcome
|
|
63
|
+
* later through this block (see `createCallbackClient`).
|
|
36
64
|
*
|
|
37
|
-
* Absent
|
|
38
|
-
*
|
|
65
|
+
* Absent in the blocking (SYNC) mode. A blocking run has no callback path:
|
|
66
|
+
* the endpoint's response body is the result. Branch on this field being
|
|
67
|
+
* present to tell the two modes apart.
|
|
39
68
|
*/
|
|
40
69
|
callback?: OperationCallback;
|
|
70
|
+
/**
|
|
71
|
+
* Where this slice of a run resumes. Present when the previous slice answered
|
|
72
|
+
* with `continueRun`: the cursor it answered, and which slice this is. Slice
|
|
73
|
+
* 0 is a run a dead-letter retry resumes. Absent on a run's first call.
|
|
74
|
+
*/
|
|
75
|
+
continuation?: OperationContinuationBlock;
|
|
76
|
+
}
|
|
77
|
+
/** The dispatch body's continuation block (see `continueRun`). */
|
|
78
|
+
export interface OperationContinuationBlock {
|
|
79
|
+
/** The cursor the previous slice answered, as it wrote it. */
|
|
80
|
+
cursor: string;
|
|
81
|
+
/** Which slice of the run this is. */
|
|
82
|
+
slice: number;
|
|
41
83
|
}
|
|
42
84
|
/**
|
|
43
|
-
* Callback context the platform hands the
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* token), and the per-op mutation names so the extension doesn't need to
|
|
48
|
-
* derive them from the operation key.
|
|
85
|
+
* Callback context the platform hands the endpoint when it dispatches an
|
|
86
|
+
* ASYNC operation. Everything the endpoint needs to call back is here: the
|
|
87
|
+
* address, the token, the key this run signs with, and the per-operation
|
|
88
|
+
* mutation names, so the endpoint derives none of them from the operation key.
|
|
49
89
|
*/
|
|
50
90
|
export interface OperationCallback {
|
|
51
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* The address to report to, e.g. https://api.foir.dev/graphql. Empty when the
|
|
93
|
+
* platform that dispatched has no public address of its own configured;
|
|
94
|
+
* `createCallbackClient` then reports to the `apiUrl` it is given.
|
|
95
|
+
*/
|
|
52
96
|
gqlEndpoint: string;
|
|
53
97
|
/** Bearer token for the Authorization header (Ed25519, single execution scope) */
|
|
54
98
|
token: string;
|
|
@@ -61,53 +105,58 @@ export interface OperationCallback {
|
|
|
61
105
|
progress: string;
|
|
62
106
|
cancel: string;
|
|
63
107
|
};
|
|
108
|
+
/**
|
|
109
|
+
* The operation keys that led to this run, its own last. Present when the
|
|
110
|
+
* run was caused by another operation's work.
|
|
111
|
+
*/
|
|
112
|
+
causationChain?: string[];
|
|
113
|
+
/**
|
|
114
|
+
* The key this run signs its callbacks with, made by the platform for this
|
|
115
|
+
* run alone. `createCallbackClient` signs with it, so an endpoint holds no
|
|
116
|
+
* signing secret of its own. Treat it as a secret: never log or store it.
|
|
117
|
+
*/
|
|
118
|
+
signingKey?: string;
|
|
64
119
|
}
|
|
65
120
|
/**
|
|
66
|
-
*
|
|
121
|
+
* How a run was started. The four values are the platform's own; a caller that
|
|
122
|
+
* names none is recorded as `api`.
|
|
123
|
+
*
|
|
124
|
+
* - `ui`: a console surface that names itself as the trigger, such as an app's
|
|
125
|
+
* configure page.
|
|
126
|
+
* - `api`: an `execute<Op>` call, or any caller that named no trigger.
|
|
127
|
+
* - `lifecycle`: a hook on a platform event (a record published, an operation
|
|
128
|
+
* completed).
|
|
129
|
+
* - `schedule`: a cron schedule firing.
|
|
67
130
|
*/
|
|
68
131
|
export interface OperationTrigger {
|
|
69
|
-
|
|
70
|
-
type: 'ui' | 'api' | 'lifecycle' | 'field';
|
|
71
|
-
/** Field key (for field-triggered operations) */
|
|
72
|
-
fieldKey?: string;
|
|
73
|
-
/** Field type (for field-triggered operations) */
|
|
74
|
-
fieldType?: string;
|
|
132
|
+
type: 'ui' | 'api' | 'lifecycle' | 'schedule';
|
|
75
133
|
}
|
|
76
134
|
/**
|
|
77
|
-
*
|
|
135
|
+
* The record block a caller attached to a run, forwarded as the caller wrote
|
|
136
|
+
* it. The platform does not shape it, so neither does this type.
|
|
78
137
|
*/
|
|
79
|
-
export
|
|
80
|
-
/** Record ID */
|
|
81
|
-
id: string;
|
|
82
|
-
/** Model key (e.g., 'tilly_note') */
|
|
83
|
-
modelKey: string;
|
|
84
|
-
/** Version ID (if versioned record) */
|
|
85
|
-
versionId?: string;
|
|
86
|
-
/** Record content/data */
|
|
87
|
-
data: Record<string, unknown>;
|
|
88
|
-
/** Record metadata */
|
|
89
|
-
metadata: {
|
|
90
|
-
/** Natural key (slug, handle, etc.) */
|
|
91
|
-
naturalKey?: string;
|
|
92
|
-
/** Additional metadata fields */
|
|
93
|
-
[key: string]: unknown;
|
|
94
|
-
};
|
|
95
|
-
}
|
|
138
|
+
export type OperationRecordContext = Record<string, unknown>;
|
|
96
139
|
/**
|
|
97
|
-
*
|
|
140
|
+
* The tenant, project and principal a run belongs to.
|
|
98
141
|
*/
|
|
99
142
|
export interface OperationContext {
|
|
100
143
|
/** Tenant ID */
|
|
101
144
|
tenantId: string;
|
|
102
145
|
/** Project ID */
|
|
103
146
|
projectId: string;
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
147
|
+
/**
|
|
148
|
+
* The principal that started the run. Always present, and the empty string
|
|
149
|
+
* when the run has none, as a hook's run does.
|
|
150
|
+
*/
|
|
151
|
+
userId: string;
|
|
152
|
+
/**
|
|
153
|
+
* The customer the run acts for, when it acts for one: the signed-in
|
|
154
|
+
* customer who ran it, or the owner of the record whose event fired the
|
|
155
|
+
* hook. The dispatch token carries the same customer, so a client built from
|
|
156
|
+
* it acts for them too.
|
|
157
|
+
*/
|
|
107
158
|
customerId?: string;
|
|
108
|
-
/**
|
|
109
|
-
locale?: string;
|
|
110
|
-
/** Timestamp when operation was triggered */
|
|
159
|
+
/** When the platform built this body, RFC 3339 in UTC to the second. */
|
|
111
160
|
timestamp: string;
|
|
112
161
|
}
|
|
113
162
|
/**
|