@openreceive/fastify 0.2.1
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 +87 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +103 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpenReceive contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# @openreceive/fastify
|
|
2
|
+
|
|
3
|
+
Fastify adapter for `@openreceive/http`.
|
|
4
|
+
|
|
5
|
+
This package is ESM-only and requires Node >= 22.
|
|
6
|
+
|
|
7
|
+
The all-in-one form is the happy path: register the plugin with the order
|
|
8
|
+
hooks and a database handle, and it builds the service and host itself.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { openReceiveFastify } from "@openreceive/fastify";
|
|
12
|
+
|
|
13
|
+
await fastify.register(openReceiveFastify, {
|
|
14
|
+
wallet: { nwc: process.env.NWC_URI! }, // receive-only; your app refuses to start otherwise
|
|
15
|
+
storage: {
|
|
16
|
+
db, // pg Pool/Client, node:sqlite, better-sqlite3, or a custom adapter
|
|
17
|
+
onPaid: async ({ reference, query }) => {
|
|
18
|
+
// Host SQL reaches your driver VERBATIM: `?` on sqlite as shown,
|
|
19
|
+
// `$1` on postgres. Nothing rewrites placeholders.
|
|
20
|
+
await query("UPDATE orders SET state = 'paid' WHERE id = ?", [reference]);
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
amountFor: (reference) => orders.find(reference)?.amount ?? null, // null → 404
|
|
24
|
+
authorize: ({ resource }) => orders.viewerOwns(resource.reference),
|
|
25
|
+
prefix: "/openreceive",
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`onPaid` runs inside the settlement transaction, only for the order's first
|
|
30
|
+
settled attempt. Do the order update (or insert an outbox row) through the
|
|
31
|
+
supplied `query`: a plain ORM call commits on its own connection, so it would
|
|
32
|
+
survive a rolled-back settlement — settlement side effects belong on `query`.
|
|
33
|
+
Delivery is at-least-once and retried until `onPaid` succeeds, so make it
|
|
34
|
+
idempotent, and keep it to database writes: an email or webhook sent from here
|
|
35
|
+
survives a rolled-back settlement and goes out again on the retry. Flag the
|
|
36
|
+
order and drain it from your own worker after commit.
|
|
37
|
+
|
|
38
|
+
The library-owned repository commits a payment-attempt row in the host's
|
|
39
|
+
existing database before payer instructions are returned, and settlement
|
|
40
|
+
piggybacks on the mounted routes by default through the durable
|
|
41
|
+
`openreceive_meta` gate (`opportunisticReconcile` disables or tunes it);
|
|
42
|
+
`startNotificationWorker` is the optional worker process. Behind a
|
|
43
|
+
reverse proxy, the `trustProxyIpHeader` option attributes `rateLimiting`
|
|
44
|
+
client IPs from a proxy-set header. OpenReceive never requires a separate
|
|
45
|
+
database or Redis.
|
|
46
|
+
|
|
47
|
+
## Advanced: composed form
|
|
48
|
+
|
|
49
|
+
Construct the pieces yourself (shared service, custom repository, tests) and
|
|
50
|
+
pass them in. `createHost` is the persistence step: it owns the
|
|
51
|
+
`openreceive_payments` rows — per-reference commit locking, write-once settlement,
|
|
52
|
+
and the reconciliation state machine.
|
|
53
|
+
|
|
54
|
+
Composing needs `@openreceive/http` and `@openreceive/node` as direct
|
|
55
|
+
dependencies of your app — they are transitive dependencies of this adapter, so
|
|
56
|
+
under pnpm or any strict-resolution install, importing them without adding them
|
|
57
|
+
fails:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
npm install @openreceive/http @openreceive/node
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { openReceiveFastify } from "@openreceive/fastify";
|
|
65
|
+
import { createHost } from "@openreceive/http";
|
|
66
|
+
import { createOpenReceive } from "@openreceive/node";
|
|
67
|
+
|
|
68
|
+
const service = await createOpenReceive(); // reads NWC_URI
|
|
69
|
+
|
|
70
|
+
const host = createHost({
|
|
71
|
+
db,
|
|
72
|
+
amountFor: (reference) => orders.find(reference)?.amount ?? null, // null → 404
|
|
73
|
+
onPaid: async ({ reference, query }) => {
|
|
74
|
+
await query("UPDATE orders SET state = 'paid' WHERE id = ?", [reference]);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await fastify.register(openReceiveFastify, { service, authorize, host, prefix: "/openreceive" });
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
This package re-exports only the curated `@openreceive/http` surface: the
|
|
82
|
+
handler/stack factories, the error surface, the notification worker, the
|
|
83
|
+
options/context/hook types, and the generated `Wire*` wire body
|
|
84
|
+
types. Host-integration internals — `createHost`, the SQL payment
|
|
85
|
+
repository, the reconcile gate, the rate-limit helpers — live only in
|
|
86
|
+
`@openreceive/http`; import them from there when composing your own host
|
|
87
|
+
(`npm run check:public-api` pins both surfaces).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { CreateHttpHandlerOptions, CreateStackOptions } from '@openreceive/http';
|
|
2
|
+
export * from '@openreceive/http/adapter-surface';
|
|
3
|
+
|
|
4
|
+
/** Minimal structural view of the Fastify surface this adapter uses. */
|
|
5
|
+
interface FastifyRequestLike {
|
|
6
|
+
readonly method: string;
|
|
7
|
+
readonly headers: Record<string, string | string[] | undefined>;
|
|
8
|
+
readonly body?: unknown;
|
|
9
|
+
readonly raw: {
|
|
10
|
+
url?: string;
|
|
11
|
+
};
|
|
12
|
+
/** Scheme, honoring Fastify's trustProxy setting (https behind TLS/proxy). */
|
|
13
|
+
readonly protocol?: string;
|
|
14
|
+
}
|
|
15
|
+
interface FastifyReplyLike {
|
|
16
|
+
code(statusCode: number): FastifyReplyLike;
|
|
17
|
+
header(key: string, value: string): FastifyReplyLike;
|
|
18
|
+
send(payload: string): unknown;
|
|
19
|
+
/** Invokes the app's not-found handler for paths this plugin does not own. */
|
|
20
|
+
callNotFound?(): unknown;
|
|
21
|
+
}
|
|
22
|
+
interface FastifyInstanceLike {
|
|
23
|
+
all(path: string, handler: (request: FastifyRequestLike, reply: FastifyReplyLike) => Promise<unknown>): unknown;
|
|
24
|
+
/** Fastify lifecycle hook; used to close an all-in-one stack with the app. */
|
|
25
|
+
addHook?(name: "onClose", hook: () => Promise<void>): unknown;
|
|
26
|
+
/** Accumulated register prefix for this instance (from register's `{ prefix }`). */
|
|
27
|
+
readonly prefix?: string;
|
|
28
|
+
}
|
|
29
|
+
interface FastifyAdapterExtras {
|
|
30
|
+
/**
|
|
31
|
+
* Opt-in client-IP attribution for `rateLimiting` behind a reverse proxy:
|
|
32
|
+
* by default the limiter reads `request.ip`, which is the proxy's address
|
|
33
|
+
* when Fastify's trustProxy is not configured — every payer would then share
|
|
34
|
+
* one budget. Only safe when YOUR reverse proxy sets the header (a
|
|
35
|
+
* direct-to-origin client can forge it). `true` reads the first hop of
|
|
36
|
+
* `x-forwarded-for`; a string names another header (e.g. `"cf-connecting-ip"`).
|
|
37
|
+
*/
|
|
38
|
+
readonly trustProxyIpHeader?: boolean | string;
|
|
39
|
+
}
|
|
40
|
+
interface FastifyHandlerOptions extends CreateHttpHandlerOptions, FastifyAdapterExtras {
|
|
41
|
+
}
|
|
42
|
+
/** All-in-one form: host hooks + `wallet` + `storage`; the plugin builds service and host. */
|
|
43
|
+
interface FastifyStackOptions extends CreateStackOptions, FastifyAdapterExtras {
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Two forms: the all-in-one happy path (host hooks + `wallet` + `storage`; the
|
|
47
|
+
* plugin builds the service and host and closes the owned service on app close
|
|
48
|
+
* — no background process, settlement is opportunistic) or the composed
|
|
49
|
+
* `{ service, host, authorize }` form.
|
|
50
|
+
*/
|
|
51
|
+
type FastifyOptions = FastifyHandlerOptions | FastifyStackOptions;
|
|
52
|
+
/** Fastify plugin serving the OpenReceive routes. Register it with a `prefix`. */
|
|
53
|
+
declare function openReceiveFastify(fastify: FastifyInstanceLike, options: FastifyOptions, done?: (error?: Error) => void): void;
|
|
54
|
+
/**
|
|
55
|
+
* Map a host/service error onto a Fastify JSON reply.
|
|
56
|
+
* Returns `true` when handled; `false` when the caller should rethrow.
|
|
57
|
+
*/
|
|
58
|
+
declare function sendHostRouteError(reply: FastifyReplyLike, error: unknown): boolean;
|
|
59
|
+
|
|
60
|
+
export { type FastifyHandlerOptions, type FastifyInstanceLike, type FastifyOptions, type FastifyReplyLike, type FastifyRequestLike, type FastifyStackOptions, openReceiveFastify, sendHostRouteError };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
createHttpHandler,
|
|
4
|
+
createStack,
|
|
5
|
+
createProxyRateLimitingConfig,
|
|
6
|
+
createRequestId,
|
|
7
|
+
errorResponse,
|
|
8
|
+
isStackOptions,
|
|
9
|
+
mapHostRouteError,
|
|
10
|
+
HttpError,
|
|
11
|
+
isUnderPrefix,
|
|
12
|
+
webRequest
|
|
13
|
+
} from "@openreceive/http";
|
|
14
|
+
export * from "@openreceive/http/adapter-surface";
|
|
15
|
+
function openReceiveFastify(fastify, options, done) {
|
|
16
|
+
const instancePrefix = normalizeFastifyPrefix(fastify.prefix);
|
|
17
|
+
const effectivePrefix = resolveHandlerPrefix(instancePrefix, options.prefix);
|
|
18
|
+
let handler;
|
|
19
|
+
if (isStackOptions(options)) {
|
|
20
|
+
const { trustProxyIpHeader, ...stackOptions } = options;
|
|
21
|
+
const stack = createStack({
|
|
22
|
+
...stackOptions,
|
|
23
|
+
...effectivePrefix,
|
|
24
|
+
...createProxyRateLimitingConfig(stackOptions.rateLimiting, trustProxyIpHeader)
|
|
25
|
+
});
|
|
26
|
+
handler = stack.handler;
|
|
27
|
+
fastify.addHook?.("onClose", () => stack.close());
|
|
28
|
+
} else {
|
|
29
|
+
const { trustProxyIpHeader, ...handlerOptions } = options;
|
|
30
|
+
handler = createHttpHandler({
|
|
31
|
+
...handlerOptions,
|
|
32
|
+
...effectivePrefix,
|
|
33
|
+
...createProxyRateLimitingConfig(handlerOptions.rateLimiting, trustProxyIpHeader)
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
fastify.all("/*", async (request, reply) => {
|
|
37
|
+
const relativeUrl = stripInstancePrefix(request.raw.url ?? "/", instancePrefix);
|
|
38
|
+
const pathname = relativeUrl.split("?")[0];
|
|
39
|
+
if (!isUnderPrefix(pathname, handler.prefix) && reply.callNotFound !== void 0) {
|
|
40
|
+
return reply.callNotFound();
|
|
41
|
+
}
|
|
42
|
+
const response = await respond(handler, request, relativeUrl);
|
|
43
|
+
reply.code(response.status);
|
|
44
|
+
response.headers.forEach((value, key) => {
|
|
45
|
+
reply.header(key, value);
|
|
46
|
+
});
|
|
47
|
+
return reply.send(await response.text());
|
|
48
|
+
});
|
|
49
|
+
done?.();
|
|
50
|
+
}
|
|
51
|
+
function sendHostRouteError(reply, error) {
|
|
52
|
+
const mapped = mapHostRouteError(error);
|
|
53
|
+
if (mapped === null) return false;
|
|
54
|
+
reply.code(mapped.status).header("content-type", "application/json; charset=utf-8").send(JSON.stringify(mapped.body));
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
function resolveHandlerPrefix(instancePrefix, optionsPrefix) {
|
|
58
|
+
if (instancePrefix === "") return {};
|
|
59
|
+
const registerPrefix = normalizeFastifyPrefix(optionsPrefix);
|
|
60
|
+
if (registerPrefix === "") return {};
|
|
61
|
+
if (instancePrefix.endsWith(registerPrefix)) return { prefix: "/" };
|
|
62
|
+
throw new TypeError(
|
|
63
|
+
`openReceiveFastify got prefix "${registerPrefix}" but the instance is registered under "${instancePrefix}". Pass the prefix at register() \u2014 fastify.register(openReceiveFastify, { prefix, ... }) \u2014 so the route scope and the handler agree.`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
function stripInstancePrefix(url, instancePrefix) {
|
|
67
|
+
if (instancePrefix === "") return url;
|
|
68
|
+
if (url === instancePrefix) return "/";
|
|
69
|
+
if (url.startsWith(`${instancePrefix}/`)) return url.slice(instancePrefix.length);
|
|
70
|
+
if (url.startsWith(`${instancePrefix}?`)) return `/${url.slice(instancePrefix.length)}`;
|
|
71
|
+
throw new Error(
|
|
72
|
+
`openReceiveFastify is registered under prefix "${instancePrefix}" but received a request for "${url}". Register the plugin on the instance that serves these routes, or pass the prefix at register() so Fastify scopes the routes to it.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
function normalizeFastifyPrefix(prefix) {
|
|
76
|
+
if (prefix === void 0) return "";
|
|
77
|
+
const trimmed = prefix.trim();
|
|
78
|
+
if (trimmed === "") return "";
|
|
79
|
+
const value = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
80
|
+
const withoutTrailing = value.length > 1 && value.endsWith("/") ? value.slice(0, -1) : value;
|
|
81
|
+
return withoutTrailing === "/" ? "" : withoutTrailing;
|
|
82
|
+
}
|
|
83
|
+
async function respond(handler, request, url) {
|
|
84
|
+
try {
|
|
85
|
+
return await handler(toWebRequest(request, url), { native: request });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof HttpError) return errorResponse(error, createRequestId());
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function toWebRequest(request, url) {
|
|
92
|
+
return webRequest({
|
|
93
|
+
method: request.method,
|
|
94
|
+
headers: request.headers,
|
|
95
|
+
url,
|
|
96
|
+
protocol: request.protocol,
|
|
97
|
+
parsedBody: request.body
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export {
|
|
101
|
+
openReceiveFastify,
|
|
102
|
+
sendHostRouteError
|
|
103
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openreceive/fastify",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Fastify plugin for the OpenReceive checkout HTTP handler.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bitcoin",
|
|
7
|
+
"lightning",
|
|
8
|
+
"payments",
|
|
9
|
+
"nwc",
|
|
10
|
+
"bolt11",
|
|
11
|
+
"openreceive",
|
|
12
|
+
"fastify",
|
|
13
|
+
"checkout"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@openreceive/http": "0.2.1"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"fastify": "^4.0.0 || ^5.0.0"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup src/index.ts --format esm --dts --clean --target es2022 --out-dir dist",
|
|
38
|
+
"prepack": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"author": "OpenReceive <info@openreceive.org>",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/openreceive/openreceive.git",
|
|
45
|
+
"directory": "packages/js/fastify"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/openreceive/openreceive/issues"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://openreceive.org",
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=22"
|
|
56
|
+
}
|
|
57
|
+
}
|