@fougere/transport-http 0.2.0-alpha.2 → 0.4.0-alpha.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 +1 -1
- package/dist/client.d.ts +31 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +47 -6
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/policy.d.ts +24 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +26 -0
- package/dist/policy.js.map +1 -0
- package/dist/receive.d.ts +33 -0
- package/dist/receive.d.ts.map +1 -0
- package/dist/receive.js +78 -0
- package/dist/receive.js.map +1 -0
- package/dist/serve.d.ts +13 -1
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +26 -11
- package/dist/serve.js.map +1 -1
- package/dist/server.d.ts +20 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +39 -3
- package/dist/server.js.map +1 -1
- package/package.json +10 -5
- package/src/client.ts +171 -0
- package/src/index.ts +7 -0
- package/src/jsonrpc.ts +32 -0
- package/src/policy.ts +29 -0
- package/src/receive.ts +111 -0
- package/src/serve.ts +129 -0
- package/src/server.ts +108 -0
package/README.md
CHANGED
|
@@ -14,4 +14,4 @@ pnpm add @fougere/transport-http
|
|
|
14
14
|
|
|
15
15
|
Part of [Fougere](https://github.com/chok/fougere) — one schema, a gradient from
|
|
16
16
|
monolith to distributed, the same user code.
|
|
17
|
-
Reference documentation: [the site](https://
|
|
17
|
+
Reference documentation: [the site](https://fougere.dev/) (en/fr).
|
package/dist/client.d.ts
CHANGED
|
@@ -5,18 +5,45 @@
|
|
|
5
5
|
* typed error (SERVICE_UNAVAILABLE, GATEWAY_TIMEOUT, BAD_GATEWAY), an
|
|
6
6
|
* application failure comes back as the FougereError the façade threw.
|
|
7
7
|
*/
|
|
8
|
-
import { type Transport, type FrondCall, type InvocationContext } from '@fougere/core/contract';
|
|
9
|
-
import type { RpcRequest
|
|
8
|
+
import { type Transport, type FrondCall, type InvocationContext, type SignedCall } from '@fougere/core/contract';
|
|
9
|
+
import type { RpcRequest } from './jsonrpc.js';
|
|
10
10
|
export type { RpcRequest, RpcResponse } from './jsonrpc.js';
|
|
11
11
|
/** Frame a call as a JSON-RPC request. */
|
|
12
12
|
export declare function frameCall(call: FrondCall, invocation: InvocationContext, id: number): RpcRequest;
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Unframe a JSON-RPC response — the result, or the revived FougereError thrown.
|
|
15
|
+
*
|
|
16
|
+
* The answer is JUDGED first: it crossed a process boundary, and `as RpcResponse` at the
|
|
17
|
+
* call site is a claim about it, not a check. A 200 carrying neither `result` nor `error`
|
|
18
|
+
* — a proxy's own JSON, a receiver that is not one — used to return `undefined` as if the
|
|
19
|
+
* op had succeeded, and a `null` body raised `Cannot use 'in' operator`, which is not the
|
|
20
|
+
* failure vocabulary this file promises.
|
|
21
|
+
*/
|
|
22
|
+
export declare function unframeResponse(response: unknown, call: FrondCall): unknown;
|
|
15
23
|
export interface HttpTransportOptions {
|
|
16
24
|
/** Abort a call after this long. A timed-out call may have executed — it is never retried. */
|
|
17
25
|
timeoutMs?: number;
|
|
18
26
|
/** Extra attempts on connection failures only — the request provably never left. */
|
|
19
27
|
retries?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Signs the state this transport sends, turning a claim into something the receiver
|
|
30
|
+
* can check. Supplied rather than built here: signing is `node:crypto`, and this
|
|
31
|
+
* module is published as the browser-safe `/client` subpath — a browser holds no key
|
|
32
|
+
* and never signs. `@fougere/app` wires it from `signEnvelope`.
|
|
33
|
+
*
|
|
34
|
+
* Absent, the state travels as a bare claim and only a receiver that asks for nothing
|
|
35
|
+
* will take it.
|
|
36
|
+
*/
|
|
37
|
+
sign?: (call: SignedCall) => Promise<string>;
|
|
38
|
+
/**
|
|
39
|
+
* Who performs the request. Defaults to the global `fetch`.
|
|
40
|
+
*
|
|
41
|
+
* A Cloudflare service binding answers this shape (`env.CATALOG.fetch`), and on that
|
|
42
|
+
* platform it is not an optimization: a Worker calling a sibling's public URL is
|
|
43
|
+
* refused by the edge (error 1042), so a binding is the only route between two Workers
|
|
44
|
+
* of one account. Nothing else about the call changes — same envelope, same retries.
|
|
45
|
+
*/
|
|
46
|
+
fetch?: (input: string, init: RequestInit) => Promise<Response>;
|
|
20
47
|
}
|
|
21
48
|
export declare function createHttpTransport(baseUrl: string, options?: HttpTransportOptions): Transport;
|
|
22
49
|
//# sourceMappingURL=client.d.ts.map
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAA2B,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAA2B,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAAE,KAAK,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAC1I,OAAO,KAAK,EAAE,UAAU,EAA8B,MAAM,cAAc,CAAC;AAC3E,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAE5D,0CAA0C;AAC1C,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,EAAE,UAAU,EAAE,iBAAiB,EAAE,EAAE,EAAE,MAAM,GAAG,UAAU,CAEhG;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CA4B3E;AAED,MAAM,WAAW,oBAAoB;IACnC,8FAA8F;IAC9F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;;;OAQG;IACH,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7C;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;CACjE;AAKD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,oBAAyB,GAAG,SAAS,CA6ElG"}
|
package/dist/client.js
CHANGED
|
@@ -10,14 +10,39 @@ import { FougereError, ErrorCode } from '@fougere/core/contract';
|
|
|
10
10
|
export function frameCall(call, invocation, id) {
|
|
11
11
|
return { jsonrpc: '2.0', id, method: `${call.entity}.${call.op}`, params: invocation };
|
|
12
12
|
}
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Unframe a JSON-RPC response — the result, or the revived FougereError thrown.
|
|
15
|
+
*
|
|
16
|
+
* The answer is JUDGED first: it crossed a process boundary, and `as RpcResponse` at the
|
|
17
|
+
* call site is a claim about it, not a check. A 200 carrying neither `result` nor `error`
|
|
18
|
+
* — a proxy's own JSON, a receiver that is not one — used to return `undefined` as if the
|
|
19
|
+
* op had succeeded, and a `null` body raised `Cannot use 'in' operator`, which is not the
|
|
20
|
+
* failure vocabulary this file promises.
|
|
21
|
+
*/
|
|
14
22
|
export function unframeResponse(response, call) {
|
|
23
|
+
if (!response || typeof response !== 'object' || !('result' in response || 'error' in response)) {
|
|
24
|
+
throw new FougereError({
|
|
25
|
+
code: ErrorCode.BAD_GATEWAY,
|
|
26
|
+
message: 'Answered neither a result nor an error — not a Fougere receiver?',
|
|
27
|
+
entity: call.entity,
|
|
28
|
+
operation: call.op,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
15
31
|
if ('error' in response) {
|
|
16
|
-
|
|
17
|
-
|
|
32
|
+
const error = response.error;
|
|
33
|
+
if (!error || typeof error !== 'object') {
|
|
34
|
+
throw new FougereError({
|
|
35
|
+
code: ErrorCode.BAD_GATEWAY,
|
|
36
|
+
message: 'Answered an error that is not a JSON-RPC error object',
|
|
37
|
+
entity: call.entity,
|
|
38
|
+
operation: call.op,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (error.data !== undefined)
|
|
42
|
+
throw FougereError.fromJSON(error.data);
|
|
18
43
|
throw new FougereError({
|
|
19
44
|
code: ErrorCode.INTERNAL_ERROR,
|
|
20
|
-
message: `${
|
|
45
|
+
message: `${error.message} (rpc ${error.code})`,
|
|
21
46
|
entity: call.entity,
|
|
22
47
|
operation: call.op,
|
|
23
48
|
});
|
|
@@ -28,15 +53,31 @@ export function unframeResponse(response, call) {
|
|
|
28
53
|
const CONNECTION_FAILURES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']);
|
|
29
54
|
export function createHttpTransport(baseUrl, options = {}) {
|
|
30
55
|
const url = `${baseUrl.replace(/\/$/, '')}/_fougere/call`;
|
|
56
|
+
// Who performs the request, when it is not the global one. A Cloudflare service binding
|
|
57
|
+
// is exactly this shape — and on that platform it is the ONLY way two Workers of one
|
|
58
|
+
// account reach each other: measured, a Worker fetching a sibling's public URL is
|
|
59
|
+
// refused by the edge with error 1042 before the request leaves. The framing above and
|
|
60
|
+
// below is identical either way, which is the whole reason this is one option and not
|
|
61
|
+
// a second transport.
|
|
62
|
+
const send = options.fetch ?? fetch;
|
|
31
63
|
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
32
64
|
const retries = options.retries ?? 1;
|
|
33
65
|
let nextId = 1;
|
|
34
66
|
return async (call, invocation) => {
|
|
35
|
-
|
|
67
|
+
// The envelope REPLACES the state on the wire — sending both would leave the
|
|
68
|
+
// receiver choosing between a proof and a claim about the same thing.
|
|
69
|
+
// `caller` is dropped on every hop: it names who signed THIS call, so carrying the
|
|
70
|
+
// one this process was handed would make `shop → catalog → billing` read `shop`.
|
|
71
|
+
// It travels outside the envelope anyway, so a stale one would be unsigned too.
|
|
72
|
+
const { caller: _established, ...forwarded } = invocation;
|
|
73
|
+
const sent = options.sign
|
|
74
|
+
? { ...forwarded, state: {}, identity: await options.sign({ ...call, ...invocation }) }
|
|
75
|
+
: forwarded;
|
|
76
|
+
const request = frameCall(call, sent, nextId++);
|
|
36
77
|
for (let attempt = 0;; attempt++) {
|
|
37
78
|
let res;
|
|
38
79
|
try {
|
|
39
|
-
res = await
|
|
80
|
+
res = await send(url, {
|
|
40
81
|
method: 'POST',
|
|
41
82
|
headers: { 'content-type': 'application/json' },
|
|
42
83
|
body: JSON.stringify(request),
|
package/dist/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,YAAY,EAAE,SAAS,
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createHttpTransport, frameCall, unframeResponse, type HttpTransportOptions } from './client.js';
|
|
2
|
-
export { handleRpc } from './server.js';
|
|
2
|
+
export { handleRpc, type ReceiveOptions } from './server.js';
|
|
3
|
+
export { receive, type ReceiveHttpOptions } from './receive.js';
|
|
4
|
+
export { CALL_PATH, MAX_BODY_BYTES } from './policy.js';
|
|
3
5
|
export { serve, type ServeOptions, type RunningReceiver } from './serve.js';
|
|
4
6
|
export { APP_ERROR, PARSE_ERROR, INVALID_REQUEST } from './jsonrpc.js';
|
|
5
7
|
export type { RpcRequest, RpcResponse, RpcErrorShape } from './jsonrpc.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACzG,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACzG,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAAE,OAAO,EAAE,KAAK,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAAE,KAAK,EAAE,KAAK,YAAY,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAC5E,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AACvE,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createHttpTransport, frameCall, unframeResponse } from './client.js';
|
|
2
2
|
export { handleRpc } from './server.js';
|
|
3
|
+
export { receive } from './receive.js';
|
|
4
|
+
export { CALL_PATH, MAX_BODY_BYTES } from './policy.js';
|
|
3
5
|
export { serve } from './serve.js';
|
|
4
6
|
export { APP_ERROR, PARSE_ERROR, INVALID_REQUEST } from './jsonrpc.js';
|
|
5
7
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,eAAe,EAA6B,MAAM,aAAa,CAAC;AACzG,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,eAAe,EAA6B,MAAM,aAAa,CAAC;AACzG,OAAO,EAAE,SAAS,EAAuB,MAAM,aAAa,CAAC;AAC7D,OAAO,EAAE,OAAO,EAA2B,MAAM,cAAc,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAAE,KAAK,EAA2C,MAAM,YAAY,CAAC;AAC5E,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every receiver of the envelope decides the same way, and what it does not.
|
|
3
|
+
*
|
|
4
|
+
* The cap and the answers are POLICY — `1024 * 1024` was spelled in four packages and
|
|
5
|
+
* omitted by the receiver written by hand. Reading the body is PLUMBING, and it belongs to
|
|
6
|
+
* the transport: a `node:http` stream and a `Request` body are read differently, and putting
|
|
7
|
+
* Node through the Web reader HALVED the door that took it (measured, 2026-08-22: the JSON-RPC
|
|
8
|
+
* door fell from 0.677 to 0.341 of its hand-written baseline, while the REST door, which never
|
|
9
|
+
* traverses `serve()`, sat at 0.653 in that same run).
|
|
10
|
+
*
|
|
11
|
+
* So this file holds the decisions. Each receiver keeps its own reader.
|
|
12
|
+
*/
|
|
13
|
+
import { type RpcResponse } from './jsonrpc.js';
|
|
14
|
+
/** What a receiver accepts before it stops reading. */
|
|
15
|
+
export declare const MAX_BODY_BYTES: number;
|
|
16
|
+
/** The path the envelope answers on. A host mounting it elsewhere passes its own. */
|
|
17
|
+
export declare const CALL_PATH = "/_fougere/call";
|
|
18
|
+
/** The answer to a payload that was not JSON. */
|
|
19
|
+
export declare const parseError: () => RpcResponse;
|
|
20
|
+
/** The answer to a body over the cap. */
|
|
21
|
+
export declare const tooLarge: () => {
|
|
22
|
+
error: string;
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=policy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAe,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAE7D,uDAAuD;AACvD,eAAO,MAAM,cAAc,QAAc,CAAC;AAE1C,qFAAqF;AACrF,eAAO,MAAM,SAAS,mBAAmB,CAAC;AAE1C,iDAAiD;AACjD,eAAO,MAAM,UAAU,QAAO,WAI5B,CAAC;AAEH,yCAAyC;AACzC,eAAO,MAAM,QAAQ;IAAY,KAAK;CAAwB,CAAC"}
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every receiver of the envelope decides the same way, and what it does not.
|
|
3
|
+
*
|
|
4
|
+
* The cap and the answers are POLICY — `1024 * 1024` was spelled in four packages and
|
|
5
|
+
* omitted by the receiver written by hand. Reading the body is PLUMBING, and it belongs to
|
|
6
|
+
* the transport: a `node:http` stream and a `Request` body are read differently, and putting
|
|
7
|
+
* Node through the Web reader HALVED the door that took it (measured, 2026-08-22: the JSON-RPC
|
|
8
|
+
* door fell from 0.677 to 0.341 of its hand-written baseline, while the REST door, which never
|
|
9
|
+
* traverses `serve()`, sat at 0.653 in that same run).
|
|
10
|
+
*
|
|
11
|
+
* So this file holds the decisions. Each receiver keeps its own reader.
|
|
12
|
+
*/
|
|
13
|
+
import { PARSE_ERROR } from './jsonrpc.js';
|
|
14
|
+
/** What a receiver accepts before it stops reading. */
|
|
15
|
+
export const MAX_BODY_BYTES = 1024 * 1024;
|
|
16
|
+
/** The path the envelope answers on. A host mounting it elsewhere passes its own. */
|
|
17
|
+
export const CALL_PATH = '/_fougere/call';
|
|
18
|
+
/** The answer to a payload that was not JSON. */
|
|
19
|
+
export const parseError = () => ({
|
|
20
|
+
jsonrpc: '2.0',
|
|
21
|
+
id: null,
|
|
22
|
+
error: { code: PARSE_ERROR, message: 'Parse error' },
|
|
23
|
+
});
|
|
24
|
+
/** The answer to a body over the cap. */
|
|
25
|
+
export const tooLarge = () => ({ error: 'Payload too large' });
|
|
26
|
+
//# sourceMappingURL=policy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy.js","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,WAAW,EAAoB,MAAM,cAAc,CAAC;AAE7D,uDAAuD;AACvD,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,GAAG,IAAI,CAAC;AAE1C,qFAAqF;AACrF,MAAM,CAAC,MAAM,SAAS,GAAG,gBAAgB,CAAC;AAE1C,iDAAiD;AACjD,MAAM,CAAC,MAAM,UAAU,GAAG,GAAgB,EAAE,CAAC,CAAC;IAC5C,OAAO,EAAE,KAAK;IACd,EAAE,EAAE,IAAI;IACR,KAAK,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,aAAa,EAAE;CACrD,CAAC,CAAC;AAEH,yCAAyC;AACzC,MAAM,CAAC,MAAM,QAAQ,GAAG,GAAG,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC,CAAC"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The envelope door for a host that speaks `Request`/`Response` — hono, a Worker, Next,
|
|
3
|
+
* SvelteKit. It reads a `Request` body; `serve` reads a `node:http` stream, and the two
|
|
4
|
+
* stay separate on purpose: putting Node through this reader HALVED the Node door
|
|
5
|
+
* (0.677 → 0.341 of its baseline, measured 2026-08-22).
|
|
6
|
+
*
|
|
7
|
+
* What they share is `policy.ts` — the cap and the answers. Nothing here is edge-specific
|
|
8
|
+
* beyond that: `handleRpc` is the wire and knows no HTTP at all.
|
|
9
|
+
*/
|
|
10
|
+
import { type ReceiveOptions } from './server.js';
|
|
11
|
+
import type { Transport } from '@fougere/core/contract';
|
|
12
|
+
export interface ReceiveHttpOptions extends ReceiveOptions {
|
|
13
|
+
/** Maximum JSON-RPC body size. Default: 1 MiB. */
|
|
14
|
+
maxBodyBytes?: number;
|
|
15
|
+
/** The path this door answers. Default: `/_fougere/call`. */
|
|
16
|
+
path?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Take unsigned calls, deliberately.
|
|
19
|
+
*
|
|
20
|
+
* `serve()` reads the decision off the ADDRESS — loopback by default, and widening it
|
|
21
|
+
* is written down. This door binds nothing: its host mounts it wherever it likes, and a
|
|
22
|
+
* `Request` arrives with no bound address to consult. So the decision has to be stated,
|
|
23
|
+
* and the default is the safe one: with no `verify` and no word here, this REFUSES to
|
|
24
|
+
* be built. A door that starts and then believes whatever `state` it is handed is the
|
|
25
|
+
* hole `identity.ts` exists to close, and it was open here.
|
|
26
|
+
*
|
|
27
|
+
* The one case that legitimately needs it beyond local development is the same as
|
|
28
|
+
* `serve`'s: something in front already established the peer.
|
|
29
|
+
*/
|
|
30
|
+
allowUnsigned?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export declare function receive(runner: Transport, options?: ReceiveHttpOptions): (request: Request) => Promise<Response>;
|
|
33
|
+
//# sourceMappingURL=receive.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"receive.d.ts","sourceRoot":"","sources":["../src/receive.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAa,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAExD,MAAM,WAAW,kBAAmB,SAAQ,cAAc;IACxD,kDAAkD;IAClD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;;;;;;;OAYG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AA4CD,wBAAgB,OAAO,CACrB,MAAM,EAAE,SAAS,EACjB,OAAO,GAAE,kBAAuB,GAC/B,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CA+BzC"}
|
package/dist/receive.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The envelope door for a host that speaks `Request`/`Response` — hono, a Worker, Next,
|
|
3
|
+
* SvelteKit. It reads a `Request` body; `serve` reads a `node:http` stream, and the two
|
|
4
|
+
* stay separate on purpose: putting Node through this reader HALVED the Node door
|
|
5
|
+
* (0.677 → 0.341 of its baseline, measured 2026-08-22).
|
|
6
|
+
*
|
|
7
|
+
* What they share is `policy.ts` — the cap and the answers. Nothing here is edge-specific
|
|
8
|
+
* beyond that: `handleRpc` is the wire and knows no HTTP at all.
|
|
9
|
+
*/
|
|
10
|
+
import { handleRpc } from './server.js';
|
|
11
|
+
import { MAX_BODY_BYTES, CALL_PATH, parseError, tooLarge } from './policy.js';
|
|
12
|
+
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
13
|
+
/** Distinguishes "the body was too large" from any body a caller could actually send. */
|
|
14
|
+
const TOO_LARGE = Symbol('too large');
|
|
15
|
+
/**
|
|
16
|
+
* The declared length is a cheap refusal; the count is the real one. A `content-length` may
|
|
17
|
+
* be absent, and it may lie — the copy that trusted it alone had no cap at all in that case.
|
|
18
|
+
*/
|
|
19
|
+
async function bodyWithin(request, max) {
|
|
20
|
+
const declared = Number(request.headers.get('content-length'));
|
|
21
|
+
if (Number.isFinite(declared) && declared > max)
|
|
22
|
+
return TOO_LARGE;
|
|
23
|
+
const reader = request.body?.getReader();
|
|
24
|
+
if (!reader)
|
|
25
|
+
return '';
|
|
26
|
+
const chunks = [];
|
|
27
|
+
let size = 0;
|
|
28
|
+
for (;;) {
|
|
29
|
+
const { done, value } = await reader.read();
|
|
30
|
+
if (done)
|
|
31
|
+
break;
|
|
32
|
+
size += value.byteLength;
|
|
33
|
+
if (size > max) {
|
|
34
|
+
await reader.cancel();
|
|
35
|
+
return TOO_LARGE;
|
|
36
|
+
}
|
|
37
|
+
chunks.push(value);
|
|
38
|
+
}
|
|
39
|
+
return new TextDecoder().decode(concat(chunks, size));
|
|
40
|
+
}
|
|
41
|
+
function concat(chunks, size) {
|
|
42
|
+
const all = new Uint8Array(size);
|
|
43
|
+
let at = 0;
|
|
44
|
+
for (const chunk of chunks) {
|
|
45
|
+
all.set(chunk, at);
|
|
46
|
+
at += chunk.byteLength;
|
|
47
|
+
}
|
|
48
|
+
return all;
|
|
49
|
+
}
|
|
50
|
+
export function receive(runner, options = {}) {
|
|
51
|
+
// At CONSTRUCTION and not per call, for the reason `serve` refuses at bind: a receiver
|
|
52
|
+
// that starts and then rejects everything is found in production.
|
|
53
|
+
if (!options.verify && !options.allowUnsigned) {
|
|
54
|
+
throw new Error('A Fougere receiver takes the `state` it is handed, so this door needs to know who is calling.\n'
|
|
55
|
+
+ ' Wire `verify` (see `verifyEnvelope`, and `fougere keys` / `fougere grant`),\n'
|
|
56
|
+
+ ' or say `allowUnsigned: true` — which is right for local development and for a\n'
|
|
57
|
+
+ ' mesh whose sidecar already established the peer.');
|
|
58
|
+
}
|
|
59
|
+
const max = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
60
|
+
const path = options.path ?? CALL_PATH;
|
|
61
|
+
return async (request) => {
|
|
62
|
+
if (request.method !== 'POST' || new URL(request.url).pathname !== path) {
|
|
63
|
+
return new Response(null, { status: 404 });
|
|
64
|
+
}
|
|
65
|
+
const raw = await bodyWithin(request, max);
|
|
66
|
+
if (raw === TOO_LARGE)
|
|
67
|
+
return json(tooLarge(), 413);
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(raw);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return json(parseError());
|
|
74
|
+
}
|
|
75
|
+
return json(await handleRpc(runner, parsed, options));
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=receive.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"receive.js","sourceRoot":"","sources":["../src/receive.ts"],"names":[],"mappings":"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"}
|
package/dist/serve.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Transport } from '@fougere/core';
|
|
2
|
-
|
|
2
|
+
import { type ReceiveOptions } from './server.js';
|
|
3
|
+
export interface ServeOptions extends ReceiveOptions {
|
|
3
4
|
/** Port to listen on. 0 (default) picks a free one. */
|
|
4
5
|
port?: number;
|
|
5
6
|
/**
|
|
@@ -13,6 +14,17 @@ export interface ServeOptions {
|
|
|
13
14
|
hosts?: string[];
|
|
14
15
|
/** Which address to bind. Must be one of `hosts`. Defaults to its first. */
|
|
15
16
|
host?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Serve unsigned calls beyond loopback, deliberately.
|
|
19
|
+
*
|
|
20
|
+
* The one case that legitimately needs it: something in front already established the
|
|
21
|
+
* peer — a service mesh whose sidecar terminated mTLS, an ingress doing client certs.
|
|
22
|
+
* Asking for a second signature there would redo what was just done a centimetre away.
|
|
23
|
+
*
|
|
24
|
+
* It is spelled separately from `requireIdentity` on purpose: that one arrives `false`
|
|
25
|
+
* by default from `identityFromEnv`, so it cannot also mean "I thought about this".
|
|
26
|
+
*/
|
|
27
|
+
allowUnsigned?: boolean;
|
|
16
28
|
/** Maximum JSON-RPC body size. Default: 1 MiB. */
|
|
17
29
|
maxBodyBytes?: number;
|
|
18
30
|
/** Time allowed to receive a request. Default: 15 seconds. */
|
package/dist/serve.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE/C,OAAO,EAAa,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAG7D,MAAM,WAAW,YAAa,SAAQ,cAAc;IAClD,uDAAuD;IACvD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,kDAAkD;IAClD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8DAA8D;IAC9D,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,qDAAqD;AACrD,eAAO,MAAM,cAAc,UAAoC,CAAC;AAEhE,wBAAgB,KAAK,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,eAAe,CAAC,CA6E7F"}
|
package/dist/serve.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* half inside an existing server (@fougere/http, Nitro) is layer-2 work.
|
|
6
6
|
*/
|
|
7
7
|
import { createServer } from 'node:http';
|
|
8
|
-
import { PARSE_ERROR } from './jsonrpc.js';
|
|
9
8
|
import { handleRpc } from './server.js';
|
|
9
|
+
import { MAX_BODY_BYTES, CALL_PATH, parseError, tooLarge } from './policy.js';
|
|
10
10
|
/** What a frond binds when nobody says otherwise. */
|
|
11
11
|
export const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1'];
|
|
12
12
|
export function serve(runner, options = {}) {
|
|
@@ -15,17 +15,32 @@ export function serve(runner, options = {}) {
|
|
|
15
15
|
return Promise.reject(new Error('A Fougere receiver needs at least one host to bind, `hosts` is empty'));
|
|
16
16
|
}
|
|
17
17
|
const host = options.host ?? allowed[0];
|
|
18
|
-
//
|
|
19
|
-
// the
|
|
20
|
-
//
|
|
21
|
-
// rather than inferred
|
|
22
|
-
// claim any user — so the answer stays identity at the Frond.
|
|
18
|
+
// Where a receiver binds, and what it admits, are two questions now. `verify` answers
|
|
19
|
+
// the second — without it this receiver still takes the `state` it is handed, and
|
|
20
|
+
// `hosts` is all that stands. The default keeps it on the machine; widening it is
|
|
21
|
+
// written down rather than inferred, and a widened receiver wants `requireIdentity`.
|
|
23
22
|
if (!allowed.includes(host)) {
|
|
24
23
|
return Promise.reject(new Error(`A Fougere receiver binds one of [${allowed.join(', ')}], got '${host}' — add it to \`hosts\` to allow it`));
|
|
25
24
|
}
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Loopback or signed — there is no third way to serve.
|
|
27
|
+
*
|
|
28
|
+
* The address already carries the decision: binding beyond loopback is a deliberate
|
|
29
|
+
* act (`hosts` says so), and a receiver reachable from outside that establishes nothing
|
|
30
|
+
* takes the `state` it is handed. Refusing at BOOT and not per call is the point — a
|
|
31
|
+
* receiver that starts and then rejects everything is discovered in production, one
|
|
32
|
+
* that will not start is discovered at deployment.
|
|
33
|
+
*/
|
|
34
|
+
if (!LOOPBACK_HOSTS.includes(host) && !options.verify && !options.allowUnsigned) {
|
|
35
|
+
return Promise.reject(new Error(`A Fougere receiver on '${host}' is reachable from outside this machine and would believe whatever `
|
|
36
|
+
+ 'state it is handed.\n'
|
|
37
|
+
+ ' - `fougere keys` once, then inject FOUGERE_ROOT_KEY here (and `fougere grant <frond>` for each caller), or\n'
|
|
38
|
+
+ ' - keep it on loopback, or\n'
|
|
39
|
+
+ ' - pass `allowUnsigned: true` if a mesh or an ingress already authenticated the caller.'));
|
|
40
|
+
}
|
|
41
|
+
const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
27
42
|
const server = createServer(async (req, res) => {
|
|
28
|
-
if (req.method !== 'POST' || req.url !==
|
|
43
|
+
if (req.method !== 'POST' || req.url !== CALL_PATH) {
|
|
29
44
|
res.writeHead(404).end();
|
|
30
45
|
return;
|
|
31
46
|
}
|
|
@@ -35,7 +50,7 @@ export function serve(runner, options = {}) {
|
|
|
35
50
|
const chunk = Buffer.from(value);
|
|
36
51
|
size += chunk.length;
|
|
37
52
|
if (size > maxBodyBytes) {
|
|
38
|
-
res.writeHead(413, { 'content-type': 'application/json' }).end(JSON.stringify(
|
|
53
|
+
res.writeHead(413, { 'content-type': 'application/json' }).end(JSON.stringify(tooLarge()));
|
|
39
54
|
return;
|
|
40
55
|
}
|
|
41
56
|
chunks.push(chunk);
|
|
@@ -43,10 +58,10 @@ export function serve(runner, options = {}) {
|
|
|
43
58
|
let response;
|
|
44
59
|
try {
|
|
45
60
|
const raw = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
46
|
-
response = await handleRpc(runner, raw);
|
|
61
|
+
response = await handleRpc(runner, raw, options);
|
|
47
62
|
}
|
|
48
63
|
catch {
|
|
49
|
-
response =
|
|
64
|
+
response = parseError();
|
|
50
65
|
}
|
|
51
66
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(response));
|
|
52
67
|
});
|
package/dist/serve.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serve.js","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"serve.js","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"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"}
|
package/dist/server.d.ts
CHANGED
|
@@ -4,7 +4,25 @@
|
|
|
4
4
|
* Judges nothing: validation and middlewares live with the handler, inside
|
|
5
5
|
* the runner. The error a façade throws is framed whole, never flattened.
|
|
6
6
|
*/
|
|
7
|
-
import { type Transport } from '@fougere/core';
|
|
7
|
+
import { type SignedCall, type Transport } from '@fougere/core/contract';
|
|
8
8
|
import { type RpcResponse } from './jsonrpc.js';
|
|
9
|
-
|
|
9
|
+
/** What a receiver does with the caller's envelope. */
|
|
10
|
+
export interface ReceiveOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Establishes who signed, or throws. Supplied rather than built here for the reason
|
|
13
|
+
* the sender's `sign` is: verifying is `node:crypto` and this package carries none.
|
|
14
|
+
* `@fougere/app` wires it from `verifyEnvelope` and the root public key.
|
|
15
|
+
*/
|
|
16
|
+
verify?: (identity: string, presented: SignedCall) => Promise<{
|
|
17
|
+
caller: string;
|
|
18
|
+
state: Record<string, unknown>;
|
|
19
|
+
}>;
|
|
20
|
+
/**
|
|
21
|
+
* Refuse a call carrying no verifiable identity — the whole of "secure by default"
|
|
22
|
+
* at the wire. Without it a receiver takes the state it is handed and its only
|
|
23
|
+
* protection is the address it bound.
|
|
24
|
+
*/
|
|
25
|
+
requireIdentity?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare function handleRpc(runner: Transport, raw: unknown, options?: ReceiveOptions): Promise<RpcResponse>;
|
|
10
28
|
//# sourceMappingURL=server.d.ts.map
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAkE,KAAK,SAAS,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAkE,KAAK,UAAU,EAAE,KAAK,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACzI,OAAO,EAA+C,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAE7F,uDAAuD;AACvD,MAAM,WAAW,cAAc;IAC7B;;;;OAIG;IACH,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,UAAU,KAAK,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAClH;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,WAAW,CAAC,CA0EnH"}
|
package/dist/server.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Judges nothing: validation and middlewares live with the handler, inside
|
|
5
5
|
* the runner. The error a façade throws is framed whole, never flattened.
|
|
6
6
|
*/
|
|
7
|
-
import { FougereError, ErrorCode, toPublicError } from '@fougere/core';
|
|
7
|
+
import { FougereError, ErrorCode, toPublicError } from '@fougere/core/contract';
|
|
8
8
|
import { APP_ERROR, INVALID_REQUEST } from './jsonrpc.js';
|
|
9
|
-
export async function handleRpc(runner, raw) {
|
|
9
|
+
export async function handleRpc(runner, raw, options = {}) {
|
|
10
10
|
const req = raw;
|
|
11
11
|
const id = typeof req?.id === 'number' || typeof req?.id === 'string' ? req.id : null;
|
|
12
12
|
if (!req || req.jsonrpc !== '2.0' || typeof req.method !== 'string' || id === null) {
|
|
@@ -24,11 +24,42 @@ export async function handleRpc(runner, raw) {
|
|
|
24
24
|
const op = req.method.slice(dot + 1);
|
|
25
25
|
// Fresh objects — middlewares deposit into state, nothing may be shared.
|
|
26
26
|
const sent = (req.params ?? {});
|
|
27
|
+
/**
|
|
28
|
+
* State is ESTABLISHED here, or it is only claimed.
|
|
29
|
+
*
|
|
30
|
+
* `sent.state` is what the caller typed; a verified envelope is what it can prove, and
|
|
31
|
+
* when a verifier is wired the envelope REPLACES the claim rather than enriching it —
|
|
32
|
+
* carrying both would leave every reader downstream choosing between them.
|
|
33
|
+
*/
|
|
34
|
+
let state = sent.state ?? {};
|
|
35
|
+
let caller;
|
|
36
|
+
if (options.verify && sent.identity) {
|
|
37
|
+
try {
|
|
38
|
+
// What ARRIVED, never what we would rather it had been — the comparison is the point.
|
|
39
|
+
({ caller, state } = await options.verify(sent.identity, {
|
|
40
|
+
entity,
|
|
41
|
+
op,
|
|
42
|
+
params: sent.params ?? {},
|
|
43
|
+
query: sent.query ?? {},
|
|
44
|
+
body: sent.body,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
return refused(id, err?.message ?? 'unverifiable identity', entity, op);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else if (options.requireIdentity) {
|
|
52
|
+
return refused(id, 'this receiver takes signed calls only', entity, op);
|
|
53
|
+
}
|
|
54
|
+
// Built field by field, never spread from `sent`: everything here is either judged
|
|
55
|
+
// above or carried deliberately, and `caller` is ours to write alone.
|
|
27
56
|
const invocation = {
|
|
28
57
|
params: sent.params ?? {},
|
|
29
58
|
query: sent.query ?? {},
|
|
30
59
|
body: sent.body,
|
|
31
|
-
state
|
|
60
|
+
state,
|
|
61
|
+
trace: sent.trace,
|
|
62
|
+
...(caller ? { caller } : {}),
|
|
32
63
|
};
|
|
33
64
|
try {
|
|
34
65
|
return { jsonrpc: '2.0', id, result: await runner({ entity, op }, invocation) };
|
|
@@ -47,4 +78,9 @@ export async function handleRpc(runner, raw) {
|
|
|
47
78
|
return { jsonrpc: '2.0', id, error: { code: APP_ERROR, message: data.message, data } };
|
|
48
79
|
}
|
|
49
80
|
}
|
|
81
|
+
/** An admission refusal, framed like any other failure so a caller reads one vocabulary. */
|
|
82
|
+
function refused(id, why, entity, op) {
|
|
83
|
+
const data = toPublicError(new FougereError({ code: ErrorCode.UNAUTHORIZED, message: `Refused: ${why}`, entity, operation: op }));
|
|
84
|
+
return { jsonrpc: '2.0', id, error: { code: APP_ERROR, message: data.message, data } };
|
|
85
|
+
}
|
|
50
86
|
//# sourceMappingURL=server.js.map
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,aAAa,
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fougere/transport-http",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-alpha.0",
|
|
4
4
|
"description": "The call contract on the wire, in JSON-RPC 2.0 (server + client).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fougere",
|
|
@@ -24,21 +24,26 @@
|
|
|
24
24
|
"types": "./dist/index.d.ts",
|
|
25
25
|
"import": "./dist/index.js"
|
|
26
26
|
},
|
|
27
|
+
"./receive": {
|
|
28
|
+
"types": "./dist/receive.d.ts",
|
|
29
|
+
"import": "./dist/receive.js"
|
|
30
|
+
},
|
|
27
31
|
"./client": {
|
|
28
32
|
"types": "./dist/client.d.ts",
|
|
29
33
|
"import": "./dist/client.js"
|
|
30
34
|
}
|
|
31
35
|
},
|
|
32
36
|
"files": [
|
|
33
|
-
"dist"
|
|
37
|
+
"dist",
|
|
38
|
+
"src"
|
|
34
39
|
],
|
|
35
40
|
"dependencies": {
|
|
36
|
-
"@fougere/core": "0.
|
|
41
|
+
"@fougere/core": "0.4.0-alpha.0"
|
|
37
42
|
},
|
|
38
43
|
"devDependencies": {
|
|
39
44
|
"jiti": "^2.6.1",
|
|
40
|
-
"@fougere/schema": "0.
|
|
41
|
-
"@fougere/container": "0.
|
|
45
|
+
"@fougere/schema": "0.4.0-alpha.0",
|
|
46
|
+
"@fougere/container": "0.4.0-alpha.0"
|
|
42
47
|
},
|
|
43
48
|
"publishConfig": {
|
|
44
49
|
"access": "public"
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending half — frame the call, POST it, unframe the result.
|
|
3
|
+
*
|
|
4
|
+
* Failure vocabulary is FougereError only: a transport failure becomes a
|
|
5
|
+
* typed error (SERVICE_UNAVAILABLE, GATEWAY_TIMEOUT, BAD_GATEWAY), an
|
|
6
|
+
* application failure comes back as the FougereError the façade threw.
|
|
7
|
+
*/
|
|
8
|
+
import { FougereError, ErrorCode, type Transport, type FrondCall, type InvocationContext, type SignedCall } from '@fougere/core/contract';
|
|
9
|
+
import type { RpcRequest, RpcResponse, RpcErrorShape } from './jsonrpc.js';
|
|
10
|
+
export type { RpcRequest, RpcResponse } from './jsonrpc.js';
|
|
11
|
+
|
|
12
|
+
/** Frame a call as a JSON-RPC request. */
|
|
13
|
+
export function frameCall(call: FrondCall, invocation: InvocationContext, id: number): RpcRequest {
|
|
14
|
+
return { jsonrpc: '2.0', id, method: `${call.entity}.${call.op}`, params: invocation };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Unframe a JSON-RPC response — the result, or the revived FougereError thrown.
|
|
19
|
+
*
|
|
20
|
+
* The answer is JUDGED first: it crossed a process boundary, and `as RpcResponse` at the
|
|
21
|
+
* call site is a claim about it, not a check. A 200 carrying neither `result` nor `error`
|
|
22
|
+
* — a proxy's own JSON, a receiver that is not one — used to return `undefined` as if the
|
|
23
|
+
* op had succeeded, and a `null` body raised `Cannot use 'in' operator`, which is not the
|
|
24
|
+
* failure vocabulary this file promises.
|
|
25
|
+
*/
|
|
26
|
+
export function unframeResponse(response: unknown, call: FrondCall): unknown {
|
|
27
|
+
if (!response || typeof response !== 'object' || !('result' in response || 'error' in response)) {
|
|
28
|
+
throw new FougereError({
|
|
29
|
+
code: ErrorCode.BAD_GATEWAY,
|
|
30
|
+
message: 'Answered neither a result nor an error — not a Fougere receiver?',
|
|
31
|
+
entity: call.entity,
|
|
32
|
+
operation: call.op,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if ('error' in response) {
|
|
36
|
+
const error = response.error as Partial<RpcErrorShape> | null;
|
|
37
|
+
if (!error || typeof error !== 'object') {
|
|
38
|
+
throw new FougereError({
|
|
39
|
+
code: ErrorCode.BAD_GATEWAY,
|
|
40
|
+
message: 'Answered an error that is not a JSON-RPC error object',
|
|
41
|
+
entity: call.entity,
|
|
42
|
+
operation: call.op,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (error.data !== undefined) throw FougereError.fromJSON(error.data);
|
|
46
|
+
throw new FougereError({
|
|
47
|
+
code: ErrorCode.INTERNAL_ERROR,
|
|
48
|
+
message: `${error.message} (rpc ${error.code})`,
|
|
49
|
+
entity: call.entity,
|
|
50
|
+
operation: call.op,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return response.result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface HttpTransportOptions {
|
|
57
|
+
/** Abort a call after this long. A timed-out call may have executed — it is never retried. */
|
|
58
|
+
timeoutMs?: number;
|
|
59
|
+
/** Extra attempts on connection failures only — the request provably never left. */
|
|
60
|
+
retries?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Signs the state this transport sends, turning a claim into something the receiver
|
|
63
|
+
* can check. Supplied rather than built here: signing is `node:crypto`, and this
|
|
64
|
+
* module is published as the browser-safe `/client` subpath — a browser holds no key
|
|
65
|
+
* and never signs. `@fougere/app` wires it from `signEnvelope`.
|
|
66
|
+
*
|
|
67
|
+
* Absent, the state travels as a bare claim and only a receiver that asks for nothing
|
|
68
|
+
* will take it.
|
|
69
|
+
*/
|
|
70
|
+
sign?: (call: SignedCall) => Promise<string>;
|
|
71
|
+
/**
|
|
72
|
+
* Who performs the request. Defaults to the global `fetch`.
|
|
73
|
+
*
|
|
74
|
+
* A Cloudflare service binding answers this shape (`env.CATALOG.fetch`), and on that
|
|
75
|
+
* platform it is not an optimization: a Worker calling a sibling's public URL is
|
|
76
|
+
* refused by the edge (error 1042), so a binding is the only route between two Workers
|
|
77
|
+
* of one account. Nothing else about the call changes — same envelope, same retries.
|
|
78
|
+
*/
|
|
79
|
+
fetch?: (input: string, init: RequestInit) => Promise<Response>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Failures where the request never reached the other side — safe to retry. */
|
|
83
|
+
const CONNECTION_FAILURES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']);
|
|
84
|
+
|
|
85
|
+
export function createHttpTransport(baseUrl: string, options: HttpTransportOptions = {}): Transport {
|
|
86
|
+
const url = `${baseUrl.replace(/\/$/, '')}/_fougere/call`;
|
|
87
|
+
// Who performs the request, when it is not the global one. A Cloudflare service binding
|
|
88
|
+
// is exactly this shape — and on that platform it is the ONLY way two Workers of one
|
|
89
|
+
// account reach each other: measured, a Worker fetching a sibling's public URL is
|
|
90
|
+
// refused by the edge with error 1042 before the request leaves. The framing above and
|
|
91
|
+
// below is identical either way, which is the whole reason this is one option and not
|
|
92
|
+
// a second transport.
|
|
93
|
+
const send = options.fetch ?? fetch;
|
|
94
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
95
|
+
const retries = options.retries ?? 1;
|
|
96
|
+
let nextId = 1;
|
|
97
|
+
|
|
98
|
+
return async (call, invocation) => {
|
|
99
|
+
// The envelope REPLACES the state on the wire — sending both would leave the
|
|
100
|
+
// receiver choosing between a proof and a claim about the same thing.
|
|
101
|
+
// `caller` is dropped on every hop: it names who signed THIS call, so carrying the
|
|
102
|
+
// one this process was handed would make `shop → catalog → billing` read `shop`.
|
|
103
|
+
// It travels outside the envelope anyway, so a stale one would be unsigned too.
|
|
104
|
+
const { caller: _established, ...forwarded } = invocation;
|
|
105
|
+
const sent = options.sign
|
|
106
|
+
? { ...forwarded, state: {}, identity: await options.sign({ ...call, ...invocation }) }
|
|
107
|
+
: forwarded;
|
|
108
|
+
const request = frameCall(call, sent, nextId++);
|
|
109
|
+
|
|
110
|
+
for (let attempt = 0; ; attempt++) {
|
|
111
|
+
let res: Response;
|
|
112
|
+
try {
|
|
113
|
+
res = await send(url, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'content-type': 'application/json' },
|
|
116
|
+
body: JSON.stringify(request),
|
|
117
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
118
|
+
});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (isTimeout(err)) {
|
|
121
|
+
throw new FougereError({
|
|
122
|
+
code: ErrorCode.GATEWAY_TIMEOUT,
|
|
123
|
+
message: `${call.entity}.${call.op} timed out after ${timeoutMs}ms (${baseUrl})`,
|
|
124
|
+
entity: call.entity,
|
|
125
|
+
operation: call.op,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (attempt < retries && isConnectionFailure(err)) continue;
|
|
129
|
+
throw new FougereError({
|
|
130
|
+
code: ErrorCode.SERVICE_UNAVAILABLE,
|
|
131
|
+
message: `${baseUrl} unreachable: ${(err as Error)?.message ?? err}`,
|
|
132
|
+
entity: call.entity,
|
|
133
|
+
operation: call.op,
|
|
134
|
+
cause: err,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
throw new FougereError({
|
|
140
|
+
code: ErrorCode.BAD_GATEWAY,
|
|
141
|
+
message: `${baseUrl} answered HTTP ${res.status} — not a Fougere receiver?`,
|
|
142
|
+
entity: call.entity,
|
|
143
|
+
operation: call.op,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let response: RpcResponse;
|
|
148
|
+
try {
|
|
149
|
+
response = (await res.json()) as RpcResponse;
|
|
150
|
+
} catch {
|
|
151
|
+
throw new FougereError({
|
|
152
|
+
code: ErrorCode.BAD_GATEWAY,
|
|
153
|
+
message: `${baseUrl} answered non-JSON`,
|
|
154
|
+
entity: call.entity,
|
|
155
|
+
operation: call.op,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return unframeResponse(response, call);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isTimeout(err: unknown): boolean {
|
|
165
|
+
return err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isConnectionFailure(err: unknown): boolean {
|
|
169
|
+
const code = (err as { cause?: { code?: string } })?.cause?.code;
|
|
170
|
+
return typeof code === 'string' && CONNECTION_FAILURES.has(code);
|
|
171
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createHttpTransport, frameCall, unframeResponse, type HttpTransportOptions } from './client.js';
|
|
2
|
+
export { handleRpc, type ReceiveOptions } from './server.js';
|
|
3
|
+
export { receive, type ReceiveHttpOptions } from './receive.js';
|
|
4
|
+
export { CALL_PATH, MAX_BODY_BYTES } from './policy.js';
|
|
5
|
+
export { serve, type ServeOptions, type RunningReceiver } from './serve.js';
|
|
6
|
+
export { APP_ERROR, PARSE_ERROR, INVALID_REQUEST } from './jsonrpc.js';
|
|
7
|
+
export type { RpcRequest, RpcResponse, RpcErrorShape } from './jsonrpc.js';
|
package/src/jsonrpc.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-RPC 2.0 framing — the standard written form of a call.
|
|
3
|
+
*
|
|
4
|
+
* The spec is the reuse: these few lines follow it, no lib adds anything.
|
|
5
|
+
* `method` is `entity.op`; `params` is the InvocationContext untouched.
|
|
6
|
+
* A FougereError travels whole in `error.data` (spec keeps `error.code`
|
|
7
|
+
* for integers — the semantic code lives in the data).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface RpcRequest {
|
|
11
|
+
jsonrpc: '2.0';
|
|
12
|
+
id: number | string;
|
|
13
|
+
method: string;
|
|
14
|
+
params?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RpcErrorShape {
|
|
18
|
+
code: number;
|
|
19
|
+
message: string;
|
|
20
|
+
data?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RpcResponse =
|
|
24
|
+
| { jsonrpc: '2.0'; id: number | string | null; result: unknown }
|
|
25
|
+
| { jsonrpc: '2.0'; id: number | string | null; error: RpcErrorShape };
|
|
26
|
+
|
|
27
|
+
/** Application failure — a FougereError, carried in error.data. */
|
|
28
|
+
export const APP_ERROR = -32000;
|
|
29
|
+
/** Body wasn't JSON (spec-reserved). */
|
|
30
|
+
export const PARSE_ERROR = -32700;
|
|
31
|
+
/** Not a valid JSON-RPC 2.0 call (spec-reserved). */
|
|
32
|
+
export const INVALID_REQUEST = -32600;
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every receiver of the envelope decides the same way, and what it does not.
|
|
3
|
+
*
|
|
4
|
+
* The cap and the answers are POLICY — `1024 * 1024` was spelled in four packages and
|
|
5
|
+
* omitted by the receiver written by hand. Reading the body is PLUMBING, and it belongs to
|
|
6
|
+
* the transport: a `node:http` stream and a `Request` body are read differently, and putting
|
|
7
|
+
* Node through the Web reader HALVED the door that took it (measured, 2026-08-22: the JSON-RPC
|
|
8
|
+
* door fell from 0.677 to 0.341 of its hand-written baseline, while the REST door, which never
|
|
9
|
+
* traverses `serve()`, sat at 0.653 in that same run).
|
|
10
|
+
*
|
|
11
|
+
* So this file holds the decisions. Each receiver keeps its own reader.
|
|
12
|
+
*/
|
|
13
|
+
import { PARSE_ERROR, type RpcResponse } from './jsonrpc.js';
|
|
14
|
+
|
|
15
|
+
/** What a receiver accepts before it stops reading. */
|
|
16
|
+
export const MAX_BODY_BYTES = 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
/** The path the envelope answers on. A host mounting it elsewhere passes its own. */
|
|
19
|
+
export const CALL_PATH = '/_fougere/call';
|
|
20
|
+
|
|
21
|
+
/** The answer to a payload that was not JSON. */
|
|
22
|
+
export const parseError = (): RpcResponse => ({
|
|
23
|
+
jsonrpc: '2.0',
|
|
24
|
+
id: null,
|
|
25
|
+
error: { code: PARSE_ERROR, message: 'Parse error' },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** The answer to a body over the cap. */
|
|
29
|
+
export const tooLarge = () => ({ error: 'Payload too large' });
|
package/src/receive.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The envelope door for a host that speaks `Request`/`Response` — hono, a Worker, Next,
|
|
3
|
+
* SvelteKit. It reads a `Request` body; `serve` reads a `node:http` stream, and the two
|
|
4
|
+
* stay separate on purpose: putting Node through this reader HALVED the Node door
|
|
5
|
+
* (0.677 → 0.341 of its baseline, measured 2026-08-22).
|
|
6
|
+
*
|
|
7
|
+
* What they share is `policy.ts` — the cap and the answers. Nothing here is edge-specific
|
|
8
|
+
* beyond that: `handleRpc` is the wire and knows no HTTP at all.
|
|
9
|
+
*/
|
|
10
|
+
import { handleRpc, type ReceiveOptions } from './server.js';
|
|
11
|
+
import { MAX_BODY_BYTES, CALL_PATH, parseError, tooLarge } from './policy.js';
|
|
12
|
+
import type { Transport } from '@fougere/core/contract';
|
|
13
|
+
|
|
14
|
+
export interface ReceiveHttpOptions extends ReceiveOptions {
|
|
15
|
+
/** Maximum JSON-RPC body size. Default: 1 MiB. */
|
|
16
|
+
maxBodyBytes?: number;
|
|
17
|
+
/** The path this door answers. Default: `/_fougere/call`. */
|
|
18
|
+
path?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Take unsigned calls, deliberately.
|
|
21
|
+
*
|
|
22
|
+
* `serve()` reads the decision off the ADDRESS — loopback by default, and widening it
|
|
23
|
+
* is written down. This door binds nothing: its host mounts it wherever it likes, and a
|
|
24
|
+
* `Request` arrives with no bound address to consult. So the decision has to be stated,
|
|
25
|
+
* and the default is the safe one: with no `verify` and no word here, this REFUSES to
|
|
26
|
+
* be built. A door that starts and then believes whatever `state` it is handed is the
|
|
27
|
+
* hole `identity.ts` exists to close, and it was open here.
|
|
28
|
+
*
|
|
29
|
+
* The one case that legitimately needs it beyond local development is the same as
|
|
30
|
+
* `serve`'s: something in front already established the peer.
|
|
31
|
+
*/
|
|
32
|
+
allowUnsigned?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const json = (body: unknown, status = 200) =>
|
|
36
|
+
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
37
|
+
|
|
38
|
+
/** Distinguishes "the body was too large" from any body a caller could actually send. */
|
|
39
|
+
const TOO_LARGE = Symbol('too large');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The declared length is a cheap refusal; the count is the real one. A `content-length` may
|
|
43
|
+
* be absent, and it may lie — the copy that trusted it alone had no cap at all in that case.
|
|
44
|
+
*/
|
|
45
|
+
async function bodyWithin(request: Request, max: number): Promise<string | typeof TOO_LARGE> {
|
|
46
|
+
const declared = Number(request.headers.get('content-length'));
|
|
47
|
+
if (Number.isFinite(declared) && declared > max) return TOO_LARGE;
|
|
48
|
+
|
|
49
|
+
const reader = request.body?.getReader();
|
|
50
|
+
if (!reader) return '';
|
|
51
|
+
|
|
52
|
+
const chunks: Uint8Array[] = [];
|
|
53
|
+
let size = 0;
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
size += value.byteLength;
|
|
58
|
+
if (size > max) {
|
|
59
|
+
await reader.cancel();
|
|
60
|
+
return TOO_LARGE;
|
|
61
|
+
}
|
|
62
|
+
chunks.push(value);
|
|
63
|
+
}
|
|
64
|
+
return new TextDecoder().decode(concat(chunks, size));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function concat(chunks: readonly Uint8Array[], size: number): Uint8Array {
|
|
68
|
+
const all = new Uint8Array(size);
|
|
69
|
+
let at = 0;
|
|
70
|
+
for (const chunk of chunks) {
|
|
71
|
+
all.set(chunk, at);
|
|
72
|
+
at += chunk.byteLength;
|
|
73
|
+
}
|
|
74
|
+
return all;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function receive(
|
|
78
|
+
runner: Transport,
|
|
79
|
+
options: ReceiveHttpOptions = {},
|
|
80
|
+
): (request: Request) => Promise<Response> {
|
|
81
|
+
// At CONSTRUCTION and not per call, for the reason `serve` refuses at bind: a receiver
|
|
82
|
+
// that starts and then rejects everything is found in production.
|
|
83
|
+
if (!options.verify && !options.allowUnsigned) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
'A Fougere receiver takes the `state` it is handed, so this door needs to know who is calling.\n'
|
|
86
|
+
+ ' Wire `verify` (see `verifyEnvelope`, and `fougere keys` / `fougere grant`),\n'
|
|
87
|
+
+ ' or say `allowUnsigned: true` — which is right for local development and for a\n'
|
|
88
|
+
+ ' mesh whose sidecar already established the peer.',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const max = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
93
|
+
const path = options.path ?? CALL_PATH;
|
|
94
|
+
|
|
95
|
+
return async (request) => {
|
|
96
|
+
if (request.method !== 'POST' || new URL(request.url).pathname !== path) {
|
|
97
|
+
return new Response(null, { status: 404 });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const raw = await bodyWithin(request, max);
|
|
101
|
+
if (raw === TOO_LARGE) return json(tooLarge(), 413);
|
|
102
|
+
|
|
103
|
+
let parsed: unknown;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(raw);
|
|
106
|
+
} catch {
|
|
107
|
+
return json(parseError());
|
|
108
|
+
}
|
|
109
|
+
return json(await handleRpc(runner, parsed, options));
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone receiver — enough to host a Frond in its own process.
|
|
3
|
+
*
|
|
4
|
+
* Plain node:http, one route: POST /_fougere/call. Mounting the receiving
|
|
5
|
+
* half inside an existing server (@fougere/http, Nitro) is layer-2 work.
|
|
6
|
+
*/
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
import type { Transport } from '@fougere/core';
|
|
9
|
+
import type { RpcResponse } from './jsonrpc.js';
|
|
10
|
+
import { handleRpc, type ReceiveOptions } from './server.js';
|
|
11
|
+
import { MAX_BODY_BYTES, CALL_PATH, parseError, tooLarge } from './policy.js';
|
|
12
|
+
|
|
13
|
+
export interface ServeOptions extends ReceiveOptions {
|
|
14
|
+
/** Port to listen on. 0 (default) picks a free one. */
|
|
15
|
+
port?: number;
|
|
16
|
+
/**
|
|
17
|
+
* The addresses this receiver may bind. Default: loopback only.
|
|
18
|
+
*
|
|
19
|
+
* A receiver reads the caller's identity off the wire and re-establishes
|
|
20
|
+
* nothing, so reaching beyond the machine is a decision its operator takes
|
|
21
|
+
* — stating it here IS taking it. The default is what a frond on a laptop
|
|
22
|
+
* wants; a container wants `hosts: ['0.0.0.0']` and says so.
|
|
23
|
+
*/
|
|
24
|
+
hosts?: string[];
|
|
25
|
+
/** Which address to bind. Must be one of `hosts`. Defaults to its first. */
|
|
26
|
+
host?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Serve unsigned calls beyond loopback, deliberately.
|
|
29
|
+
*
|
|
30
|
+
* The one case that legitimately needs it: something in front already established the
|
|
31
|
+
* peer — a service mesh whose sidecar terminated mTLS, an ingress doing client certs.
|
|
32
|
+
* Asking for a second signature there would redo what was just done a centimetre away.
|
|
33
|
+
*
|
|
34
|
+
* It is spelled separately from `requireIdentity` on purpose: that one arrives `false`
|
|
35
|
+
* by default from `identityFromEnv`, so it cannot also mean "I thought about this".
|
|
36
|
+
*/
|
|
37
|
+
allowUnsigned?: boolean;
|
|
38
|
+
/** Maximum JSON-RPC body size. Default: 1 MiB. */
|
|
39
|
+
maxBodyBytes?: number;
|
|
40
|
+
/** Time allowed to receive a request. Default: 15 seconds. */
|
|
41
|
+
requestTimeoutMs?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RunningReceiver {
|
|
45
|
+
port: number;
|
|
46
|
+
close(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a frond binds when nobody says otherwise. */
|
|
50
|
+
export const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1'];
|
|
51
|
+
|
|
52
|
+
export function serve(runner: Transport, options: ServeOptions = {}): Promise<RunningReceiver> {
|
|
53
|
+
const allowed = options.hosts ?? LOOPBACK_HOSTS;
|
|
54
|
+
if (allowed.length === 0) {
|
|
55
|
+
return Promise.reject(new Error('A Fougere receiver needs at least one host to bind, `hosts` is empty'));
|
|
56
|
+
}
|
|
57
|
+
const host = options.host ?? allowed[0];
|
|
58
|
+
// Where a receiver binds, and what it admits, are two questions now. `verify` answers
|
|
59
|
+
// the second — without it this receiver still takes the `state` it is handed, and
|
|
60
|
+
// `hosts` is all that stands. The default keeps it on the machine; widening it is
|
|
61
|
+
// written down rather than inferred, and a widened receiver wants `requireIdentity`.
|
|
62
|
+
if (!allowed.includes(host)) {
|
|
63
|
+
return Promise.reject(
|
|
64
|
+
new Error(`A Fougere receiver binds one of [${allowed.join(', ')}], got '${host}' — add it to \`hosts\` to allow it`),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Loopback or signed — there is no third way to serve.
|
|
69
|
+
*
|
|
70
|
+
* The address already carries the decision: binding beyond loopback is a deliberate
|
|
71
|
+
* act (`hosts` says so), and a receiver reachable from outside that establishes nothing
|
|
72
|
+
* takes the `state` it is handed. Refusing at BOOT and not per call is the point — a
|
|
73
|
+
* receiver that starts and then rejects everything is discovered in production, one
|
|
74
|
+
* that will not start is discovered at deployment.
|
|
75
|
+
*/
|
|
76
|
+
if (!LOOPBACK_HOSTS.includes(host) && !options.verify && !options.allowUnsigned) {
|
|
77
|
+
return Promise.reject(
|
|
78
|
+
new Error(
|
|
79
|
+
`A Fougere receiver on '${host}' is reachable from outside this machine and would believe whatever `
|
|
80
|
+
+ 'state it is handed.\n'
|
|
81
|
+
+ ' - `fougere keys` once, then inject FOUGERE_ROOT_KEY here (and `fougere grant <frond>` for each caller), or\n'
|
|
82
|
+
+ ' - keep it on loopback, or\n'
|
|
83
|
+
+ ' - pass `allowUnsigned: true` if a mesh or an ingress already authenticated the caller.',
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
89
|
+
const server = createServer(async (req, res) => {
|
|
90
|
+
if (req.method !== 'POST' || req.url !== CALL_PATH) {
|
|
91
|
+
res.writeHead(404).end();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const chunks: Buffer[] = [];
|
|
96
|
+
let size = 0;
|
|
97
|
+
for await (const value of req) {
|
|
98
|
+
const chunk = Buffer.from(value as Uint8Array);
|
|
99
|
+
size += chunk.length;
|
|
100
|
+
if (size > maxBodyBytes) {
|
|
101
|
+
res.writeHead(413, { 'content-type': 'application/json' }).end(JSON.stringify(tooLarge()));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
chunks.push(chunk);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let response: RpcResponse;
|
|
108
|
+
try {
|
|
109
|
+
const raw = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
110
|
+
response = await handleRpc(runner, raw, options);
|
|
111
|
+
} catch {
|
|
112
|
+
response = parseError();
|
|
113
|
+
}
|
|
114
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(response));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
server.once('error', reject);
|
|
119
|
+
server.requestTimeout = options.requestTimeoutMs ?? 15_000;
|
|
120
|
+
server.listen(options.port ?? 0, host, () => {
|
|
121
|
+
const address = server.address();
|
|
122
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
123
|
+
resolve({
|
|
124
|
+
port,
|
|
125
|
+
close: () => new Promise<void>((done, fail) => server.close((err) => (err ? fail(err) : done()))),
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receiving half — unframe the call, run it, frame what comes out.
|
|
3
|
+
*
|
|
4
|
+
* Judges nothing: validation and middlewares live with the handler, inside
|
|
5
|
+
* the runner. The error a façade throws is framed whole, never flattened.
|
|
6
|
+
*/
|
|
7
|
+
import { FougereError, ErrorCode, toPublicError, type InvocationContext, type SignedCall, type Transport } from '@fougere/core/contract';
|
|
8
|
+
import { APP_ERROR, INVALID_REQUEST, type RpcRequest, type RpcResponse } from './jsonrpc.js';
|
|
9
|
+
|
|
10
|
+
/** What a receiver does with the caller's envelope. */
|
|
11
|
+
export interface ReceiveOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Establishes who signed, or throws. Supplied rather than built here for the reason
|
|
14
|
+
* the sender's `sign` is: verifying is `node:crypto` and this package carries none.
|
|
15
|
+
* `@fougere/app` wires it from `verifyEnvelope` and the root public key.
|
|
16
|
+
*/
|
|
17
|
+
verify?: (identity: string, presented: SignedCall) => Promise<{ caller: string; state: Record<string, unknown> }>;
|
|
18
|
+
/**
|
|
19
|
+
* Refuse a call carrying no verifiable identity — the whole of "secure by default"
|
|
20
|
+
* at the wire. Without it a receiver takes the state it is handed and its only
|
|
21
|
+
* protection is the address it bound.
|
|
22
|
+
*/
|
|
23
|
+
requireIdentity?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function handleRpc(runner: Transport, raw: unknown, options: ReceiveOptions = {}): Promise<RpcResponse> {
|
|
27
|
+
const req = raw as Partial<RpcRequest> | null;
|
|
28
|
+
const id = typeof req?.id === 'number' || typeof req?.id === 'string' ? req.id : null;
|
|
29
|
+
|
|
30
|
+
if (!req || req.jsonrpc !== '2.0' || typeof req.method !== 'string' || id === null) {
|
|
31
|
+
return { jsonrpc: '2.0', id, error: { code: INVALID_REQUEST, message: 'Invalid JSON-RPC 2.0 request' } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const dot = req.method.indexOf('.');
|
|
35
|
+
if (dot <= 0 || dot === req.method.length - 1) {
|
|
36
|
+
return {
|
|
37
|
+
jsonrpc: '2.0',
|
|
38
|
+
id,
|
|
39
|
+
error: { code: INVALID_REQUEST, message: `Invalid method '${req.method}' — expected 'entity.op'` },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const entity = req.method.slice(0, dot);
|
|
43
|
+
const op = req.method.slice(dot + 1);
|
|
44
|
+
|
|
45
|
+
// Fresh objects — middlewares deposit into state, nothing may be shared.
|
|
46
|
+
const sent = (req.params ?? {}) as Partial<InvocationContext>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* State is ESTABLISHED here, or it is only claimed.
|
|
50
|
+
*
|
|
51
|
+
* `sent.state` is what the caller typed; a verified envelope is what it can prove, and
|
|
52
|
+
* when a verifier is wired the envelope REPLACES the claim rather than enriching it —
|
|
53
|
+
* carrying both would leave every reader downstream choosing between them.
|
|
54
|
+
*/
|
|
55
|
+
let state: Record<string, unknown> = sent.state ?? {};
|
|
56
|
+
let caller: string | undefined;
|
|
57
|
+
if (options.verify && sent.identity) {
|
|
58
|
+
try {
|
|
59
|
+
// What ARRIVED, never what we would rather it had been — the comparison is the point.
|
|
60
|
+
({ caller, state } = await options.verify(sent.identity, {
|
|
61
|
+
entity,
|
|
62
|
+
op,
|
|
63
|
+
params: sent.params ?? {},
|
|
64
|
+
query: sent.query ?? {},
|
|
65
|
+
body: sent.body,
|
|
66
|
+
}));
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return refused(id, (err as Error)?.message ?? 'unverifiable identity', entity, op);
|
|
69
|
+
}
|
|
70
|
+
} else if (options.requireIdentity) {
|
|
71
|
+
return refused(id, 'this receiver takes signed calls only', entity, op);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Built field by field, never spread from `sent`: everything here is either judged
|
|
75
|
+
// above or carried deliberately, and `caller` is ours to write alone.
|
|
76
|
+
const invocation: InvocationContext = {
|
|
77
|
+
params: sent.params ?? {},
|
|
78
|
+
query: sent.query ?? {},
|
|
79
|
+
body: sent.body,
|
|
80
|
+
state,
|
|
81
|
+
trace: sent.trace,
|
|
82
|
+
...(caller ? { caller } : {}),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
return { jsonrpc: '2.0', id, result: await runner({ entity, op }, invocation) };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const failure = err instanceof FougereError
|
|
89
|
+
? err
|
|
90
|
+
: new FougereError({
|
|
91
|
+
code: ErrorCode.INTERNAL_ERROR,
|
|
92
|
+
message: (err as Error)?.message ?? 'Internal error',
|
|
93
|
+
entity,
|
|
94
|
+
operation: op,
|
|
95
|
+
cause: err,
|
|
96
|
+
});
|
|
97
|
+
const data = toPublicError(failure);
|
|
98
|
+
return { jsonrpc: '2.0', id, error: { code: APP_ERROR, message: data.message, data } };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** An admission refusal, framed like any other failure so a caller reads one vocabulary. */
|
|
103
|
+
function refused(id: string | number, why: string, entity: string, op: string): RpcResponse {
|
|
104
|
+
const data = toPublicError(
|
|
105
|
+
new FougereError({ code: ErrorCode.UNAUTHORIZED, message: `Refused: ${why}`, entity, operation: op }),
|
|
106
|
+
);
|
|
107
|
+
return { jsonrpc: '2.0', id, error: { code: APP_ERROR, message: data.message, data } };
|
|
108
|
+
}
|