@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.
Files changed (3) hide show
  1. package/README.md +357 -0
  2. package/http.rip +219 -0
  3. 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
+ }