@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.
- package/README.md +42 -14
- package/_stack/packages/analytics/capability.json +26 -0
- package/_stack/packages/analytics/package.json +26 -0
- package/_stack/packages/analytics/src/adapters/noop/index.ts +12 -0
- package/_stack/packages/analytics/src/adapters/plausible/config.ts +10 -0
- package/_stack/packages/analytics/src/adapters/plausible/index.ts +94 -0
- package/_stack/packages/analytics/src/adapters/posthog/config.ts +7 -0
- package/_stack/packages/analytics/src/adapters/posthog/index.ts +50 -0
- package/_stack/packages/analytics/src/core/port.ts +30 -0
- package/_stack/packages/analytics/src/index.ts +17 -0
- package/_stack/packages/cache/capability.json +21 -0
- package/_stack/packages/cache/package.json +25 -0
- package/_stack/packages/cache/src/adapters/memory/index.ts +51 -0
- package/_stack/packages/cache/src/adapters/redis/config.ts +8 -0
- package/_stack/packages/cache/src/adapters/redis/index.ts +73 -0
- package/_stack/packages/cache/src/core/port.ts +29 -0
- package/_stack/packages/cache/src/core/wrap.ts +20 -0
- package/_stack/packages/cache/src/index.ts +12 -0
- package/_stack/packages/error-tracking/capability.json +21 -0
- package/_stack/packages/error-tracking/package.json +25 -0
- package/_stack/packages/error-tracking/src/adapters/console/index.ts +43 -0
- package/_stack/packages/error-tracking/src/adapters/sentry/config.ts +8 -0
- package/_stack/packages/error-tracking/src/adapters/sentry/index.ts +72 -0
- package/_stack/packages/error-tracking/src/core/port.ts +39 -0
- package/_stack/packages/error-tracking/src/index.ts +14 -0
- package/_stack/packages/http/package.json +20 -0
- package/_stack/packages/http/src/api.ts +373 -0
- package/_stack/packages/http/src/index.ts +14 -0
- package/_stack/packages/http/src/responses.ts +25 -0
- package/_stack/packages/http/src/types.ts +9 -0
- package/_stack/packages/jobs/capability.json +26 -0
- package/_stack/packages/jobs/package.json +27 -0
- package/_stack/packages/jobs/src/adapters/inngest/config.ts +8 -0
- package/_stack/packages/jobs/src/adapters/inngest/index.ts +93 -0
- package/_stack/packages/jobs/src/adapters/memory/index.ts +31 -0
- package/_stack/packages/jobs/src/adapters/trigger/config.ts +8 -0
- package/_stack/packages/jobs/src/adapters/trigger/index.ts +85 -0
- package/_stack/packages/jobs/src/core/port.ts +37 -0
- package/_stack/packages/jobs/src/index.ts +23 -0
- package/_stack/packages/logger/capability.json +21 -0
- package/_stack/packages/logger/package.json +25 -0
- package/_stack/packages/logger/src/adapters/console/config.ts +7 -0
- package/_stack/packages/logger/src/adapters/console/index.ts +69 -0
- package/_stack/packages/logger/src/adapters/pino/index.ts +54 -0
- package/_stack/packages/logger/src/core/port.ts +21 -0
- package/_stack/packages/logger/src/index.ts +12 -0
- package/_stack/packages/storage/capability.json +32 -0
- package/_stack/packages/storage/package.json +27 -0
- package/_stack/packages/storage/src/adapters/gcs/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/gcs/index.ts +111 -0
- package/_stack/packages/storage/src/adapters/local/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/local/index.ts +78 -0
- package/_stack/packages/storage/src/adapters/r2/config.ts +8 -0
- package/_stack/packages/storage/src/adapters/r2/index.ts +39 -0
- package/_stack/packages/storage/src/adapters/s3/config.ts +11 -0
- package/_stack/packages/storage/src/adapters/s3/index.ts +143 -0
- package/_stack/packages/storage/src/core/port.ts +41 -0
- package/_stack/packages/storage/src/index.ts +21 -0
- package/index.mjs +69 -18
- package/lib/build.mjs +23 -5
- package/lib/capabilities.mjs +375 -0
- package/lib/env.mjs +21 -0
- package/lib/scaffold.mjs +1 -0
- 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,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
|
+
}
|