@voltro/integration-http 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +11 -0
- package/dist/index.d.ts +186 -0
- package/dist/index.js +151 -0
- package/package.json +40 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `@voltro/*` packages are recorded here. The format
|
|
4
|
+
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## Stability contract — read this before you pin a version
|
|
7
|
+
|
|
8
|
+
Voltro is **`0.x` — pre-1.0, and deliberately so.** Under SemVer, `0.y.z` means
|
|
9
|
+
"anything MAY change." We hold to exactly that, stated out loud:
|
|
10
|
+
|
|
11
|
+
- **Pin exact versions.** Depend on `@voltro/runtime@0.4.2`, never `^0.4.2` or
|
|
12
|
+
`~0.4.2`. There is no compatible-range promise below 1.0.
|
|
13
|
+
- **Every MINOR may break.** Breaking changes land on a minor bump (`0.4.x →
|
|
14
|
+
0.5.0`); patches (`0.4.1 → 0.4.2`) are additive or fixes only. This is the
|
|
15
|
+
standard `0.x` reading of SemVer — the minor slot carries breaking under `0.x`.
|
|
16
|
+
- **Read this changelog before upgrading.** The `⚠ BREAKING` section of each
|
|
17
|
+
release lists every incompatible change with its migration. It is the only
|
|
18
|
+
migration path we provide — there is no deprecation cycle, no compat shim.
|
|
19
|
+
- **All packages release together (lockstep).** One coordinated version across
|
|
20
|
+
the whole framework; the git tag is the source of truth.
|
|
21
|
+
|
|
22
|
+
This is not a placeholder disclaimer — it is the contract. The framework is
|
|
23
|
+
refactored aggressively while it has no external stability obligations, and that
|
|
24
|
+
velocity is the point. A stable-core tier and per-package `1.0` graduation are
|
|
25
|
+
planned, but not yet; until then, treat the whole surface as movable and pin
|
|
26
|
+
exact.
|
|
27
|
+
|
|
28
|
+
The **public API** of each package is its `publishConfig.exports` entry points,
|
|
29
|
+
minus anything marked `@internal` (those are cross-package internals and are
|
|
30
|
+
stripped from the published `.d.ts`). Importing a deep path that isn't an
|
|
31
|
+
exported entry point is unsupported.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## [Unreleased]
|
|
36
|
+
|
|
37
|
+
_Changes staged for the next release accumulate here (rolled up from
|
|
38
|
+
`.changes/*.md` at tag time — see `.changes/README.md`)._
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## [0.1.0]
|
|
43
|
+
|
|
44
|
+
Initial pre-release baseline. This is the starting point the changelog tracks
|
|
45
|
+
from; it is not an exhaustive history of prior development. The framework ships
|
|
46
|
+
as `@voltro/*` packages spanning the runtime, database/query layer, web client
|
|
47
|
+
and router, durable workflows, the CLI, and the plugin ecosystem
|
|
48
|
+
(auth, storage, mail, billing, observability, and more). Not published to a
|
|
49
|
+
public registry yet.
|
|
50
|
+
|
|
51
|
+
Subsequent releases record their deltas from here under dated headings, with
|
|
52
|
+
`⚠ BREAKING` first.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Voltro — Proprietary Software License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Voltro UG. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software and all associated source code, documentation, and other
|
|
6
|
+
materials (the "Software") are the proprietary property of Voltro UG
|
|
7
|
+
("Voltro"). This is NOT open-source software.
|
|
8
|
+
|
|
9
|
+
Your access to and use of the Software is governed exclusively by the Voltro
|
|
10
|
+
Terms of Service at:
|
|
11
|
+
|
|
12
|
+
https://voltro.cloud/legal/terms
|
|
13
|
+
|
|
14
|
+
By downloading, installing, or using the Software you agree to those Terms.
|
|
15
|
+
Subject to and conditioned on your continued compliance with them, Voltro
|
|
16
|
+
grants you a limited, non-exclusive, non-transferable, non-sublicensable,
|
|
17
|
+
revocable license to use the Software. All rights not expressly granted in the
|
|
18
|
+
Terms are reserved by Voltro. If you do not agree to the Terms, you have no
|
|
19
|
+
license and may not use the Software.
|
|
20
|
+
|
|
21
|
+
The Terms of Service — including the scope of permitted use, plan
|
|
22
|
+
entitlements, fees, and pricing — may change from time to time. The version in
|
|
23
|
+
force is the one published at the URL above. Commercial terms and pricing are
|
|
24
|
+
NOT part of this notice and are not fixed by, or granted in perpetuity by, the
|
|
25
|
+
version of the Software in which this file appears.
|
|
26
|
+
|
|
27
|
+
Permitted use — your own software. Voltro is a development framework. You may
|
|
28
|
+
use the Software to build, run, and commercially distribute your own
|
|
29
|
+
applications and services, and to charge your own customers for them, without
|
|
30
|
+
owing Voltro any share of that revenue or any further permission beyond the
|
|
31
|
+
Terms. Applications you build with the Software are yours.
|
|
32
|
+
|
|
33
|
+
Prohibited use — reselling Voltro. You may NOT redistribute, resell,
|
|
34
|
+
sublicense, publish, or otherwise provide the Software itself — in whole or in
|
|
35
|
+
part, in source or compiled form — to third parties as a library, framework,
|
|
36
|
+
SDK, template, starter, or development tool for them to build upon, whether
|
|
37
|
+
standalone or repackaged. Shipping the Software as a non-separable, bundled
|
|
38
|
+
dependency inside an application you deliver (for example, compiled into your
|
|
39
|
+
deployed app) is permitted; extracting or re-exposing it as a reusable
|
|
40
|
+
component for others is not.
|
|
41
|
+
|
|
42
|
+
Except as expressly permitted above or by the Terms, you may not copy, modify,
|
|
43
|
+
merge, publish, sublicense, sell, distribute, or create derivative works of the
|
|
44
|
+
Software, in whole or in part.
|
|
45
|
+
|
|
46
|
+
Third-party open-source components distributed alongside the Software are the
|
|
47
|
+
property of their respective owners and are licensed under their own terms; see
|
|
48
|
+
THIRD-PARTY-NOTICES.md where provided. Nothing in this notice limits your
|
|
49
|
+
rights, or extends Voltro's, in those components.
|
|
50
|
+
|
|
51
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
52
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
53
|
+
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
54
|
+
VOLTRO BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM,
|
|
55
|
+
OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR ITS USE.
|
|
56
|
+
|
|
57
|
+
For licensing inquiries, contact Voltro UG.
|
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# @voltro/integration-http
|
|
4
|
+
|
|
5
|
+
**Server-side HTTP transport core for integration plugins: token auth, transient retry with Retry-After, per-request timeout, and an SSRF host-allowlist guard — over an injectable fetch seam, Effect-first with typed errors.**
|
|
6
|
+
|
|
7
|
+
[📖 Documentation](https://docs.voltro.dev/docs/intro/getting-started) · [Changelog](./CHANGELOG.md) · [voltro.dev](https://voltro.dev) · [Voltro Cloud](https://voltro.cloud)
|
|
8
|
+
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @voltro/integration-http
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
> ### [→ Read the documentation for @voltro/integration-http](https://docs.voltro.dev/docs/intro/getting-started)
|
|
18
|
+
|
|
19
|
+
Installation, guides, the full API reference, and examples all live in the
|
|
20
|
+
**[Voltro documentation](https://docs.voltro.dev/docs/intro/getting-started)** — the single source of truth. This README is
|
|
21
|
+
deliberately short so there is nothing here to drift out of date.
|
|
22
|
+
|
|
23
|
+
## License
|
|
24
|
+
|
|
25
|
+
Proprietary. © 2026 Voltro UG. Your use is governed by the
|
|
26
|
+
[Terms of Service](https://voltro.cloud/legal/terms). See the bundled `LICENSE`.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
We take the security of Voltro and the applications built on it seriously. This
|
|
4
|
+
document explains how to report a vulnerability and what to expect in return.
|
|
5
|
+
|
|
6
|
+
## Reporting a vulnerability
|
|
7
|
+
|
|
8
|
+
**Please do not open public issues or discuss suspected vulnerabilities in
|
|
9
|
+
public channels.** Report them privately by email:
|
|
10
|
+
|
|
11
|
+
**security contact: [support@voltro.dev](mailto:support@voltro.dev)**
|
|
12
|
+
(subject line: `SECURITY`).
|
|
13
|
+
|
|
14
|
+
Include, where you can:
|
|
15
|
+
|
|
16
|
+
- the affected package(s) and version(s) (e.g. `@voltro/runtime@x.y.z`);
|
|
17
|
+
- a description of the issue and its impact;
|
|
18
|
+
- a minimal reproduction or proof of concept;
|
|
19
|
+
- any suggested remediation.
|
|
20
|
+
|
|
21
|
+
If you'd like to encrypt your report, ask in a first plain email and we'll
|
|
22
|
+
arrange a key.
|
|
23
|
+
|
|
24
|
+
## What to expect
|
|
25
|
+
|
|
26
|
+
- **Acknowledgement** of your report within **3 business days**.
|
|
27
|
+
- An initial assessment (severity, affected surface) and a remediation plan as
|
|
28
|
+
soon as we've reproduced the issue.
|
|
29
|
+
- **Coordinated disclosure:** we'll agree a disclosure timeline with you and
|
|
30
|
+
credit you in the release notes if you wish. Please give us reasonable time to
|
|
31
|
+
ship a fix before any public disclosure.
|
|
32
|
+
|
|
33
|
+
We do not currently run a paid bug-bounty program, but we genuinely appreciate
|
|
34
|
+
responsible disclosure and will credit reporters.
|
|
35
|
+
|
|
36
|
+
## Software Bill of Materials (SBOM)
|
|
37
|
+
|
|
38
|
+
A CycloneDX SBOM of the third-party runtime dependency graph is generated for the
|
|
39
|
+
published packages (`pnpm gen:sbom` → `sbom.cdx.json`). If your procurement or
|
|
40
|
+
security team needs it, request it at
|
|
41
|
+
[support@voltro.dev](mailto:support@voltro.dev).
|
|
42
|
+
|
|
43
|
+
## Supported versions
|
|
44
|
+
|
|
45
|
+
Voltro is pre-1.0 and ships in lockstep: **only the latest published minor of
|
|
46
|
+
each `@voltro/*` package receives security fixes.** Please upgrade to the latest
|
|
47
|
+
release before reporting, and pin exact versions in production.
|
|
48
|
+
|
|
49
|
+
## Scope
|
|
50
|
+
|
|
51
|
+
In scope: the published `@voltro/*` packages and the framework's own code.
|
|
52
|
+
|
|
53
|
+
Out of scope: vulnerabilities in third-party dependencies that are already
|
|
54
|
+
tracked upstream (we monitor these via a production-dependency audit gate in
|
|
55
|
+
CI), and issues that require a non-default, explicitly-unsafe configuration. If
|
|
56
|
+
you're unsure, report it anyway — we'd rather hear about it.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Third-Party Notices — @voltro/integration-http
|
|
2
|
+
|
|
3
|
+
This package depends on the open-source software listed below. Each is the
|
|
4
|
+
property of its respective copyright holders and is used under the terms of
|
|
5
|
+
its license. This file is provided for attribution; it grants no rights in
|
|
6
|
+
@voltro/integration-http itself, which is proprietary (see LICENSE).
|
|
7
|
+
|
|
8
|
+
Generated from the resolved runtime dependency closure (0 packages).
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SSRF guard for an ARBITRARY (user-supplied) outbound URL — webhook targets,
|
|
5
|
+
* ingest URLs. Throws when the URL is non-http(s), or its host is an IP LITERAL
|
|
6
|
+
* in a loopback / private / link-local (incl. `169.254.169.254` cloud-metadata)
|
|
7
|
+
* / reserved range, or `localhost` / `*.internal` / `*.local`. Fails CLOSED on a
|
|
8
|
+
* malformed URL.
|
|
9
|
+
*
|
|
10
|
+
* SYNC + node-free by design (this is a leaf package — no `node:dns`): it does
|
|
11
|
+
* NOT resolve DNS, so a HOSTNAME that resolves to a private IP (DNS rebinding)
|
|
12
|
+
* is not caught here — pair it with network-layer egress control for that. It
|
|
13
|
+
* DOES block the high-severity direct-IP vector (metadata endpoint, loopback,
|
|
14
|
+
* RFC-1918), which is the common webhook-SSRF exploit.
|
|
15
|
+
*/
|
|
16
|
+
export declare const assertPublicUrl: (rawUrl: string) => void;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* SSRF guard: the resolved URL's host MUST equal the configured base host.
|
|
20
|
+
* Used by every request so a crafted ref can't pivot the server-side fetch to
|
|
21
|
+
* an internal address. Fails CLOSED — a malformed URL returns `false`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const assertSameHost: (baseUrl: string, targetUrl: string) => boolean;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How each request is authenticated. Given the (optional) per-call context the
|
|
27
|
+
* caller passes to a request method, produce the outgoing headers. Keeping this
|
|
28
|
+
* a function means bearer tokens, tracking headers, or per-subject credentials
|
|
29
|
+
* are all expressible without the core knowing any auth scheme. Return the FULL
|
|
30
|
+
* header set for the request (the client does not merge — you own it).
|
|
31
|
+
*/
|
|
32
|
+
export declare type AuthHeaders<Ctx> = (ctx: Ctx) => Record<string, string>;
|
|
33
|
+
|
|
34
|
+
/** Exponential backoff with a cap; attempt is 1-based. */
|
|
35
|
+
export declare const backoffMs: (attempt: number, policy: HttpPolicy) => number;
|
|
36
|
+
|
|
37
|
+
declare type CallOptions<Ctx> = [Ctx] extends [void] ? {
|
|
38
|
+
readonly ctx?: void;
|
|
39
|
+
readonly body?: unknown;
|
|
40
|
+
} | undefined : HttpRequestOptions<Ctx>;
|
|
41
|
+
|
|
42
|
+
export declare const DEFAULT_POLICY: HttpPolicy;
|
|
43
|
+
|
|
44
|
+
/** Minimal fetch surface so tests can inject a stub — and so the core stays
|
|
45
|
+
* transport-agnostic (a `Response` structurally satisfies this shape). */
|
|
46
|
+
export declare type FetchLike = (url: string, init: {
|
|
47
|
+
method: string;
|
|
48
|
+
headers: Record<string, string>;
|
|
49
|
+
body?: string;
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
}) => Promise<{
|
|
52
|
+
status: number;
|
|
53
|
+
headers: {
|
|
54
|
+
get: (name: string) => string | null;
|
|
55
|
+
};
|
|
56
|
+
text: () => Promise<string>;
|
|
57
|
+
arrayBuffer: () => Promise<ArrayBuffer>;
|
|
58
|
+
}>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A bound HTTP client: typed methods over one base URL + auth + policy. Each
|
|
62
|
+
* method returns an `Effect` that fails with the caller's typed error `E`.
|
|
63
|
+
*/
|
|
64
|
+
export declare interface HttpClient<E, Ctx = void> {
|
|
65
|
+
/** GET `path` → raw bytes/status. */
|
|
66
|
+
readonly get: (path: string, options?: CallOptions<Ctx>) => Effect.Effect<RawResult, E>;
|
|
67
|
+
/** GET `path` → JSON (`null` for an empty body). */
|
|
68
|
+
readonly getJson: (path: string, options?: CallOptions<Ctx>) => Effect.Effect<unknown, E>;
|
|
69
|
+
/** POST `path` (JSON body) → JSON. */
|
|
70
|
+
readonly postJson: (path: string, options?: CallOptions<Ctx>) => Effect.Effect<unknown, E>;
|
|
71
|
+
/** PUT `path` (JSON body) → JSON. */
|
|
72
|
+
readonly putJson: (path: string, options?: CallOptions<Ctx>) => Effect.Effect<unknown, E>;
|
|
73
|
+
/** DELETE `path` → JSON. */
|
|
74
|
+
readonly deleteJson: (path: string, options?: CallOptions<Ctx>) => Effect.Effect<unknown, E>;
|
|
75
|
+
/** Escape hatch: a raw request with a caller-chosen method → raw bytes. */
|
|
76
|
+
readonly request: (method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, options?: CallOptions<Ctx>) => Effect.Effect<RawResult, E>;
|
|
77
|
+
/** The resolved policy in effect (defaults merged). */
|
|
78
|
+
readonly policy: HttpPolicy;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export declare interface HttpClientConfig<E, Ctx = void> {
|
|
82
|
+
/** Absolute base URL. Every request path is joined beneath it; the SSRF
|
|
83
|
+
* guard pins outbound requests to this host. */
|
|
84
|
+
readonly baseUrl: string;
|
|
85
|
+
/** Build the request headers (auth + any per-call headers). */
|
|
86
|
+
readonly auth: AuthHeaders<Ctx>;
|
|
87
|
+
/** Map a transport failure to the caller's typed error. */
|
|
88
|
+
readonly makeError: MakeError<E>;
|
|
89
|
+
/** The fetch implementation. Pass the global `fetch` in production or a stub
|
|
90
|
+
* in tests. */
|
|
91
|
+
readonly fetchImpl: FetchLike;
|
|
92
|
+
/** Retry/backoff/timeout overrides, merged over {@link DEFAULT_POLICY}. */
|
|
93
|
+
readonly policy?: Partial<HttpPolicy>;
|
|
94
|
+
/** Injected clock for Retry-After math (tests). */
|
|
95
|
+
readonly now?: () => number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export declare interface HttpPolicy {
|
|
99
|
+
/** Max attempts INCLUDING the first. Default 4. */
|
|
100
|
+
readonly maxAttempts: number;
|
|
101
|
+
/** First-retry delay (ms). Default 500. */
|
|
102
|
+
readonly initialDelayMs: number;
|
|
103
|
+
/** Cap on any single backoff (ms). Default 10_000. */
|
|
104
|
+
readonly maxDelayMs: number;
|
|
105
|
+
/** Per-request timeout (ms). Default 20_000. */
|
|
106
|
+
readonly timeoutMs: number;
|
|
107
|
+
/** Honour a `Retry-After` response header over computed backoff. Default true. */
|
|
108
|
+
readonly honourRetryAfter: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export declare interface HttpRequestOptions<Ctx> {
|
|
112
|
+
/** Per-call auth context passed to `auth` (e.g. a resolved token / subject). */
|
|
113
|
+
readonly ctx: Ctx;
|
|
114
|
+
/** Extra JSON body for POST/PUT — serialized by the client. */
|
|
115
|
+
readonly body?: unknown;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Maps a transport-level failure into the CALLER's typed error. Supplying this
|
|
120
|
+
* is how each integration keeps ONE core but its OWN `Schema.TaggedError`
|
|
121
|
+
* (`JiraError`, `GithubError`, …). `code: 'session_expired'` marks a stale
|
|
122
|
+
* token (a 401) — non-transient, re-auth rather than retry.
|
|
123
|
+
*/
|
|
124
|
+
export declare interface MakeError<E> {
|
|
125
|
+
(args: {
|
|
126
|
+
readonly status?: number;
|
|
127
|
+
readonly message: string;
|
|
128
|
+
readonly transient: boolean;
|
|
129
|
+
readonly code?: 'session_expired';
|
|
130
|
+
}): E;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build a bound {@link HttpClient}. The SSRF host-allowlist, transient retry
|
|
135
|
+
* with Retry-After, per-request timeout, and 401 → `session_expired` all come
|
|
136
|
+
* from the shared core — this only binds the config once.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```ts no-check
|
|
140
|
+
* const github = makeHttpClient<GithubError>({
|
|
141
|
+
* baseUrl: 'https://api.github.com',
|
|
142
|
+
* auth: () => ({ authorization: `Bearer ${token}`, accept: 'application/vnd.github+json' }),
|
|
143
|
+
* makeError: (a) => new GithubError(a),
|
|
144
|
+
* fetchImpl: fetch as unknown as FetchLike,
|
|
145
|
+
* })
|
|
146
|
+
* const repo = yield* github.getJson('/repos/acme/widgets')
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
export declare const makeHttpClient: <E, Ctx = void>(config: HttpClientConfig<E, Ctx>) => HttpClient<E, Ctx>;
|
|
150
|
+
|
|
151
|
+
/** Parse a `Retry-After` header — either delta-seconds or an HTTP date. */
|
|
152
|
+
export declare const parseRetryAfter: (value: string | null, nowMs: number) => number | null;
|
|
153
|
+
|
|
154
|
+
export declare interface RawResult {
|
|
155
|
+
readonly status: number;
|
|
156
|
+
readonly bytes: Uint8Array;
|
|
157
|
+
readonly contentType: string | null;
|
|
158
|
+
readonly text: () => string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Perform one request with retry/backoff/timeout. Succeeds with the raw
|
|
163
|
+
* response (bytes + status); fails with the caller's typed error.
|
|
164
|
+
*/
|
|
165
|
+
export declare const request: <E>(args: RequestArgs<E>) => Effect.Effect<RawResult, E>;
|
|
166
|
+
|
|
167
|
+
export declare interface RequestArgs<E> {
|
|
168
|
+
readonly fetchImpl: FetchLike;
|
|
169
|
+
readonly baseUrl: string;
|
|
170
|
+
readonly method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
171
|
+
readonly path: string;
|
|
172
|
+
readonly headers: Record<string, string>;
|
|
173
|
+
readonly body?: string;
|
|
174
|
+
readonly policy: HttpPolicy;
|
|
175
|
+
readonly makeError: MakeError<E>;
|
|
176
|
+
/** Injected clock for Retry-After date math (tests). */
|
|
177
|
+
readonly now?: () => number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Convenience: `request` + JSON-parse the body (empty body → `null`). */
|
|
181
|
+
export declare const requestJson: <E>(args: RequestArgs<E>) => Effect.Effect<unknown, E>;
|
|
182
|
+
|
|
183
|
+
/** Status codes worth retrying — the conservative transient set. */
|
|
184
|
+
export declare const RETRYABLE_STATUS: Set<number>;
|
|
185
|
+
|
|
186
|
+
export { }
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Duration as e, Effect as t } from "effect";
|
|
2
|
+
//#region src/core.ts
|
|
3
|
+
var n = /* @__PURE__ */ new Set([
|
|
4
|
+
408,
|
|
5
|
+
425,
|
|
6
|
+
429,
|
|
7
|
+
500,
|
|
8
|
+
502,
|
|
9
|
+
503,
|
|
10
|
+
504
|
|
11
|
+
]), r = {
|
|
12
|
+
maxAttempts: 4,
|
|
13
|
+
initialDelayMs: 500,
|
|
14
|
+
maxDelayMs: 1e4,
|
|
15
|
+
timeoutMs: 2e4,
|
|
16
|
+
honourRetryAfter: !0
|
|
17
|
+
}, i = (e, t) => Math.min(t.initialDelayMs * 2 ** (e - 1), t.maxDelayMs), a = (e, t) => {
|
|
18
|
+
if (!e) return null;
|
|
19
|
+
let n = Number(e);
|
|
20
|
+
if (Number.isFinite(n)) return Math.max(0, n * 1e3);
|
|
21
|
+
let r = Date.parse(e);
|
|
22
|
+
return Number.isNaN(r) ? null : Math.max(0, r - t);
|
|
23
|
+
}, o = (e, t) => {
|
|
24
|
+
try {
|
|
25
|
+
return new URL(e).host === new URL(t).host;
|
|
26
|
+
} catch {
|
|
27
|
+
return !1;
|
|
28
|
+
}
|
|
29
|
+
}, s = (e, t) => e === 127 || e === 10 || e === 172 && t >= 16 && t <= 31 || e === 192 && t === 168 || e === 169 && t === 254 || e === 0 || e === 100 && t >= 64 && t <= 127 || e >= 224, c = (e) => {
|
|
30
|
+
let t;
|
|
31
|
+
try {
|
|
32
|
+
t = new URL(e);
|
|
33
|
+
} catch {
|
|
34
|
+
throw Error("SSRF: malformed url");
|
|
35
|
+
}
|
|
36
|
+
if (t.protocol !== "http:" && t.protocol !== "https:") throw Error(`SSRF: blocked url scheme "${t.protocol}"`);
|
|
37
|
+
let n = t.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
38
|
+
if (n === "localhost" || n.endsWith(".localhost") || n.endsWith(".internal") || n.endsWith(".local")) throw Error(`SSRF: blocked host "${n}"`);
|
|
39
|
+
let r = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(n);
|
|
40
|
+
if (r) {
|
|
41
|
+
let e = r.slice(1).map(Number);
|
|
42
|
+
if (e.some((e) => e > 255) || s(e[0], e[1])) throw Error(`SSRF: blocked address "${n}"`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (n.includes(":") && (n === "::1" || n === "::" || n.startsWith("::ffff:") || /^fe[89ab]/.test(n) || n.startsWith("fc") || n.startsWith("fd"))) throw Error(`SSRF: blocked address "${n}"`);
|
|
46
|
+
}, l = (e) => typeof e == "object" && !!e && e._transient === !0, u = (r) => {
|
|
47
|
+
let { fetchImpl: s, baseUrl: c, method: u, path: d, headers: f, body: p, policy: m, makeError: h } = r, g = r.now ?? Date.now, _ = `${c.replace(/\/$/, "")}${d.startsWith("/") ? d : `/${d}`}`;
|
|
48
|
+
if (!o(c, _)) return t.fail(h({
|
|
49
|
+
message: `blocked off-host request to ${_}`,
|
|
50
|
+
transient: !1
|
|
51
|
+
}));
|
|
52
|
+
let v = t.tryPromise({
|
|
53
|
+
try: async (e) => {
|
|
54
|
+
let t = await s(_, {
|
|
55
|
+
method: u,
|
|
56
|
+
headers: f,
|
|
57
|
+
...p === void 0 ? {} : { body: p },
|
|
58
|
+
signal: e
|
|
59
|
+
}), r = new Uint8Array(await t.arrayBuffer()), i = t.headers.get("content-type"), o = () => new TextDecoder().decode(r);
|
|
60
|
+
if (t.status >= 200 && t.status < 300) return {
|
|
61
|
+
status: t.status,
|
|
62
|
+
bytes: r,
|
|
63
|
+
contentType: i,
|
|
64
|
+
text: o
|
|
65
|
+
};
|
|
66
|
+
throw t.status === 401 ? h({
|
|
67
|
+
status: 401,
|
|
68
|
+
message: "session expired",
|
|
69
|
+
transient: !1,
|
|
70
|
+
code: "session_expired"
|
|
71
|
+
}) : n.has(t.status) ? {
|
|
72
|
+
_transient: !0,
|
|
73
|
+
status: t.status,
|
|
74
|
+
message: `HTTP ${t.status}: ${o().slice(0, 500)}`,
|
|
75
|
+
retryAfterMs: m.honourRetryAfter ? a(t.headers.get("retry-after"), g()) : null
|
|
76
|
+
} : h({
|
|
77
|
+
status: t.status,
|
|
78
|
+
message: `HTTP ${t.status}: ${o().slice(0, 500)}`,
|
|
79
|
+
transient: !1
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
catch: (e) => l(e) || typeof e == "object" && e && "_tag" in e ? e : {
|
|
83
|
+
_transient: !0,
|
|
84
|
+
message: `network error: ${String(e)}`,
|
|
85
|
+
retryAfterMs: null
|
|
86
|
+
}
|
|
87
|
+
}).pipe(t.timeoutFail({
|
|
88
|
+
duration: e.millis(m.timeoutMs),
|
|
89
|
+
onTimeout: () => ({
|
|
90
|
+
_transient: !0,
|
|
91
|
+
message: `request timed out after ${m.timeoutMs}ms`,
|
|
92
|
+
retryAfterMs: null
|
|
93
|
+
})
|
|
94
|
+
})), y = (n) => v.pipe(t.catchAll((r) => {
|
|
95
|
+
if (l(r) && n < m.maxAttempts) {
|
|
96
|
+
let a = r.retryAfterMs ?? i(n, m), o = r.retryAfterMs == null ? a / 2 + a / 2 * Math.random() : a, s = Math.min(o, m.maxDelayMs);
|
|
97
|
+
return t.sleep(e.millis(s)).pipe(t.flatMap(() => y(n + 1)));
|
|
98
|
+
}
|
|
99
|
+
return l(r) ? t.fail(h({
|
|
100
|
+
message: r.message,
|
|
101
|
+
transient: !0,
|
|
102
|
+
...r.status === void 0 ? {} : { status: r.status }
|
|
103
|
+
})) : t.fail(r);
|
|
104
|
+
})), b = (() => {
|
|
105
|
+
try {
|
|
106
|
+
return new URL(c).host;
|
|
107
|
+
} catch {
|
|
108
|
+
return "invalid";
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
return y(1).pipe(t.withSpan("integration-http.request", { attributes: {
|
|
112
|
+
"http.request.method": u,
|
|
113
|
+
"server.address": b
|
|
114
|
+
} }));
|
|
115
|
+
}, d = (e) => u(e).pipe(t.flatMap((n) => n.bytes.length === 0 ? t.succeed(null) : t.try({
|
|
116
|
+
try: () => JSON.parse(n.text()),
|
|
117
|
+
catch: () => e.makeError({
|
|
118
|
+
status: n.status,
|
|
119
|
+
message: "invalid JSON in response",
|
|
120
|
+
transient: !1
|
|
121
|
+
})
|
|
122
|
+
}))), f = (e) => {
|
|
123
|
+
let t = {
|
|
124
|
+
...r,
|
|
125
|
+
...e.policy
|
|
126
|
+
}, n = (n, r, i) => {
|
|
127
|
+
let a = i?.ctx ?? void 0, o = i?.body;
|
|
128
|
+
return {
|
|
129
|
+
fetchImpl: e.fetchImpl,
|
|
130
|
+
baseUrl: e.baseUrl,
|
|
131
|
+
method: n,
|
|
132
|
+
path: r,
|
|
133
|
+
headers: e.auth(a),
|
|
134
|
+
...o === void 0 ? {} : { body: JSON.stringify(o) },
|
|
135
|
+
policy: t,
|
|
136
|
+
makeError: e.makeError,
|
|
137
|
+
...e.now ? { now: e.now } : {}
|
|
138
|
+
};
|
|
139
|
+
}, i = (e, t, r) => u(n(e, t, r)), a = (e, t, r) => d(n(e, t, r));
|
|
140
|
+
return {
|
|
141
|
+
get: (e, t) => i("GET", e, t),
|
|
142
|
+
getJson: (e, t) => a("GET", e, t),
|
|
143
|
+
postJson: (e, t) => a("POST", e, t),
|
|
144
|
+
putJson: (e, t) => a("PUT", e, t),
|
|
145
|
+
deleteJson: (e, t) => a("DELETE", e, t),
|
|
146
|
+
request: (e, t, n) => i(e, t, n),
|
|
147
|
+
policy: t
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
export { r as DEFAULT_POLICY, n as RETRYABLE_STATUS, c as assertPublicUrl, o as assertSameHost, i as backoffMs, f as makeHttpClient, a as parseRetryAfter, u as request, d as requestJson };
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voltro/integration-http",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Server-side HTTP transport core for integration plugins: token auth, transient retry with Retry-After, per-request timeout, and an SSRF host-allowlist guard — over an injectable fetch seam, Effect-first with typed errors.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"voltro",
|
|
7
|
+
"typescript",
|
|
8
|
+
"framework"
|
|
9
|
+
],
|
|
10
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
11
|
+
"homepage": "https://voltro.dev",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"email": "support@voltro.dev"
|
|
14
|
+
},
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "Voltro UG",
|
|
17
|
+
"url": "https://voltro.dev"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=24.0.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"effect": "^3.21.4"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|