better-ship 0.3.2
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/LICENSE +201 -0
- package/dist/application.d.ts +127 -0
- package/dist/application.d.ts.map +1 -0
- package/dist/application.js +85 -0
- package/dist/application.js.map +1 -0
- package/dist/cloudflare.d.ts +1 -0
- package/dist/cloudflare.js +0 -0
- package/dist/core-D0asUCvq.js +282 -0
- package/dist/core-D0asUCvq.js.map +1 -0
- package/dist/core.d.ts +2 -0
- package/dist/core.js +3 -0
- package/dist/index-eL4vb02m.d.ts +183 -0
- package/dist/index-eL4vb02m.d.ts.map +1 -0
- package/dist/postgres.d.ts +1 -0
- package/dist/postgres.js +0 -0
- package/dist/tanstack.d.ts +1 -0
- package/dist/tanstack.js +0 -0
- package/dist/ui.d.ts +1 -0
- package/dist/ui.js +0 -0
- package/package.json +49 -0
- package/src/application/handlers.ts +22 -0
- package/src/application/index.ts +13 -0
- package/src/application/message-bus.errors.ts +16 -0
- package/src/application/message-bus.ts +86 -0
- package/src/application/messages.ts +31 -0
- package/src/application/registry.ts +73 -0
- package/src/core/app-error.ts +18 -0
- package/src/core/defects.ts +20 -0
- package/src/core/failure-classification.ts +27 -0
- package/src/core/index.ts +29 -0
- package/src/core/logger.ts +312 -0
- package/src/core/transport-classification.ts +28 -0
- package/src/infrastructure/cloudflare/index.ts +0 -0
- package/src/infrastructure/postgres/index.ts +0 -0
- package/src/infrastructure/tanstack/index.ts +0 -0
- package/src/ui/index.ts +0 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { panic } from 'better-result'
|
|
2
|
+
|
|
3
|
+
import { createLogger, unreachable } from '@/core'
|
|
4
|
+
|
|
5
|
+
import { DuplicateMessageError } from './message-bus.errors.ts'
|
|
6
|
+
import type { Command, Event, Message, Query } from './messages.ts'
|
|
7
|
+
import type { Handlers, MessageResult, RegistryMessage } from './registry.ts'
|
|
8
|
+
|
|
9
|
+
const logger = createLogger('message-bus')
|
|
10
|
+
|
|
11
|
+
/** The port an entrypoint dispatches through. `AppDeps` names this as `IMessageBus<typeof registry>`. */
|
|
12
|
+
export interface IMessageBus<TRegistry extends Handlers<never>> {
|
|
13
|
+
handle<TMessage extends Message & RegistryMessage<TRegistry>>(
|
|
14
|
+
message: TMessage,
|
|
15
|
+
): Promise<MessageResult<TMessage, TRegistry>>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Routes one message to its slot. The bus opens no transaction and keeps no outbox:
|
|
20
|
+
* a handler that writes opens the unit of work itself.
|
|
21
|
+
*/
|
|
22
|
+
export class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMessageBus<TRegistry> {
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly deps: Deps,
|
|
25
|
+
private readonly registry: TRegistry,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
/** Resolves to the handler's own value for a command or query, and to void for an event. */
|
|
29
|
+
async handle<TMessage extends Message & RegistryMessage<TRegistry>>(
|
|
30
|
+
message: TMessage,
|
|
31
|
+
): Promise<MessageResult<TMessage, TRegistry>> {
|
|
32
|
+
// SAFETY: the slot for this name is what `MessageResult` names, and dispatch returns its value.
|
|
33
|
+
return (await this.dispatch(message)) as MessageResult<TMessage, TRegistry>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// oxlint-disable-next-line anti-slop/no-unknown-returns -- one table holds every slot's value; `handle` names it.
|
|
37
|
+
private dispatch(message: Message): Promise<unknown> {
|
|
38
|
+
if (message.type === 'command') return this.handleCommand(message)
|
|
39
|
+
if (message.type === 'query') return this.handleQuery(message)
|
|
40
|
+
if (message.type === 'event') return this.handleEvent(message)
|
|
41
|
+
// A message parsed at a boundary cannot reach here; one that does must not resolve silently.
|
|
42
|
+
return unreachable(message)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.
|
|
46
|
+
private handleCommand(command: Command): Promise<unknown> {
|
|
47
|
+
// `satisfies` proved the registry complete, so a missing slot is a broken registry object.
|
|
48
|
+
const handler = this.registry.commands[command.name]
|
|
49
|
+
if (handler === undefined) return panic(`No handler for command ${command.name}`)
|
|
50
|
+
logger.debug('handling_command', { name: command.name, id: command.id })
|
|
51
|
+
return handler(command, this.deps)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.
|
|
55
|
+
private handleQuery(query: Query): Promise<unknown> {
|
|
56
|
+
const handler = this.registry.queries[query.name]
|
|
57
|
+
if (handler === undefined) return panic(`No handler for query ${query.name}`)
|
|
58
|
+
return handler(query, this.deps)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every subscriber runs to completion. One failure is rethrown as is. Several are thrown as
|
|
63
|
+
* one `AggregateError`. A `DuplicateMessageError` means that subscriber already committed on
|
|
64
|
+
* an earlier delivery, so it counts as delivered.
|
|
65
|
+
*/
|
|
66
|
+
private async handleEvent(event: Event): Promise<void> {
|
|
67
|
+
const subscriptions = this.registry.events[event.name] ?? []
|
|
68
|
+
const outcomes = await Promise.allSettled(
|
|
69
|
+
subscriptions.map((subscription) => subscription(event, this.deps)),
|
|
70
|
+
)
|
|
71
|
+
const failures: unknown[] = []
|
|
72
|
+
for (const [subscriber, outcome] of outcomes.entries()) {
|
|
73
|
+
if (outcome.status === 'fulfilled') continue
|
|
74
|
+
if (outcome.reason instanceof DuplicateMessageError) {
|
|
75
|
+
// The bus swallows this error, so this line is the only record that the skip happened.
|
|
76
|
+
logger.info('duplicate_subscriber_ignored', { event: event.name, id: event.id, subscriber })
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
failures.push(outcome.reason)
|
|
80
|
+
}
|
|
81
|
+
if (failures.length === 1) throw failures[0]
|
|
82
|
+
if (failures.length > 1) {
|
|
83
|
+
throw new AggregateError(failures, `${event.name}: ${failures.length} subscribers failed`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity of a command or event. Receipts and the outbox are keyed by it.
|
|
3
|
+
*
|
|
4
|
+
* Internal messages use UUID v7, so receipts sort by creation. External messages use the
|
|
5
|
+
* provider's own key, `stripe:event:evt_123`, so a redelivered webhook is a duplicate by
|
|
6
|
+
* construction. The app parses and generates ids; this package only carries them.
|
|
7
|
+
*/
|
|
8
|
+
export type MessageId = string
|
|
9
|
+
|
|
10
|
+
/** An action to perform. One handler, returns a value. The product adds its payload. */
|
|
11
|
+
export type Command<Name extends string = string> = {
|
|
12
|
+
readonly type: 'command'
|
|
13
|
+
readonly name: Name
|
|
14
|
+
readonly id: MessageId
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A request for data. One handler, returns a value. No id: nothing claims a read. */
|
|
18
|
+
export type Query<Name extends string = string> = {
|
|
19
|
+
readonly type: 'query'
|
|
20
|
+
readonly name: Name
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A fact that happened. Zero or more subscribers, returns nothing. The product adds its payload. */
|
|
24
|
+
export type Event<Name extends string = string> = {
|
|
25
|
+
readonly type: 'event'
|
|
26
|
+
readonly name: Name
|
|
27
|
+
readonly id: MessageId
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The routing fields the bus reads. `type` selects the table, `name` selects the slot. */
|
|
31
|
+
export type Message = Command | Event | Query
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { CommandHandler, EventHandler, QueryHandler } from './handlers.ts'
|
|
2
|
+
import type { Command, Event, Message, Query } from './messages.ts'
|
|
3
|
+
|
|
4
|
+
/** One slot per command. `unknown` keeps the slot covariant; `MessageResult` recovers the value. */
|
|
5
|
+
export type CommandRegistry<TCommand extends Command, Deps> = {
|
|
6
|
+
readonly [M in TCommand as M['name']]: CommandHandler<M, Deps, unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** One slot per query, typed with the read-only view of the container. */
|
|
10
|
+
export type QueryRegistry<TQuery extends Query, QueryDeps> = {
|
|
11
|
+
readonly [M in TQuery as M['name']]: QueryHandler<M, QueryDeps, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Zero or more subscribers per event. Annotate the object with this type rather than
|
|
16
|
+
* `satisfies`, so an event with no subscribers is still a message the bus accepts.
|
|
17
|
+
*/
|
|
18
|
+
export type EventRegistry<TEvent extends Event, Deps> = {
|
|
19
|
+
readonly [M in TEvent as M['name']]?: ReadonlyArray<EventHandler<M, Deps>>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The app's three tables. `satisfies MessageRegistry<AppMessage, AppDeps, AppQueryDeps>`
|
|
24
|
+
* makes a missing handler a compile error. `Deps extends QueryDeps` is checked here, the one
|
|
25
|
+
* place both types meet: the read view must be a subset of the container.
|
|
26
|
+
*/
|
|
27
|
+
export type MessageRegistry<TMessage extends Message, Deps extends QueryDeps, QueryDeps> = {
|
|
28
|
+
readonly commands: CommandRegistry<Extract<TMessage, Command>, Deps>
|
|
29
|
+
readonly queries: QueryRegistry<Extract<TMessage, Query>, QueryDeps>
|
|
30
|
+
readonly events: EventRegistry<Extract<TMessage, Event>, Deps>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Method syntax makes the parameters bivariant, so an app's slot, typed for one message, fits.
|
|
34
|
+
// oxlint-disable-next-line anti-slop/no-unknown-returns -- one table holds every slot's value; `MessageResult` names it.
|
|
35
|
+
type Slot<Deps> = { handle(message: Message, deps: Deps): Promise<unknown> }['handle']
|
|
36
|
+
type Subscription<Deps> = { handle(event: Event, deps: Deps): Promise<void> }['handle']
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The registry as the bus routes it: each table keyed by name. The app's names are unknown
|
|
40
|
+
* inside the package, so this is what a runtime string can index.
|
|
41
|
+
*/
|
|
42
|
+
export type Handlers<Deps> = {
|
|
43
|
+
readonly commands: Readonly<Record<string, Slot<Deps>>>
|
|
44
|
+
readonly queries: Readonly<Record<string, Slot<Deps>>>
|
|
45
|
+
readonly events: Readonly<Partial<Record<string, ReadonlyArray<Subscription<Deps>>>>>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type SlotMessage<TSlots> = Parameters<Extract<TSlots[keyof TSlots], Slot<never>>>[0]
|
|
49
|
+
|
|
50
|
+
type SubscriptionMessage<TSlots> =
|
|
51
|
+
NonNullable<TSlots[keyof TSlots]> extends ReadonlyArray<infer TSubscription>
|
|
52
|
+
? Parameters<Extract<TSubscription, Subscription<never>>>[0]
|
|
53
|
+
: never
|
|
54
|
+
|
|
55
|
+
/** The messages a registry has a slot for, read off each slot's first parameter. */
|
|
56
|
+
export type RegistryMessage<TRegistry extends Handlers<never>> =
|
|
57
|
+
| SlotMessage<TRegistry['commands']>
|
|
58
|
+
| SlotMessage<TRegistry['queries']>
|
|
59
|
+
| SubscriptionMessage<TRegistry['events']>
|
|
60
|
+
|
|
61
|
+
type SlotValue<TSlots, TName extends string> = TName extends keyof TSlots
|
|
62
|
+
? Awaited<ReturnType<Extract<TSlots[TName], Slot<never>>>>
|
|
63
|
+
: never
|
|
64
|
+
|
|
65
|
+
/** The value `bus.handle(message)` resolves to: the slot's own return type, or void for an event. */
|
|
66
|
+
export type MessageResult<
|
|
67
|
+
TMessage extends Message,
|
|
68
|
+
TRegistry extends Handlers<never>,
|
|
69
|
+
> = TMessage extends Command
|
|
70
|
+
? SlotValue<TRegistry['commands'], TMessage['name']>
|
|
71
|
+
: TMessage extends Query
|
|
72
|
+
? SlotValue<TRegistry['queries'], TMessage['name']>
|
|
73
|
+
: void
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FailureClassification } from './failure-classification.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The base of every expected failure. A subclass declares its tag and its classification,
|
|
5
|
+
* so no boundary has to guess what kind of failure it holds. A boundary tells our errors from
|
|
6
|
+
* raw throws with `instanceof AppError`; a value that fails it was never judged.
|
|
7
|
+
*
|
|
8
|
+
* A `readonly` field with a literal initializer keeps the literal type, so
|
|
9
|
+
* `readonly _tag = 'StoreUnavailable'` is enough for `Result` unions and `matchError`.
|
|
10
|
+
*/
|
|
11
|
+
export abstract class AppError extends Error {
|
|
12
|
+
abstract readonly _tag: string
|
|
13
|
+
abstract readonly classification: FailureClassification
|
|
14
|
+
|
|
15
|
+
override get name(): string {
|
|
16
|
+
return this._tag
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { panic } from 'better-result'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mark a line the types say cannot run. A union member without a branch stops compilation here.
|
|
5
|
+
*
|
|
6
|
+
* @throws Panic when a value outside the union arrives at runtime.
|
|
7
|
+
*/
|
|
8
|
+
export function unreachable(value: never): never {
|
|
9
|
+
return panic(`Unreachable: ${String(value)}`)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mark a body that is not written yet. A defect, not an expected failure.
|
|
14
|
+
*
|
|
15
|
+
* @param what - The behavior the body will provide, such as `invoice export`.
|
|
16
|
+
* @throws Panic always.
|
|
17
|
+
*/
|
|
18
|
+
export function notImplemented(what: string): never {
|
|
19
|
+
return panic(`Not implemented: ${what}`)
|
|
20
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one failure is, before any policy decides what to do about it.
|
|
3
|
+
*
|
|
4
|
+
* `transient`: the world may differ on the next attempt. `terminal`: repeating gives the
|
|
5
|
+
* same answer. `unknown`: nobody judged this error, so it came from outside unwrapped.
|
|
6
|
+
*/
|
|
7
|
+
export type FailureClassification = 'transient' | 'terminal' | 'unknown'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Who is asking to repeat the work.
|
|
11
|
+
*
|
|
12
|
+
* An immediate owner holds a caller and a socket open. A durable owner already stored the
|
|
13
|
+
* work, so it can afford to try again on a failure nobody classified.
|
|
14
|
+
*/
|
|
15
|
+
export type RetryOwner = 'immediate' | 'durable'
|
|
16
|
+
|
|
17
|
+
/** Decide whether one retry owner may repeat a failed operation. */
|
|
18
|
+
export function shouldRetryFailure(args: {
|
|
19
|
+
readonly classification: FailureClassification
|
|
20
|
+
readonly repeatSafe: boolean
|
|
21
|
+
readonly owner: RetryOwner
|
|
22
|
+
}): boolean {
|
|
23
|
+
if (!args.repeatSafe) return false
|
|
24
|
+
if (args.classification === 'transient') return true
|
|
25
|
+
if (args.classification === 'terminal') return false
|
|
26
|
+
return args.owner === 'durable'
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export { AppError } from './app-error.ts'
|
|
2
|
+
export { notImplemented, unreachable } from './defects.ts'
|
|
3
|
+
export {
|
|
4
|
+
shouldRetryFailure,
|
|
5
|
+
type FailureClassification,
|
|
6
|
+
type RetryOwner,
|
|
7
|
+
} from './failure-classification.ts'
|
|
8
|
+
export { isAbortError, isTransientTransportError } from './transport-classification.ts'
|
|
9
|
+
export {
|
|
10
|
+
configureLogger,
|
|
11
|
+
consoleSink,
|
|
12
|
+
createLogger,
|
|
13
|
+
Logger,
|
|
14
|
+
LogLevel,
|
|
15
|
+
setLoggerErrorHook,
|
|
16
|
+
setLogSink,
|
|
17
|
+
type ErrorCaptureEntry,
|
|
18
|
+
type ErrorLogContext,
|
|
19
|
+
type LogEntry,
|
|
20
|
+
type LogFields,
|
|
21
|
+
type LogFormat,
|
|
22
|
+
type LoggerConfig,
|
|
23
|
+
type LoggerErrorHook,
|
|
24
|
+
type LoggerSettings,
|
|
25
|
+
type LogSink,
|
|
26
|
+
type LogThreshold,
|
|
27
|
+
type LogValue,
|
|
28
|
+
type SerializedError,
|
|
29
|
+
} from './logger.ts'
|
|
@@ -0,0 +1,312 @@
|
|
|
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
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A value a log entry may carry. Errors enter only through `Logger.error`. */
|
|
9
|
+
export type LogValue =
|
|
10
|
+
| string
|
|
11
|
+
| number
|
|
12
|
+
| boolean
|
|
13
|
+
| null
|
|
14
|
+
| undefined
|
|
15
|
+
| ReadonlyArray<LogValue>
|
|
16
|
+
| LogFields
|
|
17
|
+
|
|
18
|
+
/** Named log values. */
|
|
19
|
+
export type LogFields = { readonly [key: string]: LogValue }
|
|
20
|
+
|
|
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
|
+
*/
|
|
26
|
+
export const LogLevel = {
|
|
27
|
+
DEBUG: 'DEBUG',
|
|
28
|
+
INFO: 'INFO',
|
|
29
|
+
WARN: 'WARN',
|
|
30
|
+
ERROR: 'ERROR',
|
|
31
|
+
} as const
|
|
32
|
+
export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]
|
|
33
|
+
|
|
34
|
+
/** An error as plain data: name, message, stack, allowlisted fields, and the cause chain. */
|
|
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
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One log, as the sink receives it. Plain data, safe to stringify. */
|
|
44
|
+
export type LogEntry = {
|
|
45
|
+
readonly timestamp: string
|
|
46
|
+
readonly level: LogLevel
|
|
47
|
+
readonly module: string
|
|
48
|
+
readonly message: string
|
|
49
|
+
readonly data: ReadonlyArray<LogValue>
|
|
50
|
+
/** Present on `Logger.error` entries that carried an error. */
|
|
51
|
+
readonly error?: SerializedError
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The context an error log carries. */
|
|
55
|
+
export type ErrorLogContext = {
|
|
56
|
+
readonly error?: unknown
|
|
57
|
+
readonly userId?: string
|
|
58
|
+
readonly details?: LogFields
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What a logger lets through: a level and everything above it, or nothing. */
|
|
62
|
+
export type LogThreshold = LogLevel | 'OFF'
|
|
63
|
+
|
|
64
|
+
/** Overrides the application settings for one logger. */
|
|
65
|
+
export interface LoggerConfig {
|
|
66
|
+
readonly level?: LogThreshold
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `json` writes the entry object. `pretty` writes one readable line for a person. */
|
|
70
|
+
export type LogFormat = 'json' | 'pretty'
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The application settings, set once at the composition root.
|
|
74
|
+
*
|
|
75
|
+
* An omitted field keeps its default. An explicit `undefined` is a compile error, so the root
|
|
76
|
+
* parses an environment value before passing it. The logger reads no environment itself.
|
|
77
|
+
*/
|
|
78
|
+
export interface LoggerSettings extends LoggerConfig {
|
|
79
|
+
readonly format?: LogFormat
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The production server setting, so a root that never configures still logs safely.
|
|
83
|
+
const DEFAULT_SETTINGS: Required<LoggerSettings> = {
|
|
84
|
+
level: LogLevel.INFO,
|
|
85
|
+
format: 'json',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The application configures once at startup; loggers can exist before startup completes.
|
|
89
|
+
let settings = DEFAULT_SETTINGS
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Set application settings, including for existing loggers. Per-logger overrides take priority.
|
|
93
|
+
* Call at startup, never per request. An omitted field resets to INFO and JSON output.
|
|
94
|
+
*/
|
|
95
|
+
export function configureLogger(config: LoggerSettings): void {
|
|
96
|
+
settings = {
|
|
97
|
+
level: config.level ?? DEFAULT_SETTINGS.level,
|
|
98
|
+
format: config.format ?? DEFAULT_SETTINGS.format,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The error data sent to the configured error hook. */
|
|
103
|
+
export interface ErrorCaptureEntry {
|
|
104
|
+
readonly error: unknown
|
|
105
|
+
readonly distinctId: string | undefined
|
|
106
|
+
readonly context: LogFields
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A function that forwards one logged error to an error service. */
|
|
110
|
+
export type LoggerErrorHook = (entry: ErrorCaptureEntry) => void
|
|
111
|
+
|
|
112
|
+
/** Where every log entry goes. */
|
|
113
|
+
export type LogSink = (entry: LogEntry) => void
|
|
114
|
+
|
|
115
|
+
let errorHook: LoggerErrorHook | null = null
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Register the function that receives each logged error.
|
|
119
|
+
*
|
|
120
|
+
* Set once at the process entry point. A later call replaces the earlier hook.
|
|
121
|
+
*/
|
|
122
|
+
export function setLoggerErrorHook(fn: LoggerErrorHook): void {
|
|
123
|
+
errorHook = fn
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// One console method per level, so a host that filters by method can tell them apart.
|
|
127
|
+
const CONSOLE_METHOD = {
|
|
128
|
+
[LogLevel.DEBUG]: 'debug',
|
|
129
|
+
[LogLevel.INFO]: 'info',
|
|
130
|
+
[LogLevel.WARN]: 'warn',
|
|
131
|
+
[LogLevel.ERROR]: 'error',
|
|
132
|
+
} as const satisfies Record<LogLevel, 'debug' | 'info' | 'warn' | 'error'>
|
|
133
|
+
|
|
134
|
+
// Built from the code point so no raw control character sits in the source.
|
|
135
|
+
const ESC = String.fromCharCode(27)
|
|
136
|
+
const ANSI_RESET = `${ESC}[0m`
|
|
137
|
+
const ANSI_GRAY = `${ESC}[90m`
|
|
138
|
+
const ANSI_BLUE = `${ESC}[34m`
|
|
139
|
+
|
|
140
|
+
// Severity reads at a glance: quiet levels stay dim, and an error is the only bold line.
|
|
141
|
+
const LEVEL_ANSI = {
|
|
142
|
+
DEBUG: `${ESC}[2;36m`,
|
|
143
|
+
INFO: ANSI_BLUE,
|
|
144
|
+
WARN: `${ESC}[33m`,
|
|
145
|
+
ERROR: `${ESC}[1;31m`,
|
|
146
|
+
} satisfies Record<LogLevel, string>
|
|
147
|
+
|
|
148
|
+
function prettyLine(entry: LogEntry): string {
|
|
149
|
+
// Node prints debug and info exactly like log, so the level must be in the text too.
|
|
150
|
+
const level = `[${entry.level}]`
|
|
151
|
+
const module = `[${entry.module}]`
|
|
152
|
+
// A browser console colors by method on its own; a terminal needs ANSI to do the same.
|
|
153
|
+
if ('window' in globalThis) return `${entry.timestamp} ${level} ${module} ${entry.message}`
|
|
154
|
+
const timestamp = `${ANSI_GRAY}${entry.timestamp}${ANSI_RESET}`
|
|
155
|
+
const coloredLevel = `${LEVEL_ANSI[entry.level]}${level}${ANSI_RESET}`
|
|
156
|
+
const coloredModule = `${ANSI_BLUE}${module}${ANSI_RESET}`
|
|
157
|
+
return `${timestamp} ${coloredLevel} ${coloredModule} ${entry.message}`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* JSON format writes the entry object. Pretty format writes one line, then the data,
|
|
162
|
+
* the error, and the stack as separate console arguments.
|
|
163
|
+
*/
|
|
164
|
+
export const consoleSink: LogSink = (entry) => {
|
|
165
|
+
// Resolved per call, so a console replaced after import still receives the output.
|
|
166
|
+
const write = console[CONSOLE_METHOD[entry.level]]
|
|
167
|
+
if (settings.format === 'json') {
|
|
168
|
+
write(entry)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const line = prettyLine(entry)
|
|
172
|
+
if (entry.error === undefined) {
|
|
173
|
+
write(line, ...entry.data)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
// A stack inside an object prints as one quoted string; as its own argument it prints as lines.
|
|
177
|
+
const { stack, ...error } = entry.error
|
|
178
|
+
if (stack === undefined) write(line, ...entry.data, error)
|
|
179
|
+
else write(line, ...entry.data, error, `\n${stack}`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let logSink: LogSink = consoleSink
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Send every log entry somewhere other than `console`.
|
|
186
|
+
*
|
|
187
|
+
* Set once at the process entry point, never per module. A logger is created
|
|
188
|
+
* by name and nothing else, so its destination is a fact about the process.
|
|
189
|
+
*/
|
|
190
|
+
export function setLogSink(fn: LogSink): void {
|
|
191
|
+
logSink = fn
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const LOG_LEVELS = Object.values(LogLevel)
|
|
195
|
+
const MAX_ERROR_CAUSE_DEPTH = 3
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Diagnostics worth keeping off an error, named one by one.
|
|
199
|
+
*
|
|
200
|
+
* An allowlist rather than every own property: an error raised by a library we
|
|
201
|
+
* do not control may hang a request or a user payload off itself, and a log is
|
|
202
|
+
* the wrong place to discover that. `_tag` and `classification` are what every `AppError`
|
|
203
|
+
* declares. `remote` and the three flags after it are the ones workerd sets itself.
|
|
204
|
+
*/
|
|
205
|
+
const KEPT_ERROR_FIELDS = [
|
|
206
|
+
'_tag',
|
|
207
|
+
'classification',
|
|
208
|
+
'code',
|
|
209
|
+
'operation',
|
|
210
|
+
'status',
|
|
211
|
+
'statusCode',
|
|
212
|
+
'remote',
|
|
213
|
+
'retryable',
|
|
214
|
+
'overloaded',
|
|
215
|
+
'durableObjectReset',
|
|
216
|
+
] as const
|
|
217
|
+
|
|
218
|
+
type ErrorDiagnostics = Error & { readonly [K in (typeof KEPT_ERROR_FIELDS)[number]]?: LogValue }
|
|
219
|
+
|
|
220
|
+
type MutableSerializedError = { -readonly [K in keyof SerializedError]: SerializedError[K] }
|
|
221
|
+
|
|
222
|
+
const serializeError = (error: Error, depth = 0): SerializedError => {
|
|
223
|
+
if (depth > MAX_ERROR_CAUSE_DEPTH) return { name: 'Error', message: '[cause chain truncated]' }
|
|
224
|
+
|
|
225
|
+
// Every field is optional, so an `Error` is already one of these.
|
|
226
|
+
const carrier: ErrorDiagnostics = error
|
|
227
|
+
const kept = KEPT_ERROR_FIELDS.filter((key) => carrier[key] !== undefined)
|
|
228
|
+
const serialized: MutableSerializedError = { name: error.name, message: error.message }
|
|
229
|
+
if (depth === 0 && error.stack !== undefined) serialized.stack = error.stack
|
|
230
|
+
if (kept.length > 0)
|
|
231
|
+
serialized.fields = Object.fromEntries(kept.map((key) => [key, carrier[key]]))
|
|
232
|
+
if (error.cause instanceof Error) serialized.cause = serializeError(error.cause, depth + 1)
|
|
233
|
+
else if (error.cause !== undefined)
|
|
234
|
+
serialized.cause = { name: 'Error', message: '[non-error cause]' }
|
|
235
|
+
return serialized
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Write module-scoped logs with application defaults and optional overrides. */
|
|
239
|
+
export class Logger {
|
|
240
|
+
private readonly config: LoggerConfig
|
|
241
|
+
|
|
242
|
+
/** Create a logger for one module. */
|
|
243
|
+
constructor(
|
|
244
|
+
private readonly module: string,
|
|
245
|
+
overrideConfig?: LoggerConfig,
|
|
246
|
+
) {
|
|
247
|
+
this.config = { ...overrideConfig }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private log(
|
|
251
|
+
level: LogLevel,
|
|
252
|
+
message: string,
|
|
253
|
+
data: ReadonlyArray<LogValue>,
|
|
254
|
+
error?: SerializedError,
|
|
255
|
+
): void {
|
|
256
|
+
const threshold = this.config.level ?? settings.level
|
|
257
|
+
if (threshold === 'OFF') return
|
|
258
|
+
if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(threshold)) return
|
|
259
|
+
|
|
260
|
+
const entry = { timestamp: new Date().toISOString(), level, module: this.module, message, data }
|
|
261
|
+
logSink(error === undefined ? entry : { ...entry, error })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Write a debug log. */
|
|
265
|
+
debug(message: string, ...values: ReadonlyArray<LogValue>): void {
|
|
266
|
+
this.log(LogLevel.DEBUG, message, values)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Write an information log. */
|
|
270
|
+
info(message: string, ...values: ReadonlyArray<LogValue>): void {
|
|
271
|
+
this.log(LogLevel.INFO, message, values)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Write a warning log. */
|
|
275
|
+
warn(message: string, ...values: ReadonlyArray<LogValue>): void {
|
|
276
|
+
this.log(LogLevel.WARN, message, values)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Write an error log and send the error to the configured hook.
|
|
281
|
+
*
|
|
282
|
+
* The details say which call this was, and the error says what went wrong
|
|
283
|
+
* inside it. The entry carries the details as data and the error beside them.
|
|
284
|
+
*/
|
|
285
|
+
error(message: string, context: ErrorLogContext = {}): void {
|
|
286
|
+
const details = context.details ?? {}
|
|
287
|
+
const thrown = context.error
|
|
288
|
+
let error: SerializedError | undefined
|
|
289
|
+
if (thrown instanceof Error) {
|
|
290
|
+
error = serializeError(thrown)
|
|
291
|
+
} else if (thrown !== undefined) {
|
|
292
|
+
// Not an `Error`, so there is no name or stack to keep, only its JSON form.
|
|
293
|
+
let json: string
|
|
294
|
+
try {
|
|
295
|
+
json = JSON.stringify(thrown)
|
|
296
|
+
} catch {
|
|
297
|
+
json = '[non-serializable error]'
|
|
298
|
+
}
|
|
299
|
+
error = { name: 'NonError', message: json }
|
|
300
|
+
}
|
|
301
|
+
this.log(LogLevel.ERROR, message, context.details === undefined ? [] : [details], error)
|
|
302
|
+
|
|
303
|
+
if (errorHook !== null && thrown !== undefined) {
|
|
304
|
+
errorHook({ error: thrown, distinctId: context.userId, context: details })
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Create a logger for one module. */
|
|
310
|
+
export function createLogger(module: string, config?: LoggerConfig): Logger {
|
|
311
|
+
return new Logger(module, config)
|
|
312
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages that the platform-neutral transports raise for a failure that may pass.
|
|
3
|
+
*
|
|
4
|
+
* A boundary that knows its own runtime adds its own patterns before these.
|
|
5
|
+
*/
|
|
6
|
+
const TRANSIENT_TRANSPORT_PATTERNS = [
|
|
7
|
+
/network/,
|
|
8
|
+
/fetch failed/,
|
|
9
|
+
/timeout/,
|
|
10
|
+
/timed?\s*out/,
|
|
11
|
+
/connection.*(lost|reset|refused|closed|aborted)/,
|
|
12
|
+
/econnreset/,
|
|
13
|
+
/econnrefused/,
|
|
14
|
+
/etimedout/,
|
|
15
|
+
/eai_again/,
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
/** Cancellation is a decision, not a failure to repeat. */
|
|
19
|
+
export function isAbortError(cause: unknown): boolean {
|
|
20
|
+
return cause instanceof Error && cause.name === 'AbortError'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Match known transient transport messages while excluding explicit cancellation. */
|
|
24
|
+
export function isTransientTransportError(cause: unknown): boolean {
|
|
25
|
+
if (!(cause instanceof Error) || isAbortError(cause)) return false
|
|
26
|
+
const message = cause.message.toLowerCase()
|
|
27
|
+
return TRANSIENT_TRANSPORT_PATTERNS.some((pattern) => pattern.test(message))
|
|
28
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/ui/index.ts
ADDED
|
File without changes
|