better-ship 0.23.1 → 0.24.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.
@@ -60,7 +60,7 @@ type DispatchBus<TMessage extends QueueMessage> = {
60
60
  /**
61
61
  * Parses and dispatches each queue message, then selects acknowledgement or retry.
62
62
  * Cloudflare owns the retry limit and sends exhausted messages to the configured dead letter.
63
- * The consumer logs each failure once; handlers do not.
63
+ * The consumer logs each failure once, inside the dispatch wrapper; handlers do not.
64
64
  */
65
65
  export declare class QueueConsumer<TMessage extends QueueMessage> {
66
66
  private readonly bus;
@@ -1 +1 @@
1
- {"version":3,"file":"queue.d.ts","names":[],"sources":["../../src/infrastructure/cloudflare/queue/queue-message.ts","../../src/infrastructure/cloudflare/queue/queue-client.ts","../../src/infrastructure/cloudflare/queue/queue-consumer.ts","../../src/infrastructure/cloudflare/queue/queue-limits.ts","../../src/infrastructure/cloudflare/queue/queue.errors.ts","../../src/infrastructure/cloudflare/queue/redelivery-policy.ts","../../src/infrastructure/cloudflare/queue/wait-until-wake.ts"],"mappings":";;;;;KAGY,eAAe,gBAAgB;;;;KCa/B;WAA8B;;;KAG9B,aAAa,QAAQ,KAAK,MAAM;;;;;KAMhC,mBAAmB,iBAAiB,cAAc;WACnD,QAAQ,SAAS,OAAO,MAAM,aAAa;;WAE3C,YAAY,KAAK;WACjB,SAAS,SAAS,aAAa;;;UAIzB,aAAa,iBAAiB;EAC7C,KAAK,SAAS,UAAU,UAAU,mBAAmB;EACrD,UAAU,UAAU,cAAc,YAAY;;EAE9C,iBAAiB,aAAa,UAC5B,UAAU,cAAc,QACvB,QAAQ,cAAc;;EAEzB,UAAU,SAAS,UAAU;;;;;;qBA8BlB,YACX,iBAAiB,cACjB,gCACW,aAAa;mBACK;EAA7B,YAA6B,SAAS,mBAAmB,UAAU;;EAG7D,KAAK,SAAS,UAAU,UAAU,mBAAmB;;EA4BrD,UAAU,UAAU,cAAc,YAAY;EAO9C,iBAAiB,aAAa,UAClC,UAAU,cAAc,QACvB,QAAQ,cAAc;EAMnB,UAAU,SAAS,UAAU;;UAQrB;UA4BN;UAIM;;;;;KCpJJ,gBAAgB,iBAAiB,iBAC3C,SAAS,UACT,iBACA,WAAW,kBACR;;KAGO,YAAY,iBAAiB;EAEvC,OAAO,SAAS,WAAW;;;;;;;qBAQhB,cAAc,iBAAiB;mBAQvB;mBACA;mBACA;mBACA;;;;;;;EAJnB,YACmB,KAAK,YAAY,WACjB,aAAa,KAAK,aAAa,yBAC/B,QAAQ,EAAE,QAAQ,WAClB,eAAc,gBAAgB;;EAI3C,aAAa,OAAO,eAAe;UAc3B;;UA2BA;;UAMA;;;;;UAaN;;;;;;;;;qBClGG;WACX;WACA;WACA;;;wBAIc,kBAAkB,SAAS;;KAK/B,aAAa;WAAuB,SAAS;WAAmB;;;wBAG5D,aAAa,iBAAiB,cAC5C,UAAU,cAAc,YACvB,aAAa;;wBAKA,YAAY,OAAO,aAAa;;wBAKhC,cAAc,UAC5B,OAAO,cAAc,aAAa,aACjC;;;;KCjCS;;wBA4CI,mBAAmB,iBAAiB;;qBASvC,uBAAuB;WACzB;WACA,gBAAgB;WAChB,WAAW;EAEpB,YAAY;aAAiB,WAAW;aAAyB;;;;;;;qBAWtD,8BAA8B;WAChC;WACA;WACA;WACA;WACA;EAET,YAAY;aACD;aACA;aACA;;;;;;;qBAeA,mCAAmC;WACrC;WACA;EAET,YAAY;;;;;KCrFF;WACG;WAAwB;;WAExB;WACA,gBAAgB,QAAQ;WACxB;;;;;;;;wBASC,iBAAiB,gBAAgB,kBAAkB;;;;wBCxBnD,cACd,SAAS,KAAK,gCACd,OAAO,KAAK,wBACX"}
1
+ {"version":3,"file":"queue.d.ts","names":[],"sources":["../../src/infrastructure/cloudflare/queue/queue-message.ts","../../src/infrastructure/cloudflare/queue/queue-client.ts","../../src/infrastructure/cloudflare/queue/queue-consumer.ts","../../src/infrastructure/cloudflare/queue/queue-limits.ts","../../src/infrastructure/cloudflare/queue/queue.errors.ts","../../src/infrastructure/cloudflare/queue/redelivery-policy.ts","../../src/infrastructure/cloudflare/queue/wait-until-wake.ts"],"mappings":";;;;;KAGY,eAAe,gBAAgB;;;;KCa/B;WAA8B;;;KAG9B,aAAa,QAAQ,KAAK,MAAM;;;;;KAMhC,mBAAmB,iBAAiB,cAAc;WACnD,QAAQ,SAAS,OAAO,MAAM,aAAa;;WAE3C,YAAY,KAAK;WACjB,SAAS,SAAS,aAAa;;;UAIzB,aAAa,iBAAiB;EAC7C,KAAK,SAAS,UAAU,UAAU,mBAAmB;EACrD,UAAU,UAAU,cAAc,YAAY;;EAE9C,iBAAiB,aAAa,UAC5B,UAAU,cAAc,QACvB,QAAQ,cAAc;;EAEzB,UAAU,SAAS,UAAU;;;;;;qBA8BlB,YACX,iBAAiB,cACjB,gCACW,aAAa;mBACK;EAA7B,YAA6B,SAAS,mBAAmB,UAAU;;EAG7D,KAAK,SAAS,UAAU,UAAU,mBAAmB;;EA4BrD,UAAU,UAAU,cAAc,YAAY;EAO9C,iBAAiB,aAAa,UAClC,UAAU,cAAc,QACvB,QAAQ,cAAc;EAMnB,UAAU,SAAS,UAAU;;UAQrB;UA4BN;UAIM;;;;;KCpJJ,gBAAgB,iBAAiB,iBAC3C,SAAS,UACT,iBACA,WAAW,kBACR;;KAGO,YAAY,iBAAiB;EAEvC,OAAO,SAAS,WAAW;;;;;;;qBAQhB,cAAc,iBAAiB;mBAQvB;mBACA;mBACA;mBACA;;;;;;;EAJnB,YACmB,KAAK,YAAY,WACjB,aAAa,KAAK,aAAa,yBAC/B,QAAQ,EAAE,QAAQ,WAClB,eAAc,gBAAgB;;EAI3C,aAAa,OAAO,eAAe;UAc3B;;UA+CA;;UAMA;;;;;UAaN;;;;;;;;;qBCtHG;WACX;WACA;WACA;;;wBAIc,kBAAkB,SAAS;;KAK/B,aAAa;WAAuB,SAAS;WAAmB;;;wBAG5D,aAAa,iBAAiB,cAC5C,UAAU,cAAc,YACvB,aAAa;;wBAKA,YAAY,OAAO,aAAa;;wBAKhC,cAAc,UAC5B,OAAO,cAAc,aAAa,aACjC;;;;KCjCS;;wBA4CI,mBAAmB,iBAAiB;;qBASvC,uBAAuB;WACzB;WACA,gBAAgB;WAChB,WAAW;EAEpB,YAAY;aAAiB,WAAW;aAAyB;;;;;;;qBAWtD,8BAA8B;WAChC;WACA;WACA;WACA;WACA;EAET,YAAY;aACD;aACA;aACA;;;;;;;qBAeA,mCAAmC;WACrC;WACA;EAET,YAAY;;;;;KCrFF;WACG;WAAwB;;WAExB;WACA,gBAAgB,QAAQ;WACxB;;;;;;;;wBASC,iBAAiB,gBAAgB,kBAAkB;;;;wBCxBnD,cACd,SAAS,KAAK,gCACd,OAAO,KAAK,wBACX"}
@@ -308,7 +308,7 @@ const logger$1 = createLogger("queue-consumer");
308
308
  /**
309
309
  * Parses and dispatches each queue message, then selects acknowledgement or retry.
310
310
  * Cloudflare owns the retry limit and sends exhausted messages to the configured dead letter.
311
- * The consumer logs each failure once; handlers do not.
311
+ * The consumer logs each failure once, inside the dispatch wrapper; handlers do not.
312
312
  */
313
313
  var QueueConsumer = class {
314
314
  bus;
@@ -335,6 +335,8 @@ var QueueConsumer = class {
335
335
  }
336
336
  async processMessage(message) {
337
337
  let parsed = null;
338
+ let handlerFailed = false;
339
+ let settled = false;
338
340
  try {
339
341
  const body = await this.parseMessage(message);
340
342
  if (body === null) {
@@ -347,10 +349,25 @@ var QueueConsumer = class {
347
349
  }
348
350
  parsed = body;
349
351
  await this.wrapDispatch(body, message.attempts, async () => {
350
- await this.bus.handle(body);
352
+ try {
353
+ await this.bus.handle(body);
354
+ } catch (cause) {
355
+ handlerFailed = true;
356
+ this.handleFailure(message, body, cause);
357
+ settled = true;
358
+ throw cause;
359
+ }
351
360
  });
361
+ } catch (cause) {
362
+ if (settled) return;
363
+ if (handlerFailed) throw cause;
364
+ this.handleFailure(message, parsed, cause);
365
+ return;
366
+ }
367
+ if (parsed === null || handlerFailed) return;
368
+ try {
352
369
  message.ack();
353
- logger$1.debug("queue_message_processed", { name: body.name });
370
+ logger$1.debug("queue_message_processed", { name: parsed.name });
354
371
  } catch (cause) {
355
372
  this.handleFailure(message, parsed, cause);
356
373
  }
@@ -1 +1 @@
1
- {"version":3,"file":"queue.js","names":["logger","logger"],"sources":["../../src/infrastructure/cloudflare/queue/queue-limits.ts","../../src/infrastructure/cloudflare/queue/queue.errors.ts","../../src/infrastructure/cloudflare/queue/queue-client.ts","../../src/infrastructure/cloudflare/queue/redelivery-policy.ts","../../src/infrastructure/cloudflare/queue/queue-consumer.ts","../../src/infrastructure/cloudflare/queue/wait-until-wake.ts"],"sourcesContent":["import type { QueueMessage } from './queue-message.ts'\n\n/**\n * The Cloudflare Queues producer limits with metadata headroom, checked before each send so a\n * violation never reaches the binding. The dead letter has the same per-message limit.\n * https://developers.cloudflare.com/queues/platform/limits/\n */\nexport const QUEUE_LIMITS = {\n messageBytes: 127_000,\n batchBytes: 250_000,\n batchCount: 100,\n} as const\n\n/** The size the binding measures: the JSON encoding of the body, in UTF-8 bytes. */\nexport function messageByteLength(message: QueueMessage): number {\n return new TextEncoder().encode(JSON.stringify(message)).length\n}\n\n/** A message with its measured size. */\nexport type SizedMessage<TMessage> = { readonly message: TMessage; readonly bytes: number }\n\n/** Measure every message once. */\nexport function sizeMessages<TMessage extends QueueMessage>(\n messages: ReadonlyArray<TMessage>,\n): SizedMessage<TMessage>[] {\n return messages.map((message) => ({ message, bytes: messageByteLength(message) }))\n}\n\n/** Whether one message is over the per-message limit. */\nexport function isOversized(sized: SizedMessage<QueueMessage>): boolean {\n return sized.bytes > QUEUE_LIMITS.messageBytes\n}\n\n/** Split a batch at the message-count and byte limits, keeping the order. */\nexport function chunkByLimits<TMessage>(\n sized: ReadonlyArray<SizedMessage<TMessage>>,\n): TMessage[][] {\n const chunks: TMessage[][] = []\n let current: TMessage[] = []\n let bytes = 0\n for (const item of sized) {\n const full =\n current.length >= QUEUE_LIMITS.batchCount || bytes + item.bytes > QUEUE_LIMITS.batchBytes\n if (current.length > 0 && full) {\n chunks.push(current)\n current = []\n bytes = 0\n }\n current.push(item.message)\n bytes += item.bytes\n }\n if (current.length > 0) chunks.push(current)\n return chunks\n}\n","import { AppError, classifyBoundaryError, type FailureClassification } from '@/core'\n\n/** The Queue binding calls the client makes. Each is safe to repeat. */\nexport type QueueOperation = 'send' | 'send_batch' | 'send_dlq'\n\nconst QUEUE_OPERATION_MESSAGES = {\n send: 'Failed to send queue message',\n send_batch: 'Failed to send queue batch',\n send_dlq: 'Failed to send DLQ message',\n} satisfies Readonly<Record<QueueOperation, string>>\n\n/**\n * Cloudflare Queues error codes, by whether a repeat could change the answer. The reference\n * marks only 10201 and 10250 for retry; 10251 waits for processing, 15000 is nobody's call.\n * https://developers.cloudflare.com/queues/reference/error-codes/\n */\nconst QUEUE_ERROR_CLASSIFICATIONS = new Map<number, FailureClassification>([\n [10104, 'terminal'], // QueueNotFound\n [10106, 'terminal'], // Unauthorized\n [10107, 'terminal'], // QueueIDMalformed\n [10201, 'transient'], // ClientDisconnected\n [10202, 'terminal'], // BatchDelayInvalid\n [10203, 'terminal'], // MessageMetadataInvalid\n [10204, 'terminal'], // MessageSizeOutOfBounds\n [10205, 'terminal'], // BatchSizeOutOfBounds\n [10206, 'terminal'], // BatchCountOutOfBounds\n [10207, 'terminal'], // JSONRequestBodyInvalid\n [10208, 'terminal'], // JSONRequestBodyMalformed\n [10250, 'transient'], // QueueOverloaded\n [10251, 'transient'], // QueueStorageLimitExceeded\n [10252, 'terminal'], // QueueDisabled\n [10253, 'terminal'], // FreeTierLimitExceeded\n [15000, 'unknown'], // UnknownInternalError\n])\n\nfunction extractQueueErrorCode(cause: unknown): number | undefined {\n if (!(cause instanceof Error)) return undefined\n const match = /(\\d{5})\\)?\\s*$/.exec(cause.message)\n return match?.[1] === undefined ? undefined : Number(match[1])\n}\n\nfunction classifyQueueBoundaryError(cause: unknown): FailureClassification | undefined {\n const code = extractQueueErrorCode(cause)\n return code === undefined ? undefined : QUEUE_ERROR_CLASSIFICATIONS.get(code)\n}\n\n/** Classify a raw Queue binding failure: the code table first, then Cloudflare's signals, then transport. */\nexport function classifyQueueError(cause: unknown): FailureClassification {\n return classifyBoundaryError({\n cause,\n classifyBoundary: classifyQueueBoundaryError,\n includeCloudflare: true,\n })\n}\n\n/** A send through a Queue binding failed. The classification says whether an immediate retry may help. */\nexport class QueueSendError extends AppError {\n readonly _tag = 'QueueSendError'\n readonly classification: FailureClassification\n readonly operation: QueueOperation\n\n constructor(args: { readonly operation: QueueOperation; readonly cause: unknown }) {\n super(QUEUE_OPERATION_MESSAGES[args.operation], { cause: args.cause })\n this.classification = classifyQueueError(args.cause)\n this.operation = args.operation\n }\n}\n\n/**\n * A message over the per-message cap can never be sent on any queue; the dead letter has the\n * same cap. The client detects it before the send, so it never comes from a platform error.\n */\nexport class OversizedMessageError extends AppError {\n readonly _tag = 'OversizedMessageError'\n readonly classification = 'terminal'\n readonly messageName: string\n readonly bytes: number\n readonly limit: number\n\n constructor(args: {\n readonly messageName: string\n readonly bytes: number\n readonly limit: number\n }) {\n super(\n `Queue message \"${args.messageName}\" is ${String(args.bytes)} bytes, over the ${String(args.limit)} limit`,\n )\n this.messageName = args.messageName\n this.bytes = args.bytes\n this.limit = args.limit\n }\n}\n\n/**\n * The dead letter did not accept an unparseable message. Always transient, whatever the send\n * failure was, so the source delivery retries instead of acknowledging and losing the body.\n */\nexport class DeadLetterUnavailableError extends AppError {\n readonly _tag = 'DeadLetterUnavailableError'\n readonly classification = 'transient'\n\n constructor(cause: unknown) {\n super('The dead letter did not accept the message', { cause })\n }\n}\n","import { Result } from 'better-result'\n\nimport { createLogger, shouldRetryFailure } from '@/core'\n\nimport {\n chunkByLimits,\n isOversized,\n messageByteLength,\n QUEUE_LIMITS,\n sizeMessages,\n type SizedMessage,\n} from './queue-limits.ts'\nimport type { QueueMessage } from './queue-message.ts'\nimport { OversizedMessageError, QueueSendError, type QueueOperation } from './queue.errors.ts'\n\n/** Cloudflare's delivery delay, in seconds. */\nexport type QueueSendOptions = { readonly delaySeconds: number }\n\n/** The two calls the client makes on a queue binding. */\nexport type QueueBinding<Body> = Pick<Queue<Body>, 'send' | 'sendBatch'>\n\n/**\n * The lanes and the dead letter of one application. A lane is a queue with its own consumer\n * settings; `laneOf` is the product's routing rule, written once at the composition root.\n */\nexport type QueueClientOptions<TMessage extends QueueMessage, Lane extends string> = {\n readonly queues: Readonly<Record<Lane, QueueBinding<TMessage>>>\n /** Where the consumer parks poison bodies. Cloudflare also routes exhausted retries here. */\n readonly deadLetter: Pick<Queue, 'send'>\n readonly laneOf: (message: TMessage) => Lane\n}\n\n/** The port an entrypoint, a relay, or a consumer sends through. */\nexport interface IQueueClient<TMessage extends QueueMessage> {\n send(message: TMessage, options?: QueueSendOptions): Promise<void>\n sendBatch(messages: ReadonlyArray<TMessage>): Promise<void>\n /** Sends every message within the limits and returns the oversized ones. */\n sendBatchPartial<Sent extends TMessage>(\n messages: ReadonlyArray<Sent>,\n ): Promise<ReadonlyArray<Sent>>\n /** Parks a delivered message's body as it arrived. */\n sendToDlq(message: Message): Promise<void>\n}\n\nconst logger = createLogger('queue-client')\n\n/** Immediate retries of one binding call: three attempts, 250 ms then 500 ms, with jitter. */\nconst RETRY_TIMES = 2\nconst RETRY_BASE_DELAY_MS = 250\n\n/** Every send is safe to repeat, so a transient failure gets an immediate retry; terminal and unknown stop. */\nfunction shouldRetrySend(error: QueueSendError): boolean {\n return shouldRetryFailure({\n classification: error.classification,\n repeatSafe: true,\n owner: 'immediate',\n })\n}\n\nfunction oversizedError(sized: SizedMessage<QueueMessage>): OversizedMessageError {\n return new OversizedMessageError({\n messageName: sized.message.name,\n bytes: sized.bytes,\n limit: QUEUE_LIMITS.messageBytes,\n })\n}\n\n/**\n * Sends messages to the application's queues. Checks the size limits before a send, retries a\n * transient binding failure a bounded number of times, and converts the final failure to `QueueSendError`.\n */\nexport class QueueClient<\n TMessage extends QueueMessage,\n Lane extends string,\n> implements IQueueClient<TMessage> {\n constructor(private readonly options: QueueClientOptions<TMessage, Lane>) {}\n\n /** @throws OversizedMessageError before the send. @throws QueueSendError after the retries. */\n async send(message: TMessage, options?: QueueSendOptions): Promise<void> {\n const sized = { message, bytes: messageByteLength(message) }\n if (isOversized(sized)) {\n logger.warn('queue_message_oversized', {\n name: message.name,\n type: message.type,\n bytes: sized.bytes,\n })\n throw oversizedError(sized)\n }\n\n const queue = this.queueFor(message)\n logger.debug('sending_message', {\n name: message.name,\n type: message.type,\n delaySeconds: options?.delaySeconds,\n })\n\n await this.callQueue('send', async () => {\n if (options) {\n await queue.send(message, options)\n } else {\n await queue.send(message)\n }\n })\n }\n\n /** Rejects the whole batch before any send when one message is oversized. */\n async sendBatch(messages: ReadonlyArray<TMessage>): Promise<void> {\n const sized = sizeMessages(messages)\n const oversized = sized.find(isOversized)\n if (oversized) throw oversizedError(oversized)\n await this.sendSizedBatch(sized)\n }\n\n async sendBatchPartial<Sent extends TMessage>(\n messages: ReadonlyArray<Sent>,\n ): Promise<ReadonlyArray<Sent>> {\n const sized = sizeMessages(messages)\n await this.sendSizedBatch(sized.filter((item) => !isOversized(item)))\n return sized.filter(isOversized).map((item) => item.message)\n }\n\n async sendToDlq(message: Message): Promise<void> {\n logger.debug('sending_to_dlq', { queueMessageId: message.id })\n await this.callQueue('send_dlq', async () => {\n await this.options.deadLetter.send(message.body)\n })\n }\n\n /** Each lane's chunks retry on their own, so one failed chunk does not resend the others. */\n private async sendSizedBatch(sized: ReadonlyArray<SizedMessage<TMessage>>): Promise<void> {\n const byLane = new Map<Lane, SizedMessage<TMessage>[]>()\n for (const item of sized) {\n const lane = this.options.laneOf(item.message)\n const items = byLane.get(lane) ?? []\n items.push(item)\n byLane.set(lane, items)\n }\n\n logger.debug('sending_batch', {\n count: sized.length,\n lanes: [...byLane].map(([lane, items]) => ({ lane, count: items.length })),\n })\n\n const sends: Promise<void>[] = []\n for (const [lane, items] of byLane) {\n const queue = this.options.queues[lane]\n for (const group of chunkByLimits(items)) {\n sends.push(\n this.callQueue('send_batch', async () => {\n await queue.sendBatch(group.map((body) => ({ body })))\n }),\n )\n }\n }\n await Promise.all(sends)\n }\n\n private queueFor(message: TMessage): QueueBinding<TMessage> {\n return this.options.queues[this.options.laneOf(message)]\n }\n\n private async callQueue<T>(operation: QueueOperation, call: () => Promise<T>): Promise<T> {\n const result = await Result.tryPromise(\n {\n try: call,\n catch: (cause) => new QueueSendError({ operation, cause }),\n },\n {\n retry: {\n times: RETRY_TIMES,\n delayMs: RETRY_BASE_DELAY_MS,\n backoff: 'exponential',\n jitter: true,\n shouldRetry: shouldRetrySend,\n },\n },\n )\n if (result.isErr()) throw result.error\n return result.value\n }\n}\n","import {\n classifyFailure,\n exponentialEqualJitterMs,\n retryAfterSecondsOf,\n shouldRetryFailure,\n spreadJitterMs,\n type FailureClassification,\n} from '@/core'\n\nconst BASE_RETRY_DELAY_MS = 5_000\nconst MAX_RETRY_DELAY_MS = 120_000\nconst RETRY_AFTER_SPREAD_MS = 5_000\n/** Cloudflare Queues accept a redelivery delay of at most one day. */\nconst MAX_QUEUE_DELAY_SECONDS = 24 * 60 * 60\n\n/** How the consumer settles one failed message: acknowledge it, or have Cloudflare redeliver it after a delay. */\nexport type RedeliveryDecision =\n | { readonly action: 'ack'; readonly classification: 'terminal' }\n | {\n readonly action: 'retry'\n readonly classification: Exclude<FailureClassification, 'terminal'>\n readonly delaySeconds: number\n }\n\n/**\n * Decide whether the consumer acknowledges a failed message or asks Cloudflare to redeliver it.\n * A terminal failure acknowledges. Transient and unknown failures redeliver: after the advertised\n * wait plus a spread when the error carries one, else with exponential equal-jitter backoff.\n * An `AggregateError` from event delivery redelivers when any member does, after the longest wait.\n */\nexport function decideRedelivery(cause: unknown, attempt: number): RedeliveryDecision {\n if (cause instanceof AggregateError && cause.errors.length > 0) {\n const decisions = cause.errors.map((member) => decideRedelivery(member, attempt))\n const retries = decisions.filter((decision) => decision.action === 'retry')\n if (retries.length === 0) return { action: 'ack', classification: 'terminal' }\n\n // Redelivery must respect every failed subscriber's required wait.\n return {\n action: 'retry',\n classification: retries.some((decision) => decision.classification === 'unknown')\n ? 'unknown'\n : 'transient',\n delaySeconds: Math.max(...retries.map((decision) => decision.delaySeconds)),\n }\n }\n\n const classification = classifyFailure(cause)\n if (classification === 'terminal') return { action: 'ack', classification }\n\n const canRetry = shouldRetryFailure({ classification, repeatSafe: true, owner: 'durable' })\n if (!canRetry) return { action: 'ack', classification: 'terminal' }\n\n const retryAfterSeconds = retryAfterSecondsOf(cause)\n const delayMs =\n retryAfterSeconds === null\n ? exponentialEqualJitterMs(BASE_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS, attempt)\n : retryAfterSeconds * 1_000 + spreadJitterMs(RETRY_AFTER_SPREAD_MS)\n\n return {\n action: 'retry',\n classification,\n delaySeconds: Math.min(MAX_QUEUE_DELAY_SECONDS, Math.max(1, Math.ceil(delayMs / 1_000))),\n }\n}\n","import type { z } from 'zod'\n\nimport { MessageAlreadyProcessedError } from '@/application'\nimport { createLogger } from '@/core'\n\nimport type { IQueueClient } from './queue-client.ts'\nimport type { QueueMessage } from './queue-message.ts'\nimport { DeadLetterUnavailableError } from './queue.errors.ts'\nimport { decideRedelivery } from './redelivery-policy.ts'\n\nconst logger = createLogger('queue-consumer')\n\n/** Wraps one dispatch: an error-tracking scope, an analytics context. The default runs it as is. */\nexport type DispatchWrapper<TMessage extends QueueMessage> = (\n message: TMessage,\n attempt: number,\n run: () => Promise<void>,\n) => Promise<void>\n\n/** The one call the consumer makes on the bus. */\nexport type DispatchBus<TMessage extends QueueMessage> = {\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- the bus resolves to each handler's own value; the consumer discards it.\n handle(message: TMessage): Promise<unknown>\n}\n\n/**\n * Parses and dispatches each queue message, then selects acknowledgement or retry.\n * Cloudflare owns the retry limit and sends exhausted messages to the configured dead letter.\n * The consumer logs each failure once; handlers do not.\n */\nexport class QueueConsumer<TMessage extends QueueMessage> {\n /**\n * @param bus - Where a parsed message goes.\n * @param queueClient - Parks an unparseable body in the dead letter.\n * @param schema - What a body may be: the application's `messages.queueSchema`.\n * @param wrapDispatch - Wraps one dispatch in the application's context. Default: run it as is.\n */\n constructor(\n private readonly bus: DispatchBus<TMessage>,\n private readonly queueClient: Pick<IQueueClient<TMessage>, 'sendToDlq'>,\n private readonly schema: z.ZodType<TMessage>,\n private readonly wrapDispatch: DispatchWrapper<TMessage> = (_message, _attempt, run) => run(),\n ) {}\n\n /** Every message settles on its own. A settlement that throws rejects the batch after its siblings settle. */\n async processBatch(batch: MessageBatch): Promise<void> {\n logger.debug('queue_batch_received', { count: batch.messages.length })\n const results = await Promise.allSettled(\n batch.messages.map((message) => this.processMessage(message)),\n )\n const rejected = results\n .filter((result) => result.status === 'rejected')\n .map((result) => result.reason)\n\n if (rejected.length > 0) {\n throw new AggregateError(rejected, 'Queue message settlement failed')\n }\n }\n\n private async processMessage(message: Message): Promise<void> {\n let parsed: TMessage | null = null\n\n try {\n const body = await this.parseMessage(message)\n if (body === null) {\n // An unparseable body cannot succeed on retry, so park it for inspection.\n logger.error('invalid_message_format', {\n attempt: message.attempts,\n queueMessageId: message.id,\n })\n await this.parkPoison(message)\n return\n }\n parsed = body\n\n await this.wrapDispatch(body, message.attempts, async () => {\n await this.bus.handle(body)\n })\n message.ack()\n logger.debug('queue_message_processed', { name: body.name })\n } catch (cause) {\n this.handleFailure(message, parsed, cause)\n }\n }\n\n /** Returns null on failure; the caller owns the single log point. */\n private async parseMessage(message: Message): Promise<TMessage | null> {\n const result = await this.schema.safeParseAsync(message.body)\n return result.success ? result.data : null\n }\n\n /** Park an unparseable message in the dead letter, then acknowledge its source delivery. */\n private async parkPoison(message: Message): Promise<void> {\n try {\n await this.queueClient.sendToDlq(message)\n message.ack()\n } catch (cause) {\n throw new DeadLetterUnavailableError(cause)\n }\n }\n\n /**\n * Retry declared transient and unknown failures so Cloudflare can enforce the attempt ceiling.\n * Acknowledge only declared terminal failures.\n */\n private handleFailure(message: Message, parsed: TMessage | null, cause: unknown): void {\n if (cause instanceof MessageAlreadyProcessedError) {\n logger.debug('duplicate_skipped', {\n name: parsed?.name,\n messageId: cause.messageId,\n attempt: message.attempts,\n })\n message.ack()\n return\n }\n\n const decision = decideRedelivery(cause, message.attempts)\n if (decision.action === 'ack') {\n logger.error('message_discarded', {\n error: cause,\n name: parsed?.name,\n messageId: parsed?.id,\n queueMessageId: message.id,\n attempt: message.attempts,\n })\n message.ack()\n return\n }\n\n const context = {\n name: parsed?.name,\n messageId: parsed?.id,\n queueMessageId: message.id,\n attempt: message.attempts,\n classification: decision.classification,\n delaySeconds: decision.delaySeconds,\n }\n if (decision.classification === 'unknown' && message.attempts === 1) {\n logger.error('queue_unknown_failure', { error: cause, ...context })\n } else {\n logger.debug('redelivery_requested', context)\n }\n message.retry({ delaySeconds: decision.delaySeconds })\n }\n}\n","import type { IOutboxRelay, OutboxRelay } from '@/application'\nimport { createLogger } from '@/core'\n\nconst logger = createLogger('outbox-wake')\n\n/** Register delivery after commit; log a failed drain once and leave recovery to the scheduled sweep. */\nexport function waitUntilWake(\n context: Pick<ExecutionContext, 'waitUntil'>,\n relay: Pick<OutboxRelay, 'drain'>,\n): IOutboxRelay {\n return {\n wake: () =>\n context.waitUntil(\n relay.drain().catch((cause: unknown) => {\n logger.error('outbox_wake_failed', { error: cause })\n }),\n ),\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"queue.js","names":["logger","logger"],"sources":["../../src/infrastructure/cloudflare/queue/queue-limits.ts","../../src/infrastructure/cloudflare/queue/queue.errors.ts","../../src/infrastructure/cloudflare/queue/queue-client.ts","../../src/infrastructure/cloudflare/queue/redelivery-policy.ts","../../src/infrastructure/cloudflare/queue/queue-consumer.ts","../../src/infrastructure/cloudflare/queue/wait-until-wake.ts"],"sourcesContent":["import type { QueueMessage } from './queue-message.ts'\n\n/**\n * The Cloudflare Queues producer limits with metadata headroom, checked before each send so a\n * violation never reaches the binding. The dead letter has the same per-message limit.\n * https://developers.cloudflare.com/queues/platform/limits/\n */\nexport const QUEUE_LIMITS = {\n messageBytes: 127_000,\n batchBytes: 250_000,\n batchCount: 100,\n} as const\n\n/** The size the binding measures: the JSON encoding of the body, in UTF-8 bytes. */\nexport function messageByteLength(message: QueueMessage): number {\n return new TextEncoder().encode(JSON.stringify(message)).length\n}\n\n/** A message with its measured size. */\nexport type SizedMessage<TMessage> = { readonly message: TMessage; readonly bytes: number }\n\n/** Measure every message once. */\nexport function sizeMessages<TMessage extends QueueMessage>(\n messages: ReadonlyArray<TMessage>,\n): SizedMessage<TMessage>[] {\n return messages.map((message) => ({ message, bytes: messageByteLength(message) }))\n}\n\n/** Whether one message is over the per-message limit. */\nexport function isOversized(sized: SizedMessage<QueueMessage>): boolean {\n return sized.bytes > QUEUE_LIMITS.messageBytes\n}\n\n/** Split a batch at the message-count and byte limits, keeping the order. */\nexport function chunkByLimits<TMessage>(\n sized: ReadonlyArray<SizedMessage<TMessage>>,\n): TMessage[][] {\n const chunks: TMessage[][] = []\n let current: TMessage[] = []\n let bytes = 0\n for (const item of sized) {\n const full =\n current.length >= QUEUE_LIMITS.batchCount || bytes + item.bytes > QUEUE_LIMITS.batchBytes\n if (current.length > 0 && full) {\n chunks.push(current)\n current = []\n bytes = 0\n }\n current.push(item.message)\n bytes += item.bytes\n }\n if (current.length > 0) chunks.push(current)\n return chunks\n}\n","import { AppError, classifyBoundaryError, type FailureClassification } from '@/core'\n\n/** The Queue binding calls the client makes. Each is safe to repeat. */\nexport type QueueOperation = 'send' | 'send_batch' | 'send_dlq'\n\nconst QUEUE_OPERATION_MESSAGES = {\n send: 'Failed to send queue message',\n send_batch: 'Failed to send queue batch',\n send_dlq: 'Failed to send DLQ message',\n} satisfies Readonly<Record<QueueOperation, string>>\n\n/**\n * Cloudflare Queues error codes, by whether a repeat could change the answer. The reference\n * marks only 10201 and 10250 for retry; 10251 waits for processing, 15000 is nobody's call.\n * https://developers.cloudflare.com/queues/reference/error-codes/\n */\nconst QUEUE_ERROR_CLASSIFICATIONS = new Map<number, FailureClassification>([\n [10104, 'terminal'], // QueueNotFound\n [10106, 'terminal'], // Unauthorized\n [10107, 'terminal'], // QueueIDMalformed\n [10201, 'transient'], // ClientDisconnected\n [10202, 'terminal'], // BatchDelayInvalid\n [10203, 'terminal'], // MessageMetadataInvalid\n [10204, 'terminal'], // MessageSizeOutOfBounds\n [10205, 'terminal'], // BatchSizeOutOfBounds\n [10206, 'terminal'], // BatchCountOutOfBounds\n [10207, 'terminal'], // JSONRequestBodyInvalid\n [10208, 'terminal'], // JSONRequestBodyMalformed\n [10250, 'transient'], // QueueOverloaded\n [10251, 'transient'], // QueueStorageLimitExceeded\n [10252, 'terminal'], // QueueDisabled\n [10253, 'terminal'], // FreeTierLimitExceeded\n [15000, 'unknown'], // UnknownInternalError\n])\n\nfunction extractQueueErrorCode(cause: unknown): number | undefined {\n if (!(cause instanceof Error)) return undefined\n const match = /(\\d{5})\\)?\\s*$/.exec(cause.message)\n return match?.[1] === undefined ? undefined : Number(match[1])\n}\n\nfunction classifyQueueBoundaryError(cause: unknown): FailureClassification | undefined {\n const code = extractQueueErrorCode(cause)\n return code === undefined ? undefined : QUEUE_ERROR_CLASSIFICATIONS.get(code)\n}\n\n/** Classify a raw Queue binding failure: the code table first, then Cloudflare's signals, then transport. */\nexport function classifyQueueError(cause: unknown): FailureClassification {\n return classifyBoundaryError({\n cause,\n classifyBoundary: classifyQueueBoundaryError,\n includeCloudflare: true,\n })\n}\n\n/** A send through a Queue binding failed. The classification says whether an immediate retry may help. */\nexport class QueueSendError extends AppError {\n readonly _tag = 'QueueSendError'\n readonly classification: FailureClassification\n readonly operation: QueueOperation\n\n constructor(args: { readonly operation: QueueOperation; readonly cause: unknown }) {\n super(QUEUE_OPERATION_MESSAGES[args.operation], { cause: args.cause })\n this.classification = classifyQueueError(args.cause)\n this.operation = args.operation\n }\n}\n\n/**\n * A message over the per-message cap can never be sent on any queue; the dead letter has the\n * same cap. The client detects it before the send, so it never comes from a platform error.\n */\nexport class OversizedMessageError extends AppError {\n readonly _tag = 'OversizedMessageError'\n readonly classification = 'terminal'\n readonly messageName: string\n readonly bytes: number\n readonly limit: number\n\n constructor(args: {\n readonly messageName: string\n readonly bytes: number\n readonly limit: number\n }) {\n super(\n `Queue message \"${args.messageName}\" is ${String(args.bytes)} bytes, over the ${String(args.limit)} limit`,\n )\n this.messageName = args.messageName\n this.bytes = args.bytes\n this.limit = args.limit\n }\n}\n\n/**\n * The dead letter did not accept an unparseable message. Always transient, whatever the send\n * failure was, so the source delivery retries instead of acknowledging and losing the body.\n */\nexport class DeadLetterUnavailableError extends AppError {\n readonly _tag = 'DeadLetterUnavailableError'\n readonly classification = 'transient'\n\n constructor(cause: unknown) {\n super('The dead letter did not accept the message', { cause })\n }\n}\n","import { Result } from 'better-result'\n\nimport { createLogger, shouldRetryFailure } from '@/core'\n\nimport {\n chunkByLimits,\n isOversized,\n messageByteLength,\n QUEUE_LIMITS,\n sizeMessages,\n type SizedMessage,\n} from './queue-limits.ts'\nimport type { QueueMessage } from './queue-message.ts'\nimport { OversizedMessageError, QueueSendError, type QueueOperation } from './queue.errors.ts'\n\n/** Cloudflare's delivery delay, in seconds. */\nexport type QueueSendOptions = { readonly delaySeconds: number }\n\n/** The two calls the client makes on a queue binding. */\nexport type QueueBinding<Body> = Pick<Queue<Body>, 'send' | 'sendBatch'>\n\n/**\n * The lanes and the dead letter of one application. A lane is a queue with its own consumer\n * settings; `laneOf` is the product's routing rule, written once at the composition root.\n */\nexport type QueueClientOptions<TMessage extends QueueMessage, Lane extends string> = {\n readonly queues: Readonly<Record<Lane, QueueBinding<TMessage>>>\n /** Where the consumer parks poison bodies. Cloudflare also routes exhausted retries here. */\n readonly deadLetter: Pick<Queue, 'send'>\n readonly laneOf: (message: TMessage) => Lane\n}\n\n/** The port an entrypoint, a relay, or a consumer sends through. */\nexport interface IQueueClient<TMessage extends QueueMessage> {\n send(message: TMessage, options?: QueueSendOptions): Promise<void>\n sendBatch(messages: ReadonlyArray<TMessage>): Promise<void>\n /** Sends every message within the limits and returns the oversized ones. */\n sendBatchPartial<Sent extends TMessage>(\n messages: ReadonlyArray<Sent>,\n ): Promise<ReadonlyArray<Sent>>\n /** Parks a delivered message's body as it arrived. */\n sendToDlq(message: Message): Promise<void>\n}\n\nconst logger = createLogger('queue-client')\n\n/** Immediate retries of one binding call: three attempts, 250 ms then 500 ms, with jitter. */\nconst RETRY_TIMES = 2\nconst RETRY_BASE_DELAY_MS = 250\n\n/** Every send is safe to repeat, so a transient failure gets an immediate retry; terminal and unknown stop. */\nfunction shouldRetrySend(error: QueueSendError): boolean {\n return shouldRetryFailure({\n classification: error.classification,\n repeatSafe: true,\n owner: 'immediate',\n })\n}\n\nfunction oversizedError(sized: SizedMessage<QueueMessage>): OversizedMessageError {\n return new OversizedMessageError({\n messageName: sized.message.name,\n bytes: sized.bytes,\n limit: QUEUE_LIMITS.messageBytes,\n })\n}\n\n/**\n * Sends messages to the application's queues. Checks the size limits before a send, retries a\n * transient binding failure a bounded number of times, and converts the final failure to `QueueSendError`.\n */\nexport class QueueClient<\n TMessage extends QueueMessage,\n Lane extends string,\n> implements IQueueClient<TMessage> {\n constructor(private readonly options: QueueClientOptions<TMessage, Lane>) {}\n\n /** @throws OversizedMessageError before the send. @throws QueueSendError after the retries. */\n async send(message: TMessage, options?: QueueSendOptions): Promise<void> {\n const sized = { message, bytes: messageByteLength(message) }\n if (isOversized(sized)) {\n logger.warn('queue_message_oversized', {\n name: message.name,\n type: message.type,\n bytes: sized.bytes,\n })\n throw oversizedError(sized)\n }\n\n const queue = this.queueFor(message)\n logger.debug('sending_message', {\n name: message.name,\n type: message.type,\n delaySeconds: options?.delaySeconds,\n })\n\n await this.callQueue('send', async () => {\n if (options) {\n await queue.send(message, options)\n } else {\n await queue.send(message)\n }\n })\n }\n\n /** Rejects the whole batch before any send when one message is oversized. */\n async sendBatch(messages: ReadonlyArray<TMessage>): Promise<void> {\n const sized = sizeMessages(messages)\n const oversized = sized.find(isOversized)\n if (oversized) throw oversizedError(oversized)\n await this.sendSizedBatch(sized)\n }\n\n async sendBatchPartial<Sent extends TMessage>(\n messages: ReadonlyArray<Sent>,\n ): Promise<ReadonlyArray<Sent>> {\n const sized = sizeMessages(messages)\n await this.sendSizedBatch(sized.filter((item) => !isOversized(item)))\n return sized.filter(isOversized).map((item) => item.message)\n }\n\n async sendToDlq(message: Message): Promise<void> {\n logger.debug('sending_to_dlq', { queueMessageId: message.id })\n await this.callQueue('send_dlq', async () => {\n await this.options.deadLetter.send(message.body)\n })\n }\n\n /** Each lane's chunks retry on their own, so one failed chunk does not resend the others. */\n private async sendSizedBatch(sized: ReadonlyArray<SizedMessage<TMessage>>): Promise<void> {\n const byLane = new Map<Lane, SizedMessage<TMessage>[]>()\n for (const item of sized) {\n const lane = this.options.laneOf(item.message)\n const items = byLane.get(lane) ?? []\n items.push(item)\n byLane.set(lane, items)\n }\n\n logger.debug('sending_batch', {\n count: sized.length,\n lanes: [...byLane].map(([lane, items]) => ({ lane, count: items.length })),\n })\n\n const sends: Promise<void>[] = []\n for (const [lane, items] of byLane) {\n const queue = this.options.queues[lane]\n for (const group of chunkByLimits(items)) {\n sends.push(\n this.callQueue('send_batch', async () => {\n await queue.sendBatch(group.map((body) => ({ body })))\n }),\n )\n }\n }\n await Promise.all(sends)\n }\n\n private queueFor(message: TMessage): QueueBinding<TMessage> {\n return this.options.queues[this.options.laneOf(message)]\n }\n\n private async callQueue<T>(operation: QueueOperation, call: () => Promise<T>): Promise<T> {\n const result = await Result.tryPromise(\n {\n try: call,\n catch: (cause) => new QueueSendError({ operation, cause }),\n },\n {\n retry: {\n times: RETRY_TIMES,\n delayMs: RETRY_BASE_DELAY_MS,\n backoff: 'exponential',\n jitter: true,\n shouldRetry: shouldRetrySend,\n },\n },\n )\n if (result.isErr()) throw result.error\n return result.value\n }\n}\n","import {\n classifyFailure,\n exponentialEqualJitterMs,\n retryAfterSecondsOf,\n shouldRetryFailure,\n spreadJitterMs,\n type FailureClassification,\n} from '@/core'\n\nconst BASE_RETRY_DELAY_MS = 5_000\nconst MAX_RETRY_DELAY_MS = 120_000\nconst RETRY_AFTER_SPREAD_MS = 5_000\n/** Cloudflare Queues accept a redelivery delay of at most one day. */\nconst MAX_QUEUE_DELAY_SECONDS = 24 * 60 * 60\n\n/** How the consumer settles one failed message: acknowledge it, or have Cloudflare redeliver it after a delay. */\nexport type RedeliveryDecision =\n | { readonly action: 'ack'; readonly classification: 'terminal' }\n | {\n readonly action: 'retry'\n readonly classification: Exclude<FailureClassification, 'terminal'>\n readonly delaySeconds: number\n }\n\n/**\n * Decide whether the consumer acknowledges a failed message or asks Cloudflare to redeliver it.\n * A terminal failure acknowledges. Transient and unknown failures redeliver: after the advertised\n * wait plus a spread when the error carries one, else with exponential equal-jitter backoff.\n * An `AggregateError` from event delivery redelivers when any member does, after the longest wait.\n */\nexport function decideRedelivery(cause: unknown, attempt: number): RedeliveryDecision {\n if (cause instanceof AggregateError && cause.errors.length > 0) {\n const decisions = cause.errors.map((member) => decideRedelivery(member, attempt))\n const retries = decisions.filter((decision) => decision.action === 'retry')\n if (retries.length === 0) return { action: 'ack', classification: 'terminal' }\n\n // Redelivery must respect every failed subscriber's required wait.\n return {\n action: 'retry',\n classification: retries.some((decision) => decision.classification === 'unknown')\n ? 'unknown'\n : 'transient',\n delaySeconds: Math.max(...retries.map((decision) => decision.delaySeconds)),\n }\n }\n\n const classification = classifyFailure(cause)\n if (classification === 'terminal') return { action: 'ack', classification }\n\n const canRetry = shouldRetryFailure({ classification, repeatSafe: true, owner: 'durable' })\n if (!canRetry) return { action: 'ack', classification: 'terminal' }\n\n const retryAfterSeconds = retryAfterSecondsOf(cause)\n const delayMs =\n retryAfterSeconds === null\n ? exponentialEqualJitterMs(BASE_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS, attempt)\n : retryAfterSeconds * 1_000 + spreadJitterMs(RETRY_AFTER_SPREAD_MS)\n\n return {\n action: 'retry',\n classification,\n delaySeconds: Math.min(MAX_QUEUE_DELAY_SECONDS, Math.max(1, Math.ceil(delayMs / 1_000))),\n }\n}\n","import type { z } from 'zod'\n\nimport { MessageAlreadyProcessedError } from '@/application'\nimport { createLogger } from '@/core'\n\nimport type { IQueueClient } from './queue-client.ts'\nimport type { QueueMessage } from './queue-message.ts'\nimport { DeadLetterUnavailableError } from './queue.errors.ts'\nimport { decideRedelivery } from './redelivery-policy.ts'\n\nconst logger = createLogger('queue-consumer')\n\n/** Wraps one dispatch: an error-tracking scope, an analytics context. The default runs it as is. */\nexport type DispatchWrapper<TMessage extends QueueMessage> = (\n message: TMessage,\n attempt: number,\n run: () => Promise<void>,\n) => Promise<void>\n\n/** The one call the consumer makes on the bus. */\nexport type DispatchBus<TMessage extends QueueMessage> = {\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- the bus resolves to each handler's own value; the consumer discards it.\n handle(message: TMessage): Promise<unknown>\n}\n\n/**\n * Parses and dispatches each queue message, then selects acknowledgement or retry.\n * Cloudflare owns the retry limit and sends exhausted messages to the configured dead letter.\n * The consumer logs each failure once, inside the dispatch wrapper; handlers do not.\n */\nexport class QueueConsumer<TMessage extends QueueMessage> {\n /**\n * @param bus - Where a parsed message goes.\n * @param queueClient - Parks an unparseable body in the dead letter.\n * @param schema - What a body may be: the application's `messages.queueSchema`.\n * @param wrapDispatch - Wraps one dispatch in the application's context. Default: run it as is.\n */\n constructor(\n private readonly bus: DispatchBus<TMessage>,\n private readonly queueClient: Pick<IQueueClient<TMessage>, 'sendToDlq'>,\n private readonly schema: z.ZodType<TMessage>,\n private readonly wrapDispatch: DispatchWrapper<TMessage> = (_message, _attempt, run) => run(),\n ) {}\n\n /** Every message settles on its own. A settlement that throws rejects the batch after its siblings settle. */\n async processBatch(batch: MessageBatch): Promise<void> {\n logger.debug('queue_batch_received', { count: batch.messages.length })\n const results = await Promise.allSettled(\n batch.messages.map((message) => this.processMessage(message)),\n )\n const rejected = results\n .filter((result) => result.status === 'rejected')\n .map((result) => result.reason)\n\n if (rejected.length > 0) {\n throw new AggregateError(rejected, 'Queue message settlement failed')\n }\n }\n\n private async processMessage(message: Message): Promise<void> {\n let parsed: TMessage | null = null\n let handlerFailed = false\n let settled = false\n\n try {\n const body = await this.parseMessage(message)\n if (body === null) {\n // An unparseable body cannot succeed on retry, so park it for inspection.\n logger.error('invalid_message_format', {\n attempt: message.attempts,\n queueMessageId: message.id,\n })\n await this.parkPoison(message)\n return\n }\n parsed = body\n\n await this.wrapDispatch(body, message.attempts, async () => {\n try {\n await this.bus.handle(body)\n } catch (cause) {\n handlerFailed = true\n this.handleFailure(message, body, cause)\n settled = true\n // The wrapper still sees the rejection, and this method does not settle it again.\n throw cause\n }\n })\n } catch (cause) {\n if (settled) return\n // A settlement inside run threw: reject the batch, as a settlement failure does elsewhere.\n if (handlerFailed) throw cause\n this.handleFailure(message, parsed, cause)\n return\n }\n // A wrapper can resolve after run rejects; that message is already settled.\n if (parsed === null || handlerFailed) return\n try {\n message.ack()\n logger.debug('queue_message_processed', { name: parsed.name })\n } catch (cause) {\n this.handleFailure(message, parsed, cause)\n }\n }\n\n /** Returns null on failure; the caller owns the single log point. */\n private async parseMessage(message: Message): Promise<TMessage | null> {\n const result = await this.schema.safeParseAsync(message.body)\n return result.success ? result.data : null\n }\n\n /** Park an unparseable message in the dead letter, then acknowledge its source delivery. */\n private async parkPoison(message: Message): Promise<void> {\n try {\n await this.queueClient.sendToDlq(message)\n message.ack()\n } catch (cause) {\n throw new DeadLetterUnavailableError(cause)\n }\n }\n\n /**\n * Retry declared transient and unknown failures so Cloudflare can enforce the attempt ceiling.\n * Acknowledge only declared terminal failures.\n */\n private handleFailure(message: Message, parsed: TMessage | null, cause: unknown): void {\n if (cause instanceof MessageAlreadyProcessedError) {\n logger.debug('duplicate_skipped', {\n name: parsed?.name,\n messageId: cause.messageId,\n attempt: message.attempts,\n })\n message.ack()\n return\n }\n\n const decision = decideRedelivery(cause, message.attempts)\n if (decision.action === 'ack') {\n logger.error('message_discarded', {\n error: cause,\n name: parsed?.name,\n messageId: parsed?.id,\n queueMessageId: message.id,\n attempt: message.attempts,\n })\n message.ack()\n return\n }\n\n const context = {\n name: parsed?.name,\n messageId: parsed?.id,\n queueMessageId: message.id,\n attempt: message.attempts,\n classification: decision.classification,\n delaySeconds: decision.delaySeconds,\n }\n if (decision.classification === 'unknown' && message.attempts === 1) {\n logger.error('queue_unknown_failure', { error: cause, ...context })\n } else {\n logger.debug('redelivery_requested', context)\n }\n message.retry({ delaySeconds: decision.delaySeconds })\n }\n}\n","import type { IOutboxRelay, OutboxRelay } from '@/application'\nimport { createLogger } from '@/core'\n\nconst logger = createLogger('outbox-wake')\n\n/** Register delivery after commit; log a failed drain once and leave recovery to the scheduled sweep. */\nexport function waitUntilWake(\n context: Pick<ExecutionContext, 'waitUntil'>,\n relay: Pick<OutboxRelay, 'drain'>,\n): IOutboxRelay {\n return {\n wake: () =>\n context.waitUntil(\n relay.drain().catch((cause: unknown) => {\n logger.error('outbox_wake_failed', { error: cause })\n }),\n ),\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ //#region src/infrastructure/cloudflare/tracing.d.ts
2
+ /**
3
+ * Workers tracing for Workers Issues.
4
+ *
5
+ * Ported from Suparelay `apps/web/src/server/infrastructure/tracing/span.ts`.
6
+ * Pass `tracing` in. This file does not import `cloudflare:workers`.
7
+ * A client bundle can import a server module that uses these helpers.
8
+ *
9
+ * Issues shows the active span's attributes beside a failure. Put identifiers on the span.
10
+ * Outside an invocation there is no active span, and a missing span records nothing.
11
+ */
12
+ /** Attribute values a Workers span accepts. `undefined` leaves that attribute unset. */
13
+ export type SpanAttributes = Record<string, string | number | boolean | undefined>;
14
+ /** Add attributes to the active span. That span is the invocation root unless `withSpan` encloses the call. */
15
+ export declare function annotateSpan(tracing: Tracing, attributes: SpanAttributes): void;
16
+ /**
17
+ * Run `operation` in a child span.
18
+ * Use it when one invocation runs several units of work, such as each message in a queue batch.
19
+ * This helper does not record an exception. A failure reaches Issues as the uncaught error or `logger.error`.
20
+ * An expected throw, such as a duplicate message, is not an exception record.
21
+ */
22
+ export declare function withSpan<T>(tracing: Tracing, name: string, attributes: SpanAttributes, operation: () => Promise<T>): Promise<T>;
23
+ //#endregion
24
+ //# sourceMappingURL=tracing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tracing.d.ts","names":[],"sources":["../../src/infrastructure/cloudflare/tracing.ts"],"mappings":";;;;;;;;;;;;YAYY,iBAAiB;;wBAGb,aAAa,SAAS,SAAS,YAAY;;;;;;;wBAU3C,SAAS,GACvB,SAAS,SACT,cACA,YAAY,gBACZ,iBAAiB,QAAQ,KACxB,QAAQ"}
@@ -0,0 +1,21 @@
1
+ //#region src/infrastructure/cloudflare/tracing.ts
2
+ /** Add attributes to the active span. That span is the invocation root unless `withSpan` encloses the call. */
3
+ function annotateSpan(tracing, attributes) {
4
+ tracing.getActiveSpan()?.setAttributes(attributes);
5
+ }
6
+ /**
7
+ * Run `operation` in a child span.
8
+ * Use it when one invocation runs several units of work, such as each message in a queue batch.
9
+ * This helper does not record an exception. A failure reaches Issues as the uncaught error or `logger.error`.
10
+ * An expected throw, such as a duplicate message, is not an exception record.
11
+ */
12
+ function withSpan(tracing, name, attributes, operation) {
13
+ return tracing.enterSpan(name, (span) => {
14
+ span.setAttributes(attributes);
15
+ return operation();
16
+ });
17
+ }
18
+
19
+ //#endregion
20
+ export { annotateSpan, withSpan };
21
+ //# sourceMappingURL=tracing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tracing.js","names":[],"sources":["../../src/infrastructure/cloudflare/tracing.ts"],"sourcesContent":["/**\n * Workers tracing for Workers Issues.\n *\n * Ported from Suparelay `apps/web/src/server/infrastructure/tracing/span.ts`.\n * Pass `tracing` in. This file does not import `cloudflare:workers`.\n * A client bundle can import a server module that uses these helpers.\n *\n * Issues shows the active span's attributes beside a failure. Put identifiers on the span.\n * Outside an invocation there is no active span, and a missing span records nothing.\n */\n\n/** Attribute values a Workers span accepts. `undefined` leaves that attribute unset. */\nexport type SpanAttributes = Record<string, string | number | boolean | undefined>\n\n/** Add attributes to the active span. That span is the invocation root unless `withSpan` encloses the call. */\nexport function annotateSpan(tracing: Tracing, attributes: SpanAttributes): void {\n tracing.getActiveSpan()?.setAttributes(attributes)\n}\n\n/**\n * Run `operation` in a child span.\n * Use it when one invocation runs several units of work, such as each message in a queue batch.\n * This helper does not record an exception. A failure reaches Issues as the uncaught error or `logger.error`.\n * An expected throw, such as a duplicate message, is not an exception record.\n */\nexport function withSpan<T>(\n tracing: Tracing,\n name: string,\n attributes: SpanAttributes,\n operation: () => Promise<T>,\n): Promise<T> {\n return tracing.enterSpan(name, (span) => {\n span.setAttributes(attributes)\n return operation()\n })\n}\n"],"mappings":";;AAeA,SAAgB,aAAa,SAAkB,YAAkC;CAC/E,QAAQ,cAAc,CAAC,EAAE,cAAc,UAAU;AACnD;;;;;;;AAQA,SAAgB,SACd,SACA,MACA,YACA,WACY;CACZ,OAAO,QAAQ,UAAU,OAAO,SAAS;EACvC,KAAK,cAAc,UAAU;EAC7B,OAAO,UAAU;CACnB,CAAC;AACH"}
@@ -34,6 +34,12 @@ type AuthContext<Session extends AuthSession> = RequiredAuthContext<Session> | {
34
34
  readonly user: undefined;
35
35
  readonly session: undefined;
36
36
  };
37
+ /** A resolved session and the request context that produced it. */
38
+ type AuthenticatedContext<Session extends AuthSession> = {
39
+ readonly user: Session['user'];
40
+ readonly session: Session['session'];
41
+ readonly requestContext: RequestContext;
42
+ };
37
43
  /** Select the current request's Better Auth instance from TanStack's registered context. */
38
44
  type AuthMiddlewareOptions<Session extends AuthSession> = {
39
45
  readonly getAuth: (context: RequestContext) => {
@@ -43,10 +49,13 @@ type AuthMiddlewareOptions<Session extends AuthSession> = {
43
49
  }) => Promise<Session | null>;
44
50
  };
45
51
  };
52
+ /** Runs after a session resolves and before the handler. A guest or a failed lookup does not call it. */
53
+ readonly onAuthenticated?: (context: AuthenticatedContext<Session>) => void;
46
54
  };
47
55
  /**
48
56
  * Preserve Porte's function and request middleware contracts with application-owned services.
49
57
  * Optional lookup failures log once and continue as guests. Required lookup failures propagate.
58
+ * `onAuthenticated` runs after a session resolves and before the rest of the chain.
50
59
  */
51
60
  export declare function createAuthMiddleware<Session extends AuthSession>(options: AuthMiddlewareOptions<Session>): {
52
61
  extractAuth: import("@tanstack/react-start").FunctionMiddlewareAfterServer<{}, unknown, undefined, AuthContext<Session>, undefined, undefined, undefined>;
@@ -88,5 +97,5 @@ export declare function createErrorMiddleware<Entries>(options: ErrorMiddlewareO
88
97
  toHttpErrorResponse: (cause: unknown) => Response;
89
98
  };
90
99
  //#endregion
91
- export type { AuthContext, AuthCookieSource, AuthMiddlewareOptions, AuthSession, ErrorMiddlewareOptions, RequiredAuthContext };
100
+ export type { AuthContext, AuthCookieSource, AuthMiddlewareOptions, AuthSession, AuthenticatedContext, ErrorMiddlewareOptions, RequiredAuthContext };
92
101
  //# sourceMappingURL=tanstack.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tanstack.d.ts","names":[],"sources":["../src/infrastructure/tanstack/request-context.ts","../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/error.middleware.ts"],"mappings":";;;;;;;;;;KAQY,iBAAiB;EAAmB;IAAU,sBAAsB;;IAC5E;;;;KCCQ;WACD;aAAiB;;WACjB;aAAoB;;;;KAInB,oBAAoB,gBAAgB;WACrC,MAAM;WACN,SAAS;;;KAIR,YAAY,gBAAgB,eACpC,oBAAoB;WAET;WACA;;;KAIH,sBAAsB,gBAAgB;WACvC,UAAU,SAAS;aACjB;eACE,aAAa;QAAW,SAAS;YAAc,QAAQ;;;;;;;;wBAetD,qBAAqB,gBAAgB,aACnD,SAAS,sBAAsB;;;;;;;;KCrCrB;WACD,UAAU;aAAmB,aAAa;;;;KAIzC,uBAAuB;WACxB,QAAQ,WAAW;;WAEnB;;WAEA,UAAU,SAAS,mBAAmB;;;;;;wBAOjC,sBAAsB,SAAS,SAAS,uBAAuB"}
1
+ {"version":3,"file":"tanstack.d.ts","names":[],"sources":["../src/infrastructure/tanstack/request-context.ts","../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/error.middleware.ts"],"mappings":";;;;;;;;;;KAQY,iBAAiB;EAAmB;IAAU,sBAAsB;;IAC5E;;;;KCCQ;WACD;aAAiB;;WACjB;aAAoB;;;;KAInB,oBAAoB,gBAAgB;WACrC,MAAM;WACN,SAAS;;;KAIR,YAAY,gBAAgB,eACpC,oBAAoB;WAET;WACA;;;KAIH,qBAAqB,gBAAgB;WACtC,MAAM;WACN,SAAS;WACT,gBAAgB;;;KAIf,sBAAsB,gBAAgB;WACvC,UAAU,SAAS;aACjB;eACE,aAAa;QAAW,SAAS;YAAc,QAAQ;;;;WAI3D,mBAAmB,SAAS,qBAAqB;;;;;;;wBAc5C,qBAAqB,gBAAgB,aACnD,SAAS,sBAAsB;;;;;;;;KC/CrB;WACD,UAAU;aAAmB,aAAa;;;;KAIzC,uBAAuB;WACxB,QAAQ,WAAW;;WAEnB;;WAEA,UAAU,SAAS,mBAAmB;;;;;;wBAOjC,sBAAsB,SAAS,SAAS,uBAAuB"}
package/dist/tanstack.js CHANGED
@@ -14,8 +14,16 @@ const requestHeaders = async () => {
14
14
  /**
15
15
  * Preserve Porte's function and request middleware contracts with application-owned services.
16
16
  * Optional lookup failures log once and continue as guests. Required lookup failures propagate.
17
+ * `onAuthenticated` runs after a session resolves and before the rest of the chain.
17
18
  */
18
19
  function createAuthMiddleware(options) {
20
+ function notifyAuthenticated(requestContext, resolved) {
21
+ options.onAuthenticated?.({
22
+ user: resolved.user,
23
+ session: resolved.session,
24
+ requestContext
25
+ });
26
+ }
19
27
  const extractAuth = createMiddleware({ type: "function" }).server(async ({ next, context }) => {
20
28
  let resolved;
21
29
  try {
@@ -29,6 +37,7 @@ function createAuthMiddleware(options) {
29
37
  user: void 0,
30
38
  session: void 0
31
39
  } });
40
+ notifyAuthenticated(context, resolved);
32
41
  return next({ context: {
33
42
  user: resolved.user,
34
43
  session: resolved.session
@@ -46,6 +55,7 @@ function createAuthMiddleware(options) {
46
55
  extractAuth,
47
56
  requireAuth: createMiddleware({ type: "function" }).server(async ({ next, context }) => {
48
57
  const resolved = await resolveRequiredSession(context, await requestHeaders());
58
+ notifyAuthenticated(context, resolved);
49
59
  return next({ context: {
50
60
  user: resolved.user,
51
61
  session: resolved.session
@@ -53,6 +63,7 @@ function createAuthMiddleware(options) {
53
63
  }),
54
64
  requireAuthRequest: createMiddleware({ type: "request" }).server(async ({ request, next, context }) => {
55
65
  const resolved = await resolveRequiredSession(context, request.headers);
66
+ notifyAuthenticated(context, resolved);
56
67
  return next({ context: {
57
68
  user: resolved.user,
58
69
  session: resolved.session
@@ -1 +1 @@
1
- {"version":3,"file":"tanstack.js","names":["logger"],"sources":["../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/resolve-failure.ts","../src/infrastructure/tanstack/http-error-response.ts","../src/infrastructure/tanstack/session-cookies.ts","../src/infrastructure/tanstack/to-error-payload.ts","../src/infrastructure/tanstack/error.middleware.ts"],"sourcesContent":["import { createMiddleware } from '@tanstack/react-start'\nimport { getSessionCookie } from 'better-auth/cookies'\n\nimport { AuthenticationError, StaleSessionError, createLogger } from '@/core'\n\nimport type { RequestContext } from './request-context.ts'\n\nconst logger = createLogger('auth-middleware')\n\n/** The fields middleware reads; generic callers retain every additional session and user field. */\nexport type AuthSession = {\n readonly user: { readonly id: string }\n readonly session: { readonly id: string }\n}\n\n/** Authenticated context retains the application's complete user and session types. */\nexport type RequiredAuthContext<Session extends AuthSession> = {\n readonly user: Session['user']\n readonly session: Session['session']\n}\n\n/** Optional authentication supplies both fields together or neither field. */\nexport type AuthContext<Session extends AuthSession> =\n | RequiredAuthContext<Session>\n | {\n readonly user: undefined\n readonly session: undefined\n }\n\n/** Select the current request's Better Auth instance from TanStack's registered context. */\nexport type AuthMiddlewareOptions<Session extends AuthSession> = {\n readonly getAuth: (context: RequestContext) => {\n readonly api: {\n readonly getSession: (options: { headers: Headers }) => Promise<Session | null>\n }\n }\n}\n\n// The server entry loads react-dom/server; the browser evaluates this module, so it loads lazily inside `.server()`.\nconst requestHeaders = async () => {\n const { getRequestHeaders } = await import('@tanstack/react-start/server')\n return getRequestHeaders()\n}\n\n/**\n * Preserve Porte's function and request middleware contracts with application-owned services.\n * Optional lookup failures log once and continue as guests. Required lookup failures propagate.\n */\nexport function createAuthMiddleware<Session extends AuthSession>(\n options: AuthMiddlewareOptions<Session>,\n) {\n const extractAuth = createMiddleware({ type: 'function' }).server<AuthContext<Session>>(\n async ({ next, context }) => {\n let resolved: Session | null\n try {\n const headers = await requestHeaders()\n resolved = await options.getAuth(context).api.getSession({ headers })\n } catch (error) {\n logger.error('get_session_failed', { error })\n resolved = null\n }\n if (resolved === null)\n return next<AuthContext<Session>>({ context: { user: undefined, session: undefined } })\n return next<AuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n },\n )\n\n async function resolveRequiredSession(\n context: RequestContext,\n headers: Headers,\n ): Promise<Session> {\n const resolved = await options.getAuth(context).api.getSession({ headers })\n if (resolved === null) {\n if (getSessionCookie(headers)) throw new StaleSessionError()\n throw new AuthenticationError()\n }\n return resolved\n }\n\n const requireAuth = createMiddleware({ type: 'function' }).server(async ({ next, context }) => {\n const resolved = await resolveRequiredSession(context, await requestHeaders())\n return next<RequiredAuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n })\n const requireAuthRequest = createMiddleware({ type: 'request' }).server(\n async ({ request, next, context }) => {\n const resolved = await resolveRequiredSession(context, request.headers)\n return next<RequiredAuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n },\n )\n return { extractAuth, requireAuth, requireAuthRequest }\n}\n","import { AppError, createLogger, type ErrorTable, type ResolvedError } from '@/core'\nimport type { ErrorPayloadOf } from '@/core'\n\nconst logger = createLogger('error-boundary')\n\n/**\n * Resolve one failure through the table or fall back to the generic replacement, and log it once.\n * Both transport converters call this, so one rule decides the log level: mapped is a warning, unmapped is an error with the cause.\n */\nexport function resolveFailure<Entries>(\n table: ErrorTable<Entries>,\n cause: unknown,\n): ResolvedError<ErrorPayloadOf<ErrorTable<Entries>>> {\n const resolved = table.resolve(cause)\n if (resolved !== null) {\n logger.warn('handled_error', {\n tag: resolved.payload._tag,\n status: resolved.status,\n })\n return resolved\n }\n const classification = cause instanceof AppError ? cause.classification : 'unknown'\n logger.error('unexpected_error', { error: cause, classification })\n return table.fallback(cause)\n}\n","import { getReasonPhrase } from 'http-status-codes'\nimport { z } from 'zod'\n\nimport type { ErrorTable, ProblemDetails } from '@/core'\n\nimport { resolveFailure } from './resolve-failure.ts'\n\n// A known delay becomes the header; a null delay sends no header.\nconst RetryAfterSchema = z.object({ retryAfter: z.number() })\n\n/**\n * Build the API route converter for one error table.\n * The body is RFC 9457 problem details; public fields become extension members.\n */\nexport function createHttpErrorResponseConverter<Entries>(table: ErrorTable<Entries>) {\n return function toHttpErrorResponse(cause: unknown): Response {\n const { status, payload } = resolveFailure(table, cause)\n const { _tag, message, ...fields } = payload\n const body: ProblemDetails = {\n type: 'about:blank',\n title: getReasonPhrase(status),\n detail: message,\n ...fields,\n }\n const headers = new Headers({ 'Content-Type': 'application/problem+json' })\n const retry = RetryAfterSchema.safeParse(payload)\n if (retry.success) headers.set('Retry-After', String(retry.data.retryAfter))\n return Response.json(body, { status, headers })\n }\n}\n","import type { deleteCookie } from '@tanstack/react-start/server'\nimport type { BetterAuthCookies } from 'better-auth'\nimport { parseCookies } from 'better-auth/cookies'\n\ntype AuthCookie = BetterAuthCookies['sessionToken']\ntype DeleteCookieOptions = NonNullable<Parameters<typeof deleteCookie>[1]>\n\n/**\n * Delete the session, session cache, account cache, and remember-me cookies of one instance.\n * Chunks present on the request go too. Names and attributes come from the instance, so custom prefixes and domain cookies clear.\n */\nexport async function clearStaleSessionCookies(cookies: BetterAuthCookies): Promise<void> {\n // The server entry loads react-dom/server; the browser evaluates this module, so it loads lazily here.\n const server = await import('@tanstack/react-start/server')\n const present = [...parseCookies(server.getRequestHeaders().get('cookie') ?? '').keys()]\n const definitions = [\n cookies.sessionToken,\n cookies.sessionData,\n cookies.accountData,\n cookies.dontRememberToken,\n ]\n for (const cookie of definitions) {\n // Better Auth names a chunk `${name}.${index}`, so a missed chunk would resurrect the value.\n const chunks = present.filter((name) => name.startsWith(`${cookie.name}.`))\n for (const name of new Set([cookie.name, ...chunks]))\n server.deleteCookie(name, deletionOf(cookie.attributes))\n }\n}\n\nfunction deletionOf(attributes: AuthCookie['attributes']): DeleteCookieOptions {\n // Better Auth sets `/` and the secure flag at runtime; the types leave both optional.\n const options: DeleteCookieOptions = {\n path: attributes.path ?? '/',\n secure: attributes.secure ?? false,\n }\n if (attributes.domain !== undefined) options.domain = attributes.domain\n return options\n}\n","import { z } from 'zod'\n\nimport { ValidationError, type ErrorPayloadOf, type ErrorTable } from '@/core'\n\nimport { resolveFailure } from './resolve-failure.ts'\n\n// TanStack serializes a validator's issues into `Error.message` as JSON.\nconst SerializedIssuesSchema = z\n .array(z.object({ message: z.string(), path: z.array(z.union([z.string(), z.number()])) }))\n .min(1)\n\nfunction reconstructValidationError(cause: unknown): ValidationError | null {\n if (!(cause instanceof Error)) return null\n let message: unknown\n try {\n message = JSON.parse(cause.message)\n } catch {\n return null\n }\n const parsed = SerializedIssuesSchema.safeParse(message)\n return parsed.success ? ValidationError.fromIssues(parsed.data) : null\n}\n\n/**\n * Build the server-function converter for one error table.\n * The result is a plain object, because only own data survives serialization.\n */\nexport function createErrorPayloadConverter<Entries>(table: ErrorTable<Entries>) {\n return function toErrorPayload(cause: unknown): ErrorPayloadOf<ErrorTable<Entries>> {\n return resolveFailure(table, reconstructValidationError(cause) ?? cause).payload\n }\n}\n","import { isNotFound, isRedirect, redirect } from '@tanstack/react-router'\nimport { createMiddleware } from '@tanstack/react-start'\nimport type { BetterAuthCookies } from 'better-auth'\n\nimport { StaleSessionError, type ErrorTable } from '@/core'\n\nimport { createHttpErrorResponseConverter } from './http-error-response.ts'\nimport type { RequestContext } from './request-context.ts'\nimport { clearStaleSessionCookies } from './session-cookies.ts'\nimport { createErrorPayloadConverter } from './to-error-payload.ts'\n\n/** The part of a Better Auth instance the boundary reads: its computed cookie definitions. */\nexport type AuthCookieSource = {\n readonly $context: Promise<{ readonly authCookies: BetterAuthCookies }>\n}\n\n/** The application's error table, its stale-session redirect, and its Better Auth instance. */\nexport type ErrorMiddlewareOptions<Entries> = {\n readonly errors: ErrorTable<Entries>\n /** The path a server function redirects to for a stale session, with any search string. */\n readonly signIn: string\n /** Select the request's Better Auth instance; its cookie definitions say what to clear. */\n readonly getAuth: (context: RequestContext) => AuthCookieSource\n}\n\n/**\n * Build both transport boundaries from one error table.\n * Register `functionErrorMiddleware` globally. Put `routeErrorMiddleware` first in an API route's `server.middleware`.\n */\nexport function createErrorMiddleware<Entries>(options: ErrorMiddlewareOptions<Entries>) {\n const toErrorPayload = createErrorPayloadConverter(options.errors)\n const toHttpErrorResponse = createHttpErrorResponseConverter(options.errors)\n\n async function clearSession(context: RequestContext): Promise<void> {\n const { authCookies } = await options.getAuth(context).$context\n await clearStaleSessionCookies(authCookies)\n }\n\n async function handleFunctionError(cause: unknown, context: RequestContext): Promise<never> {\n // oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.\n if (isNotFound(cause) || isRedirect(cause)) throw cause\n if (cause instanceof StaleSessionError) {\n await clearSession(context)\n // oxlint-disable-next-line typescript/only-throw-error -- Router redirects by throwing this.\n throw redirect({ href: options.signIn })\n }\n // oxlint-disable-next-line typescript/only-throw-error -- The client rejects with this and reads its tag.\n throw toErrorPayload(cause)\n }\n\n // A route answers 401 instead of a redirect because fetch would follow it into the HTML shell.\n async function handleRouteError(cause: unknown, context: RequestContext): Promise<Response> {\n // oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.\n if (isNotFound(cause) || isRedirect(cause)) throw cause\n if (cause instanceof StaleSessionError) await clearSession(context)\n return toHttpErrorResponse(cause)\n }\n\n const functionErrorMiddleware = createMiddleware({ type: 'function' }).server(\n async ({ next, context }) => {\n try {\n return await next()\n } catch (cause) {\n return handleFunctionError(cause, context)\n }\n },\n )\n\n const routeErrorMiddleware = createMiddleware({ type: 'request' }).server(\n async ({ next, context }) => {\n try {\n return await next()\n } catch (cause) {\n return handleRouteError(cause, context)\n }\n },\n )\n\n return { functionErrorMiddleware, routeErrorMiddleware, toErrorPayload, toHttpErrorResponse }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tanstack.js","names":["logger"],"sources":["../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/resolve-failure.ts","../src/infrastructure/tanstack/http-error-response.ts","../src/infrastructure/tanstack/session-cookies.ts","../src/infrastructure/tanstack/to-error-payload.ts","../src/infrastructure/tanstack/error.middleware.ts"],"sourcesContent":["import { createMiddleware } from '@tanstack/react-start'\nimport { getSessionCookie } from 'better-auth/cookies'\n\nimport { AuthenticationError, StaleSessionError, createLogger } from '@/core'\n\nimport type { RequestContext } from './request-context.ts'\n\nconst logger = createLogger('auth-middleware')\n\n/** The fields middleware reads; generic callers retain every additional session and user field. */\nexport type AuthSession = {\n readonly user: { readonly id: string }\n readonly session: { readonly id: string }\n}\n\n/** Authenticated context retains the application's complete user and session types. */\nexport type RequiredAuthContext<Session extends AuthSession> = {\n readonly user: Session['user']\n readonly session: Session['session']\n}\n\n/** Optional authentication supplies both fields together or neither field. */\nexport type AuthContext<Session extends AuthSession> =\n | RequiredAuthContext<Session>\n | {\n readonly user: undefined\n readonly session: undefined\n }\n\n/** A resolved session and the request context that produced it. */\nexport type AuthenticatedContext<Session extends AuthSession> = {\n readonly user: Session['user']\n readonly session: Session['session']\n readonly requestContext: RequestContext\n}\n\n/** Select the current request's Better Auth instance from TanStack's registered context. */\nexport type AuthMiddlewareOptions<Session extends AuthSession> = {\n readonly getAuth: (context: RequestContext) => {\n readonly api: {\n readonly getSession: (options: { headers: Headers }) => Promise<Session | null>\n }\n }\n /** Runs after a session resolves and before the handler. A guest or a failed lookup does not call it. */\n readonly onAuthenticated?: (context: AuthenticatedContext<Session>) => void\n}\n\n// The server entry loads react-dom/server; the browser evaluates this module, so it loads lazily inside `.server()`.\nconst requestHeaders = async () => {\n const { getRequestHeaders } = await import('@tanstack/react-start/server')\n return getRequestHeaders()\n}\n\n/**\n * Preserve Porte's function and request middleware contracts with application-owned services.\n * Optional lookup failures log once and continue as guests. Required lookup failures propagate.\n * `onAuthenticated` runs after a session resolves and before the rest of the chain.\n */\nexport function createAuthMiddleware<Session extends AuthSession>(\n options: AuthMiddlewareOptions<Session>,\n) {\n function notifyAuthenticated(requestContext: RequestContext, resolved: Session): void {\n options.onAuthenticated?.({\n user: resolved.user,\n session: resolved.session,\n requestContext,\n })\n }\n const extractAuth = createMiddleware({ type: 'function' }).server<AuthContext<Session>>(\n async ({ next, context }) => {\n let resolved: Session | null\n try {\n const headers = await requestHeaders()\n resolved = await options.getAuth(context).api.getSession({ headers })\n } catch (error) {\n logger.error('get_session_failed', { error })\n resolved = null\n }\n if (resolved === null)\n return next<AuthContext<Session>>({ context: { user: undefined, session: undefined } })\n notifyAuthenticated(context, resolved)\n return next<AuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n },\n )\n\n async function resolveRequiredSession(\n context: RequestContext,\n headers: Headers,\n ): Promise<Session> {\n const resolved = await options.getAuth(context).api.getSession({ headers })\n if (resolved === null) {\n if (getSessionCookie(headers)) throw new StaleSessionError()\n throw new AuthenticationError()\n }\n return resolved\n }\n\n const requireAuth = createMiddleware({ type: 'function' }).server(async ({ next, context }) => {\n const resolved = await resolveRequiredSession(context, await requestHeaders())\n notifyAuthenticated(context, resolved)\n return next<RequiredAuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n })\n const requireAuthRequest = createMiddleware({ type: 'request' }).server(\n async ({ request, next, context }) => {\n const resolved = await resolveRequiredSession(context, request.headers)\n notifyAuthenticated(context, resolved)\n return next<RequiredAuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n },\n )\n return { extractAuth, requireAuth, requireAuthRequest }\n}\n","import { AppError, createLogger, type ErrorTable, type ResolvedError } from '@/core'\nimport type { ErrorPayloadOf } from '@/core'\n\nconst logger = createLogger('error-boundary')\n\n/**\n * Resolve one failure through the table or fall back to the generic replacement, and log it once.\n * Both transport converters call this, so one rule decides the log level: mapped is a warning, unmapped is an error with the cause.\n */\nexport function resolveFailure<Entries>(\n table: ErrorTable<Entries>,\n cause: unknown,\n): ResolvedError<ErrorPayloadOf<ErrorTable<Entries>>> {\n const resolved = table.resolve(cause)\n if (resolved !== null) {\n logger.warn('handled_error', {\n tag: resolved.payload._tag,\n status: resolved.status,\n })\n return resolved\n }\n const classification = cause instanceof AppError ? cause.classification : 'unknown'\n logger.error('unexpected_error', { error: cause, classification })\n return table.fallback(cause)\n}\n","import { getReasonPhrase } from 'http-status-codes'\nimport { z } from 'zod'\n\nimport type { ErrorTable, ProblemDetails } from '@/core'\n\nimport { resolveFailure } from './resolve-failure.ts'\n\n// A known delay becomes the header; a null delay sends no header.\nconst RetryAfterSchema = z.object({ retryAfter: z.number() })\n\n/**\n * Build the API route converter for one error table.\n * The body is RFC 9457 problem details; public fields become extension members.\n */\nexport function createHttpErrorResponseConverter<Entries>(table: ErrorTable<Entries>) {\n return function toHttpErrorResponse(cause: unknown): Response {\n const { status, payload } = resolveFailure(table, cause)\n const { _tag, message, ...fields } = payload\n const body: ProblemDetails = {\n type: 'about:blank',\n title: getReasonPhrase(status),\n detail: message,\n ...fields,\n }\n const headers = new Headers({ 'Content-Type': 'application/problem+json' })\n const retry = RetryAfterSchema.safeParse(payload)\n if (retry.success) headers.set('Retry-After', String(retry.data.retryAfter))\n return Response.json(body, { status, headers })\n }\n}\n","import type { deleteCookie } from '@tanstack/react-start/server'\nimport type { BetterAuthCookies } from 'better-auth'\nimport { parseCookies } from 'better-auth/cookies'\n\ntype AuthCookie = BetterAuthCookies['sessionToken']\ntype DeleteCookieOptions = NonNullable<Parameters<typeof deleteCookie>[1]>\n\n/**\n * Delete the session, session cache, account cache, and remember-me cookies of one instance.\n * Chunks present on the request go too. Names and attributes come from the instance, so custom prefixes and domain cookies clear.\n */\nexport async function clearStaleSessionCookies(cookies: BetterAuthCookies): Promise<void> {\n // The server entry loads react-dom/server; the browser evaluates this module, so it loads lazily here.\n const server = await import('@tanstack/react-start/server')\n const present = [...parseCookies(server.getRequestHeaders().get('cookie') ?? '').keys()]\n const definitions = [\n cookies.sessionToken,\n cookies.sessionData,\n cookies.accountData,\n cookies.dontRememberToken,\n ]\n for (const cookie of definitions) {\n // Better Auth names a chunk `${name}.${index}`, so a missed chunk would resurrect the value.\n const chunks = present.filter((name) => name.startsWith(`${cookie.name}.`))\n for (const name of new Set([cookie.name, ...chunks]))\n server.deleteCookie(name, deletionOf(cookie.attributes))\n }\n}\n\nfunction deletionOf(attributes: AuthCookie['attributes']): DeleteCookieOptions {\n // Better Auth sets `/` and the secure flag at runtime; the types leave both optional.\n const options: DeleteCookieOptions = {\n path: attributes.path ?? '/',\n secure: attributes.secure ?? false,\n }\n if (attributes.domain !== undefined) options.domain = attributes.domain\n return options\n}\n","import { z } from 'zod'\n\nimport { ValidationError, type ErrorPayloadOf, type ErrorTable } from '@/core'\n\nimport { resolveFailure } from './resolve-failure.ts'\n\n// TanStack serializes a validator's issues into `Error.message` as JSON.\nconst SerializedIssuesSchema = z\n .array(z.object({ message: z.string(), path: z.array(z.union([z.string(), z.number()])) }))\n .min(1)\n\nfunction reconstructValidationError(cause: unknown): ValidationError | null {\n if (!(cause instanceof Error)) return null\n let message: unknown\n try {\n message = JSON.parse(cause.message)\n } catch {\n return null\n }\n const parsed = SerializedIssuesSchema.safeParse(message)\n return parsed.success ? ValidationError.fromIssues(parsed.data) : null\n}\n\n/**\n * Build the server-function converter for one error table.\n * The result is a plain object, because only own data survives serialization.\n */\nexport function createErrorPayloadConverter<Entries>(table: ErrorTable<Entries>) {\n return function toErrorPayload(cause: unknown): ErrorPayloadOf<ErrorTable<Entries>> {\n return resolveFailure(table, reconstructValidationError(cause) ?? cause).payload\n }\n}\n","import { isNotFound, isRedirect, redirect } from '@tanstack/react-router'\nimport { createMiddleware } from '@tanstack/react-start'\nimport type { BetterAuthCookies } from 'better-auth'\n\nimport { StaleSessionError, type ErrorTable } from '@/core'\n\nimport { createHttpErrorResponseConverter } from './http-error-response.ts'\nimport type { RequestContext } from './request-context.ts'\nimport { clearStaleSessionCookies } from './session-cookies.ts'\nimport { createErrorPayloadConverter } from './to-error-payload.ts'\n\n/** The part of a Better Auth instance the boundary reads: its computed cookie definitions. */\nexport type AuthCookieSource = {\n readonly $context: Promise<{ readonly authCookies: BetterAuthCookies }>\n}\n\n/** The application's error table, its stale-session redirect, and its Better Auth instance. */\nexport type ErrorMiddlewareOptions<Entries> = {\n readonly errors: ErrorTable<Entries>\n /** The path a server function redirects to for a stale session, with any search string. */\n readonly signIn: string\n /** Select the request's Better Auth instance; its cookie definitions say what to clear. */\n readonly getAuth: (context: RequestContext) => AuthCookieSource\n}\n\n/**\n * Build both transport boundaries from one error table.\n * Register `functionErrorMiddleware` globally. Put `routeErrorMiddleware` first in an API route's `server.middleware`.\n */\nexport function createErrorMiddleware<Entries>(options: ErrorMiddlewareOptions<Entries>) {\n const toErrorPayload = createErrorPayloadConverter(options.errors)\n const toHttpErrorResponse = createHttpErrorResponseConverter(options.errors)\n\n async function clearSession(context: RequestContext): Promise<void> {\n const { authCookies } = await options.getAuth(context).$context\n await clearStaleSessionCookies(authCookies)\n }\n\n async function handleFunctionError(cause: unknown, context: RequestContext): Promise<never> {\n // oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.\n if (isNotFound(cause) || isRedirect(cause)) throw cause\n if (cause instanceof StaleSessionError) {\n await clearSession(context)\n // oxlint-disable-next-line typescript/only-throw-error -- Router redirects by throwing this.\n throw redirect({ href: options.signIn })\n }\n // oxlint-disable-next-line typescript/only-throw-error -- The client rejects with this and reads its tag.\n throw toErrorPayload(cause)\n }\n\n // A route answers 401 instead of a redirect because fetch would follow it into the HTML shell.\n async function handleRouteError(cause: unknown, context: RequestContext): Promise<Response> {\n // oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.\n if (isNotFound(cause) || isRedirect(cause)) throw cause\n if (cause instanceof StaleSessionError) await clearSession(context)\n return toHttpErrorResponse(cause)\n }\n\n const functionErrorMiddleware = createMiddleware({ type: 'function' }).server(\n async ({ next, context }) => {\n try {\n return await next()\n } catch (cause) {\n return handleFunctionError(cause, context)\n }\n },\n )\n\n const routeErrorMiddleware = createMiddleware({ type: 'request' }).server(\n async ({ next, context }) => {\n try {\n return await next()\n } catch (cause) {\n return handleRouteError(cause, context)\n }\n },\n )\n\n return { functionErrorMiddleware, routeErrorMiddleware, toErrorPayload, toHttpErrorResponse }\n}\n"],"mappings":"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"}
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.23.1",
4
+ "version": "0.24.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": {
@@ -33,6 +33,7 @@
33
33
  "./cloudflare/r2-client": "./dist/cloudflare/r2-client.js",
34
34
  "./cloudflare/r2-errors": "./dist/cloudflare/r2-errors.js",
35
35
  "./cloudflare/scheduled": "./dist/cloudflare/scheduled.js",
36
+ "./cloudflare/tracing": "./dist/cloudflare/tracing.js",
36
37
  "./core": "./dist/core.js",
37
38
  "./http": "./dist/http.js",
38
39
  "./postgres": "./dist/postgres.js",
@@ -69,49 +70,49 @@
69
70
  "zod": "^4.6.5"
70
71
  },
71
72
  "devDependencies": {
72
- "@better-auth/stripe": "^1.7.6",
73
- "@cloudflare/vitest-plugin": "^1.3.1",
74
- "@cloudflare/workers-types": "5.20260929.1",
75
- "@posthog/react": "^1.11.2",
76
- "@sentry/cloudflare": "^11.1.0",
77
- "@sentry/tanstackstart-react": "^11.1.0",
78
- "@tanstack/react-router": "^1.170.40",
79
- "@tanstack/react-start": "^1.168.59",
80
- "@tanstack/start-client-core": "^1.170.33",
81
- "@tanstack/start-storage-context": "^1.167.35",
73
+ "@better-auth/stripe": "^1.7.7",
74
+ "@cloudflare/vitest-plugin": "^1.3.4",
75
+ "@cloudflare/workers-types": "5.20260930.2",
76
+ "@posthog/react": "^1.11.3",
77
+ "@sentry/cloudflare": "^11.2.0",
78
+ "@sentry/tanstackstart-react": "^11.2.0",
79
+ "@tanstack/react-router": "^1.170.41",
80
+ "@tanstack/react-start": "^1.168.60",
81
+ "@tanstack/start-client-core": "^1.170.34",
82
+ "@tanstack/start-storage-context": "^1.167.36",
82
83
  "@types/node": "^26.6.3",
83
84
  "@types/react": "^19.3.0",
84
85
  "@vitest/browser-playwright": "^4.1.11",
85
- "auth": "^1.7.6",
86
- "better-auth": "^1.7.6",
86
+ "auth": "^1.7.7",
87
+ "better-auth": "^1.7.7",
87
88
  "drizzle-kit": "^0.31.11",
88
89
  "drizzle-orm": "^0.45.3",
89
90
  "playwright": "^1.63.0",
90
91
  "postgres": "^3.4.9",
91
- "posthog-js": "^1.434.18",
92
- "posthog-node": "^5.54.1",
92
+ "posthog-js": "^1.435.6",
93
+ "posthog-node": "^5.55.0",
93
94
  "react": "^19.3.0",
94
- "resend": "^6.30.0",
95
+ "resend": "^6.31.0",
95
96
  "standardwebhooks": "^1.1.1",
96
97
  "stripe": "^22.6.2",
97
98
  "tsdown": "^0.23.0",
98
99
  "typescript": "^7.0.2",
99
100
  "vitest": "^4.1.11",
100
- "wrangler": "^4.143.0"
101
+ "wrangler": "^4.145.0"
101
102
  },
102
103
  "peerDependencies": {
103
- "@better-auth/stripe": "^1.7.6",
104
- "@posthog/react": "^1.11.2",
105
- "@sentry/cloudflare": "^11.1.0",
106
- "@sentry/tanstackstart-react": "^11.1.0",
107
- "@tanstack/react-router": "^1.170.40",
108
- "@tanstack/react-start": "^1.168.59",
109
- "better-auth": "^1.7.6",
104
+ "@better-auth/stripe": "^1.7.7",
105
+ "@posthog/react": "^1.11.3",
106
+ "@sentry/cloudflare": "^11.2.0",
107
+ "@sentry/tanstackstart-react": "^11.2.0",
108
+ "@tanstack/react-router": "^1.170.41",
109
+ "@tanstack/react-start": "^1.168.60",
110
+ "better-auth": "^1.7.7",
110
111
  "drizzle-orm": "^0.45.3",
111
- "posthog-js": "^1.434.18",
112
- "posthog-node": "^5.54.1",
112
+ "posthog-js": "^1.435.6",
113
+ "posthog-node": "^5.55.0",
113
114
  "react": "^19.3.0",
114
- "resend": "^6.30.0",
115
+ "resend": "^6.31.0",
115
116
  "stripe": "^22.6.2"
116
117
  },
117
118
  "peerDependenciesMeta": {
@@ -26,7 +26,7 @@ export type DispatchBus<TMessage extends QueueMessage> = {
26
26
  /**
27
27
  * Parses and dispatches each queue message, then selects acknowledgement or retry.
28
28
  * Cloudflare owns the retry limit and sends exhausted messages to the configured dead letter.
29
- * The consumer logs each failure once; handlers do not.
29
+ * The consumer logs each failure once, inside the dispatch wrapper; handlers do not.
30
30
  */
31
31
  export class QueueConsumer<TMessage extends QueueMessage> {
32
32
  /**
@@ -59,6 +59,8 @@ export class QueueConsumer<TMessage extends QueueMessage> {
59
59
 
60
60
  private async processMessage(message: Message): Promise<void> {
61
61
  let parsed: TMessage | null = null
62
+ let handlerFailed = false
63
+ let settled = false
62
64
 
63
65
  try {
64
66
  const body = await this.parseMessage(message)
@@ -74,10 +76,28 @@ export class QueueConsumer<TMessage extends QueueMessage> {
74
76
  parsed = body
75
77
 
76
78
  await this.wrapDispatch(body, message.attempts, async () => {
77
- await this.bus.handle(body)
79
+ try {
80
+ await this.bus.handle(body)
81
+ } catch (cause) {
82
+ handlerFailed = true
83
+ this.handleFailure(message, body, cause)
84
+ settled = true
85
+ // The wrapper still sees the rejection, and this method does not settle it again.
86
+ throw cause
87
+ }
78
88
  })
89
+ } catch (cause) {
90
+ if (settled) return
91
+ // A settlement inside run threw: reject the batch, as a settlement failure does elsewhere.
92
+ if (handlerFailed) throw cause
93
+ this.handleFailure(message, parsed, cause)
94
+ return
95
+ }
96
+ // A wrapper can resolve after run rejects; that message is already settled.
97
+ if (parsed === null || handlerFailed) return
98
+ try {
79
99
  message.ack()
80
- logger.debug('queue_message_processed', { name: body.name })
100
+ logger.debug('queue_message_processed', { name: parsed.name })
81
101
  } catch (cause) {
82
102
  this.handleFailure(message, parsed, cause)
83
103
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Workers tracing for Workers Issues.
3
+ *
4
+ * Ported from Suparelay `apps/web/src/server/infrastructure/tracing/span.ts`.
5
+ * Pass `tracing` in. This file does not import `cloudflare:workers`.
6
+ * A client bundle can import a server module that uses these helpers.
7
+ *
8
+ * Issues shows the active span's attributes beside a failure. Put identifiers on the span.
9
+ * Outside an invocation there is no active span, and a missing span records nothing.
10
+ */
11
+
12
+ /** Attribute values a Workers span accepts. `undefined` leaves that attribute unset. */
13
+ export type SpanAttributes = Record<string, string | number | boolean | undefined>
14
+
15
+ /** Add attributes to the active span. That span is the invocation root unless `withSpan` encloses the call. */
16
+ export function annotateSpan(tracing: Tracing, attributes: SpanAttributes): void {
17
+ tracing.getActiveSpan()?.setAttributes(attributes)
18
+ }
19
+
20
+ /**
21
+ * Run `operation` in a child span.
22
+ * Use it when one invocation runs several units of work, such as each message in a queue batch.
23
+ * This helper does not record an exception. A failure reaches Issues as the uncaught error or `logger.error`.
24
+ * An expected throw, such as a duplicate message, is not an exception record.
25
+ */
26
+ export function withSpan<T>(
27
+ tracing: Tracing,
28
+ name: string,
29
+ attributes: SpanAttributes,
30
+ operation: () => Promise<T>,
31
+ ): Promise<T> {
32
+ return tracing.enterSpan(name, (span) => {
33
+ span.setAttributes(attributes)
34
+ return operation()
35
+ })
36
+ }
@@ -27,6 +27,13 @@ export type AuthContext<Session extends AuthSession> =
27
27
  readonly session: undefined
28
28
  }
29
29
 
30
+ /** A resolved session and the request context that produced it. */
31
+ export type AuthenticatedContext<Session extends AuthSession> = {
32
+ readonly user: Session['user']
33
+ readonly session: Session['session']
34
+ readonly requestContext: RequestContext
35
+ }
36
+
30
37
  /** Select the current request's Better Auth instance from TanStack's registered context. */
31
38
  export type AuthMiddlewareOptions<Session extends AuthSession> = {
32
39
  readonly getAuth: (context: RequestContext) => {
@@ -34,6 +41,8 @@ export type AuthMiddlewareOptions<Session extends AuthSession> = {
34
41
  readonly getSession: (options: { headers: Headers }) => Promise<Session | null>
35
42
  }
36
43
  }
44
+ /** Runs after a session resolves and before the handler. A guest or a failed lookup does not call it. */
45
+ readonly onAuthenticated?: (context: AuthenticatedContext<Session>) => void
37
46
  }
38
47
 
39
48
  // The server entry loads react-dom/server; the browser evaluates this module, so it loads lazily inside `.server()`.
@@ -45,10 +54,18 @@ const requestHeaders = async () => {
45
54
  /**
46
55
  * Preserve Porte's function and request middleware contracts with application-owned services.
47
56
  * Optional lookup failures log once and continue as guests. Required lookup failures propagate.
57
+ * `onAuthenticated` runs after a session resolves and before the rest of the chain.
48
58
  */
49
59
  export function createAuthMiddleware<Session extends AuthSession>(
50
60
  options: AuthMiddlewareOptions<Session>,
51
61
  ) {
62
+ function notifyAuthenticated(requestContext: RequestContext, resolved: Session): void {
63
+ options.onAuthenticated?.({
64
+ user: resolved.user,
65
+ session: resolved.session,
66
+ requestContext,
67
+ })
68
+ }
52
69
  const extractAuth = createMiddleware({ type: 'function' }).server<AuthContext<Session>>(
53
70
  async ({ next, context }) => {
54
71
  let resolved: Session | null
@@ -61,6 +78,7 @@ export function createAuthMiddleware<Session extends AuthSession>(
61
78
  }
62
79
  if (resolved === null)
63
80
  return next<AuthContext<Session>>({ context: { user: undefined, session: undefined } })
81
+ notifyAuthenticated(context, resolved)
64
82
  return next<AuthContext<Session>>({
65
83
  context: { user: resolved.user, session: resolved.session },
66
84
  })
@@ -81,6 +99,7 @@ export function createAuthMiddleware<Session extends AuthSession>(
81
99
 
82
100
  const requireAuth = createMiddleware({ type: 'function' }).server(async ({ next, context }) => {
83
101
  const resolved = await resolveRequiredSession(context, await requestHeaders())
102
+ notifyAuthenticated(context, resolved)
84
103
  return next<RequiredAuthContext<Session>>({
85
104
  context: { user: resolved.user, session: resolved.session },
86
105
  })
@@ -88,6 +107,7 @@ export function createAuthMiddleware<Session extends AuthSession>(
88
107
  const requireAuthRequest = createMiddleware({ type: 'request' }).server(
89
108
  async ({ request, next, context }) => {
90
109
  const resolved = await resolveRequiredSession(context, request.headers)
110
+ notifyAuthenticated(context, resolved)
91
111
  return next<RequiredAuthContext<Session>>({
92
112
  context: { user: resolved.user, session: resolved.session },
93
113
  })
@@ -2,6 +2,7 @@ export {
2
2
  createAuthMiddleware,
3
3
  type AuthSession,
4
4
  type AuthContext,
5
+ type AuthenticatedContext,
5
6
  type RequiredAuthContext,
6
7
  type AuthMiddlewareOptions,
7
8
  } from './auth.middleware.ts'