@alfredmouelle/create-stack 0.1.1 → 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 +41 -18
- package/_stack/apps/next-base/.turbo/turbo-typecheck.log +1 -0
- package/_stack/apps/next-base/.vscode/settings.json +35 -0
- package/_stack/apps/next-base/.zed/settings.json +45 -0
- package/_stack/apps/next-base/src/components/ui/spinner.tsx +1 -1
- package/_stack/apps/next-base/src/emails/components/components.tsx +2 -2
- package/_stack/apps/next-base/src/emails/components/context.tsx +1 -1
- package/_stack/apps/next-base/src/emails/components/theme.ts +6 -7
- package/_stack/apps/next-base/src/env.ts +2 -3
- package/_stack/apps/next-base/src/lib/date.ts +1 -1
- package/_stack/apps/next-base/src/server/auth/guards.ts +1 -1
- package/_stack/apps/next-base/src/server/better-auth/config.ts +1 -1
- package/_stack/apps/next-base/src/server/better-auth/server.ts +2 -2
- package/_stack/apps/next-base/src/server/db/schemas/index.ts +1 -1
- package/_stack/apps/next-base/src/server/db/seed.ts +2 -2
- package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +3 -3
- package/_stack/apps/next-base/src/server/email/core/address.ts +3 -3
- package/_stack/apps/next-base/src/server/email/core/port.ts +13 -20
- package/_stack/apps/next-base/src/server/email/core/render.ts +2 -2
- package/_stack/apps/next-base/src/server/email/factory.ts +7 -9
- package/_stack/apps/next-base/src/server/email/index.ts +1 -1
- package/_stack/apps/next-base/src/trpc/react.tsx +2 -2
- package/_stack/apps/next-base/src/trpc/server.ts +1 -1
- package/_stack/apps/tanstack-base/.turbo/turbo-typecheck.log +1 -0
- package/_stack/apps/tanstack-base/.vscode/settings.json +35 -0
- package/_stack/apps/tanstack-base/.zed/settings.json +45 -0
- package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +1 -1
- package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +1 -1
- package/_stack/apps/tanstack-base/src/emails/components/components.tsx +2 -2
- package/_stack/apps/tanstack-base/src/emails/components/context.tsx +1 -1
- package/_stack/apps/tanstack-base/src/emails/components/theme.ts +6 -7
- package/_stack/apps/tanstack-base/src/env.ts +2 -6
- package/_stack/apps/tanstack-base/src/lib/date.ts +1 -1
- package/_stack/apps/tanstack-base/src/routes/__root.tsx +1 -1
- package/_stack/apps/tanstack-base/src/routes/_authed.tsx +1 -4
- package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +1 -1
- package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +1 -2
- package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +6 -7
- package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/db/seed.ts +2 -2
- package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +3 -3
- package/_stack/apps/tanstack-base/src/server/email/core/address.ts +3 -3
- package/_stack/apps/tanstack-base/src/server/email/core/port.ts +12 -22
- package/_stack/apps/tanstack-base/src/server/email/core/render.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/email/factory.ts +7 -8
- package/_stack/apps/tanstack-base/src/server/email/index.ts +1 -1
- 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/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/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/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/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/mailer/src/adapters/brevo/index.ts +3 -3
- package/_stack/packages/mailer/src/adapters/resend/index.ts +3 -3
- package/_stack/packages/mailer/src/adapters/ses/config.ts +3 -3
- package/_stack/packages/mailer/src/adapters/ses/index.ts +4 -5
- 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 +89 -55
- package/lib/build.mjs +21 -11
- package/lib/capabilities.mjs +375 -0
- package/lib/env.mjs +26 -6
- package/lib/foundations.mjs +35 -0
- package/lib/identity.mjs +4 -5
- package/lib/mailer.mjs +9 -13
- package/lib/paths.mjs +15 -0
- package/lib/scaffold.mjs +12 -11
- package/lib/strip.mjs +9 -24
- package/lib/util.mjs +8 -9
- package/package.json +1 -1
- package/_stack/packages/mailer/capability.json +0 -28
- package/_stack/patterns/README.md +0 -58
- package/_stack/patterns/_baseline/env.ts +0 -31
- package/_stack/patterns/_baseline/tsconfig.json +0 -27
- package/_stack/patterns/better-auth/pattern.json +0 -73
- package/_stack/patterns/better-auth-next/pattern.json +0 -76
- package/_stack/patterns/data-table/pattern.json +0 -43
- package/_stack/patterns/drizzle/pattern.json +0 -61
- package/_stack/patterns/trpc/pattern.json +0 -61
- package/_stack/patterns/trpc-next/pattern.json +0 -64
- package/lib/manifests.mjs +0 -61
- /package/{_stack/patterns/_baseline → templates}/README-author.md +0 -0
- /package/{_stack/patterns/_baseline → templates}/biome.jsonc +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Event through the jobs system. `name` routes to subscribed jobs; `data` is the serializable payload. */
|
|
2
|
+
export interface JobEvent<T = unknown> {
|
|
3
|
+
name: string
|
|
4
|
+
data: T
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Background work unit. Subscribes to one `event` name; `handler` runs on a matching event. */
|
|
8
|
+
export interface JobDefinition<T = unknown> {
|
|
9
|
+
id: string
|
|
10
|
+
event: string
|
|
11
|
+
handler: (event: JobEvent<T>) => Promise<void> | void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The port the app depends on. Swap provider = swap adapter; this never changes.
|
|
16
|
+
*
|
|
17
|
+
* Minimal and event-driven. Rich provider features (steps, concurrency, cron,
|
|
18
|
+
* fan-out) are NOT modeled — use the underlying SDK directly. See the README.
|
|
19
|
+
*/
|
|
20
|
+
export interface JobsPort {
|
|
21
|
+
readonly name: string
|
|
22
|
+
/** Register a job. Returns the definition for collection/export. */
|
|
23
|
+
defineJob<T>(def: JobDefinition<T>): JobDefinition<T>
|
|
24
|
+
/** Emit an event, triggering every job subscribed to `event.name`. */
|
|
25
|
+
trigger<T>(event: JobEvent<T>): Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Normalized adapter error so callers never catch provider types. */
|
|
29
|
+
export class JobsError extends Error {
|
|
30
|
+
readonly adapter: string
|
|
31
|
+
|
|
32
|
+
constructor(message: string, options: { adapter: string; cause?: unknown }) {
|
|
33
|
+
super(message, { cause: options.cause })
|
|
34
|
+
this.name = 'JobsError'
|
|
35
|
+
this.adapter = options.adapter
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { type InngestConfig, InngestConfigSchema } from './adapters/inngest/config.js'
|
|
2
|
+
export {
|
|
3
|
+
type InngestAdapterOptions,
|
|
4
|
+
type InngestFunction,
|
|
5
|
+
type InngestJobsAdapter,
|
|
6
|
+
type InngestLike,
|
|
7
|
+
type InngestServe,
|
|
8
|
+
inngestAdapter,
|
|
9
|
+
inngestFunctions,
|
|
10
|
+
inngestServeHandler,
|
|
11
|
+
} from './adapters/inngest/index.js'
|
|
12
|
+
export { type MemoryJobsAdapter, memoryAdapter } from './adapters/memory/index.js'
|
|
13
|
+
export { type TriggerDevConfig, TriggerDevConfigSchema } from './adapters/trigger/config.js'
|
|
14
|
+
export {
|
|
15
|
+
type TriggerDevAdapterOptions,
|
|
16
|
+
type TriggerDevJobsAdapter,
|
|
17
|
+
type TriggerDevLike,
|
|
18
|
+
type TriggerTask,
|
|
19
|
+
triggerDevAdapter,
|
|
20
|
+
triggerDevTasks,
|
|
21
|
+
} from './adapters/trigger/index.js'
|
|
22
|
+
export type { JobDefinition, JobEvent, JobsPort } from './core/port.js'
|
|
23
|
+
export { JobsError } from './core/port.js'
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alfredmouelle/logger",
|
|
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
|
+
"pino": "^10.3.1",
|
|
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,69 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
import type { LogFields, Logger, LogLevel } from '../../core/port.js'
|
|
3
|
+
import { ConsoleConfigSchema } from './config.js'
|
|
4
|
+
|
|
5
|
+
export interface ConsoleAdapterOptions {
|
|
6
|
+
/** Min level to emit; lower-severity dropped. Defaults to `'info'`. */
|
|
7
|
+
level?: LogLevel
|
|
8
|
+
/** Fields pinned onto every line. */
|
|
9
|
+
bindings?: LogFields
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ORDER: Record<LogLevel, number> = {
|
|
13
|
+
trace: 10,
|
|
14
|
+
debug: 20,
|
|
15
|
+
info: 30,
|
|
16
|
+
warn: 40,
|
|
17
|
+
error: 50,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SINK: Record<LogLevel, (...args: unknown[]) => void> = {
|
|
21
|
+
// biome-ignore lint/suspicious/noConsole: console adapter
|
|
22
|
+
trace: (...args) => console.debug(...args),
|
|
23
|
+
// biome-ignore lint/suspicious/noConsole: console adapter
|
|
24
|
+
debug: (...args) => console.debug(...args),
|
|
25
|
+
// biome-ignore lint/suspicious/noConsole: console adapter
|
|
26
|
+
info: (...args) => console.info(...args),
|
|
27
|
+
warn: (...args) => console.warn(...args),
|
|
28
|
+
error: (...args) => console.error(...args),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function format(level: LogLevel, msg: string, fields: LogFields): string {
|
|
32
|
+
const hasFields = Object.keys(fields).length > 0
|
|
33
|
+
return hasFields ? `[${level}] ${msg} ${JSON.stringify(fields)}` : `[${level}] ${msg}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function build(min: number, bindings: LogFields): Logger {
|
|
37
|
+
const log = (level: LogLevel, msg: string, fields: LogFields = {}) => {
|
|
38
|
+
if (ORDER[level] < min) return
|
|
39
|
+
const merged = { ...bindings, ...fields }
|
|
40
|
+
SINK[level](format(level, msg, merged))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
name: 'console',
|
|
45
|
+
trace(msg, fields) {
|
|
46
|
+
log('trace', msg, fields)
|
|
47
|
+
},
|
|
48
|
+
debug(msg, fields) {
|
|
49
|
+
log('debug', msg, fields)
|
|
50
|
+
},
|
|
51
|
+
info(msg, fields) {
|
|
52
|
+
log('info', msg, fields)
|
|
53
|
+
},
|
|
54
|
+
warn(msg, fields) {
|
|
55
|
+
log('warn', msg, fields)
|
|
56
|
+
},
|
|
57
|
+
error(msg, fields) {
|
|
58
|
+
log('error', msg, fields)
|
|
59
|
+
},
|
|
60
|
+
child(childBindings) {
|
|
61
|
+
return build(min, { ...bindings, ...childBindings })
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function consoleAdapter(options: ConsoleAdapterOptions = {}): Logger {
|
|
67
|
+
const config = v.parse(ConsoleConfigSchema, { level: options.level })
|
|
68
|
+
return build(ORDER[config.level], options.bindings ?? {})
|
|
69
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import pino from 'pino'
|
|
2
|
+
import type { LogFields, Logger, LogLevel } from '../../core/port.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal structural view of a pino logger (eases testing).
|
|
6
|
+
* Level methods follow pino's `(mergingObject, message)` convention.
|
|
7
|
+
*/
|
|
8
|
+
export interface PinoLike {
|
|
9
|
+
trace(obj: LogFields, msg: string): void
|
|
10
|
+
debug(obj: LogFields, msg: string): void
|
|
11
|
+
info(obj: LogFields, msg: string): void
|
|
12
|
+
warn(obj: LogFields, msg: string): void
|
|
13
|
+
error(obj: LogFields, msg: string): void
|
|
14
|
+
child(bindings: LogFields): PinoLike
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PinoAdapterOptions {
|
|
18
|
+
/** Min level to emit; defaults to `'info'`. */
|
|
19
|
+
level?: LogLevel
|
|
20
|
+
/** Fields pinned onto every line. */
|
|
21
|
+
bindings?: LogFields
|
|
22
|
+
/** Inject a custom/mock pino; defaults to a real `pino()`. */
|
|
23
|
+
client?: PinoLike
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function wrap(client: PinoLike): Logger {
|
|
27
|
+
return {
|
|
28
|
+
name: 'pino',
|
|
29
|
+
trace(msg, fields = {}) {
|
|
30
|
+
client.trace(fields, msg)
|
|
31
|
+
},
|
|
32
|
+
debug(msg, fields = {}) {
|
|
33
|
+
client.debug(fields, msg)
|
|
34
|
+
},
|
|
35
|
+
info(msg, fields = {}) {
|
|
36
|
+
client.info(fields, msg)
|
|
37
|
+
},
|
|
38
|
+
warn(msg, fields = {}) {
|
|
39
|
+
client.warn(fields, msg)
|
|
40
|
+
},
|
|
41
|
+
error(msg, fields = {}) {
|
|
42
|
+
client.error(fields, msg)
|
|
43
|
+
},
|
|
44
|
+
child(bindings) {
|
|
45
|
+
return wrap(client.child(bindings))
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function pinoAdapter(options: PinoAdapterOptions = {}): Logger {
|
|
51
|
+
const base = options.client ?? (pino({ level: options.level ?? 'info' }) as unknown as PinoLike)
|
|
52
|
+
const client = options.bindings ? base.child(options.bindings) : base
|
|
53
|
+
return wrap(client)
|
|
54
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Severity levels, from most to least verbose. */
|
|
2
|
+
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'
|
|
3
|
+
|
|
4
|
+
/** Arbitrary structured context attached to a log line. */
|
|
5
|
+
export type LogFields = Record<string, unknown>
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* App-facing port; swap adapters at the composition root, never this interface.
|
|
9
|
+
* Each method takes a message + optional fields; `child` merges `bindings` into every line.
|
|
10
|
+
*/
|
|
11
|
+
export interface Logger {
|
|
12
|
+
/** Adapter name (`'pino'`, `'console'`, …). */
|
|
13
|
+
readonly name: string
|
|
14
|
+
trace(msg: string, fields?: LogFields): void
|
|
15
|
+
debug(msg: string, fields?: LogFields): void
|
|
16
|
+
info(msg: string, fields?: LogFields): void
|
|
17
|
+
warn(msg: string, fields?: LogFields): void
|
|
18
|
+
error(msg: string, fields?: LogFields): void
|
|
19
|
+
/** Derive a logger pinning `bindings` onto subsequent lines. */
|
|
20
|
+
child(bindings: LogFields): Logger
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { type ConsoleConfig, ConsoleConfigSchema } from './adapters/console/config.js'
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
type ConsoleAdapterOptions,
|
|
5
|
+
consoleAdapter,
|
|
6
|
+
} from './adapters/console/index.js'
|
|
7
|
+
export {
|
|
8
|
+
type PinoAdapterOptions,
|
|
9
|
+
type PinoLike,
|
|
10
|
+
pinoAdapter,
|
|
11
|
+
} from './adapters/pino/index.js'
|
|
12
|
+
export type { LogFields, Logger, LogLevel } from './core/port.js'
|
|
@@ -28,7 +28,7 @@ interface BrevoSendResponse {
|
|
|
28
28
|
messageIds?: string[]
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/** Structural view of the Brevo client (eases testing). */
|
|
32
32
|
export interface BrevoClientLike {
|
|
33
33
|
transactionalEmails: {
|
|
34
34
|
sendTransacEmail(request: BrevoSendRequest): Promise<BrevoSendResponse>
|
|
@@ -37,7 +37,7 @@ export interface BrevoClientLike {
|
|
|
37
37
|
|
|
38
38
|
export interface BrevoAdapterOptions {
|
|
39
39
|
apiKey: string
|
|
40
|
-
/** Inject
|
|
40
|
+
/** Inject custom/mock client. Defaults to real `BrevoClient`. */
|
|
41
41
|
client?: BrevoClientLike
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -50,7 +50,7 @@ function toBase64(content: Uint8Array | string): string {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export function brevoAdapter(options: BrevoAdapterOptions): MailerAdapter {
|
|
53
|
-
// Validate
|
|
53
|
+
// Validate early: missing key fails at construction, not send().
|
|
54
54
|
const config = v.parse(BrevoConfigSchema, { apiKey: options.apiKey })
|
|
55
55
|
const client: BrevoClientLike =
|
|
56
56
|
options.client ?? (new BrevoClient({ apiKey: config.apiKey }) as unknown as BrevoClientLike)
|
|
@@ -4,7 +4,7 @@ import { formatAddress } from '../../core/address.js'
|
|
|
4
4
|
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port.js'
|
|
5
5
|
import { ResendConfigSchema } from './config.js'
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/** Structural view of the Resend client (eases testing). */
|
|
8
8
|
export interface ResendClient {
|
|
9
9
|
emails: {
|
|
10
10
|
send(payload: ResendSendPayload): Promise<{
|
|
@@ -30,7 +30,7 @@ interface ResendSendPayload {
|
|
|
30
30
|
|
|
31
31
|
export interface ResendAdapterOptions {
|
|
32
32
|
apiKey: string
|
|
33
|
-
/** Inject
|
|
33
|
+
/** Inject custom/mock client. Defaults to real `Resend`. */
|
|
34
34
|
client?: ResendClient
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -39,7 +39,7 @@ function toAttachmentContent(content: Uint8Array | string): Buffer | string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function resendAdapter(options: ResendAdapterOptions): MailerAdapter {
|
|
42
|
-
// Validate
|
|
42
|
+
// Validate early: missing key fails at construction, not send().
|
|
43
43
|
const config = v.parse(ResendConfigSchema, { apiKey: options.apiKey })
|
|
44
44
|
const client: ResendClient =
|
|
45
45
|
options.client ?? (new Resend(config.apiKey) as unknown as ResendClient)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import * as v from 'valibot'
|
|
2
2
|
|
|
3
3
|
export const SesConfigSchema = v.object({
|
|
4
|
-
/** AWS region. Falls back to
|
|
4
|
+
/** AWS region. Falls back to SDK resolution (e.g. `AWS_REGION`). */
|
|
5
5
|
region: v.optional(v.string()),
|
|
6
|
-
/** Static credentials. Omit to let the SDK resolve
|
|
6
|
+
/** Static credentials. Omit to let the SDK resolve (env, profile, IAM). */
|
|
7
7
|
accessKeyId: v.optional(v.string()),
|
|
8
8
|
secretAccessKey: v.optional(v.string()),
|
|
9
|
-
/** SES configuration set
|
|
9
|
+
/** SES configuration set (event publishing, dedicated IPs, …). */
|
|
10
10
|
configurationSetName: v.optional(v.string()),
|
|
11
11
|
})
|
|
12
12
|
|
|
@@ -5,7 +5,7 @@ import { formatAddress } from '../../core/address.js'
|
|
|
5
5
|
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port.js'
|
|
6
6
|
import { SesConfigSchema } from './config.js'
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/** Structural view of the SESv2 client (eases testing). */
|
|
9
9
|
export interface SesClientLike {
|
|
10
10
|
send(command: { input: SesSendEmailInput }): Promise<{ MessageId?: string }>
|
|
11
11
|
}
|
|
@@ -26,12 +26,12 @@ interface SesSendEmailInput {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export interface SesAdapterOptions {
|
|
29
|
-
/** AWS region. Falls back to
|
|
29
|
+
/** AWS region. Falls back to SDK resolution (e.g. `AWS_REGION`). */
|
|
30
30
|
region?: string
|
|
31
31
|
accessKeyId?: string
|
|
32
32
|
secretAccessKey?: string
|
|
33
33
|
configurationSetName?: string
|
|
34
|
-
/** Inject
|
|
34
|
+
/** Inject custom/mock client. Defaults to real `SESv2Client`. */
|
|
35
35
|
client?: SesClientLike
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -56,8 +56,7 @@ export function sesAdapter(options: SesAdapterOptions = {}): MailerAdapter {
|
|
|
56
56
|
return {
|
|
57
57
|
name: 'ses',
|
|
58
58
|
async send(message: RenderedMessage) {
|
|
59
|
-
// SES
|
|
60
|
-
// message, which this adapter intentionally doesn't build. Fail loudly.
|
|
59
|
+
// SES Simple content can't carry attachments (needs raw MIME, not built here). Fail loudly.
|
|
61
60
|
if (message.attachments?.length) {
|
|
62
61
|
throw new MailerError('SES adapter does not support attachments (requires raw MIME)', {
|
|
63
62
|
adapter: 'ses',
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alfredmouelle/storage",
|
|
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
|
+
"@aws-sdk/client-s3": "^3.1073.0",
|
|
17
|
+
"@aws-sdk/s3-request-presigner": "^3.1073.0",
|
|
18
|
+
"@google-cloud/storage": "^7.21.0",
|
|
19
|
+
"valibot": "^1.4.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^22.10.2",
|
|
23
|
+
"tsdown": "^0.22.3",
|
|
24
|
+
"typescript": "^5.9.3",
|
|
25
|
+
"vitest": "^4.1.9"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Storage } from '@google-cloud/storage'
|
|
2
|
+
import * as v from 'valibot'
|
|
3
|
+
import {
|
|
4
|
+
type PutOptions,
|
|
5
|
+
type SignedUrlOptions,
|
|
6
|
+
StorageError,
|
|
7
|
+
type StoragePort,
|
|
8
|
+
} from '../../core/port.js'
|
|
9
|
+
import { GcsConfigSchema } from './config.js'
|
|
10
|
+
|
|
11
|
+
/** Structural view of a GCS file handle (eases testing). */
|
|
12
|
+
export interface GcsFileLike {
|
|
13
|
+
save(data: Buffer | string, options?: { contentType?: string }): Promise<void>
|
|
14
|
+
download(): Promise<[Buffer]>
|
|
15
|
+
delete(): Promise<unknown>
|
|
16
|
+
exists(): Promise<[boolean]>
|
|
17
|
+
getSignedUrl(options: {
|
|
18
|
+
version: 'v4'
|
|
19
|
+
action: 'read' | 'write'
|
|
20
|
+
expires: number
|
|
21
|
+
contentType?: string
|
|
22
|
+
}): Promise<[string]>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Structural view of a GCS bucket handle. */
|
|
26
|
+
export interface GcsBucketLike {
|
|
27
|
+
file(key: string): GcsFileLike
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Structural view of the GCS Storage client. */
|
|
31
|
+
export interface GcsStorageLike {
|
|
32
|
+
bucket(name: string): GcsBucketLike
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GcsAdapterOptions {
|
|
36
|
+
bucket: string
|
|
37
|
+
projectId?: string
|
|
38
|
+
/** Inject custom/mock client. Defaults to real `Storage`. */
|
|
39
|
+
client?: GcsStorageLike
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_EXPIRES_IN = 900
|
|
43
|
+
|
|
44
|
+
function isNotFound(error: unknown): boolean {
|
|
45
|
+
if (typeof error !== 'object' || error === null) return false
|
|
46
|
+
return (error as { code?: number }).code === 404
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function gcsAdapter(options: GcsAdapterOptions): StoragePort {
|
|
50
|
+
// Validate early: missing config fails at construction, not use.
|
|
51
|
+
const config = v.parse(GcsConfigSchema, {
|
|
52
|
+
bucket: options.bucket,
|
|
53
|
+
projectId: options.projectId,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const client: GcsStorageLike =
|
|
57
|
+
options.client ?? (new Storage({ projectId: config.projectId }) as unknown as GcsStorageLike)
|
|
58
|
+
|
|
59
|
+
const bucket = client.bucket(config.bucket)
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
name: 'gcs',
|
|
63
|
+
async put(key: string, data: Uint8Array | string, putOptions?: PutOptions) {
|
|
64
|
+
const body = typeof data === 'string' ? data : Buffer.from(data)
|
|
65
|
+
try {
|
|
66
|
+
await bucket.file(key).save(body, { contentType: putOptions?.contentType })
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
throw new StorageError('GCS put failed', { adapter: 'gcs', cause })
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
async get(key: string) {
|
|
72
|
+
try {
|
|
73
|
+
const [contents] = await bucket.file(key).download()
|
|
74
|
+
return new Uint8Array(contents)
|
|
75
|
+
} catch (cause) {
|
|
76
|
+
if (isNotFound(cause)) return null
|
|
77
|
+
throw new StorageError('GCS get failed', { adapter: 'gcs', cause })
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
async delete(key: string) {
|
|
81
|
+
try {
|
|
82
|
+
await bucket.file(key).delete()
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
if (isNotFound(cause)) return
|
|
85
|
+
throw new StorageError('GCS delete failed', { adapter: 'gcs', cause })
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async exists(key: string) {
|
|
89
|
+
try {
|
|
90
|
+
const [found] = await bucket.file(key).exists()
|
|
91
|
+
return found
|
|
92
|
+
} catch (cause) {
|
|
93
|
+
throw new StorageError('GCS exists failed', { adapter: 'gcs', cause })
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
async getSignedUrl(key: string, urlOptions: SignedUrlOptions) {
|
|
97
|
+
const expiresInSeconds = urlOptions.expiresInSeconds ?? DEFAULT_EXPIRES_IN
|
|
98
|
+
try {
|
|
99
|
+
const [url] = await bucket.file(key).getSignedUrl({
|
|
100
|
+
version: 'v4',
|
|
101
|
+
action: urlOptions.operation === 'put' ? 'write' : 'read',
|
|
102
|
+
expires: Date.now() + expiresInSeconds * 1000,
|
|
103
|
+
contentType: urlOptions.contentType,
|
|
104
|
+
})
|
|
105
|
+
return url
|
|
106
|
+
} catch (cause) {
|
|
107
|
+
throw new StorageError('GCS getSignedUrl failed', { adapter: 'gcs', cause })
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
|
|
3
|
+
export const LocalConfigSchema = v.object({
|
|
4
|
+
baseDir: v.pipe(v.string(), v.minLength(1, 'STORAGE_LOCAL_DIR is required')),
|
|
5
|
+
publicBaseUrl: v.optional(v.string()),
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
export type LocalConfig = v.InferOutput<typeof LocalConfigSchema>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import * as v from 'valibot'
|
|
4
|
+
import {
|
|
5
|
+
type PutOptions,
|
|
6
|
+
type SignedUrlOptions,
|
|
7
|
+
StorageError,
|
|
8
|
+
type StoragePort,
|
|
9
|
+
} from '../../core/port.js'
|
|
10
|
+
import { LocalConfigSchema } from './config.js'
|
|
11
|
+
|
|
12
|
+
export interface LocalAdapterOptions {
|
|
13
|
+
/** Root directory for stored objects. */
|
|
14
|
+
baseDir: string
|
|
15
|
+
/**
|
|
16
|
+
* Base URL prefixed to keys by {@link StoragePort.getSignedUrl}. NOT signed —
|
|
17
|
+
* returns `${publicBaseUrl}/${key}`. Dev/tests only.
|
|
18
|
+
*/
|
|
19
|
+
publicBaseUrl?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isNotFound(error: unknown): boolean {
|
|
23
|
+
if (typeof error !== 'object' || error === null) return false
|
|
24
|
+
return (error as { code?: string }).code === 'ENOENT'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function localAdapter(options: LocalAdapterOptions): StoragePort {
|
|
28
|
+
const config = v.parse(LocalConfigSchema, {
|
|
29
|
+
baseDir: options.baseDir,
|
|
30
|
+
publicBaseUrl: options.publicBaseUrl,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const resolve = (key: string): string => join(config.baseDir, key)
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
name: 'local',
|
|
37
|
+
async put(key: string, data: Uint8Array | string, _options?: PutOptions) {
|
|
38
|
+
const path = resolve(key)
|
|
39
|
+
try {
|
|
40
|
+
await mkdir(dirname(path), { recursive: true })
|
|
41
|
+
await writeFile(path, typeof data === 'string' ? data : Buffer.from(data))
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
throw new StorageError('Local put failed', { adapter: 'local', cause })
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
async get(key: string) {
|
|
47
|
+
try {
|
|
48
|
+
const buffer = await readFile(resolve(key))
|
|
49
|
+
return new Uint8Array(buffer)
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
if (isNotFound(cause)) return null
|
|
52
|
+
throw new StorageError('Local get failed', { adapter: 'local', cause })
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
async delete(key: string) {
|
|
56
|
+
try {
|
|
57
|
+
await unlink(resolve(key))
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
if (isNotFound(cause)) return
|
|
60
|
+
throw new StorageError('Local delete failed', { adapter: 'local', cause })
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
async exists(key: string) {
|
|
64
|
+
try {
|
|
65
|
+
await stat(resolve(key))
|
|
66
|
+
return true
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
if (isNotFound(cause)) return false
|
|
69
|
+
throw new StorageError('Local exists failed', { adapter: 'local', cause })
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
async getSignedUrl(key: string, _options: SignedUrlOptions) {
|
|
73
|
+
// No real signing; dev/test only.
|
|
74
|
+
const base = config.publicBaseUrl ?? ''
|
|
75
|
+
return `${base}/${key}`
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
|
|
3
|
+
export const R2ConfigSchema = v.object({
|
|
4
|
+
bucket: v.pipe(v.string(), v.minLength(1, 'R2_BUCKET is required')),
|
|
5
|
+
accountId: v.pipe(v.string(), v.minLength(1, 'R2_ACCOUNT_ID is required')),
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
export type R2Config = v.InferOutput<typeof R2ConfigSchema>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
import type { StoragePort } from '../../core/port.js'
|
|
3
|
+
import { type S3ClientLike, type S3Presigner, s3Adapter } from '../s3/index.js'
|
|
4
|
+
import { R2ConfigSchema } from './config.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* R2 is S3-compatible: {@link s3Adapter} with R2's endpoint
|
|
8
|
+
* (`https://<accountId>.r2.cloudflarestorage.com`) and `auto` region. All behavior inherited.
|
|
9
|
+
*/
|
|
10
|
+
export interface R2AdapterOptions {
|
|
11
|
+
bucket: string
|
|
12
|
+
/** Cloudflare account id (forms the R2 endpoint). */
|
|
13
|
+
accountId: string
|
|
14
|
+
accessKeyId?: string
|
|
15
|
+
secretAccessKey?: string
|
|
16
|
+
/** Inject custom/mock client. Defaults to real `S3Client`. */
|
|
17
|
+
client?: S3ClientLike
|
|
18
|
+
/** Inject custom/mock presigner. */
|
|
19
|
+
presign?: S3Presigner
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function r2Adapter(options: R2AdapterOptions): StoragePort {
|
|
23
|
+
const config = v.parse(R2ConfigSchema, {
|
|
24
|
+
bucket: options.bucket,
|
|
25
|
+
accountId: options.accountId,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const s3 = s3Adapter({
|
|
29
|
+
bucket: config.bucket,
|
|
30
|
+
region: 'auto',
|
|
31
|
+
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
|
|
32
|
+
accessKeyId: options.accessKeyId,
|
|
33
|
+
secretAccessKey: options.secretAccessKey,
|
|
34
|
+
client: options.client,
|
|
35
|
+
presign: options.presign,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return { ...s3, name: 'r2' }
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
|
|
3
|
+
export const S3ConfigSchema = v.object({
|
|
4
|
+
bucket: v.pipe(v.string(), v.minLength(1, 'S3_BUCKET is required')),
|
|
5
|
+
region: v.pipe(v.string(), v.minLength(1, 'S3_REGION is required')),
|
|
6
|
+
accessKeyId: v.optional(v.string()),
|
|
7
|
+
secretAccessKey: v.optional(v.string()),
|
|
8
|
+
endpoint: v.optional(v.string()),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export type S3Config = v.InferOutput<typeof S3ConfigSchema>
|