better-ship 0.16.0 → 0.18.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 (129) hide show
  1. package/dist/{application-Cf9SER-i.js → application-CNdzHIXk.js} +5 -7
  2. package/dist/{application-Cf9SER-i.js.map → application-CNdzHIXk.js.map} +1 -1
  3. package/dist/application.d.ts +2 -2
  4. package/dist/application.js +1 -1
  5. package/dist/better-auth/apple-client-secret.d.ts +1 -1
  6. package/dist/better-auth/apple-client-secret.js +2 -2
  7. package/dist/better-auth/apple-client-secret.js.map +1 -1
  8. package/dist/better-auth/auth-client.js +1 -1
  9. package/dist/better-auth/rate-limit.d.ts +1 -1
  10. package/dist/better-auth/rate-limit.js +4 -6
  11. package/dist/better-auth/rate-limit.js.map +1 -1
  12. package/dist/better-auth.js +1 -1
  13. package/dist/billing/client.d.ts +86 -0
  14. package/dist/billing/client.d.ts.map +1 -0
  15. package/dist/billing/client.js +77 -0
  16. package/dist/billing/client.js.map +1 -0
  17. package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
  18. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  19. package/dist/cloudflare/durable-object-client.js +1 -1
  20. package/dist/cloudflare/durable-object-errors.d.ts +1 -1
  21. package/dist/cloudflare/durable-object-errors.js +1 -1
  22. package/dist/cloudflare/kv-client.d.ts +31 -0
  23. package/dist/cloudflare/kv-client.d.ts.map +1 -0
  24. package/dist/cloudflare/kv-client.js +62 -0
  25. package/dist/cloudflare/kv-client.js.map +1 -0
  26. package/dist/cloudflare/kv-errors.d.ts +52 -0
  27. package/dist/cloudflare/kv-errors.d.ts.map +1 -0
  28. package/dist/cloudflare/kv-errors.js +144 -0
  29. package/dist/cloudflare/kv-errors.js.map +1 -0
  30. package/dist/cloudflare/queue.d.ts +2 -2
  31. package/dist/cloudflare/queue.d.ts.map +1 -1
  32. package/dist/cloudflare/queue.js +10 -12
  33. package/dist/cloudflare/queue.js.map +1 -1
  34. package/dist/cloudflare/r2-client.d.ts +70 -0
  35. package/dist/cloudflare/r2-client.d.ts.map +1 -0
  36. package/dist/cloudflare/r2-client.js +153 -0
  37. package/dist/cloudflare/r2-client.js.map +1 -0
  38. package/dist/cloudflare/r2-errors.d.ts +34 -0
  39. package/dist/cloudflare/r2-errors.d.ts.map +1 -0
  40. package/dist/cloudflare/r2-errors.js +90 -0
  41. package/dist/cloudflare/r2-errors.js.map +1 -0
  42. package/dist/cloudflare/scheduled.d.ts +1 -1
  43. package/dist/cloudflare/scheduled.js +2 -2
  44. package/dist/cloudflare/scheduled.js.map +1 -1
  45. package/dist/cloudflare.d.ts +10 -5
  46. package/dist/cloudflare.d.ts.map +1 -1
  47. package/dist/cloudflare.js +21 -10
  48. package/dist/cloudflare.js.map +1 -1
  49. package/dist/{core-ClSP5-V9.js → core-UUW2unJd.js} +106 -195
  50. package/dist/core-UUW2unJd.js.map +1 -0
  51. package/dist/core.d.ts +2 -2
  52. package/dist/core.js +2 -2
  53. package/dist/http.d.ts +2 -2
  54. package/dist/http.js +3 -3
  55. package/dist/http.js.map +1 -1
  56. package/dist/{index-Cchdg2aQ.d.ts → index-CYHXOH08.d.ts} +2 -2
  57. package/dist/{index-Cchdg2aQ.d.ts.map → index-CYHXOH08.d.ts.map} +1 -1
  58. package/dist/{index-DvJUcSW1.d.ts → index-DUFS0SJ_.d.ts} +41 -102
  59. package/dist/index-DUFS0SJ_.d.ts.map +1 -0
  60. package/dist/{outbox-repository-options-ICOF8g2h.js → outbox-repository-options-CgdqU20T.js} +2 -2
  61. package/dist/{outbox-repository-options-ICOF8g2h.js.map → outbox-repository-options-CgdqU20T.js.map} +1 -1
  62. package/dist/{outbox-repository-options-AkihEaik.d.ts → outbox-repository-options-DjBgFhMi.d.ts} +3 -3
  63. package/dist/{outbox-repository-options-AkihEaik.d.ts.map → outbox-repository-options-DjBgFhMi.d.ts.map} +1 -1
  64. package/dist/postgres.d.ts +10 -5
  65. package/dist/postgres.d.ts.map +1 -1
  66. package/dist/postgres.js +21 -10
  67. package/dist/postgres.js.map +1 -1
  68. package/dist/posthog/metrics.d.ts +1 -1
  69. package/dist/posthog/server.d.ts +2 -2
  70. package/dist/posthog/server.js +1 -1
  71. package/dist/resend.d.ts +3 -3
  72. package/dist/resend.js +2 -2
  73. package/dist/sentry/client.d.ts +1 -1
  74. package/dist/sentry/client.d.ts.map +1 -1
  75. package/dist/sentry/client.js +10 -4
  76. package/dist/sentry/client.js.map +1 -1
  77. package/dist/sentry/server.d.ts +2 -2
  78. package/dist/sentry/server.js +5 -5
  79. package/dist/sentry/server.js.map +1 -1
  80. package/dist/stripe/payment-service.d.ts +52 -0
  81. package/dist/stripe/payment-service.d.ts.map +1 -0
  82. package/dist/stripe/payment-service.error.d.ts +13 -0
  83. package/dist/stripe/payment-service.error.d.ts.map +1 -0
  84. package/dist/stripe/payment-service.error.js +30 -0
  85. package/dist/stripe/payment-service.error.js.map +1 -0
  86. package/dist/stripe/payment-service.js +72 -0
  87. package/dist/stripe/payment-service.js.map +1 -0
  88. package/dist/stripe/payment-webhook.d.ts +18 -0
  89. package/dist/stripe/payment-webhook.d.ts.map +1 -0
  90. package/dist/stripe/payment-webhook.js +47 -0
  91. package/dist/stripe/payment-webhook.js.map +1 -0
  92. package/dist/tanstack.d.ts +1 -1
  93. package/dist/tanstack.js +4 -4
  94. package/dist/tanstack.js.map +1 -1
  95. package/dist/testing/outbox-repository.d.ts +2 -2
  96. package/dist/testing/payment-service.d.ts +10 -0
  97. package/dist/testing/payment-service.d.ts.map +1 -0
  98. package/dist/testing/payment-service.js +76 -0
  99. package/dist/testing/payment-service.js.map +1 -0
  100. package/dist/{webhook-verification-D6aGQfNi.js → webhook-verification-BBmjkHgI.js} +2 -2
  101. package/dist/{webhook-verification-D6aGQfNi.js.map → webhook-verification-BBmjkHgI.js.map} +1 -1
  102. package/dist/{webhook-verification-BcQQG-vY.d.ts → webhook-verification-BuxPzdr4.d.ts} +2 -2
  103. package/dist/{webhook-verification-BcQQG-vY.d.ts.map → webhook-verification-BuxPzdr4.d.ts.map} +1 -1
  104. package/package.json +15 -6
  105. package/src/application/outbox-relay.ts +3 -1
  106. package/src/core/console-log-sink.ts +65 -0
  107. package/src/core/index.ts +1 -4
  108. package/src/core/logger.ts +80 -281
  109. package/src/infrastructure/better-auth/apple-client-secret.ts +1 -1
  110. package/src/infrastructure/better-auth/rate-limit.ts +3 -1
  111. package/src/infrastructure/billing/client.ts +173 -0
  112. package/src/infrastructure/cloudflare/d1-database-execution.ts +27 -6
  113. package/src/infrastructure/cloudflare/kv-client.ts +79 -0
  114. package/src/infrastructure/cloudflare/kv-errors.ts +157 -0
  115. package/src/infrastructure/cloudflare/queue/queue-consumer.ts +8 -9
  116. package/src/infrastructure/cloudflare/r2-client.ts +162 -0
  117. package/src/infrastructure/cloudflare/r2-errors.ts +111 -0
  118. package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +1 -1
  119. package/src/infrastructure/http/webhook-handler.ts +1 -1
  120. package/src/infrastructure/postgres/postgres-database-execution.ts +27 -6
  121. package/src/infrastructure/sentry/client.ts +6 -3
  122. package/src/infrastructure/sentry/server.ts +4 -4
  123. package/src/infrastructure/stripe/payment-service.error.ts +42 -0
  124. package/src/infrastructure/stripe/payment-service.ts +129 -0
  125. package/src/infrastructure/stripe/payment-webhook.ts +49 -0
  126. package/src/infrastructure/tanstack/resolve-failure.ts +3 -2
  127. package/src/infrastructure/testing/payment-service-contract.ts +91 -0
  128. package/dist/core-ClSP5-V9.js.map +0 -1
  129. package/dist/index-DvJUcSW1.d.ts.map +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "better-ship",
4
- "version": "0.16.0",
4
+ "version": "0.18.0",
5
5
  "description": "Foundation for applications on Cloudflare Workers: logger, errors, message bus, infrastructure adapters, and UI primitives",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -22,11 +22,16 @@
22
22
  "./better-auth/rate-limit": "./dist/better-auth/rate-limit.js",
23
23
  "./better-auth/secondary-storage": "./dist/better-auth/secondary-storage.js",
24
24
  "./better-auth/stripe": "./dist/better-auth/stripe.js",
25
+ "./billing/client": "./dist/billing/client.js",
25
26
  "./cloudflare": "./dist/cloudflare.js",
26
27
  "./cloudflare/auth-secondary-storage": "./dist/cloudflare/auth-secondary-storage.js",
27
28
  "./cloudflare/durable-object-client": "./dist/cloudflare/durable-object-client.js",
28
29
  "./cloudflare/durable-object-errors": "./dist/cloudflare/durable-object-errors.js",
30
+ "./cloudflare/kv-client": "./dist/cloudflare/kv-client.js",
31
+ "./cloudflare/kv-errors": "./dist/cloudflare/kv-errors.js",
29
32
  "./cloudflare/queue": "./dist/cloudflare/queue.js",
33
+ "./cloudflare/r2-client": "./dist/cloudflare/r2-client.js",
34
+ "./cloudflare/r2-errors": "./dist/cloudflare/r2-errors.js",
30
35
  "./cloudflare/scheduled": "./dist/cloudflare/scheduled.js",
31
36
  "./core": "./dist/core.js",
32
37
  "./http": "./dist/http.js",
@@ -39,9 +44,13 @@
39
44
  "./sentry/client": "./dist/sentry/client.js",
40
45
  "./sentry/server": "./dist/sentry/server.js",
41
46
  "./sentry/vite": "./dist/sentry/vite.js",
47
+ "./stripe/payment-service": "./dist/stripe/payment-service.js",
48
+ "./stripe/payment-service.error": "./dist/stripe/payment-service.error.js",
49
+ "./stripe/payment-webhook": "./dist/stripe/payment-webhook.js",
42
50
  "./tanstack": "./dist/tanstack.js",
43
51
  "./tanstack/server": "./dist/tanstack/server.js",
44
52
  "./testing/outbox-repository": "./dist/testing/outbox-repository.js",
53
+ "./testing/payment-service": "./dist/testing/payment-service.js",
45
54
  "./ui": "./dist/ui.js",
46
55
  "./package.json": "./package.json"
47
56
  },
@@ -52,11 +61,11 @@
52
61
  "better-result": "^3.0.1",
53
62
  "http-status-codes": "^2.3.0",
54
63
  "uuid": "^14.0.2",
55
- "zod": "^4.6.2"
64
+ "zod": "^4.6.4"
56
65
  },
57
66
  "devDependencies": {
58
67
  "@better-auth/stripe": "^1.7.4",
59
- "@cloudflare/vitest-plugin": "^1.1.7",
68
+ "@cloudflare/vitest-plugin": "^1.1.8",
60
69
  "@cloudflare/workers-types": "5.20260911.1",
61
70
  "@posthog/react": "^1.10.6",
62
71
  "@sentry/cloudflare": "^10.74.0",
@@ -74,7 +83,7 @@
74
83
  "drizzle-orm": "^0.45.2",
75
84
  "playwright": "^1.63.0",
76
85
  "postgres": "^3.4.9",
77
- "posthog-js": "^1.430.2",
86
+ "posthog-js": "^1.430.3",
78
87
  "posthog-node": "^5.52.1",
79
88
  "react": "^19.3.0",
80
89
  "resend": "^6.28.0",
@@ -83,7 +92,7 @@
83
92
  "tsdown": "^0.23.0",
84
93
  "typescript": "^7.0.2",
85
94
  "vitest": "^4.1.11",
86
- "wrangler": "^4.131.0"
95
+ "wrangler": "^4.131.1"
87
96
  },
88
97
  "peerDependencies": {
89
98
  "@better-auth/stripe": "^1.7.4",
@@ -94,7 +103,7 @@
94
103
  "@tanstack/react-start": "^1.168.52",
95
104
  "better-auth": "^1.7.4",
96
105
  "drizzle-orm": "^0.45.2",
97
- "posthog-js": "^1.430.2",
106
+ "posthog-js": "^1.430.3",
98
107
  "posthog-node": "^5.52.1",
99
108
  "react": "^19.3.0",
100
109
  "resend": "^6.28.0",
@@ -119,7 +119,9 @@ export class OutboxRelay {
119
119
  await this.repository.deadLetter(claim, messages, reason, errorMessage)
120
120
  logger.error('outbox_messages_dead_lettered', {
121
121
  error: cause,
122
- details: { claimToken: claim.token, count: messages.length, reason },
122
+ claimToken: claim.token,
123
+ count: messages.length,
124
+ reason,
123
125
  })
124
126
  }
125
127
  }
@@ -0,0 +1,65 @@
1
+ import type { LogEntry, LogFields, LogFormat, LogLevel, LogSink } from './logger.ts'
2
+
3
+ const METHOD = {
4
+ DEBUG: 'debug',
5
+ INFO: 'info',
6
+ WARN: 'warn',
7
+ ERROR: 'error',
8
+ } as const satisfies Record<LogLevel, 'debug' | 'info' | 'warn' | 'error'>
9
+
10
+ const ESC = String.fromCharCode(27)
11
+ const RESET = `${ESC}[0m`
12
+ const LEVEL_COLOR = {
13
+ DEBUG: `${ESC}[2;36m`,
14
+ INFO: `${ESC}[34m`,
15
+ WARN: `${ESC}[33m`,
16
+ ERROR: `${ESC}[1;31m`,
17
+ } satisfies Record<LogLevel, string>
18
+
19
+ let format: LogFormat = 'json'
20
+
21
+ export function setConsoleLogFormat(value: LogFormat): void {
22
+ format = value
23
+ }
24
+
25
+ function contextOf(entry: LogEntry): LogFields | undefined {
26
+ const context = { ...entry.attributes }
27
+ if (entry.userId !== undefined) context.userId = entry.userId
28
+ return Object.keys(context).length === 0 ? undefined : context
29
+ }
30
+
31
+ function jsonEntry(entry: LogEntry): LogEntry {
32
+ if (!(entry.error instanceof Error)) return entry
33
+ const error = { name: entry.error.name, message: entry.error.message, stack: entry.error.stack }
34
+ return { ...entry, error }
35
+ }
36
+
37
+ /** Write one structured record or one readable development entry to `console`. */
38
+ export const consoleSink: LogSink = (entry) => {
39
+ const write = console[METHOD[entry.level]]
40
+ if (format === 'json') {
41
+ write(jsonEntry(entry))
42
+ return
43
+ }
44
+
45
+ const context = contextOf(entry)
46
+ const values = context === undefined ? [] : [context]
47
+ const errors = entry.error === undefined ? [] : [entry.error]
48
+ if (format === 'pretty-browser') {
49
+ write(
50
+ `%c${entry.timestamp}%c [${entry.level}] %c[${entry.module}]%c ${entry.event}`,
51
+ 'color: #737373',
52
+ '',
53
+ 'color: #2563eb',
54
+ '',
55
+ ...values,
56
+ ...errors,
57
+ )
58
+ return
59
+ }
60
+ write(
61
+ `${ESC}[90m${entry.timestamp}${RESET} ${LEVEL_COLOR[entry.level]}[${entry.level}]${RESET} ${ESC}[34m[${entry.module}]${RESET} ${entry.event}`,
62
+ ...values,
63
+ ...errors,
64
+ )
65
+ }
package/src/core/index.ts CHANGED
@@ -104,20 +104,17 @@ export {
104
104
  configureLogger,
105
105
  consoleSink,
106
106
  createLogger,
107
- Logger,
108
107
  LogLevel,
109
108
  setLoggerErrorHook,
110
109
  setLogSink,
111
110
  type ErrorCaptureEntry,
112
- type ErrorLogContext,
113
111
  type LogEntry,
114
112
  type LogFields,
115
113
  type LogFormat,
116
- type LoggerConfig,
114
+ type Logger,
117
115
  type LoggerErrorHook,
118
116
  type LoggerSettings,
119
117
  type LogSink,
120
118
  type LogThreshold,
121
119
  type LogValue,
122
- type SerializedError,
123
120
  } from './logger.ts'
@@ -1,28 +1,19 @@
1
- /**
2
- * Module-scoped logging for every host.
3
- *
4
- * A logger emits one plain-data entry per log. The default sink hands it to `console` as an
5
- * entry object for a log service or as a colored line for a person, selected at startup.
6
- */
1
+ import { consoleSink, setConsoleLogFormat } from './console-log-sink.ts'
7
2
 
8
- /** A value a log entry may carry. Errors enter only through `Logger.error`. */
3
+ /** One JSON-compatible value in a normal log field. */
9
4
  export type LogValue =
10
5
  | string
11
6
  | number
12
7
  | boolean
13
8
  | null
14
- | undefined
15
- | ReadonlyArray<LogValue>
16
- | LogFields
9
+ | readonly LogValue[]
10
+ | { readonly [key: string]: LogValue }
17
11
 
18
- /** Named log values. */
19
- export type LogFields = { readonly [key: string]: LogValue }
12
+ /** Flat fields for one log call. Only `error` can contain an arbitrary value. */
13
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- A logger accepts caller-owned fields at its public boundary.
14
+ export type LogFields = Readonly<Record<string, unknown>>
20
15
 
21
- /**
22
- * The supported log severity levels, in ascending order.
23
- *
24
- * A const object, not an enum, so a parsed environment value typed `'INFO'` is a `LogLevel`.
25
- */
16
+ /** Supported log levels in ascending order. */
26
17
  export const LogLevel = {
27
18
  DEBUG: 'DEBUG',
28
19
  INFO: 'INFO',
@@ -31,303 +22,111 @@ export const LogLevel = {
31
22
  } as const
32
23
  export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]
33
24
 
34
- /** An error as plain data, including diagnostics, causes, and aggregate members. */
35
- export type SerializedError = {
36
- readonly name: string
37
- readonly message: string
38
- readonly stack?: string
39
- readonly fields?: LogFields
40
- readonly cause?: SerializedError
41
- readonly errors?: ReadonlyArray<SerializedError>
25
+ /** The minimum emitted level, or `OFF` for no console output. */
26
+ export type LogThreshold = LogLevel | 'OFF'
27
+
28
+ /** Explicit output formats for production, browser development, and terminal development. */
29
+ export type LogFormat = 'json' | 'pretty-browser' | 'pretty-terminal'
30
+
31
+ /** Process-wide logger settings supplied at startup. */
32
+ export interface LoggerSettings {
33
+ readonly level?: LogThreshold
34
+ readonly format?: LogFormat
42
35
  }
43
36
 
44
- /** One log, as the sink receives it. Plain data, safe to stringify. */
37
+ /** One structured record sent to a log sink. */
45
38
  export type LogEntry = {
46
39
  readonly timestamp: string
47
40
  readonly level: LogLevel
48
41
  readonly module: string
49
- readonly message: string
50
- readonly details: LogFields
42
+ readonly event: string
43
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- The entry keeps caller-owned fields for its sink.
44
+ readonly attributes: LogFields
51
45
  readonly userId?: string
52
- /** Present on `Logger.error` entries that carried an error. */
53
- readonly error?: SerializedError
54
- }
55
-
56
- /** The context an error log carries. */
57
- export type ErrorLogContext = {
58
46
  readonly error?: unknown
59
- readonly userId?: string
60
- readonly details?: LogFields
61
- }
62
-
63
- /** What a logger lets through: a level and everything above it, or nothing. */
64
- export type LogThreshold = LogLevel | 'OFF'
65
-
66
- /** Overrides the application settings for one logger. */
67
- export interface LoggerConfig {
68
- readonly level?: LogThreshold
69
- }
70
-
71
- /** `json` writes the entry object. `pretty` writes one readable line for a person. */
72
- export type LogFormat = 'json' | 'pretty'
73
-
74
- /**
75
- * The application settings, set once at the composition root.
76
- *
77
- * An omitted field keeps its default. An explicit `undefined` is a compile error, so the root
78
- * parses an environment value before passing it. The logger reads no environment itself.
79
- */
80
- export interface LoggerSettings extends LoggerConfig {
81
- readonly format?: LogFormat
82
- }
83
-
84
- // The production server setting, so a root that never configures still logs safely.
85
- const DEFAULT_SETTINGS: Required<LoggerSettings> = {
86
- level: LogLevel.INFO,
87
- format: 'json',
88
- }
89
-
90
- // The application configures once at startup; loggers can exist before startup completes.
91
- let settings = DEFAULT_SETTINGS
92
-
93
- /**
94
- * Set application settings, including for existing loggers. Per-logger overrides take priority.
95
- * Call at startup, never per request. An omitted field resets to INFO and JSON output.
96
- */
97
- export function configureLogger(config: LoggerSettings): void {
98
- settings = {
99
- level: config.level ?? DEFAULT_SETTINGS.level,
100
- format: config.format ?? DEFAULT_SETTINGS.format,
101
- }
102
47
  }
103
48
 
104
- /** The error data sent to the configured error hook. */
49
+ /** The original error and fields sent to an error service. */
105
50
  export interface ErrorCaptureEntry {
106
51
  readonly error: unknown
107
- readonly distinctId: string | undefined
108
- readonly context: LogFields
52
+ readonly userId: string | undefined
53
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- Sentry accepts the caller-owned fields as extra context.
54
+ readonly attributes: LogFields
109
55
  }
110
56
 
111
- /** A function that forwards one logged error to an error service. */
57
+ /** A function that forwards one error to an error service. */
112
58
  export type LoggerErrorHook = (entry: ErrorCaptureEntry) => void
113
59
 
114
- /** Where every log entry goes. */
60
+ /** A destination for structured log records. */
115
61
  export type LogSink = (entry: LogEntry) => void
116
62
 
117
- let errorHook: LoggerErrorHook | null = null
118
-
119
- /**
120
- * Register the function that receives each logged error.
121
- *
122
- * Set once at the process entry point. A later call replaces the earlier hook.
123
- */
124
- export function setLoggerErrorHook(fn: LoggerErrorHook): void {
125
- errorHook = fn
63
+ /** The common flat logger API for all levels. */
64
+ export interface Logger {
65
+ debug(event: string, fields?: LogFields): void
66
+ info(event: string, fields?: LogFields): void
67
+ warn(event: string, fields?: LogFields): void
68
+ error(event: string, fields?: LogFields): void
126
69
  }
127
70
 
128
- // One console method per level, so a host that filters by method can tell them apart.
129
- const CONSOLE_METHOD = {
130
- [LogLevel.DEBUG]: 'debug',
131
- [LogLevel.INFO]: 'info',
132
- [LogLevel.WARN]: 'warn',
133
- [LogLevel.ERROR]: 'error',
134
- } as const satisfies Record<LogLevel, 'debug' | 'info' | 'warn' | 'error'>
135
-
136
- // Built from the code point so no raw control character sits in the source.
137
- const ESC = String.fromCharCode(27)
138
- const ANSI_RESET = `${ESC}[0m`
139
- const ANSI_GRAY = `${ESC}[90m`
140
- const ANSI_BLUE = `${ESC}[34m`
141
-
142
- // Severity reads at a glance: quiet levels stay dim, and an error is the only bold line.
143
- const LEVEL_ANSI = {
144
- DEBUG: `${ESC}[2;36m`,
145
- INFO: ANSI_BLUE,
146
- WARN: `${ESC}[33m`,
147
- ERROR: `${ESC}[1;31m`,
148
- } satisfies Record<LogLevel, string>
71
+ const LEVELS = Object.values(LogLevel)
72
+ let threshold: LogThreshold = LogLevel.INFO
73
+ let logSink: LogSink = consoleSink
74
+ let errorHook: LoggerErrorHook | null = null
149
75
 
150
- function prettyLine(entry: LogEntry): string {
151
- // Node prints debug and info exactly like log, so the level must be in the text too.
152
- const level = `[${entry.level}]`
153
- const module = `[${entry.module}]`
154
- // A browser console colors by method on its own; a terminal needs ANSI to do the same.
155
- if ('window' in globalThis) return `${entry.timestamp} ${level} ${module} ${entry.message}`
156
- const timestamp = `${ANSI_GRAY}${entry.timestamp}${ANSI_RESET}`
157
- const coloredLevel = `${LEVEL_ANSI[entry.level]}${level}${ANSI_RESET}`
158
- const coloredModule = `${ANSI_BLUE}${module}${ANSI_RESET}`
159
- return `${timestamp} ${coloredLevel} ${coloredModule} ${entry.message}`
76
+ /** Set the process-wide level and output format. Omitted fields restore production defaults. */
77
+ export function configureLogger(settings: LoggerSettings): void {
78
+ threshold = settings.level ?? LogLevel.INFO
79
+ setConsoleLogFormat(settings.format ?? 'json')
160
80
  }
161
81
 
162
- /**
163
- * JSON format writes the entry object. Pretty format writes one line, then the details,
164
- * the error, and the stack as separate console arguments.
165
- */
166
- export const consoleSink: LogSink = (entry) => {
167
- // Resolved per call, so a console replaced after import still receives the output.
168
- const write = console[CONSOLE_METHOD[entry.level]]
169
- if (settings.format === 'json') {
170
- write(entry)
171
- return
172
- }
173
- const line = prettyLine(entry)
174
- const fields =
175
- entry.userId === undefined ? entry.details : { ...entry.details, userId: entry.userId }
176
- const details = Object.keys(fields).length === 0 ? [] : [fields]
177
- if (entry.error === undefined) {
178
- write(line, ...details)
179
- return
180
- }
181
- // A stack inside an object prints as one quoted string; as its own argument it prints as lines.
182
- const { stack, ...error } = entry.error
183
- if (stack === undefined) write(line, ...details, error)
184
- else write(line, ...details, error, `\n${stack}`)
82
+ /** Set the process-wide log destination. */
83
+ export function setLogSink(sink: LogSink): void {
84
+ logSink = sink
185
85
  }
186
86
 
187
- let logSink: LogSink = consoleSink
188
-
189
- /**
190
- * Send every log entry somewhere other than `console`.
191
- *
192
- * Set once at the process entry point, never per module. A logger is created
193
- * by name and nothing else, so its destination is a fact about the process.
194
- */
195
- export function setLogSink(fn: LogSink): void {
196
- logSink = fn
87
+ /** Set or clear the process-wide error service hook. */
88
+ export function setLoggerErrorHook(hook: LoggerErrorHook | null): void {
89
+ errorHook = hook
197
90
  }
198
91
 
199
- const LOG_LEVELS = Object.values(LogLevel)
200
- const MAX_ERROR_DEPTH = 3
201
-
202
- /**
203
- * Diagnostics worth keeping off an error, named one by one.
204
- *
205
- * An allowlist rather than every own property: an error raised by a library we
206
- * do not control may hang a request or a user payload off itself, and a log is
207
- * the wrong place to discover that. `_tag` and `classification` are what every `AppError`
208
- * declares. `remote` and the three flags after it are the ones workerd sets itself.
209
- */
210
- const KEPT_ERROR_FIELDS = [
211
- '_tag',
212
- 'classification',
213
- 'code',
214
- 'operation',
215
- 'status',
216
- 'statusCode',
217
- 'remote',
218
- 'retryable',
219
- 'overloaded',
220
- 'durableObjectReset',
221
- ] as const
222
-
223
- type ErrorDiagnostics = Error & { readonly [K in (typeof KEPT_ERROR_FIELDS)[number]]?: LogValue }
224
-
225
- type MutableSerializedError = { -readonly [K in keyof SerializedError]: SerializedError[K] }
226
-
227
- const serializeError = (error: Error, depth = 0): SerializedError => {
228
- if (depth > MAX_ERROR_DEPTH) return { name: 'Error', message: '[error nesting truncated]' }
229
-
230
- // Every field is optional, so an `Error` is already one of these.
231
- const carrier: ErrorDiagnostics = error
232
- const kept = KEPT_ERROR_FIELDS.filter((key) => carrier[key] !== undefined)
233
- const serialized: MutableSerializedError = { name: error.name, message: error.message }
234
- if (error.stack !== undefined) serialized.stack = error.stack
235
- if (kept.length > 0)
236
- serialized.fields = Object.fromEntries(kept.map((key) => [key, carrier[key]]))
237
- if (error.cause instanceof Error) serialized.cause = serializeError(error.cause, depth + 1)
238
- else if (error.cause !== undefined)
239
- serialized.cause = { name: 'Error', message: '[non-error cause]' }
240
- if (error instanceof AggregateError) {
241
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- AggregateError accepts arbitrary values; instanceof Error validates each member.
242
- serialized.errors = error.errors.map((failure: unknown) =>
243
- failure instanceof Error
244
- ? serializeError(failure, depth + 1)
245
- : { name: 'NonError', message: '[non-error aggregate member]' },
246
- )
247
- }
248
- return serialized
92
+ /* oxlint-disable anti-slop/no-runtime-typeof -- The logger validates its reserved userId field. */
93
+ function splitFields(fields: LogFields): ErrorCaptureEntry {
94
+ const { error, userId, ...attributes } = fields
95
+ return { error, userId: typeof userId === 'string' ? userId : undefined, attributes }
249
96
  }
250
-
251
- /** Write module-scoped logs with application defaults and optional overrides. */
252
- export class Logger {
253
- private readonly config: LoggerConfig
254
-
255
- /** Create a logger for one module. */
256
- constructor(
257
- private readonly module: string,
258
- overrideConfig?: LoggerConfig,
259
- ) {
260
- this.config = { ...overrideConfig }
261
- }
262
-
263
- private log(
264
- level: LogLevel,
265
- message: string,
266
- details: LogFields,
267
- error?: SerializedError,
268
- userId?: string,
269
- ): void {
270
- const threshold = this.config.level ?? settings.level
271
- if (threshold === 'OFF') return
272
- if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(threshold)) return
273
-
274
- let entry: LogEntry = {
275
- timestamp: new Date().toISOString(),
276
- level,
277
- module: this.module,
278
- message,
279
- details,
280
- }
281
- if (userId !== undefined) entry = { ...entry, userId }
282
- logSink(error === undefined ? entry : { ...entry, error })
283
- }
284
-
285
- /** Write a debug log. */
286
- debug(message: string, details: LogFields = {}): void {
287
- this.log(LogLevel.DEBUG, message, details)
288
- }
289
-
290
- /** Write an information log. */
291
- info(message: string, details: LogFields = {}): void {
292
- this.log(LogLevel.INFO, message, details)
293
- }
294
-
295
- /** Write a warning log. */
296
- warn(message: string, details: LogFields = {}): void {
297
- this.log(LogLevel.WARN, message, details)
97
+ /* oxlint-enable anti-slop/no-runtime-typeof */
98
+
99
+ function writeLog(module: string, level: LogLevel, event: string, fields: LogFields): void {
100
+ if (threshold === 'OFF' || LEVELS.indexOf(level) < LEVELS.indexOf(threshold)) return
101
+
102
+ const { error, userId, attributes } = splitFields(fields)
103
+ let entry: LogEntry = { timestamp: new Date().toISOString(), level, module, event, attributes }
104
+ if (userId !== undefined) entry = { ...entry, userId }
105
+ if (error !== undefined) entry = { ...entry, error }
106
+ try {
107
+ logSink(entry)
108
+ } catch {
109
+ // A diagnostic failure must not change application control flow.
298
110
  }
111
+ }
299
112
 
300
- /**
301
- * Write an error log and send the error to the configured hook.
302
- *
303
- * The details say which call this was, and the error says what went wrong
304
- * inside it. Both the entry and the error hook receive the supplied user ID.
305
- */
306
- error(message: string, context: ErrorLogContext = {}): void {
307
- const details = context.details ?? {}
308
- const thrown = context.error
309
- let error: SerializedError | undefined
310
- if (thrown instanceof Error) {
311
- error = serializeError(thrown)
312
- } else if (thrown !== undefined) {
313
- // Not an `Error`, so there is no name or stack to keep, only its JSON form.
314
- let json: string
113
+ /** Create a logger for one module. */
114
+ export function createLogger(module: string): Logger {
115
+ return {
116
+ debug: (event, fields = {}) => writeLog(module, LogLevel.DEBUG, event, fields),
117
+ info: (event, fields = {}) => writeLog(module, LogLevel.INFO, event, fields),
118
+ warn: (event, fields = {}) => writeLog(module, LogLevel.WARN, event, fields),
119
+ error: (event, fields = {}) => {
120
+ writeLog(module, LogLevel.ERROR, event, fields)
121
+ const capture = splitFields(fields)
122
+ if (errorHook === null || capture.error === undefined) return
315
123
  try {
316
- json = JSON.stringify(thrown)
124
+ errorHook(capture)
317
125
  } catch {
318
- json = '[non-serializable error]'
126
+ // An error service failure must not change application control flow.
319
127
  }
320
- error = { name: 'NonError', message: json }
321
- }
322
- this.log(LogLevel.ERROR, message, details, error, context.userId)
323
-
324
- if (errorHook !== null && thrown !== undefined) {
325
- errorHook({ error: thrown, distinctId: context.userId, context: details })
326
- }
128
+ },
327
129
  }
328
130
  }
329
131
 
330
- /** Create a logger for one module. */
331
- export function createLogger(module: string, config?: LoggerConfig): Logger {
332
- return new Logger(module, config)
333
- }
132
+ export { consoleSink }
@@ -56,7 +56,7 @@ export function generateAppleClientSecret(config: AppleKeyConfig): string {
56
56
  } catch (error) {
57
57
  if (!appleKeyFailureLogged) {
58
58
  appleKeyFailureLogged = true
59
- logger.error('apple_private_key_invalid', { error, details: { keyId: config.appleKeyId } })
59
+ logger.error('apple_private_key_invalid', { error, keyId: config.appleKeyId })
60
60
  }
61
61
  return ''
62
62
  }
@@ -103,7 +103,9 @@ export function createAuthRateLimitStorage(
103
103
  if (failureMode === 'block') throw new AuthRateLimitError(cause)
104
104
  logger.error('rate_limit_binding_failed', {
105
105
  error: cause,
106
- details: { window: rule.window, max: rule.max, failureMode },
106
+ window: rule.window,
107
+ max: rule.max,
108
+ failureMode,
107
109
  })
108
110
  return { allowed: true, retryAfter: null }
109
111
  }