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.
Files changed (72) hide show
  1. package/dist/adapters/aws-lambda.d.mts +4 -1
  2. package/dist/adapters/aws-lambda.mjs +25 -4
  3. package/dist/adapters/express.mjs +59 -4
  4. package/dist/callable.d.mts +2 -0
  5. package/dist/callable.mjs +2 -2
  6. package/dist/caller.mjs +97 -0
  7. package/dist/client/client.mjs +1 -1
  8. package/dist/client/plugins/batch.mjs +2 -2
  9. package/dist/client/plugins/circuit-breaker.d.mts +24 -0
  10. package/dist/client/plugins/circuit-breaker.mjs +60 -0
  11. package/dist/client/plugins/index.d.mts +3 -1
  12. package/dist/client/plugins/index.mjs +3 -1
  13. package/dist/client/plugins/retry.d.mts +23 -2
  14. package/dist/client/plugins/retry.mjs +51 -7
  15. package/dist/client/plugins/timeout.d.mts +10 -0
  16. package/dist/client/plugins/timeout.mjs +14 -0
  17. package/dist/codec/devalue.d.mts +2 -2
  18. package/dist/codec/devalue.mjs +30 -3
  19. package/dist/codec/msgpack.d.mts +3 -6
  20. package/dist/codec/msgpack.mjs +14 -7
  21. package/dist/compile.d.mts +12 -20
  22. package/dist/compile.mjs +113 -96
  23. package/dist/core/codec.mjs +67 -0
  24. package/dist/core/handler.mjs +161 -391
  25. package/dist/core/input.mjs +45 -0
  26. package/dist/core/router-utils.mjs +10 -4
  27. package/dist/core/schema.d.mts +2 -1
  28. package/dist/core/schema.mjs +11 -4
  29. package/dist/core/serve.mjs +1 -1
  30. package/dist/core/sse.d.mts +5 -3
  31. package/dist/core/sse.mjs +14 -16
  32. package/dist/core/utils.mjs +3 -0
  33. package/dist/index.d.mts +2 -2
  34. package/dist/index.mjs +2 -2
  35. package/dist/integrations/react/index.mjs +1 -1
  36. package/dist/lazy.d.mts +0 -2
  37. package/dist/lazy.mjs +14 -7
  38. package/dist/map-input.mjs +25 -2
  39. package/dist/plugins/analytics.d.mts +1 -0
  40. package/dist/plugins/analytics.mjs +4 -16
  41. package/dist/plugins/batch-server.mjs +5 -0
  42. package/dist/plugins/body-limit.d.mts +4 -1
  43. package/dist/plugins/body-limit.mjs +8 -3
  44. package/dist/plugins/cache.mjs +18 -6
  45. package/dist/plugins/coerce.d.mts +5 -2
  46. package/dist/plugins/coerce.mjs +29 -5
  47. package/dist/plugins/cookies.d.mts +8 -38
  48. package/dist/plugins/cookies.mjs +23 -40
  49. package/dist/plugins/cors.d.mts +8 -4
  50. package/dist/plugins/cors.mjs +7 -3
  51. package/dist/plugins/file-upload.mjs +11 -9
  52. package/dist/plugins/ratelimit.d.mts +2 -0
  53. package/dist/plugins/ratelimit.mjs +11 -0
  54. package/dist/plugins/signing.mjs +4 -1
  55. package/dist/scalar.d.mts +0 -3
  56. package/dist/scalar.mjs +109 -146
  57. package/dist/silgi.d.mts +8 -2
  58. package/dist/silgi.mjs +12 -5
  59. package/dist/types.d.mts +24 -1
  60. package/dist/ws.d.mts +10 -7
  61. package/dist/ws.mjs +48 -13
  62. package/lib/dashboard/index.html +43 -43
  63. package/package.json +12 -14
  64. package/dist/adapters/fastify.d.mts +0 -15
  65. package/dist/adapters/fastify.mjs +0 -78
  66. package/dist/analyze.mjs +0 -26
  67. package/dist/fast-stringify.mjs +0 -125
  68. package/dist/route/add.mjs +0 -240
  69. package/dist/route/compiler.mjs +0 -373
  70. package/dist/route/context.mjs +0 -12
  71. package/dist/route/types.d.mts +0 -11
  72. 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
- const signal = AbortSignal.timeout(3e4);
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
- req.on("close", () => ac.abort());
58
- const output = await route.handler(ctx, input, ac.signal);
59
- res.json(output);
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
@@ -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 signal = new AbortController().signal;
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
@@ -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 };
@@ -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, Object.freeze([...path, prop]));
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
- for (const chunk of chunks) await this.#sendChunk(chunk);
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]?.options.signal });
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
- retryDelay?: number | ((attempt: number) => number);
7
- shouldRetry?: (error: unknown) => boolean;
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 getDelay = typeof options.retryDelay === "function" ? options.retryDelay : () => options.retryDelay ?? 1e3;
5
- const shouldRetry = options.shouldRetry ?? (() => true);
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
- lastError = error;
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
- await new Promise((resolve) => setTimeout(resolve, delay));
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 lastError;
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 };
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * devalue codec — rich type serialization.
4
4
  *
5
- * Supports Date, Map, Set, BigInt, RegExp, undefined, circular refs.
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;
@@ -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, RegExp, undefined, circular refs.
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 };
@@ -1,12 +1,9 @@
1
1
  //#region src/codec/msgpack.d.ts
2
2
  /**
3
- * MessagePack codec for Silgi binary protocol.
3
+ * MessagePack codec — binary transport for Silgi.
4
4
  *
5
- * 2-4x faster encoding, ~50% smaller payloads vs JSON.
6
- * No competitor (oRPC, tRPC) offers binary transport.
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) */
@@ -1,13 +1,10 @@
1
1
  import { Packr } from "msgpackr";
2
2
  //#region src/codec/msgpack.ts
3
3
  /**
4
- * MessagePack codec for Silgi binary protocol.
4
+ * MessagePack codec — binary transport for Silgi.
5
5
  *
6
- * 2-4x faster encoding, ~50% smaller payloads vs JSON.
7
- * No competitor (oRPC, tRPC) offers binary transport.
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 || value instanceof Map || value instanceof Set) return value;
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;