silgi 0.1.0-beta.4 → 0.1.0-beta.5
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/dist/adapters/aws-lambda.d.mts +4 -1
- package/dist/adapters/aws-lambda.mjs +25 -4
- package/dist/adapters/express.mjs +59 -4
- package/dist/callable.d.mts +2 -0
- package/dist/callable.mjs +2 -2
- package/dist/caller.mjs +97 -0
- package/dist/client/client.mjs +1 -1
- package/dist/client/plugins/batch.mjs +2 -2
- package/dist/client/plugins/circuit-breaker.d.mts +24 -0
- package/dist/client/plugins/circuit-breaker.mjs +60 -0
- package/dist/client/plugins/index.d.mts +3 -1
- package/dist/client/plugins/index.mjs +3 -1
- package/dist/client/plugins/retry.d.mts +23 -2
- package/dist/client/plugins/retry.mjs +51 -7
- package/dist/client/plugins/timeout.d.mts +10 -0
- package/dist/client/plugins/timeout.mjs +14 -0
- package/dist/codec/devalue.d.mts +2 -2
- package/dist/codec/devalue.mjs +30 -3
- package/dist/codec/msgpack.d.mts +3 -6
- package/dist/codec/msgpack.mjs +14 -7
- package/dist/compile.d.mts +12 -20
- package/dist/compile.mjs +113 -96
- package/dist/core/codec.mjs +67 -0
- package/dist/core/handler.mjs +161 -391
- package/dist/core/input.mjs +45 -0
- package/dist/core/router-utils.mjs +10 -4
- package/dist/core/schema.d.mts +2 -1
- package/dist/core/schema.mjs +11 -4
- package/dist/core/serve.mjs +1 -1
- package/dist/core/sse.d.mts +5 -3
- package/dist/core/sse.mjs +14 -16
- package/dist/core/utils.mjs +3 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/integrations/react/index.mjs +1 -1
- package/dist/lazy.d.mts +0 -2
- package/dist/lazy.mjs +14 -7
- package/dist/map-input.mjs +25 -2
- package/dist/plugins/analytics.d.mts +1 -0
- package/dist/plugins/analytics.mjs +4 -16
- package/dist/plugins/batch-server.mjs +5 -0
- package/dist/plugins/body-limit.d.mts +4 -1
- package/dist/plugins/body-limit.mjs +8 -3
- package/dist/plugins/cache.mjs +18 -6
- package/dist/plugins/coerce.d.mts +5 -2
- package/dist/plugins/coerce.mjs +29 -5
- package/dist/plugins/cookies.d.mts +8 -38
- package/dist/plugins/cookies.mjs +23 -40
- package/dist/plugins/cors.d.mts +8 -4
- package/dist/plugins/cors.mjs +7 -3
- package/dist/plugins/file-upload.mjs +11 -9
- package/dist/plugins/ratelimit.d.mts +2 -0
- package/dist/plugins/ratelimit.mjs +11 -0
- package/dist/plugins/signing.mjs +4 -1
- package/dist/scalar.d.mts +0 -3
- package/dist/scalar.mjs +109 -146
- package/dist/silgi.d.mts +8 -2
- package/dist/silgi.mjs +12 -5
- package/dist/types.d.mts +24 -1
- package/dist/ws.d.mts +10 -7
- package/dist/ws.mjs +48 -13
- package/lib/dashboard/index.html +43 -43
- package/package.json +12 -14
- package/dist/adapters/fastify.d.mts +0 -15
- package/dist/adapters/fastify.mjs +0 -78
- package/dist/analyze.mjs +0 -26
- package/dist/fast-stringify.mjs +0 -125
- package/dist/route/add.mjs +0 -240
- package/dist/route/compiler.mjs +0 -373
- package/dist/route/context.mjs +0 -12
- package/dist/route/types.d.mts +0 -11
- package/dist/route/utils.mjs +0 -17
|
@@ -16,6 +16,9 @@ interface LambdaEvent {
|
|
|
16
16
|
requestContext?: Record<string, unknown>;
|
|
17
17
|
isBase64Encoded?: boolean;
|
|
18
18
|
}
|
|
19
|
+
interface LambdaContext {
|
|
20
|
+
getRemainingTimeInMillis?: () => number;
|
|
21
|
+
}
|
|
19
22
|
interface LambdaResponse {
|
|
20
23
|
statusCode: number;
|
|
21
24
|
headers: Record<string, string>;
|
|
@@ -26,6 +29,6 @@ interface LambdaResponse {
|
|
|
26
29
|
*
|
|
27
30
|
* Supports API Gateway v1 (REST) and v2 (HTTP) event formats.
|
|
28
31
|
*/
|
|
29
|
-
declare function silgiLambda<TCtx extends Record<string, unknown>>(router: RouterDef, options?: LambdaAdapterOptions<TCtx>): (event: LambdaEvent) => Promise<LambdaResponse>;
|
|
32
|
+
declare function silgiLambda<TCtx extends Record<string, unknown>>(router: RouterDef, options?: LambdaAdapterOptions<TCtx>): (event: LambdaEvent, context?: LambdaContext) => Promise<LambdaResponse>;
|
|
30
33
|
//#endregion
|
|
31
34
|
export { LambdaAdapterOptions, silgiLambda };
|
|
@@ -22,7 +22,7 @@ import { compileRouter } from "../compile.mjs";
|
|
|
22
22
|
function silgiLambda(router, options = {}) {
|
|
23
23
|
const flatRouter = compileRouter(router);
|
|
24
24
|
const prefix = options.prefix ?? "";
|
|
25
|
-
return async (event) => {
|
|
25
|
+
return async (event, context) => {
|
|
26
26
|
let pathname = event.path;
|
|
27
27
|
if (prefix && pathname.startsWith(prefix)) pathname = pathname.slice(prefix.length);
|
|
28
28
|
if (pathname.startsWith("/")) pathname = pathname.slice(1);
|
|
@@ -50,11 +50,32 @@ function silgiLambda(router, options = {}) {
|
|
|
50
50
|
const body = event.isBase64Encoded ? Buffer.from(event.body, "base64").toString("utf-8") : event.body;
|
|
51
51
|
try {
|
|
52
52
|
input = JSON.parse(body);
|
|
53
|
-
} catch {
|
|
53
|
+
} catch {
|
|
54
|
+
return {
|
|
55
|
+
statusCode: 400,
|
|
56
|
+
headers: { "content-type": "application/json" },
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
code: "BAD_REQUEST",
|
|
59
|
+
status: 400,
|
|
60
|
+
message: "Invalid JSON body"
|
|
61
|
+
})
|
|
62
|
+
};
|
|
63
|
+
}
|
|
54
64
|
} else if (event.queryStringParameters?.data) try {
|
|
55
65
|
input = JSON.parse(event.queryStringParameters.data);
|
|
56
|
-
} catch {
|
|
57
|
-
|
|
66
|
+
} catch {
|
|
67
|
+
return {
|
|
68
|
+
statusCode: 400,
|
|
69
|
+
headers: { "content-type": "application/json" },
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
code: "BAD_REQUEST",
|
|
72
|
+
status: 400,
|
|
73
|
+
message: "Invalid JSON in data parameter"
|
|
74
|
+
})
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const timeoutMs = context?.getRemainingTimeInMillis ? context.getRemainingTimeInMillis() - 500 : 3e4;
|
|
78
|
+
const signal = AbortSignal.timeout(Math.max(timeoutMs, 1e3));
|
|
58
79
|
const output = await route.handler(ctx, input, signal);
|
|
59
80
|
return {
|
|
60
81
|
statusCode: 200,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SilgiError, toSilgiError } from "../core/error.mjs";
|
|
2
2
|
import { ValidationError } from "../core/schema.mjs";
|
|
3
3
|
import { compileRouter } from "../compile.mjs";
|
|
4
|
+
import { iteratorToEventStream } from "../core/sse.mjs";
|
|
4
5
|
//#region src/adapters/express.ts
|
|
5
6
|
/**
|
|
6
7
|
* Express adapter — use Silgi as Express middleware.
|
|
@@ -31,6 +32,15 @@ function silgiExpress(router, options = {}) {
|
|
|
31
32
|
const match = flatRouter(req.method, "/" + pathname);
|
|
32
33
|
if (!match) return next();
|
|
33
34
|
const route = match.data;
|
|
35
|
+
const method = req.method;
|
|
36
|
+
if (route.method !== "*" && method !== route.method && method !== "OPTIONS" && !(method === "GET" && route.method === "POST")) {
|
|
37
|
+
res.status(405).set("allow", route.method).json({
|
|
38
|
+
code: "METHOD_NOT_ALLOWED",
|
|
39
|
+
status: 405,
|
|
40
|
+
message: `Method ${method} not allowed`
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
34
44
|
const handle = async () => {
|
|
35
45
|
try {
|
|
36
46
|
const ctx = Object.create(null);
|
|
@@ -54,9 +64,49 @@ function silgiExpress(router, options = {}) {
|
|
|
54
64
|
}
|
|
55
65
|
else input = req.query.data;
|
|
56
66
|
const ac = new AbortController();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
const onClose = () => ac.abort();
|
|
68
|
+
req.on("close", onClose);
|
|
69
|
+
try {
|
|
70
|
+
const output = await route.handler(ctx, input, ac.signal);
|
|
71
|
+
if (output instanceof Response) {
|
|
72
|
+
res.status(output.status);
|
|
73
|
+
output.headers.forEach((v, k) => res.setHeader(k, v));
|
|
74
|
+
const body = output.body ? Buffer.from(await output.arrayBuffer()) : "";
|
|
75
|
+
res.end(body);
|
|
76
|
+
} else if (output instanceof ReadableStream) {
|
|
77
|
+
res.setHeader("content-type", "application/octet-stream");
|
|
78
|
+
const reader = output.getReader();
|
|
79
|
+
const pump = async () => {
|
|
80
|
+
while (true) {
|
|
81
|
+
const { done, value } = await reader.read();
|
|
82
|
+
if (done) {
|
|
83
|
+
res.end();
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
res.write(value);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
await pump();
|
|
90
|
+
} else if (output && typeof output === "object" && Symbol.asyncIterator in output) {
|
|
91
|
+
const stream = iteratorToEventStream(output);
|
|
92
|
+
res.setHeader("content-type", "text/event-stream");
|
|
93
|
+
res.setHeader("cache-control", "no-cache");
|
|
94
|
+
const reader = stream.getReader();
|
|
95
|
+
const pump = async () => {
|
|
96
|
+
while (true) {
|
|
97
|
+
const { done, value } = await reader.read();
|
|
98
|
+
if (done) {
|
|
99
|
+
res.end();
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
res.write(value);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
await pump();
|
|
106
|
+
} else res.json(output);
|
|
107
|
+
} finally {
|
|
108
|
+
req.removeListener("close", onClose);
|
|
109
|
+
}
|
|
60
110
|
} catch (error) {
|
|
61
111
|
if (error instanceof ValidationError) {
|
|
62
112
|
res.status(400).json({
|
|
@@ -71,7 +121,12 @@ function silgiExpress(router, options = {}) {
|
|
|
71
121
|
res.status(e.status).json(e.toJSON());
|
|
72
122
|
}
|
|
73
123
|
};
|
|
74
|
-
handle()
|
|
124
|
+
handle().catch((error) => {
|
|
125
|
+
if (!res.headersSent) {
|
|
126
|
+
const e = error instanceof SilgiError ? error : toSilgiError(error);
|
|
127
|
+
res.status(e.status).json(e.toJSON());
|
|
128
|
+
}
|
|
129
|
+
});
|
|
75
130
|
};
|
|
76
131
|
}
|
|
77
132
|
//#endregion
|
package/dist/callable.d.mts
CHANGED
|
@@ -4,6 +4,8 @@ import { ErrorDef, ProcedureDef } from "./types.mjs";
|
|
|
4
4
|
interface CallableOptions<TCtx extends Record<string, unknown>> {
|
|
5
5
|
/** Context factory — called on every invocation */
|
|
6
6
|
context: () => TCtx | Promise<TCtx>;
|
|
7
|
+
/** Default timeout in ms. Default: 30000. Set null to disable. */
|
|
8
|
+
timeout?: number | null;
|
|
7
9
|
}
|
|
8
10
|
type CallableFn<TInput, TOutput> = undefined extends TInput ? (input?: TInput) => Promise<TOutput> : (input: TInput) => Promise<TOutput>;
|
|
9
11
|
/**
|
package/dist/callable.mjs
CHANGED
|
@@ -29,13 +29,13 @@ import { compileProcedure } from "./compile.mjs";
|
|
|
29
29
|
function callable(procedure, options) {
|
|
30
30
|
const handler = compileProcedure(procedure);
|
|
31
31
|
const contextFactory = options.context;
|
|
32
|
-
const
|
|
32
|
+
const defaultTimeout = options.timeout !== void 0 ? options.timeout : 3e4;
|
|
33
33
|
return (async (input) => {
|
|
34
34
|
const ctx = await contextFactory();
|
|
35
35
|
const ctxObj = Object.create(null);
|
|
36
36
|
const keys = Object.keys(ctx);
|
|
37
37
|
for (let i = 0; i < keys.length; i++) ctxObj[keys[i]] = ctx[keys[i]];
|
|
38
|
-
return handler(ctxObj, input, signal);
|
|
38
|
+
return handler(ctxObj, input, defaultTimeout !== null ? AbortSignal.timeout(defaultTimeout) : new AbortController().signal);
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
//#endregion
|
package/dist/caller.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { routerCache } from "./core/router-utils.mjs";
|
|
2
|
+
import { compileRouter, createContext, releaseContext } from "./compile.mjs";
|
|
3
|
+
//#region src/caller.ts
|
|
4
|
+
/**
|
|
5
|
+
* createCaller — call procedures directly without HTTP.
|
|
6
|
+
*
|
|
7
|
+
* Compiles the router, creates context, and runs the pipeline
|
|
8
|
+
* for each procedure call. Perfect for testing and server-side usage.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const caller = s.createCaller(appRouter)
|
|
13
|
+
*
|
|
14
|
+
* // Call procedures directly
|
|
15
|
+
* const users = await caller.users.list({ limit: 10 })
|
|
16
|
+
* const user = await caller.users.get({ id: 1 })
|
|
17
|
+
*
|
|
18
|
+
* // With custom context override
|
|
19
|
+
* const adminCaller = s.createCaller(appRouter, {
|
|
20
|
+
* contextOverride: { user: { id: 1, role: 'admin' } },
|
|
21
|
+
* })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Create a direct caller for a router — no HTTP, no serialization.
|
|
26
|
+
*
|
|
27
|
+
* Returns a proxy that mirrors the router's nested structure.
|
|
28
|
+
* Calling a leaf procedure invokes the compiled pipeline directly.
|
|
29
|
+
*/
|
|
30
|
+
function createCaller(routerDef, contextFactory, options) {
|
|
31
|
+
let compiledRouter = routerCache.get(routerDef);
|
|
32
|
+
if (!compiledRouter) {
|
|
33
|
+
compiledRouter = compileRouter(routerDef);
|
|
34
|
+
routerCache.set(routerDef, compiledRouter);
|
|
35
|
+
}
|
|
36
|
+
const router = compiledRouter;
|
|
37
|
+
const defaultTimeout = options?.timeout !== void 0 ? options.timeout : 3e4;
|
|
38
|
+
function createMockRequest(extraHeaders) {
|
|
39
|
+
const headers = new Headers(options?.headers);
|
|
40
|
+
if (extraHeaders) for (const [k, v] of Object.entries(extraHeaders)) headers.set(k, v);
|
|
41
|
+
return new Request("http://localhost/__caller", { headers });
|
|
42
|
+
}
|
|
43
|
+
async function resolveContext(perCallContext) {
|
|
44
|
+
const ctx = createContext();
|
|
45
|
+
if (contextFactory) {
|
|
46
|
+
const result = contextFactory(createMockRequest());
|
|
47
|
+
const baseCtx = result instanceof Promise ? await result : result;
|
|
48
|
+
const keys = Object.keys(baseCtx);
|
|
49
|
+
for (let i = 0; i < keys.length; i++) ctx[keys[i]] = baseCtx[keys[i]];
|
|
50
|
+
}
|
|
51
|
+
if (options?.contextOverride) {
|
|
52
|
+
const keys = Object.keys(options.contextOverride);
|
|
53
|
+
for (let i = 0; i < keys.length; i++) ctx[keys[i]] = options.contextOverride[keys[i]];
|
|
54
|
+
}
|
|
55
|
+
if (perCallContext) {
|
|
56
|
+
const keys = Object.keys(perCallContext);
|
|
57
|
+
for (let i = 0; i < keys.length; i++) ctx[keys[i]] = perCallContext[keys[i]];
|
|
58
|
+
}
|
|
59
|
+
return ctx;
|
|
60
|
+
}
|
|
61
|
+
function createProxy(segments) {
|
|
62
|
+
const cache = /* @__PURE__ */ new Map();
|
|
63
|
+
return new Proxy(() => {}, {
|
|
64
|
+
get(_target, prop) {
|
|
65
|
+
if (typeof prop === "symbol") return void 0;
|
|
66
|
+
if (prop === "then" || prop === "toJSON" || prop === "toString" || prop === "$$typeof") return;
|
|
67
|
+
let sub = cache.get(prop);
|
|
68
|
+
if (!sub) {
|
|
69
|
+
sub = createProxy([...segments, prop]);
|
|
70
|
+
cache.set(prop, sub);
|
|
71
|
+
}
|
|
72
|
+
return sub;
|
|
73
|
+
},
|
|
74
|
+
apply(_target, _thisArg, args) {
|
|
75
|
+
const path = "/" + segments.join("/");
|
|
76
|
+
const input = args[0];
|
|
77
|
+
const callOptions = args[1];
|
|
78
|
+
return (async () => {
|
|
79
|
+
const match = router("", path);
|
|
80
|
+
if (!match) throw new Error(`Procedure not found: ${path}`);
|
|
81
|
+
const ctx = await resolveContext(callOptions?.context);
|
|
82
|
+
if (match.params) ctx.params = match.params;
|
|
83
|
+
const signal = callOptions?.signal ?? (defaultTimeout !== null ? AbortSignal.timeout(defaultTimeout) : void 0);
|
|
84
|
+
try {
|
|
85
|
+
const result = match.data.handler(ctx, input, signal);
|
|
86
|
+
return result instanceof Promise ? await result : result;
|
|
87
|
+
} finally {
|
|
88
|
+
releaseContext(ctx);
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return createProxy([]);
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
export { createCaller };
|
package/dist/client/client.mjs
CHANGED
|
@@ -23,7 +23,7 @@ function createClientProxy(link, path) {
|
|
|
23
23
|
if (typeof prop !== "string") return void 0;
|
|
24
24
|
let cached = cache.get(prop);
|
|
25
25
|
if (!cached) {
|
|
26
|
-
cached = createClientProxy(link,
|
|
26
|
+
cached = createClientProxy(link, [...path, prop]);
|
|
27
27
|
cache.set(prop, cached);
|
|
28
28
|
}
|
|
29
29
|
return cached;
|
|
@@ -31,7 +31,7 @@ var BatchLink = class {
|
|
|
31
31
|
if (batch.length === 0) return;
|
|
32
32
|
const chunks = [];
|
|
33
33
|
for (let i = 0; i < batch.length; i += this.#maxSize) chunks.push(batch.slice(i, i + this.#maxSize));
|
|
34
|
-
|
|
34
|
+
await Promise.all(chunks.map((chunk) => this.#sendChunk(chunk)));
|
|
35
35
|
}
|
|
36
36
|
async #sendChunk(chunk) {
|
|
37
37
|
const batchBody = chunk.map((call) => ({
|
|
@@ -40,7 +40,7 @@ var BatchLink = class {
|
|
|
40
40
|
body: call.input
|
|
41
41
|
}));
|
|
42
42
|
try {
|
|
43
|
-
const responses = await this.#link.call([this.#batchPath.slice(1)], batchBody, { signal: chunk[0]
|
|
43
|
+
const responses = await this.#link.call([this.#batchPath.slice(1)], batchBody, { signal: chunk.length === 1 ? chunk[0].options.signal : AbortSignal.any(chunk.map((c) => c.options.signal).filter(Boolean)) });
|
|
44
44
|
if (!Array.isArray(responses)) {
|
|
45
45
|
for (const call of chunk) call.reject(/* @__PURE__ */ new Error("Invalid batch response"));
|
|
46
46
|
return;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ClientContext, ClientLink } from "../types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/client/plugins/circuit-breaker.d.ts
|
|
4
|
+
type CircuitState = 'closed' | 'open' | 'half-open';
|
|
5
|
+
declare class CircuitBreakerOpenError extends Error {
|
|
6
|
+
readonly state: CircuitState;
|
|
7
|
+
constructor();
|
|
8
|
+
}
|
|
9
|
+
interface CircuitBreakerOptions {
|
|
10
|
+
/** Number of consecutive failures before opening (default: 5) */
|
|
11
|
+
failureThreshold?: number;
|
|
12
|
+
/** Time in ms to wait before moving to half-open (default: 30000) */
|
|
13
|
+
resetTimeout?: number;
|
|
14
|
+
/** Called when circuit state changes */
|
|
15
|
+
onStateChange?: (state: CircuitState, info: {
|
|
16
|
+
failures: number;
|
|
17
|
+
}) => void;
|
|
18
|
+
}
|
|
19
|
+
declare function withCircuitBreaker<TClientContext extends ClientContext>(link: ClientLink<TClientContext>, options?: CircuitBreakerOptions): ClientLink<TClientContext> & {
|
|
20
|
+
getState: () => CircuitState;
|
|
21
|
+
reset: () => void;
|
|
22
|
+
};
|
|
23
|
+
//#endregion
|
|
24
|
+
export { CircuitBreakerOpenError, CircuitBreakerOptions, CircuitState, withCircuitBreaker };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//#region src/client/plugins/circuit-breaker.ts
|
|
2
|
+
var CircuitBreakerOpenError = class extends Error {
|
|
3
|
+
state = "open";
|
|
4
|
+
constructor() {
|
|
5
|
+
super("Circuit breaker is open — requests are blocked. Try again later.");
|
|
6
|
+
this.name = "CircuitBreakerOpenError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
function withCircuitBreaker(link, options = {}) {
|
|
10
|
+
const threshold = options.failureThreshold ?? 5;
|
|
11
|
+
const resetTimeout = options.resetTimeout ?? 3e4;
|
|
12
|
+
let state = "closed";
|
|
13
|
+
let failures = 0;
|
|
14
|
+
let openedAt = 0;
|
|
15
|
+
let probeSent = false;
|
|
16
|
+
function setState(newState) {
|
|
17
|
+
if (state !== newState) {
|
|
18
|
+
state = newState;
|
|
19
|
+
if (newState !== "half-open") probeSent = false;
|
|
20
|
+
options.onStateChange?.(state, { failures });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function recordSuccess() {
|
|
24
|
+
failures = 0;
|
|
25
|
+
setState("closed");
|
|
26
|
+
}
|
|
27
|
+
function recordFailure() {
|
|
28
|
+
failures++;
|
|
29
|
+
if (failures >= threshold) {
|
|
30
|
+
openedAt = Date.now();
|
|
31
|
+
setState("open");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
async call(path, input, callOptions) {
|
|
36
|
+
if (state === "open") if (Date.now() - openedAt >= resetTimeout) setState("half-open");
|
|
37
|
+
else throw new CircuitBreakerOpenError();
|
|
38
|
+
if (state === "half-open") {
|
|
39
|
+
if (probeSent) throw new CircuitBreakerOpenError();
|
|
40
|
+
probeSent = true;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const result = await link.call(path, input, callOptions);
|
|
44
|
+
recordSuccess();
|
|
45
|
+
return result;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
recordFailure();
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
getState: () => state,
|
|
52
|
+
reset: () => {
|
|
53
|
+
failures = 0;
|
|
54
|
+
probeSent = false;
|
|
55
|
+
setState("closed");
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { CircuitBreakerOpenError, withCircuitBreaker };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { RetryOptions, withRetry } from "./retry.mjs";
|
|
2
|
+
import { CircuitBreakerOpenError, CircuitBreakerOptions, CircuitState, withCircuitBreaker } from "./circuit-breaker.mjs";
|
|
3
|
+
import { TimeoutOptions, withTimeout } from "./timeout.mjs";
|
|
2
4
|
import { BatchLink, BatchLinkOptions } from "./batch.mjs";
|
|
3
5
|
import { DedupeOptions, withDedupe } from "./dedupe.mjs";
|
|
4
6
|
import { CSRFLinkOptions, withCSRF } from "./csrf.mjs";
|
|
5
|
-
export { BatchLink, type BatchLinkOptions, type CSRFLinkOptions, type DedupeOptions, type RetryOptions, withCSRF, withDedupe, withRetry };
|
|
7
|
+
export { BatchLink, type BatchLinkOptions, type CSRFLinkOptions, CircuitBreakerOpenError, type CircuitBreakerOptions, type CircuitState, type DedupeOptions, type RetryOptions, type TimeoutOptions, withCSRF, withCircuitBreaker, withDedupe, withRetry, withTimeout };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { withRetry } from "./retry.mjs";
|
|
2
|
+
import { CircuitBreakerOpenError, withCircuitBreaker } from "./circuit-breaker.mjs";
|
|
3
|
+
import { withTimeout } from "./timeout.mjs";
|
|
2
4
|
import { BatchLink } from "./batch.mjs";
|
|
3
5
|
import { withDedupe } from "./dedupe.mjs";
|
|
4
6
|
import { withCSRF } from "./csrf.mjs";
|
|
5
|
-
export { BatchLink, withCSRF, withDedupe, withRetry };
|
|
7
|
+
export { BatchLink, CircuitBreakerOpenError, withCSRF, withCircuitBreaker, withDedupe, withRetry, withTimeout };
|
|
@@ -2,9 +2,30 @@ import { ClientContext, ClientLink } from "../types.mjs";
|
|
|
2
2
|
|
|
3
3
|
//#region src/client/plugins/retry.d.ts
|
|
4
4
|
interface RetryOptions {
|
|
5
|
+
/** Maximum number of retry attempts (default: 3) */
|
|
5
6
|
maxRetries?: number;
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Base delay in ms for exponential backoff (default: 1000).
|
|
9
|
+
* Actual delay: `baseDelay * 2^attempt + jitter`
|
|
10
|
+
* Or pass a function: `(attempt: number) => delayMs`
|
|
11
|
+
*/
|
|
12
|
+
baseDelay?: number | ((attempt: number) => number);
|
|
13
|
+
/** Add random jitter 0-25% to prevent thundering herd (default: true) */
|
|
14
|
+
jitter?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* HTTP status codes to retry on (default: [408, 429, 500, 502, 503, 504]).
|
|
17
|
+
* Network errors (no status) are always retried unless shouldRetry returns false.
|
|
18
|
+
*/
|
|
19
|
+
retryOn?: number[];
|
|
20
|
+
/** Custom retry predicate — return false to stop retrying */
|
|
21
|
+
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
|
22
|
+
/** Called before each retry attempt */
|
|
23
|
+
onRetry?: (info: {
|
|
24
|
+
attempt: number;
|
|
25
|
+
delay: number;
|
|
26
|
+
error: unknown;
|
|
27
|
+
path: readonly string[];
|
|
28
|
+
}) => void;
|
|
8
29
|
}
|
|
9
30
|
declare function withRetry<TClientContext extends ClientContext>(link: ClientLink<TClientContext>, options?: RetryOptions): ClientLink<TClientContext>;
|
|
10
31
|
//#endregion
|
|
@@ -1,20 +1,64 @@
|
|
|
1
1
|
//#region src/client/plugins/retry.ts
|
|
2
|
+
const DEFAULT_RETRY_CODES = [
|
|
3
|
+
408,
|
|
4
|
+
429,
|
|
5
|
+
500,
|
|
6
|
+
502,
|
|
7
|
+
503,
|
|
8
|
+
504
|
|
9
|
+
];
|
|
10
|
+
function getStatusFromError(error) {
|
|
11
|
+
if (error && typeof error === "object") {
|
|
12
|
+
const e = error;
|
|
13
|
+
if (typeof e.status === "number") return e.status;
|
|
14
|
+
if (typeof e.statusCode === "number") return e.statusCode;
|
|
15
|
+
if (e.response && typeof e.response === "object") {
|
|
16
|
+
const r = e.response;
|
|
17
|
+
if (typeof r.status === "number") return r.status;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
2
21
|
function withRetry(link, options = {}) {
|
|
3
22
|
const maxRetries = options.maxRetries ?? 3;
|
|
4
|
-
const
|
|
5
|
-
const
|
|
23
|
+
const baseDelay = options.baseDelay ?? 1e3;
|
|
24
|
+
const useJitter = options.jitter ?? true;
|
|
25
|
+
const retryCodes = new Set(options.retryOn ?? DEFAULT_RETRY_CODES);
|
|
26
|
+
const shouldRetry = options.shouldRetry;
|
|
27
|
+
const onRetry = options.onRetry;
|
|
28
|
+
function getDelay(attempt) {
|
|
29
|
+
const base = typeof baseDelay === "function" ? baseDelay(attempt) : baseDelay * 2 ** attempt;
|
|
30
|
+
const jitter = useJitter ? base * Math.random() * .25 : 0;
|
|
31
|
+
return Math.round(base + jitter);
|
|
32
|
+
}
|
|
33
|
+
function isRetryable(error, attempt) {
|
|
34
|
+
if (shouldRetry && !shouldRetry(error, attempt)) return false;
|
|
35
|
+
const status = getStatusFromError(error);
|
|
36
|
+
if (status === void 0) return true;
|
|
37
|
+
return retryCodes.has(status);
|
|
38
|
+
}
|
|
6
39
|
return { async call(path, input, callOptions) {
|
|
7
|
-
let lastError;
|
|
8
40
|
for (let attempt = 0; attempt <= maxRetries; attempt++) try {
|
|
9
41
|
return await link.call(path, input, callOptions);
|
|
10
42
|
} catch (error) {
|
|
11
|
-
|
|
12
|
-
if (attempt === maxRetries || !shouldRetry(error)) throw error;
|
|
43
|
+
if (attempt === maxRetries) throw error;
|
|
13
44
|
if (callOptions.signal?.aborted) throw error;
|
|
45
|
+
if (!isRetryable(error, attempt)) throw error;
|
|
14
46
|
const delay = getDelay(attempt);
|
|
15
|
-
|
|
47
|
+
onRetry?.({
|
|
48
|
+
attempt: attempt + 1,
|
|
49
|
+
delay,
|
|
50
|
+
error,
|
|
51
|
+
path
|
|
52
|
+
});
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
const timer = setTimeout(resolve, delay);
|
|
55
|
+
callOptions.signal?.addEventListener("abort", () => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
reject(callOptions.signal.reason);
|
|
58
|
+
}, { once: true });
|
|
59
|
+
});
|
|
16
60
|
}
|
|
17
|
-
throw
|
|
61
|
+
throw new Error("Retry exhausted");
|
|
18
62
|
} };
|
|
19
63
|
}
|
|
20
64
|
//#endregion
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ClientContext, ClientLink } from "../types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/client/plugins/timeout.d.ts
|
|
4
|
+
interface TimeoutOptions {
|
|
5
|
+
/** Timeout in ms (default: 30000) */
|
|
6
|
+
timeout?: number;
|
|
7
|
+
}
|
|
8
|
+
declare function withTimeout<TClientContext extends ClientContext>(link: ClientLink<TClientContext>, options?: TimeoutOptions): ClientLink<TClientContext>;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { TimeoutOptions, withTimeout };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/client/plugins/timeout.ts
|
|
2
|
+
function withTimeout(link, options = {}) {
|
|
3
|
+
const timeout = options.timeout ?? 3e4;
|
|
4
|
+
return { async call(path, input, callOptions) {
|
|
5
|
+
const timeoutSignal = AbortSignal.timeout(timeout);
|
|
6
|
+
const signal = callOptions.signal ? AbortSignal.any([callOptions.signal, timeoutSignal]) : timeoutSignal;
|
|
7
|
+
return link.call(path, input, {
|
|
8
|
+
...callOptions,
|
|
9
|
+
signal
|
|
10
|
+
});
|
|
11
|
+
} };
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
export { withTimeout };
|
package/dist/codec/devalue.d.mts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* devalue codec — rich type serialization.
|
|
4
4
|
*
|
|
5
|
-
* Supports Date, Map, Set, BigInt,
|
|
5
|
+
* Supports Date, Map, Set, BigInt, undefined, circular refs.
|
|
6
6
|
* 2.7x faster than superjson, 37% smaller output.
|
|
7
7
|
*
|
|
8
8
|
* Use when your RPC procedures return rich JS types
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
declare const DEVALUE_CONTENT_TYPE = "application/x-devalue+json";
|
|
12
12
|
/** Serialize a value with devalue (handles Date, Map, Set, BigInt, etc.) */
|
|
13
13
|
declare function encode(value: unknown): string;
|
|
14
|
-
/** Deserialize a devalue string back to the original value */
|
|
14
|
+
/** Deserialize a devalue string back to the original value, sanitized for safety */
|
|
15
15
|
declare function decode(text: string): unknown;
|
|
16
16
|
/** Check if request body uses devalue encoding */
|
|
17
17
|
declare function isDevalue(contentType: string | null | undefined): boolean;
|
package/dist/codec/devalue.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { parse, stringify } from "devalue";
|
|
|
3
3
|
/**
|
|
4
4
|
* devalue codec — rich type serialization.
|
|
5
5
|
*
|
|
6
|
-
* Supports Date, Map, Set, BigInt,
|
|
6
|
+
* Supports Date, Map, Set, BigInt, undefined, circular refs.
|
|
7
7
|
* 2.7x faster than superjson, 37% smaller output.
|
|
8
8
|
*
|
|
9
9
|
* Use when your RPC procedures return rich JS types
|
|
@@ -14,9 +14,9 @@ const DEVALUE_CONTENT_TYPE = "application/x-devalue+json";
|
|
|
14
14
|
function encode(value) {
|
|
15
15
|
return stringify(value);
|
|
16
16
|
}
|
|
17
|
-
/** Deserialize a devalue string back to the original value */
|
|
17
|
+
/** Deserialize a devalue string back to the original value, sanitized for safety */
|
|
18
18
|
function decode(text) {
|
|
19
|
-
return parse(text);
|
|
19
|
+
return sanitizeDecoded(parse(text));
|
|
20
20
|
}
|
|
21
21
|
/** Check if request body uses devalue encoding */
|
|
22
22
|
function isDevalue(contentType) {
|
|
@@ -28,5 +28,32 @@ function acceptsDevalue(acceptHeader) {
|
|
|
28
28
|
if (!acceptHeader) return false;
|
|
29
29
|
return acceptHeader.includes(DEVALUE_CONTENT_TYPE);
|
|
30
30
|
}
|
|
31
|
+
/** Strip potentially dangerous types (RegExp, Error) from decoded values */
|
|
32
|
+
function sanitizeDecoded(value) {
|
|
33
|
+
if (value === null || value === void 0) return value;
|
|
34
|
+
if (typeof value !== "object") return value;
|
|
35
|
+
if (value instanceof RegExp) return String(value);
|
|
36
|
+
if (value instanceof Error) return { message: value.message };
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
for (let i = 0; i < value.length; i++) value[i] = sanitizeDecoded(value[i]);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
if (value instanceof Map) {
|
|
42
|
+
const clean = /* @__PURE__ */ new Map();
|
|
43
|
+
for (const [k, v] of value) clean.set(sanitizeDecoded(k), sanitizeDecoded(v));
|
|
44
|
+
return clean;
|
|
45
|
+
}
|
|
46
|
+
if (value instanceof Set) {
|
|
47
|
+
const clean = /* @__PURE__ */ new Set();
|
|
48
|
+
for (const v of value) clean.add(sanitizeDecoded(v));
|
|
49
|
+
return clean;
|
|
50
|
+
}
|
|
51
|
+
if (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) {
|
|
52
|
+
const obj = value;
|
|
53
|
+
for (const key of Object.keys(obj)) if (key === "__proto__") delete obj[key];
|
|
54
|
+
else obj[key] = sanitizeDecoded(obj[key]);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
31
58
|
//#endregion
|
|
32
59
|
export { DEVALUE_CONTENT_TYPE, acceptsDevalue, decode, encode, isDevalue };
|
package/dist/codec/msgpack.d.mts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
//#region src/codec/msgpack.d.ts
|
|
2
2
|
/**
|
|
3
|
-
* MessagePack codec for Silgi
|
|
3
|
+
* MessagePack codec — binary transport for Silgi.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Uses msgpackr with record extension — repeated object shapes
|
|
9
|
-
* (common in RPC: same fields every request) get 2-3x decode speedup.
|
|
5
|
+
* ~50% smaller payloads vs JSON. Uses msgpackr with record extension
|
|
6
|
+
* for fast encoding of repeated object shapes.
|
|
10
7
|
*/
|
|
11
8
|
declare const MSGPACK_CONTENT_TYPE = "application/x-msgpack";
|
|
12
9
|
/** Encode a value to MessagePack binary (usable as Response body) */
|
package/dist/codec/msgpack.mjs
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { Packr } from "msgpackr";
|
|
2
2
|
//#region src/codec/msgpack.ts
|
|
3
3
|
/**
|
|
4
|
-
* MessagePack codec for Silgi
|
|
4
|
+
* MessagePack codec — binary transport for Silgi.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Uses msgpackr with record extension — repeated object shapes
|
|
10
|
-
* (common in RPC: same fields every request) get 2-3x decode speedup.
|
|
6
|
+
* ~50% smaller payloads vs JSON. Uses msgpackr with record extension
|
|
7
|
+
* for fast encoding of repeated object shapes.
|
|
11
8
|
*/
|
|
12
9
|
const MSGPACK_CONTENT_TYPE = "application/x-msgpack";
|
|
13
10
|
/**
|
|
@@ -40,7 +37,17 @@ function sanitizeDecoded(value) {
|
|
|
40
37
|
if (value instanceof Error) return { message: value.message };
|
|
41
38
|
if (value instanceof RegExp) return String(value);
|
|
42
39
|
if (Array.isArray(value)) return value.map(sanitizeDecoded);
|
|
43
|
-
if (value instanceof Date
|
|
40
|
+
if (value instanceof Date) return value;
|
|
41
|
+
if (value instanceof Map) {
|
|
42
|
+
const sanitized = /* @__PURE__ */ new Map();
|
|
43
|
+
for (const [k, v] of value) sanitized.set(k, sanitizeDecoded(v));
|
|
44
|
+
return sanitized;
|
|
45
|
+
}
|
|
46
|
+
if (value instanceof Set) {
|
|
47
|
+
const sanitized = /* @__PURE__ */ new Set();
|
|
48
|
+
for (const v of value) sanitized.add(sanitizeDecoded(v));
|
|
49
|
+
return sanitized;
|
|
50
|
+
}
|
|
44
51
|
const result = {};
|
|
45
52
|
for (const [k, v] of Object.entries(value)) result[k] = sanitizeDecoded(v);
|
|
46
53
|
return result;
|