@alfredmouelle/create-stack 0.1.2 → 0.2.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 (64) hide show
  1. package/README.md +42 -14
  2. package/_stack/packages/analytics/capability.json +26 -0
  3. package/_stack/packages/analytics/package.json +26 -0
  4. package/_stack/packages/analytics/src/adapters/noop/index.ts +12 -0
  5. package/_stack/packages/analytics/src/adapters/plausible/config.ts +10 -0
  6. package/_stack/packages/analytics/src/adapters/plausible/index.ts +94 -0
  7. package/_stack/packages/analytics/src/adapters/posthog/config.ts +7 -0
  8. package/_stack/packages/analytics/src/adapters/posthog/index.ts +50 -0
  9. package/_stack/packages/analytics/src/core/port.ts +30 -0
  10. package/_stack/packages/analytics/src/index.ts +17 -0
  11. package/_stack/packages/cache/capability.json +21 -0
  12. package/_stack/packages/cache/package.json +25 -0
  13. package/_stack/packages/cache/src/adapters/memory/index.ts +51 -0
  14. package/_stack/packages/cache/src/adapters/redis/config.ts +8 -0
  15. package/_stack/packages/cache/src/adapters/redis/index.ts +73 -0
  16. package/_stack/packages/cache/src/core/port.ts +29 -0
  17. package/_stack/packages/cache/src/core/wrap.ts +20 -0
  18. package/_stack/packages/cache/src/index.ts +12 -0
  19. package/_stack/packages/error-tracking/capability.json +21 -0
  20. package/_stack/packages/error-tracking/package.json +25 -0
  21. package/_stack/packages/error-tracking/src/adapters/console/index.ts +43 -0
  22. package/_stack/packages/error-tracking/src/adapters/sentry/config.ts +8 -0
  23. package/_stack/packages/error-tracking/src/adapters/sentry/index.ts +72 -0
  24. package/_stack/packages/error-tracking/src/core/port.ts +39 -0
  25. package/_stack/packages/error-tracking/src/index.ts +14 -0
  26. package/_stack/packages/http/package.json +20 -0
  27. package/_stack/packages/http/src/api.ts +373 -0
  28. package/_stack/packages/http/src/index.ts +14 -0
  29. package/_stack/packages/http/src/responses.ts +25 -0
  30. package/_stack/packages/http/src/types.ts +9 -0
  31. package/_stack/packages/jobs/capability.json +26 -0
  32. package/_stack/packages/jobs/package.json +27 -0
  33. package/_stack/packages/jobs/src/adapters/inngest/config.ts +8 -0
  34. package/_stack/packages/jobs/src/adapters/inngest/index.ts +93 -0
  35. package/_stack/packages/jobs/src/adapters/memory/index.ts +31 -0
  36. package/_stack/packages/jobs/src/adapters/trigger/config.ts +8 -0
  37. package/_stack/packages/jobs/src/adapters/trigger/index.ts +85 -0
  38. package/_stack/packages/jobs/src/core/port.ts +37 -0
  39. package/_stack/packages/jobs/src/index.ts +23 -0
  40. package/_stack/packages/logger/capability.json +21 -0
  41. package/_stack/packages/logger/package.json +25 -0
  42. package/_stack/packages/logger/src/adapters/console/config.ts +7 -0
  43. package/_stack/packages/logger/src/adapters/console/index.ts +69 -0
  44. package/_stack/packages/logger/src/adapters/pino/index.ts +54 -0
  45. package/_stack/packages/logger/src/core/port.ts +21 -0
  46. package/_stack/packages/logger/src/index.ts +12 -0
  47. package/_stack/packages/storage/capability.json +32 -0
  48. package/_stack/packages/storage/package.json +27 -0
  49. package/_stack/packages/storage/src/adapters/gcs/config.ts +8 -0
  50. package/_stack/packages/storage/src/adapters/gcs/index.ts +111 -0
  51. package/_stack/packages/storage/src/adapters/local/config.ts +8 -0
  52. package/_stack/packages/storage/src/adapters/local/index.ts +78 -0
  53. package/_stack/packages/storage/src/adapters/r2/config.ts +8 -0
  54. package/_stack/packages/storage/src/adapters/r2/index.ts +39 -0
  55. package/_stack/packages/storage/src/adapters/s3/config.ts +11 -0
  56. package/_stack/packages/storage/src/adapters/s3/index.ts +143 -0
  57. package/_stack/packages/storage/src/core/port.ts +41 -0
  58. package/_stack/packages/storage/src/index.ts +21 -0
  59. package/index.mjs +69 -18
  60. package/lib/build.mjs +23 -5
  61. package/lib/capabilities.mjs +375 -0
  62. package/lib/env.mjs +21 -0
  63. package/lib/scaffold.mjs +1 -0
  64. package/package.json +1 -1
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@alfredmouelle/error-tracking",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "build": "tsdown",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "dependencies": {
16
+ "@sentry/node": "^10.59.0",
17
+ "valibot": "^1.4.1"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.10.2",
21
+ "tsdown": "^0.22.3",
22
+ "typescript": "^5.9.3",
23
+ "vitest": "^4.1.9"
24
+ }
25
+ }
@@ -0,0 +1,43 @@
1
+ import type {
2
+ Breadcrumb,
3
+ CaptureContext,
4
+ ErrorTrackingPort,
5
+ ErrorUser,
6
+ SeverityLevel,
7
+ } from '../../core/port.js'
8
+
9
+ export interface ConsoleAdapterOptions {
10
+ /** Max breadcrumbs kept in memory (oldest dropped). Default 20. */
11
+ maxBreadcrumbs?: number
12
+ }
13
+
14
+ /** Dev/test adapter logging to console; user + breadcrumb state kept in-memory (no transport). */
15
+ export function consoleAdapter(options: ConsoleAdapterOptions = {}): ErrorTrackingPort {
16
+ const maxBreadcrumbs = options.maxBreadcrumbs ?? 20
17
+ let user: ErrorUser | null = null
18
+ const breadcrumbs: Breadcrumb[] = []
19
+
20
+ function state() {
21
+ return { user, breadcrumbs }
22
+ }
23
+
24
+ return {
25
+ name: 'console',
26
+ captureException(error: unknown, context?: CaptureContext) {
27
+ console.error('[error-tracking] exception', error, { ...context, ...state() })
28
+ },
29
+ captureMessage(message: string, level: SeverityLevel = 'info') {
30
+ console.error('[error-tracking] message', { message, level, ...state() })
31
+ },
32
+ setUser(next: ErrorUser | null) {
33
+ user = next
34
+ },
35
+ addBreadcrumb(breadcrumb: Breadcrumb) {
36
+ breadcrumbs.push(breadcrumb)
37
+ if (breadcrumbs.length > maxBreadcrumbs) breadcrumbs.shift()
38
+ },
39
+ flush() {
40
+ return Promise.resolve(true)
41
+ },
42
+ }
43
+ }
@@ -0,0 +1,8 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const SentryConfigSchema = v.object({
4
+ dsn: v.pipe(v.string(), v.minLength(1, 'SENTRY_DSN is required')),
5
+ environment: v.optional(v.string()),
6
+ })
7
+
8
+ export type SentryConfig = v.InferOutput<typeof SentryConfigSchema>
@@ -0,0 +1,72 @@
1
+ import * as Sentry from '@sentry/node'
2
+ import * as v from 'valibot'
3
+ import type {
4
+ Breadcrumb,
5
+ CaptureContext,
6
+ ErrorTrackingPort,
7
+ ErrorUser,
8
+ SeverityLevel,
9
+ } from '../../core/port.js'
10
+ import { SentryConfigSchema } from './config.js'
11
+
12
+ /** Minimal structural view of the Sentry namespace (eases testing). */
13
+ export interface SentryCaptureContext {
14
+ tags?: Record<string, string>
15
+ extra?: Record<string, unknown>
16
+ level?: SeverityLevel
17
+ }
18
+
19
+ export interface SentryLike {
20
+ init(options: { dsn: string; environment?: string }): void
21
+ captureException(error: unknown, context?: SentryCaptureContext): void
22
+ captureMessage(message: string, level?: SeverityLevel): void
23
+ setUser(user: ErrorUser | null): void
24
+ addBreadcrumb(breadcrumb: Breadcrumb): void
25
+ flush(timeoutMs?: number): Promise<boolean>
26
+ }
27
+
28
+ export interface SentryAdapterOptions {
29
+ dsn: string
30
+ environment?: string
31
+ /** Inject a custom/mock client; defaults to the real `@sentry/node`. */
32
+ client?: SentryLike
33
+ /** Skip the implicit `Sentry.init` (e.g. init happens elsewhere). */
34
+ init?: boolean
35
+ }
36
+
37
+ export function sentryAdapter(options: SentryAdapterOptions): ErrorTrackingPort {
38
+ // Validate early: missing DSN fails at construction, not at capture().
39
+ const config = v.parse(SentryConfigSchema, {
40
+ dsn: options.dsn,
41
+ environment: options.environment,
42
+ })
43
+ const client: SentryLike = options.client ?? (Sentry as unknown as SentryLike)
44
+
45
+ // Only init the real namespace; injected clients are assumed already wired.
46
+ if (options.init !== false && !options.client) {
47
+ client.init({ dsn: config.dsn, environment: config.environment })
48
+ }
49
+
50
+ return {
51
+ name: 'sentry',
52
+ captureException(error: unknown, context?: CaptureContext) {
53
+ client.captureException(error, {
54
+ tags: context?.tags,
55
+ extra: context?.extra,
56
+ level: context?.level,
57
+ })
58
+ },
59
+ captureMessage(message: string, level?: SeverityLevel) {
60
+ client.captureMessage(message, level)
61
+ },
62
+ setUser(user: ErrorUser | null) {
63
+ client.setUser(user)
64
+ },
65
+ addBreadcrumb(breadcrumb: Breadcrumb) {
66
+ client.addBreadcrumb(breadcrumb)
67
+ },
68
+ flush(timeoutMs?: number) {
69
+ return client.flush(timeoutMs)
70
+ },
71
+ }
72
+ }
@@ -0,0 +1,39 @@
1
+ /** Captured-event severity, matching common provider levels. */
2
+ export type SeverityLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug'
3
+
4
+ /** User an event is associated with. */
5
+ export interface ErrorUser {
6
+ id?: string
7
+ email?: string
8
+ username?: string
9
+ }
10
+
11
+ /** Trail of events leading up to an error, for debugging. */
12
+ export interface Breadcrumb {
13
+ message: string
14
+ category?: string
15
+ level?: SeverityLevel
16
+ data?: Record<string, unknown>
17
+ }
18
+
19
+ /** Extra context attached to a single capture. */
20
+ export interface CaptureContext {
21
+ tags?: Record<string, string>
22
+ extra?: Record<string, unknown>
23
+ level?: SeverityLevel
24
+ }
25
+
26
+ /** App-facing port; swap adapters at the composition root, never this interface. */
27
+ export interface ErrorTrackingPort {
28
+ readonly name: string
29
+ /** Report an error (or any thrown value) with optional context. */
30
+ captureException(error: unknown, context?: CaptureContext): void
31
+ /** Report a standalone message at severity (default `info`). */
32
+ captureMessage(message: string, level?: SeverityLevel): void
33
+ /** Associate subsequent events with a user; `null` clears. */
34
+ setUser(user: ErrorUser | null): void
35
+ /** Record a breadcrumb for subsequent events. */
36
+ addBreadcrumb(breadcrumb: Breadcrumb): void
37
+ /** Flush buffered events; resolves `true` if all sent in time. */
38
+ flush(timeoutMs?: number): Promise<boolean>
39
+ }
@@ -0,0 +1,14 @@
1
+ export { type ConsoleAdapterOptions, consoleAdapter } from './adapters/console/index.js'
2
+ export { type SentryConfig, SentryConfigSchema } from './adapters/sentry/config.js'
3
+ export {
4
+ type SentryAdapterOptions,
5
+ type SentryLike,
6
+ sentryAdapter,
7
+ } from './adapters/sentry/index.js'
8
+ export type {
9
+ Breadcrumb,
10
+ CaptureContext,
11
+ ErrorTrackingPort,
12
+ ErrorUser,
13
+ SeverityLevel,
14
+ } from './core/port.js'
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@alfredmouelle/http",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "build": "tsdown",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "devDependencies": {
16
+ "tsdown": "^0.22.3",
17
+ "typescript": "^5.9.3",
18
+ "vitest": "^4.1.9"
19
+ }
20
+ }
@@ -0,0 +1,373 @@
1
+ export type ApiFetchMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
2
+
3
+ export type ApiParseMode = 'json' | 'text' | 'blob' | 'arrayBuffer' | 'none'
4
+
5
+ export type QueryValue = string | number | boolean | null | undefined
6
+
7
+ export type QueryParams = Record<string, QueryValue | QueryValue[]> | URLSearchParams
8
+
9
+ export interface ApiFetchOptions {
10
+ method?: ApiFetchMethod
11
+ baseUrl?: string
12
+ query?: QueryParams
13
+ body?: unknown
14
+ headers?: HeadersInit
15
+ signal?: AbortSignal
16
+ timeoutMs?: number
17
+ parseAs?: ApiParseMode
18
+ credentials?: RequestCredentials
19
+ cache?: RequestCache
20
+ init?: RequestInit
21
+ /** Inject custom fetch (mock in tests, scoped client in adapters). */
22
+ fetchImpl?: typeof globalThis.fetch
23
+ }
24
+
25
+ export class ApiFetchError extends Error {
26
+ readonly status: number
27
+ readonly statusText: string
28
+ readonly url: string
29
+ readonly method: string
30
+ readonly body: unknown
31
+ readonly response?: Response
32
+
33
+ constructor(
34
+ message: string,
35
+ details: {
36
+ status: number
37
+ statusText: string
38
+ url: string
39
+ method: string
40
+ body?: unknown
41
+ response?: Response
42
+ cause?: unknown
43
+ },
44
+ ) {
45
+ super(message, { cause: details.cause })
46
+ this.name = 'ApiFetchError'
47
+ this.status = details.status
48
+ this.statusText = details.statusText
49
+ this.url = details.url
50
+ this.method = details.method
51
+ this.body = details.body
52
+ this.response = details.response
53
+ }
54
+
55
+ get isNetworkError(): boolean {
56
+ return this.status === 0
57
+ }
58
+
59
+ get isTimeout(): boolean {
60
+ return this.status === 408 || this.status === 504
61
+ }
62
+
63
+ get serverMessage(): string | undefined {
64
+ const body = this.body
65
+ if (typeof body === 'string') return body.trim() || undefined
66
+ if (body && typeof body === 'object') {
67
+ const record = body as Record<string, unknown>
68
+ for (const key of ['error', 'message', 'detail']) {
69
+ const value = record[key]
70
+ if (typeof value === 'string' && value.trim()) return value
71
+ }
72
+ }
73
+ return undefined
74
+ }
75
+ }
76
+
77
+ export function isApiFetchError(error: unknown): error is ApiFetchError {
78
+ return error instanceof ApiFetchError
79
+ }
80
+
81
+ export class ApiParseError extends Error {
82
+ readonly url: string
83
+ readonly method: string
84
+ readonly status: number
85
+ readonly raw: string
86
+ readonly response: Response
87
+
88
+ constructor(
89
+ message: string,
90
+ details: {
91
+ url: string
92
+ method: string
93
+ status: number
94
+ raw: string
95
+ response: Response
96
+ cause?: unknown
97
+ },
98
+ ) {
99
+ super(message, { cause: details.cause })
100
+ this.name = 'ApiParseError'
101
+ this.url = details.url
102
+ this.method = details.method
103
+ this.status = details.status
104
+ this.raw = details.raw
105
+ this.response = details.response
106
+ }
107
+ }
108
+
109
+ export function isApiParseError(error: unknown): error is ApiParseError {
110
+ return error instanceof ApiParseError
111
+ }
112
+
113
+ function isCancellation(error: unknown): boolean {
114
+ return (
115
+ error instanceof DOMException && (error.name === 'AbortError' || error.name === 'TimeoutError')
116
+ )
117
+ }
118
+
119
+ function isTimeoutAbort(error: unknown): boolean {
120
+ return error instanceof DOMException && error.name === 'TimeoutError'
121
+ }
122
+
123
+ function timeoutError(
124
+ error: unknown,
125
+ url: string,
126
+ method: string,
127
+ response?: Response,
128
+ ): ApiFetchError {
129
+ return new ApiFetchError(`${method} ${url} timed out`, {
130
+ status: 408,
131
+ statusText: 'Request Timeout',
132
+ url,
133
+ method,
134
+ response,
135
+ cause: error,
136
+ })
137
+ }
138
+
139
+ function isRawBody(body: unknown): body is BodyInit {
140
+ return (
141
+ typeof body === 'string' ||
142
+ body instanceof FormData ||
143
+ body instanceof URLSearchParams ||
144
+ body instanceof Blob ||
145
+ body instanceof ArrayBuffer ||
146
+ ArrayBuffer.isView(body) ||
147
+ (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream)
148
+ )
149
+ }
150
+
151
+ function toSearchParams(query: QueryParams): URLSearchParams {
152
+ if (query instanceof URLSearchParams) return query
153
+ const params = new URLSearchParams()
154
+ for (const [key, value] of Object.entries(query)) {
155
+ if (value === null || value === undefined) continue
156
+ const values = Array.isArray(value) ? value : [value]
157
+ for (const item of values) {
158
+ if (item === null || item === undefined) continue
159
+ params.append(key, String(item))
160
+ }
161
+ }
162
+ return params
163
+ }
164
+
165
+ function buildUrl(
166
+ path: string,
167
+ baseUrl: string | undefined,
168
+ query: QueryParams | undefined,
169
+ ): string {
170
+ const isAbsolute = /^[a-z][a-z\d+.-]*:\/\//i.test(path)
171
+ const base = baseUrl ? baseUrl.replace(/\/+$/, '') : ''
172
+ let url = isAbsolute || !base ? path : `${base}${path.startsWith('/') ? path : `/${path}`}`
173
+
174
+ if (query) {
175
+ const qs = toSearchParams(query).toString()
176
+ if (qs) url += `${url.includes('?') ? '&' : '?'}${qs}`
177
+ }
178
+ return url
179
+ }
180
+
181
+ function resolveSignal(
182
+ signal: AbortSignal | undefined,
183
+ timeoutMs: number | undefined,
184
+ ): AbortSignal | undefined {
185
+ if (timeoutMs === undefined) return signal
186
+ const timeout = AbortSignal.timeout(timeoutMs)
187
+ if (!signal) return timeout
188
+ if (typeof AbortSignal.any === 'function') {
189
+ return AbortSignal.any([signal, timeout])
190
+ }
191
+ return signal
192
+ }
193
+
194
+ function inferParseMode(response: Response): ApiParseMode {
195
+ const contentType = response.headers.get('content-type') ?? ''
196
+ if (contentType.includes('application/json') || contentType.includes('+json')) {
197
+ return 'json'
198
+ }
199
+ if (!contentType || contentType.startsWith('text/')) return 'text'
200
+ return 'blob'
201
+ }
202
+
203
+ async function parseResponse<T>(
204
+ response: Response,
205
+ parseAs: ApiParseMode | undefined,
206
+ url: string,
207
+ method: string,
208
+ ): Promise<T> {
209
+ if (parseAs === 'none' || response.status === 204 || response.status === 205) {
210
+ return undefined as T
211
+ }
212
+ const mode = parseAs ?? inferParseMode(response)
213
+ try {
214
+ switch (mode) {
215
+ case 'json': {
216
+ const text = await response.text()
217
+ if (!text) return undefined as T
218
+ try {
219
+ return JSON.parse(text) as T
220
+ } catch (error) {
221
+ throw new ApiParseError(`${method} ${url}: invalid JSON response`, {
222
+ url,
223
+ method,
224
+ status: response.status,
225
+ raw: text,
226
+ response,
227
+ cause: error,
228
+ })
229
+ }
230
+ }
231
+ case 'text':
232
+ return (await response.text()) as T
233
+ case 'blob':
234
+ return (await response.blob()) as T
235
+ case 'arrayBuffer':
236
+ return (await response.arrayBuffer()) as T
237
+ default:
238
+ return undefined as T
239
+ }
240
+ } catch (error) {
241
+ if (error instanceof ApiParseError) throw error
242
+ if (isTimeoutAbort(error)) throw timeoutError(error, url, method, response)
243
+ if (isCancellation(error)) throw error
244
+ throw new ApiParseError(`${method} ${url}: failed to read response body`, {
245
+ url,
246
+ method,
247
+ status: response.status,
248
+ raw: '',
249
+ response,
250
+ cause: error,
251
+ })
252
+ }
253
+ }
254
+
255
+ async function parseErrorBody(response: Response): Promise<unknown> {
256
+ try {
257
+ const text = await response.text()
258
+ if (!text) return undefined
259
+ const contentType = response.headers.get('content-type') ?? ''
260
+ if (contentType.includes('json')) {
261
+ try {
262
+ return JSON.parse(text)
263
+ } catch {
264
+ return text
265
+ }
266
+ }
267
+ return text
268
+ } catch {
269
+ return undefined
270
+ }
271
+ }
272
+
273
+ function buildRequestHeaders(
274
+ headers: HeadersInit | undefined,
275
+ initHeaders: HeadersInit | undefined,
276
+ parseAs: ApiParseMode | undefined,
277
+ ): Headers {
278
+ const requestHeaders = new Headers(initHeaders)
279
+ if (headers) {
280
+ new Headers(headers).forEach((value, key) => {
281
+ requestHeaders.set(key, value)
282
+ })
283
+ }
284
+ if (!requestHeaders.has('Accept') && parseAs !== 'blob' && parseAs !== 'arrayBuffer') {
285
+ requestHeaders.set('Accept', 'application/json')
286
+ }
287
+ return requestHeaders
288
+ }
289
+
290
+ function buildRequestBody(
291
+ body: unknown,
292
+ method: ApiFetchMethod,
293
+ requestHeaders: Headers,
294
+ ): BodyInit | undefined {
295
+ const sendsBody = body !== undefined && body !== null
296
+ if (!sendsBody || method === 'GET' || method === 'HEAD') return undefined
297
+ if (isRawBody(body)) return body
298
+ if (!requestHeaders.has('Content-Type')) {
299
+ requestHeaders.set('Content-Type', 'application/json')
300
+ }
301
+ return JSON.stringify(body)
302
+ }
303
+
304
+ /**
305
+ * Typed `fetch` wrapper: URL/query building, JSON encoding, timeouts,
306
+ * content-negotiated parsing, `ApiFetchError`/`ApiParseError` on failure.
307
+ * Non-2xx throws.
308
+ */
309
+ export async function apiFetch<T = unknown>(
310
+ path: string,
311
+ options: ApiFetchOptions = {},
312
+ ): Promise<T> {
313
+ const {
314
+ method = 'GET',
315
+ baseUrl,
316
+ query,
317
+ body,
318
+ headers,
319
+ signal,
320
+ timeoutMs,
321
+ parseAs,
322
+ credentials,
323
+ cache,
324
+ init,
325
+ fetchImpl,
326
+ } = options
327
+
328
+ const doFetch = fetchImpl ?? globalThis.fetch
329
+ const url = buildUrl(path, baseUrl, query)
330
+ const requestHeaders = buildRequestHeaders(headers, init?.headers, parseAs)
331
+ const requestBody = buildRequestBody(body, method, requestHeaders)
332
+
333
+ const userSignal = signal ?? init?.signal ?? undefined
334
+ const requestInit: RequestInit = { ...init }
335
+ requestInit.method = method
336
+ requestInit.headers = requestHeaders
337
+ requestInit.signal = resolveSignal(userSignal, timeoutMs)
338
+ if (method === 'GET' || method === 'HEAD') {
339
+ requestInit.body = undefined
340
+ } else if (requestBody !== undefined) {
341
+ requestInit.body = requestBody
342
+ }
343
+ if (credentials !== undefined) requestInit.credentials = credentials
344
+ if (cache !== undefined) requestInit.cache = cache
345
+
346
+ let response: Response
347
+ try {
348
+ response = await doFetch(url, requestInit)
349
+ } catch (error) {
350
+ if (isTimeoutAbort(error)) throw timeoutError(error, url, method)
351
+ if (isCancellation(error)) throw error
352
+ throw new ApiFetchError(`${method} ${url} failed: network error`, {
353
+ status: 0,
354
+ statusText: '',
355
+ url,
356
+ method,
357
+ cause: error,
358
+ })
359
+ }
360
+
361
+ if (!response.ok) {
362
+ throw new ApiFetchError(`${method} ${url} → ${response.status} ${response.statusText}`.trim(), {
363
+ status: response.status,
364
+ statusText: response.statusText,
365
+ url,
366
+ method,
367
+ body: await parseErrorBody(response),
368
+ response,
369
+ })
370
+ }
371
+
372
+ return parseResponse<T>(response, parseAs, url, method)
373
+ }
@@ -0,0 +1,14 @@
1
+ export {
2
+ ApiFetchError,
3
+ type ApiFetchMethod,
4
+ type ApiFetchOptions,
5
+ ApiParseError,
6
+ type ApiParseMode,
7
+ apiFetch,
8
+ isApiFetchError,
9
+ isApiParseError,
10
+ type QueryParams,
11
+ type QueryValue,
12
+ } from './api.js'
13
+ export { error, json, noContent, text } from './responses.js'
14
+ export type { FetchHandler, WebhookHandler } from './types.js'
@@ -0,0 +1,25 @@
1
+ /** JSON `Response` with correct content-type. */
2
+ export function json(data: unknown, init: ResponseInit = {}): Response {
3
+ return new Response(JSON.stringify(data), {
4
+ ...init,
5
+ headers: { 'content-type': 'application/json', ...init.headers },
6
+ })
7
+ }
8
+
9
+ /** `204 No Content` — canonical webhook ack. */
10
+ export function noContent(init: ResponseInit = {}): Response {
11
+ return new Response(null, { status: 204, ...init })
12
+ }
13
+
14
+ /** Plain-text `Response`. */
15
+ export function text(body: string, init: ResponseInit = {}): Response {
16
+ return new Response(body, {
17
+ ...init,
18
+ headers: { 'content-type': 'text/plain; charset=utf-8', ...init.headers },
19
+ })
20
+ }
21
+
22
+ /** JSON error envelope. */
23
+ export function error(message: string, status = 400, init: ResponseInit = {}): Response {
24
+ return json({ error: message }, { ...init, status })
25
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Web Fetch `Request -> Response` handler. Next.js route handlers and TanStack
3
+ * Start server routes both speak it, so HTTP surfaces (webhooks, callbacks)
4
+ * target this type; mounting is a one-line shim per framework.
5
+ */
6
+ export type FetchHandler = (request: Request) => Response | Promise<Response>
7
+
8
+ /** Alias of {@link FetchHandler}; documents intent at call sites. */
9
+ export type WebhookHandler = FetchHandler
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "../../capability.schema.json",
3
+ "name": "jobs",
4
+ "description": "Background jobs / events behind a swappable port. Event-driven: define jobs against named events and trigger them; the adapter handles delivery and execution.",
5
+ "port": "src/core/port.ts",
6
+ "defaultAdapter": "inngest",
7
+ "adapters": {
8
+ "inngest": {
9
+ "deps": ["inngest"],
10
+ "env": ["INNGEST_EVENT_KEY", "INNGEST_SIGNING_KEY"],
11
+ "files": ["src/adapters/inngest"]
12
+ },
13
+ "trigger": {
14
+ "deps": ["@trigger.dev/sdk"],
15
+ "env": ["TRIGGER_SECRET_KEY"],
16
+ "files": ["src/adapters/trigger"]
17
+ },
18
+ "memory": {
19
+ "deps": [],
20
+ "env": [],
21
+ "files": ["src/adapters/memory"]
22
+ }
23
+ },
24
+ "sharedDeps": ["valibot"],
25
+ "sharedFiles": ["src/core", "src/index.ts"]
26
+ }