@rip-lang/http 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +357 -0
- package/http.rip +219 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/rip.png" style="width:50px" /> <br>
|
|
2
|
+
|
|
3
|
+
# Rip HTTP - @rip-lang/http
|
|
4
|
+
|
|
5
|
+
> **Zero-dependency HTTP client for Rip — ky-inspired convenience over native fetch**
|
|
6
|
+
|
|
7
|
+
A lightweight HTTP client that wraps Bun's native `fetch` with method shortcuts,
|
|
8
|
+
JSON convenience, automatic error throwing, retries with exponential backoff,
|
|
9
|
+
timeouts, lifecycle hooks, and reusable instances. 220 lines of Rip, zero
|
|
10
|
+
dependencies.
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun add @rip-lang/http
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```coffee
|
|
19
|
+
import { http } from '@rip-lang/http'
|
|
20
|
+
|
|
21
|
+
# Simple GET
|
|
22
|
+
data = http.get!('https://api.example.com/users').json!
|
|
23
|
+
|
|
24
|
+
# POST with JSON body
|
|
25
|
+
user = http.post!('https://api.example.com/users', json: { name: 'Alice' }).json!
|
|
26
|
+
|
|
27
|
+
# Reusable API client
|
|
28
|
+
api = http.create
|
|
29
|
+
prefixUrl: 'https://api.example.com/v1'
|
|
30
|
+
headers: { Authorization: "Bearer #{token}" }
|
|
31
|
+
timeout: 5000
|
|
32
|
+
retry: 3
|
|
33
|
+
|
|
34
|
+
users = api.get!('users').json!
|
|
35
|
+
user = api.post!('users', json: { name: 'Alice' }).json!
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **Method shortcuts** — `http.get`, `http.post`, `http.put`, `http.patch`, `http.del`, `http.head`
|
|
41
|
+
- **JSON convenience** — `json:` option auto-stringifies body and sets Content-Type
|
|
42
|
+
- **Auto error throwing** — non-2xx responses throw `HTTPError` (native fetch doesn't)
|
|
43
|
+
- **Timeouts** — built-in via `AbortSignal.timeout()`, default 10s
|
|
44
|
+
- **Retries** — exponential backoff with jitter, `Retry-After` header support
|
|
45
|
+
- **Lifecycle hooks** — `beforeRequest`, `afterResponse`, `beforeRetry`, `beforeError`
|
|
46
|
+
- **Reusable instances** — `create()` and `extend()` with `prefixUrl` and default headers
|
|
47
|
+
- **Bun-native** — no cross-platform shims, no polyfills, no feature detection
|
|
48
|
+
|
|
49
|
+
| File | Lines | Role |
|
|
50
|
+
|------|-------|------|
|
|
51
|
+
| `http.rip` | ~220 | Everything |
|
|
52
|
+
|
|
53
|
+
## Methods
|
|
54
|
+
|
|
55
|
+
All methods return a `Promise<Response>`. Use Rip's dammit operator (`!`) to
|
|
56
|
+
call and await in one step.
|
|
57
|
+
|
|
58
|
+
```coffee
|
|
59
|
+
res = http.get!(url)
|
|
60
|
+
res = http.post!(url, opts)
|
|
61
|
+
res = http.put!(url, opts)
|
|
62
|
+
res = http.patch!(url, opts)
|
|
63
|
+
res = http.del!(url, opts)
|
|
64
|
+
res = http.head!(url, opts)
|
|
65
|
+
|
|
66
|
+
# Read the response
|
|
67
|
+
data = res.json! # Parse JSON
|
|
68
|
+
text = res.text! # Read text
|
|
69
|
+
buf = res.arrayBuffer! # Read binary
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## JSON Convenience
|
|
73
|
+
|
|
74
|
+
The `json:` option stringifies the body and sets `Content-Type: application/json`
|
|
75
|
+
automatically.
|
|
76
|
+
|
|
77
|
+
```coffee
|
|
78
|
+
# Without json: option
|
|
79
|
+
res = http.post! url,
|
|
80
|
+
body: JSON.stringify({ name: 'Alice' })
|
|
81
|
+
headers: { 'Content-Type': 'application/json' }
|
|
82
|
+
|
|
83
|
+
# With json: option
|
|
84
|
+
res = http.post! url, json: { name: 'Alice' }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Error Handling
|
|
88
|
+
|
|
89
|
+
By default, non-2xx responses throw an `HTTPError` with the response attached.
|
|
90
|
+
Native fetch silently returns error responses — this catches bugs earlier.
|
|
91
|
+
|
|
92
|
+
```coffee
|
|
93
|
+
# Auto-throws on 4xx/5xx
|
|
94
|
+
try
|
|
95
|
+
data = http.get!('https://api.example.com/missing').json!
|
|
96
|
+
catch err
|
|
97
|
+
if err instanceof http.HTTPError
|
|
98
|
+
console.log err.response.status # 404
|
|
99
|
+
body = err.response.json! # Read error body
|
|
100
|
+
console.log body
|
|
101
|
+
|
|
102
|
+
# Opt out of auto-throwing
|
|
103
|
+
res = http.get! url, throwHttpErrors: false
|
|
104
|
+
if res.ok
|
|
105
|
+
data = res.json!
|
|
106
|
+
else
|
|
107
|
+
console.log "Failed:", res.status
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Timeouts
|
|
111
|
+
|
|
112
|
+
Default timeout is 10 seconds. Uses `AbortSignal.timeout()` under the hood.
|
|
113
|
+
|
|
114
|
+
```coffee
|
|
115
|
+
# Custom timeout
|
|
116
|
+
res = http.get! url, timeout: 5000
|
|
117
|
+
|
|
118
|
+
# No timeout
|
|
119
|
+
res = http.get! url, timeout: false
|
|
120
|
+
|
|
121
|
+
# Catch timeout errors
|
|
122
|
+
try
|
|
123
|
+
res = http.get! url, timeout: 1000
|
|
124
|
+
catch err
|
|
125
|
+
if err instanceof http.TimeoutError
|
|
126
|
+
console.log 'Request timed out'
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Retries
|
|
130
|
+
|
|
131
|
+
Failed requests are automatically retried with exponential backoff and jitter.
|
|
132
|
+
Only safe methods (`GET`, `PUT`, `HEAD`, `DELETE`, `OPTIONS`, `TRACE`) are
|
|
133
|
+
retried by default.
|
|
134
|
+
|
|
135
|
+
```coffee
|
|
136
|
+
# Retry up to 5 times
|
|
137
|
+
res = http.get! url, retry: 5
|
|
138
|
+
|
|
139
|
+
# Disable retries
|
|
140
|
+
res = http.get! url, retry: false
|
|
141
|
+
|
|
142
|
+
# Fine-grained control
|
|
143
|
+
res = http.get! url,
|
|
144
|
+
retry:
|
|
145
|
+
limit: 3
|
|
146
|
+
methods: ['GET', 'POST']
|
|
147
|
+
statusCodes: [408, 429, 500, 502, 503, 504]
|
|
148
|
+
backoffLimit: 10000
|
|
149
|
+
delay: (attempt) -> attempt * 1000
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Defaults
|
|
153
|
+
|
|
154
|
+
| Option | Default |
|
|
155
|
+
|--------|---------|
|
|
156
|
+
| `limit` | 2 |
|
|
157
|
+
| `methods` | `GET`, `PUT`, `HEAD`, `DELETE`, `OPTIONS`, `TRACE` |
|
|
158
|
+
| `statusCodes` | 408, 413, 429, 500, 502, 503, 504 |
|
|
159
|
+
| `backoffLimit` | Infinity |
|
|
160
|
+
| `delay` | `0.3 * 2^(attempt-1) * 1000` ms with ~10% jitter |
|
|
161
|
+
|
|
162
|
+
The retry engine respects `Retry-After` headers (both seconds and date formats).
|
|
163
|
+
|
|
164
|
+
## Hooks
|
|
165
|
+
|
|
166
|
+
Lifecycle hooks let you intercept requests and responses without modifying
|
|
167
|
+
the core logic. All hooks are async-compatible.
|
|
168
|
+
|
|
169
|
+
```coffee
|
|
170
|
+
api = http.create
|
|
171
|
+
hooks:
|
|
172
|
+
beforeRequest: [
|
|
173
|
+
(req, opts) ->
|
|
174
|
+
token = getToken!
|
|
175
|
+
req.headers.set 'Authorization', "Bearer #{token}"
|
|
176
|
+
]
|
|
177
|
+
afterResponse: [
|
|
178
|
+
(req, opts, res) ->
|
|
179
|
+
console.log "#{req.method} #{req.url} → #{res.status}"
|
|
180
|
+
]
|
|
181
|
+
beforeRetry: [
|
|
182
|
+
({ request, options, error, retryCount }) ->
|
|
183
|
+
console.log "Retry #{retryCount}..."
|
|
184
|
+
]
|
|
185
|
+
beforeError: [
|
|
186
|
+
(error) ->
|
|
187
|
+
error.customMessage = "API Error: #{error.response.status}"
|
|
188
|
+
error
|
|
189
|
+
]
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Hook Types
|
|
193
|
+
|
|
194
|
+
| Hook | Arguments | Can Return |
|
|
195
|
+
|------|-----------|------------|
|
|
196
|
+
| `beforeRequest` | `(request, options)` | `Request` (modify), `Response` (short-circuit) |
|
|
197
|
+
| `afterResponse` | `(request, options, response)` | `Response` (replace) |
|
|
198
|
+
| `beforeRetry` | `({ request, options, error, retryCount })` | — |
|
|
199
|
+
| `beforeError` | `(error)` | `HTTPError` (replace) |
|
|
200
|
+
|
|
201
|
+
## Instances
|
|
202
|
+
|
|
203
|
+
Create reusable client instances with default options. Instances support
|
|
204
|
+
all the same methods as the top-level `http` export.
|
|
205
|
+
|
|
206
|
+
### create
|
|
207
|
+
|
|
208
|
+
Build a new instance from scratch.
|
|
209
|
+
|
|
210
|
+
```coffee
|
|
211
|
+
api = http.create
|
|
212
|
+
prefixUrl: 'https://api.example.com/v1'
|
|
213
|
+
headers: { 'X-API-Key': 'secret' }
|
|
214
|
+
timeout: 5000
|
|
215
|
+
retry: 3
|
|
216
|
+
|
|
217
|
+
users = api.get!('users').json!
|
|
218
|
+
user = api.post!('users', json: { name: 'Alice' }).json!
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### extend
|
|
222
|
+
|
|
223
|
+
Build a new instance that inherits from an existing one.
|
|
224
|
+
|
|
225
|
+
```coffee
|
|
226
|
+
api = http.create
|
|
227
|
+
prefixUrl: 'https://api.example.com/v1'
|
|
228
|
+
headers: { 'X-API-Key': 'secret' }
|
|
229
|
+
|
|
230
|
+
admin = api.extend
|
|
231
|
+
headers: { 'X-Admin': 'true' }
|
|
232
|
+
|
|
233
|
+
# admin inherits prefixUrl and X-API-Key, adds X-Admin
|
|
234
|
+
admin.get!('dashboard').json!
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Headers are deep-merged (new headers add to or override existing ones).
|
|
238
|
+
Hooks are concatenated (parent hooks run first, then child hooks).
|
|
239
|
+
|
|
240
|
+
## Search Params
|
|
241
|
+
|
|
242
|
+
```coffee
|
|
243
|
+
# Object
|
|
244
|
+
res = http.get! url, searchParams: { page: 1, limit: 20 }
|
|
245
|
+
|
|
246
|
+
# String
|
|
247
|
+
res = http.get! url, searchParams: 'page=1&limit=20'
|
|
248
|
+
|
|
249
|
+
# URLSearchParams
|
|
250
|
+
params = new URLSearchParams()
|
|
251
|
+
params.set 'page', '1'
|
|
252
|
+
res = http.get! url, searchParams: params
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Undefined values in objects are automatically filtered out.
|
|
256
|
+
|
|
257
|
+
## Options Reference
|
|
258
|
+
|
|
259
|
+
| Option | Type | Default | Description |
|
|
260
|
+
|--------|------|---------|-------------|
|
|
261
|
+
| `method` | string | `'GET'` | HTTP method |
|
|
262
|
+
| `json` | any | — | Auto-stringify body, set Content-Type |
|
|
263
|
+
| `body` | BodyInit | — | Raw request body |
|
|
264
|
+
| `headers` | object/Headers | — | Request headers |
|
|
265
|
+
| `prefixUrl` | string | — | Base URL prepended to input |
|
|
266
|
+
| `searchParams` | object/string/URLSearchParams | — | Query parameters |
|
|
267
|
+
| `timeout` | number/false | `10000` | Timeout in ms (false to disable) |
|
|
268
|
+
| `retry` | number/object/false | `{ limit: 2 }` | Retry configuration |
|
|
269
|
+
| `throwHttpErrors` | boolean | `true` | Throw on non-2xx responses |
|
|
270
|
+
| `hooks` | object | — | Lifecycle hooks |
|
|
271
|
+
|
|
272
|
+
All native fetch options (`mode`, `credentials`, `cache`, `redirect`, `signal`,
|
|
273
|
+
etc.) are passed through to the underlying `fetch()` call.
|
|
274
|
+
|
|
275
|
+
## Error Types
|
|
276
|
+
|
|
277
|
+
### HTTPError
|
|
278
|
+
|
|
279
|
+
Thrown when a response has a non-2xx status code (when `throwHttpErrors` is true).
|
|
280
|
+
|
|
281
|
+
```coffee
|
|
282
|
+
try
|
|
283
|
+
http.get!(url)
|
|
284
|
+
catch err
|
|
285
|
+
err.name # 'HTTPError'
|
|
286
|
+
err.message # 'Request failed with status 404'
|
|
287
|
+
err.response # Response object
|
|
288
|
+
err.request # Request object
|
|
289
|
+
err.options # Options used for the request
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### TimeoutError
|
|
293
|
+
|
|
294
|
+
Thrown when a request exceeds the timeout.
|
|
295
|
+
|
|
296
|
+
```coffee
|
|
297
|
+
try
|
|
298
|
+
http.get! url, timeout: 100
|
|
299
|
+
catch err
|
|
300
|
+
err.name # 'TimeoutError'
|
|
301
|
+
err.message # 'Request timed out'
|
|
302
|
+
err.request # Request object
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Comparison with ky
|
|
306
|
+
|
|
307
|
+
This package is inspired by [ky](https://github.com/sindresorhus/ky) and covers
|
|
308
|
+
the same core feature set in a fraction of the code.
|
|
309
|
+
|
|
310
|
+
| | ky | @rip-lang/http |
|
|
311
|
+
|---|---|---|
|
|
312
|
+
| Source files | 24 | 1 |
|
|
313
|
+
| Runtime code | ~1,200 lines | 220 lines |
|
|
314
|
+
| Dependencies | 0 | 0 |
|
|
315
|
+
| Method shortcuts | yes | yes |
|
|
316
|
+
| JSON convenience | yes | yes |
|
|
317
|
+
| Auto error throwing | yes | yes |
|
|
318
|
+
| Timeout | yes | yes |
|
|
319
|
+
| Retry + backoff | yes | yes |
|
|
320
|
+
| Hooks | yes | yes |
|
|
321
|
+
| Instances | yes | yes |
|
|
322
|
+
| Search params | yes | yes |
|
|
323
|
+
| Retry-After | yes | yes |
|
|
324
|
+
| Progress callbacks | yes | — |
|
|
325
|
+
| Custom JSON parser | yes | — |
|
|
326
|
+
| Cross-platform shims | yes (browser/Node/Deno/Bun) | — (Bun only) |
|
|
327
|
+
|
|
328
|
+
The size difference comes from two things: Rip's concise syntax and the fact
|
|
329
|
+
that ky must support browsers, Node.js, Deno, and Bun simultaneously — requiring
|
|
330
|
+
extensive feature detection, AbortController polyfills, ReadableStream
|
|
331
|
+
compatibility checks, and careful response body memory management. We target
|
|
332
|
+
Bun only, so none of that is needed.
|
|
333
|
+
|
|
334
|
+
## API Summary
|
|
335
|
+
|
|
336
|
+
```coffee
|
|
337
|
+
# Top-level methods
|
|
338
|
+
http(url, opts) # Generic request
|
|
339
|
+
http.get(url, opts) # GET
|
|
340
|
+
http.post(url, opts) # POST
|
|
341
|
+
http.put(url, opts) # PUT
|
|
342
|
+
http.patch(url, opts) # PATCH
|
|
343
|
+
http.del(url, opts) # DELETE
|
|
344
|
+
http.head(url, opts) # HEAD
|
|
345
|
+
|
|
346
|
+
# Instance management
|
|
347
|
+
http.create(opts) # New instance from scratch
|
|
348
|
+
http.extend(opts) # New instance inheriting defaults
|
|
349
|
+
|
|
350
|
+
# Error classes
|
|
351
|
+
http.HTTPError # Non-2xx response error
|
|
352
|
+
http.TimeoutError # Timeout error
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
## License
|
|
356
|
+
|
|
357
|
+
MIT
|
package/http.rip
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# @rip-lang/http — Zero-dependency HTTP client for Rip
|
|
3
|
+
#
|
|
4
|
+
# Author: Steve Shreeve (steve.shreeve@gmail.com)
|
|
5
|
+
# Date: February 18, 2026
|
|
6
|
+
#
|
|
7
|
+
# A ky-inspired convenience layer over native fetch. Method shortcuts, JSON
|
|
8
|
+
# body/response handling, auto error throwing on non-2xx, retries with
|
|
9
|
+
# exponential backoff and jitter, timeouts, lifecycle hooks, and reusable
|
|
10
|
+
# instances with prefixUrl and default headers — all in pure Rip, zero deps.
|
|
11
|
+
# ==============================================================================
|
|
12
|
+
|
|
13
|
+
# ==[ Defaults ]==
|
|
14
|
+
|
|
15
|
+
RETRY_METHODS =! ['GET', 'PUT', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE']
|
|
16
|
+
RETRY_CODES =! [408, 413, 429, 500, 502, 503, 504]
|
|
17
|
+
RETRY_LIMIT =! 2
|
|
18
|
+
BACKOFF_BASE =! 0.3
|
|
19
|
+
TIMEOUT_MS =! 10000
|
|
20
|
+
|
|
21
|
+
# ==[ Error Classes ]==
|
|
22
|
+
|
|
23
|
+
class HTTPError extends Error
|
|
24
|
+
constructor: (@response, @request, @options) ->
|
|
25
|
+
super("Request failed with status #{@response.status}")
|
|
26
|
+
@name = 'HTTPError'
|
|
27
|
+
|
|
28
|
+
class TimeoutError extends Error
|
|
29
|
+
constructor: (@request) ->
|
|
30
|
+
super('Request timed out')
|
|
31
|
+
@name = 'TimeoutError'
|
|
32
|
+
|
|
33
|
+
# ==[ Helpers ]==
|
|
34
|
+
|
|
35
|
+
def buildUrl(input, opts)
|
|
36
|
+
base = opts.prefixUrl
|
|
37
|
+
if base
|
|
38
|
+
unless base.endsWith('/')
|
|
39
|
+
base += '/'
|
|
40
|
+
input = input.slice(1) if typeof input is 'string' and input.startsWith('/')
|
|
41
|
+
url = new URL(input, base)
|
|
42
|
+
else
|
|
43
|
+
url = new URL(input)
|
|
44
|
+
|
|
45
|
+
sp = opts.searchParams
|
|
46
|
+
if sp
|
|
47
|
+
if sp instanceof URLSearchParams
|
|
48
|
+
sp.forEach (v, k) -> url.searchParams.set(k, v)
|
|
49
|
+
else if typeof sp is 'string'
|
|
50
|
+
new URLSearchParams(sp).forEach (v, k) -> url.searchParams.set(k, v)
|
|
51
|
+
else if typeof sp is 'object'
|
|
52
|
+
for own k, v of sp
|
|
53
|
+
url.searchParams.set(k, String(v)) if v?
|
|
54
|
+
|
|
55
|
+
url
|
|
56
|
+
|
|
57
|
+
def mergeHeaders(a, b)
|
|
58
|
+
result = new Headers(a ?? {})
|
|
59
|
+
if b
|
|
60
|
+
h = new Headers(b)
|
|
61
|
+
h.forEach (v, k) -> result.set(k, v)
|
|
62
|
+
result
|
|
63
|
+
|
|
64
|
+
def mergeHooks(a = {}, b = {})
|
|
65
|
+
merged = {}
|
|
66
|
+
for key in ['beforeRequest', 'beforeRetry', 'afterResponse', 'beforeError']
|
|
67
|
+
merged[key] = [...(a[key] ?? []), ...(b[key] ?? [])]
|
|
68
|
+
merged
|
|
69
|
+
|
|
70
|
+
def mergeOptions(defaults, overrides)
|
|
71
|
+
opts = { ...defaults, ...overrides }
|
|
72
|
+
if defaults.headers or overrides.headers
|
|
73
|
+
opts.headers = mergeHeaders(defaults.headers, overrides.headers)
|
|
74
|
+
if defaults.hooks or overrides.hooks
|
|
75
|
+
opts.hooks = mergeHooks(defaults.hooks, overrides.hooks)
|
|
76
|
+
opts
|
|
77
|
+
|
|
78
|
+
def normalizeRetry(retry)
|
|
79
|
+
return { limit: 0 } if retry is false
|
|
80
|
+
if typeof retry is 'number'
|
|
81
|
+
{ limit: retry, methods: RETRY_METHODS, statusCodes: RETRY_CODES, backoffLimit: Infinity }
|
|
82
|
+
else if typeof retry is 'object'
|
|
83
|
+
limit: retry.limit ?? RETRY_LIMIT
|
|
84
|
+
methods: retry.methods ?? RETRY_METHODS
|
|
85
|
+
statusCodes: retry.statusCodes ?? RETRY_CODES
|
|
86
|
+
backoffLimit: retry.backoffLimit ?? Infinity
|
|
87
|
+
delay: retry.delay ?? null
|
|
88
|
+
else
|
|
89
|
+
{ limit: RETRY_LIMIT, methods: RETRY_METHODS, statusCodes: RETRY_CODES, backoffLimit: Infinity }
|
|
90
|
+
|
|
91
|
+
def delay(ms)
|
|
92
|
+
new Promise (resolve) -> setTimeout(resolve, ms)
|
|
93
|
+
|
|
94
|
+
def backoff(attempt, cfg)
|
|
95
|
+
if cfg.delay
|
|
96
|
+
cfg.delay(attempt)
|
|
97
|
+
else
|
|
98
|
+
jitter = 1 + Math.random() * 0.1
|
|
99
|
+
Math.min(BACKOFF_BASE * (2 ** (attempt - 1)) * 1000 * jitter, cfg.backoffLimit)
|
|
100
|
+
|
|
101
|
+
def retryDelay(attempt, res, cfg)
|
|
102
|
+
header = res?.headers.get('retry-after')
|
|
103
|
+
if header
|
|
104
|
+
secs = Number(header)
|
|
105
|
+
if Number.isNaN(secs)
|
|
106
|
+
Math.max(0, new Date(header) - Date.now())
|
|
107
|
+
else
|
|
108
|
+
secs * 1000
|
|
109
|
+
else
|
|
110
|
+
backoff(attempt, cfg)
|
|
111
|
+
|
|
112
|
+
# ==[ Core Request ]==
|
|
113
|
+
|
|
114
|
+
def request(input, opts = {})
|
|
115
|
+
method = (opts.method ?? 'GET').toUpperCase()
|
|
116
|
+
timeout = opts.timeout ?? TIMEOUT_MS
|
|
117
|
+
throwHttpErrors = opts.throwHttpErrors ?? true
|
|
118
|
+
retry = normalizeRetry(opts.retry)
|
|
119
|
+
hooks = opts.hooks ?? {}
|
|
120
|
+
|
|
121
|
+
# Build URL
|
|
122
|
+
url = buildUrl(input, opts)
|
|
123
|
+
|
|
124
|
+
# Build headers
|
|
125
|
+
headers = mergeHeaders(opts.headers)
|
|
126
|
+
|
|
127
|
+
# JSON body convenience
|
|
128
|
+
body = opts.body
|
|
129
|
+
if opts.json?
|
|
130
|
+
body = JSON.stringify(opts.json)
|
|
131
|
+
headers.set('content-type', 'application/json') unless headers.has('content-type')
|
|
132
|
+
|
|
133
|
+
# Assemble fetch options, passing through native fetch fields
|
|
134
|
+
fetchOpts = { method, headers, body }
|
|
135
|
+
for key in ['mode', 'credentials', 'cache', 'redirect', 'referrer', 'referrerPolicy', 'integrity', 'keepalive', 'signal']
|
|
136
|
+
fetchOpts[key] = opts[key] if opts[key]?
|
|
137
|
+
|
|
138
|
+
# Timeout via AbortSignal (unless caller provided their own signal)
|
|
139
|
+
if timeout and timeout isnt Infinity and not fetchOpts.signal
|
|
140
|
+
fetchOpts.signal = AbortSignal.timeout(timeout)
|
|
141
|
+
|
|
142
|
+
# Build Request
|
|
143
|
+
req = new Request(url, fetchOpts)
|
|
144
|
+
|
|
145
|
+
# Run beforeRequest hooks — may modify the request or short-circuit with a Response
|
|
146
|
+
if hooks.beforeRequest?.length
|
|
147
|
+
for hook in hooks.beforeRequest
|
|
148
|
+
result = hook!(req, opts)
|
|
149
|
+
if result instanceof Request
|
|
150
|
+
req = result
|
|
151
|
+
else if result instanceof Response
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
# Retry loop
|
|
155
|
+
retryCount = 0
|
|
156
|
+
loop
|
|
157
|
+
res = null
|
|
158
|
+
|
|
159
|
+
try
|
|
160
|
+
res = fetch!(req.clone())
|
|
161
|
+
catch err
|
|
162
|
+
if err.name is 'AbortError' or err.name is 'TimeoutError'
|
|
163
|
+
throw new TimeoutError(req)
|
|
164
|
+
|
|
165
|
+
# Network error — retry if eligible
|
|
166
|
+
if retryCount >= retry.limit or not retry.methods.includes(method)
|
|
167
|
+
throw err
|
|
168
|
+
|
|
169
|
+
retryCount++
|
|
170
|
+
if hooks.beforeRetry?.length
|
|
171
|
+
for hook in hooks.beforeRetry
|
|
172
|
+
hook!({ request: req, options: opts, error: err, retryCount })
|
|
173
|
+
delay!(backoff(retryCount, retry))
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# Run afterResponse hooks — may replace the response
|
|
177
|
+
if hooks.afterResponse?.length
|
|
178
|
+
for hook in hooks.afterResponse
|
|
179
|
+
result = hook!(req, opts, res)
|
|
180
|
+
res = result if result instanceof Response
|
|
181
|
+
|
|
182
|
+
# Retryable HTTP status
|
|
183
|
+
if not res.ok and retryCount < retry.limit and retry.methods.includes(method) and retry.statusCodes.includes(res.status)
|
|
184
|
+
retryCount++
|
|
185
|
+
if hooks.beforeRetry?.length
|
|
186
|
+
for hook in hooks.beforeRetry
|
|
187
|
+
hook!({ request: req, options: opts, error: null, retryCount })
|
|
188
|
+
delay!(retryDelay(retryCount, res, retry))
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
# Auto-throw on non-2xx
|
|
192
|
+
if throwHttpErrors and not res.ok
|
|
193
|
+
error = new HTTPError(res, req, opts)
|
|
194
|
+
if hooks.beforeError?.length
|
|
195
|
+
for hook in hooks.beforeError
|
|
196
|
+
error = hook!(error) ?? error
|
|
197
|
+
throw error
|
|
198
|
+
|
|
199
|
+
return res
|
|
200
|
+
|
|
201
|
+
# ==[ Instance Factory ]==
|
|
202
|
+
|
|
203
|
+
def makeInstance(defaults = {})
|
|
204
|
+
inst = (input, opts = {}) -> request(input, mergeOptions(defaults, opts))
|
|
205
|
+
|
|
206
|
+
for own m, name of { get: 'GET', post: 'POST', put: 'PUT', patch: 'PATCH', del: 'DELETE', head: 'HEAD' }
|
|
207
|
+
inst[m] = (input, opts = {}) -> inst(input, { ...opts, method: name })
|
|
208
|
+
|
|
209
|
+
inst.create = (opts = {}) -> makeInstance(opts)
|
|
210
|
+
inst.extend = (opts = {}) -> makeInstance(mergeOptions(defaults, opts))
|
|
211
|
+
|
|
212
|
+
inst.HTTPError = HTTPError
|
|
213
|
+
inst.TimeoutError = TimeoutError
|
|
214
|
+
|
|
215
|
+
inst
|
|
216
|
+
|
|
217
|
+
# ==[ Public API ]==
|
|
218
|
+
|
|
219
|
+
export http = makeInstance()
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rip-lang/http",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zero-dependency HTTP client for Rip — ky-inspired convenience over native fetch",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "http.rip",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./http.rip"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"http",
|
|
12
|
+
"client",
|
|
13
|
+
"fetch",
|
|
14
|
+
"request",
|
|
15
|
+
"ky",
|
|
16
|
+
"bun",
|
|
17
|
+
"rip"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/shreeve/rip-lang.git",
|
|
22
|
+
"directory": "packages/http"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/shreeve/rip-lang/tree/main/packages/http#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/shreeve/rip-lang/issues"
|
|
27
|
+
},
|
|
28
|
+
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"rip-lang": "^3.9.1"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"http.rip",
|
|
35
|
+
"README.md"
|
|
36
|
+
]
|
|
37
|
+
}
|