fareql 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +186 -2
- package/dist/apollo.d.ts +19 -0
- package/dist/apollo.js +100 -0
- package/dist/core.d.ts +119 -0
- package/dist/core.js +211 -0
- package/dist/fetch.d.ts +81 -0
- package/dist/fetch.js +291 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/price.d.ts +103 -0
- package/dist/price.js +485 -0
- package/dist/yoga.d.ts +26 -0
- package/dist/yoga.js +205 -0
- package/package.json +83 -4
package/dist/yoga.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { GraphQLError } from 'graphql';
|
|
2
|
+
import { chargeOperations, createDocumentCache, paymentError, pricingErrorBody, receiptHeaders, } from './core.js';
|
|
3
|
+
import { PricingError, priceOperation } from './price.js';
|
|
4
|
+
const failure = (message, extensions, status, headers) => ({
|
|
5
|
+
errors: [new GraphQLError(message, { extensions: { ...extensions, http: headers ? { status, headers } : { status } } })],
|
|
6
|
+
});
|
|
7
|
+
const isAsyncIterable = (v) => typeof v === 'object' && v !== null && Symbol.asyncIterator in v;
|
|
8
|
+
const hasData = (r) => isAsyncIterable(r) || (typeof r === 'object' && r !== null && r.data != null);
|
|
9
|
+
async function* limitEvents(source, max) {
|
|
10
|
+
if (max <= 0)
|
|
11
|
+
return;
|
|
12
|
+
let n = 0;
|
|
13
|
+
for await (const event of source) {
|
|
14
|
+
yield event;
|
|
15
|
+
if (++n >= max) {
|
|
16
|
+
yield {
|
|
17
|
+
errors: [new GraphQLError('The events paid for are used up; subscribe again to continue', { extensions: { code: 'PAYMENT_EXHAUSTED' } })],
|
|
18
|
+
};
|
|
19
|
+
return; // leaving the loop closes the source iterator
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* GraphQL Yoga plugin. It charges right before execution, after Yoga and every other plugin have
|
|
25
|
+
* parsed, validated and restored the document (APQ, persisted operations, depth limits…), so
|
|
26
|
+
* what is priced is exactly what runs, and nobody pays for an operation a later check rejects.
|
|
27
|
+
* A backstop before the response is sent charges results that skipped execution (response
|
|
28
|
+
* caches, other plugins' shortcuts), so a cached priced result is never served unpaid.
|
|
29
|
+
*
|
|
30
|
+
* Priced operations answer with the MPP Challenge status (402) and `WWW-Authenticate` header
|
|
31
|
+
* until the client retries with a valid Credential; the paid response carries `Payment-Receipt`.
|
|
32
|
+
*/
|
|
33
|
+
export function useFareql(options) {
|
|
34
|
+
const receipts = new WeakMap();
|
|
35
|
+
const batches = new WeakSet();
|
|
36
|
+
const allowed = new WeakMap(); // operations of a request that passed the gate
|
|
37
|
+
const paramsOf = new WeakMap();
|
|
38
|
+
const documents = createDocumentCache(options);
|
|
39
|
+
const exposePrice = options.exposePrice ?? true;
|
|
40
|
+
const maxEvents = options.maxSubscriptionEvents ?? 100;
|
|
41
|
+
let servedSchema;
|
|
42
|
+
const reject = (e) => failure(pricingErrorBody(e, exposePrice).message, pricingErrorBody(e, exposePrice).extensions, 400);
|
|
43
|
+
/** Turns a charge outcome into a result to stop with (or undefined to go on). */
|
|
44
|
+
const settle = (request, outcome) => {
|
|
45
|
+
switch (outcome.status) {
|
|
46
|
+
case 'free':
|
|
47
|
+
return undefined;
|
|
48
|
+
case 'rejected':
|
|
49
|
+
return reject(outcome.error);
|
|
50
|
+
case 'payment-required': {
|
|
51
|
+
const headers = {};
|
|
52
|
+
outcome.challenge.headers.forEach((value, key) => {
|
|
53
|
+
if (key !== 'content-type' && key !== 'content-length')
|
|
54
|
+
headers[key] = value;
|
|
55
|
+
});
|
|
56
|
+
const body = paymentError(outcome.challenge.status, outcome.price, exposePrice);
|
|
57
|
+
return failure(body.message, body.extensions, outcome.challenge.status, headers);
|
|
58
|
+
}
|
|
59
|
+
case 'paid':
|
|
60
|
+
receipts.set(request, receiptHeaders(outcome.withReceipt));
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
// Yoga has consumed the body by the time we charge; give mppx a body-less copy it can clone.
|
|
65
|
+
const forPayment = (request) => new Request(request.url, { method: request.method, headers: request.headers });
|
|
66
|
+
/** Charges one operation. Returns a result to stop with, or undefined to let it run. */
|
|
67
|
+
const gate = async (args) => {
|
|
68
|
+
const schema = options.schema ?? args.schema;
|
|
69
|
+
const input = { document: args.document, operationName: args.operationName, variables: args.variableValues };
|
|
70
|
+
const request = options.getRequest?.(args.contextValue) ?? args.contextValue.request;
|
|
71
|
+
let units;
|
|
72
|
+
try {
|
|
73
|
+
units = priceOperation({ schema, ...input }, options.pricing).units;
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
if (e instanceof PricingError)
|
|
77
|
+
return { stop: reject(e), paid: false };
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
if (!request) {
|
|
81
|
+
// No HTTP request to carry a Credential (e.g. graphql-ws): fail closed for priced operations.
|
|
82
|
+
if (units === 0n)
|
|
83
|
+
return { paid: false };
|
|
84
|
+
return {
|
|
85
|
+
stop: failure('Priced operations need an HTTP request; pass getRequest to charge over this transport', { code: 'PAYMENT_TRANSPORT_UNSUPPORTED' }, 402),
|
|
86
|
+
paid: false,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (units === 0n) {
|
|
90
|
+
allowed.set(request, (allowed.get(request) ?? 0) + 1);
|
|
91
|
+
return { paid: false };
|
|
92
|
+
}
|
|
93
|
+
if (batches.has(request)) {
|
|
94
|
+
// One request carries one Credential, so priced operations cannot share a batch.
|
|
95
|
+
return { stop: failure('Priced operations cannot be batched; send them one per request', { code: 'BATCHING_NOT_SUPPORTED' }, 400), paid: false };
|
|
96
|
+
}
|
|
97
|
+
const outcome = await chargeOperations(forPayment(request), schema, [input], options);
|
|
98
|
+
const stop = settle(request, outcome);
|
|
99
|
+
if (!stop)
|
|
100
|
+
allowed.set(request, (allowed.get(request) ?? 0) + 1);
|
|
101
|
+
return { stop, paid: outcome.status === 'paid' };
|
|
102
|
+
};
|
|
103
|
+
/** Price of params that never reached execution, from their query text. */
|
|
104
|
+
const priceParams = (schema, p) => {
|
|
105
|
+
if (typeof p.query !== 'string' || p.query.length === 0) {
|
|
106
|
+
return new PricingError('Result produced without a query FareQL could price; register useFareql after APQ/persisted-operation plugins', 'UNKNOWN_OPERATION');
|
|
107
|
+
}
|
|
108
|
+
const parsed = documents.get(schema, p.query);
|
|
109
|
+
if (parsed.errors)
|
|
110
|
+
return new PricingError(parsed.errors[0]?.message ?? 'Invalid document', 'UNKNOWN_OPERATION');
|
|
111
|
+
const input = { document: parsed.document, operationName: p.operationName, variables: p.variables };
|
|
112
|
+
try {
|
|
113
|
+
return { input, units: priceOperation({ schema, ...input }, options.pricing).units };
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
if (e instanceof PricingError)
|
|
117
|
+
return e;
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
onSchemaChange({ schema }) {
|
|
123
|
+
servedSchema = schema;
|
|
124
|
+
},
|
|
125
|
+
onRequestParse({ request }) {
|
|
126
|
+
return {
|
|
127
|
+
onRequestParseDone({ requestParserResult }) {
|
|
128
|
+
if (Array.isArray(requestParserResult) && requestParserResult.length > 1)
|
|
129
|
+
batches.add(request);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
onParams({ request, params }) {
|
|
134
|
+
const list = paramsOf.get(request) ?? [];
|
|
135
|
+
list.push(params);
|
|
136
|
+
paramsOf.set(request, list);
|
|
137
|
+
},
|
|
138
|
+
async onExecute({ args, setResultAndStopExecution }) {
|
|
139
|
+
const { stop } = await gate(args);
|
|
140
|
+
if (stop)
|
|
141
|
+
setResultAndStopExecution(stop);
|
|
142
|
+
},
|
|
143
|
+
async onSubscribe({ args, setResultAndStopExecution }) {
|
|
144
|
+
const { stop, paid } = await gate(args);
|
|
145
|
+
if (stop) {
|
|
146
|
+
setResultAndStopExecution(stop);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (!paid)
|
|
150
|
+
return;
|
|
151
|
+
return {
|
|
152
|
+
onSubscribeResult({ result, setResult }) {
|
|
153
|
+
if (isAsyncIterable(result))
|
|
154
|
+
setResult(limitEvents(result, maxEvents));
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
// Backstop: results that did not go through execution (response caches, plugins that
|
|
159
|
+
// short-circuit earlier) are priced here, before anything is sent.
|
|
160
|
+
async onResultProcess({ request, result, setResult }) {
|
|
161
|
+
const schema = options.schema ?? servedSchema;
|
|
162
|
+
const params = paramsOf.get(request) ?? [];
|
|
163
|
+
if (!schema)
|
|
164
|
+
return;
|
|
165
|
+
if (Array.isArray(result)) {
|
|
166
|
+
// Batches: priced operations are never allowed, whichever path produced them.
|
|
167
|
+
const next = result.map((r, i) => {
|
|
168
|
+
if (!hasData(r))
|
|
169
|
+
return r;
|
|
170
|
+
const p = params[i];
|
|
171
|
+
const priced = p ? priceParams(schema, p) : new PricingError('Unmatched batch result', 'UNKNOWN_OPERATION');
|
|
172
|
+
if (priced instanceof PricingError)
|
|
173
|
+
return reject(priced);
|
|
174
|
+
return priced.units === 0n ? r : failure('Priced operations cannot be batched; send them one per request', { code: 'BATCHING_NOT_SUPPORTED' }, 400);
|
|
175
|
+
});
|
|
176
|
+
if (next.some((r, i) => r !== result[i]))
|
|
177
|
+
setResult(next);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if ((allowed.get(request) ?? 0) > 0 || !hasData(result))
|
|
181
|
+
return;
|
|
182
|
+
const p = params[0];
|
|
183
|
+
const priced = p ? priceParams(schema, p) : new PricingError('Result without GraphQL params', 'UNKNOWN_OPERATION');
|
|
184
|
+
if (priced instanceof PricingError)
|
|
185
|
+
return setResult(reject(priced));
|
|
186
|
+
if (priced.units === 0n || !priced.input)
|
|
187
|
+
return;
|
|
188
|
+
const outcome = await chargeOperations(forPayment(request), schema, [priced.input], options);
|
|
189
|
+
const stop = settle(request, outcome);
|
|
190
|
+
if (stop)
|
|
191
|
+
setResult(stop);
|
|
192
|
+
},
|
|
193
|
+
onResponse({ request, response }) {
|
|
194
|
+
const headers = receipts.get(request);
|
|
195
|
+
if (!headers)
|
|
196
|
+
return;
|
|
197
|
+
receipts.delete(request);
|
|
198
|
+
headers.forEach((value, key) => response.headers.set(key, value));
|
|
199
|
+
// Paid content belongs to the payer: never let a shared cache or CDN store it.
|
|
200
|
+
const cc = response.headers.get('cache-control') ?? '';
|
|
201
|
+
if (!/\b(private|no-store)\b/i.test(cc))
|
|
202
|
+
response.headers.set('cache-control', 'private, no-store');
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,85 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fareql",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Charge for GraphQL operations over MPP (HTTP 402), priced by what each operation selects. Plugins for GraphQL Yoga and Apollo Server, plus a wrapper for any Fetch handler.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Bruce0X (https://github.com/Bruce039)",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Bruce039/fareql.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/Bruce039/fareql#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Bruce039/fareql/issues"
|
|
15
|
+
},
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./yoga": {
|
|
23
|
+
"types": "./dist/yoga.d.ts",
|
|
24
|
+
"import": "./dist/yoga.js"
|
|
25
|
+
},
|
|
26
|
+
"./apollo": {
|
|
27
|
+
"types": "./dist/apollo.d.ts",
|
|
28
|
+
"import": "./dist/apollo.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"mpp",
|
|
44
|
+
"mppx",
|
|
45
|
+
"graphql",
|
|
46
|
+
"http-402",
|
|
47
|
+
"payments",
|
|
48
|
+
"tempo",
|
|
49
|
+
"graphql-yoga",
|
|
50
|
+
"apollo-server",
|
|
51
|
+
"pay-per-query",
|
|
52
|
+
"monetization"
|
|
53
|
+
],
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=20"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@apollo/server": "^4.0.0 || ^5.0.0",
|
|
59
|
+
"graphql": "^16.8.0 || ^17.0.0",
|
|
60
|
+
"graphql-yoga": "^5.0.0 || ^6.0.0",
|
|
61
|
+
"mppx": ">=0.17.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@apollo/server": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"graphql-yoga": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@apollo/server": "^5.5.1",
|
|
73
|
+
"@apollo/server-plugin-response-cache": "^5.0.0",
|
|
74
|
+
"@graphql-yoga/plugin-apq": "^4.0.1",
|
|
75
|
+
"@graphql-yoga/plugin-persisted-operations": "^4.0.1",
|
|
76
|
+
"@graphql-yoga/plugin-response-cache": "^4.0.1",
|
|
77
|
+
"@types/node": "^22.19.1",
|
|
78
|
+
"graphql": "^16.15.0",
|
|
79
|
+
"graphql-yoga": "^6.0.1",
|
|
80
|
+
"mppx": "^0.17.1",
|
|
81
|
+
"typescript": "^5.9.3",
|
|
82
|
+
"viem": "^2.57.16",
|
|
83
|
+
"vitest": "^5.1.0"
|
|
84
|
+
}
|
|
85
|
+
}
|