better-ship 0.7.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/{application-DjsQtpgw.js → application-DYfcAy-Q.js} +2 -2
  2. package/dist/{application-DjsQtpgw.js.map → application-DYfcAy-Q.js.map} +1 -1
  3. package/dist/application.d.ts +2 -2
  4. package/dist/application.js +1 -1
  5. package/dist/better-auth/apple-client-secret.d.ts +11 -4
  6. package/dist/better-auth/apple-client-secret.d.ts.map +1 -1
  7. package/dist/better-auth/apple-client-secret.js +39 -11
  8. package/dist/better-auth/apple-client-secret.js.map +1 -1
  9. package/dist/better-auth/auth-client.js +1 -1
  10. package/dist/better-auth/rate-limit.d.ts +21 -2
  11. package/dist/better-auth/rate-limit.d.ts.map +1 -1
  12. package/dist/better-auth/rate-limit.js +38 -2
  13. package/dist/better-auth/rate-limit.js.map +1 -1
  14. package/dist/better-auth.js +1 -1
  15. package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
  16. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  17. package/dist/cloudflare/queue.d.ts +175 -0
  18. package/dist/cloudflare/queue.d.ts.map +1 -0
  19. package/dist/cloudflare/queue.js +419 -0
  20. package/dist/cloudflare/queue.js.map +1 -0
  21. package/dist/cloudflare/scheduled.d.ts +36 -0
  22. package/dist/cloudflare/scheduled.d.ts.map +1 -0
  23. package/dist/cloudflare/scheduled.js +37 -0
  24. package/dist/cloudflare/scheduled.js.map +1 -0
  25. package/dist/cloudflare.d.ts +3 -3
  26. package/dist/cloudflare.js +3 -3
  27. package/dist/{core-Cx5SeyKV.js → core-DC_4pOrI.js} +92 -2
  28. package/dist/core-DC_4pOrI.js.map +1 -0
  29. package/dist/core.d.ts +2 -2
  30. package/dist/core.js +2 -2
  31. package/dist/{index-BStnggQO.d.ts → index-6XMPJu7u.d.ts} +49 -2
  32. package/dist/index-6XMPJu7u.d.ts.map +1 -0
  33. package/dist/{index-DWIRrJMP.d.ts → index-_Cihfq1J.d.ts} +2 -2
  34. package/dist/{index-DWIRrJMP.d.ts.map → index-_Cihfq1J.d.ts.map} +1 -1
  35. package/dist/postgres.d.ts +3 -3
  36. package/dist/postgres.js +3 -3
  37. package/dist/tanstack.d.ts +18 -7
  38. package/dist/tanstack.d.ts.map +1 -1
  39. package/dist/tanstack.js +3 -3
  40. package/dist/tanstack.js.map +1 -1
  41. package/dist/{unit-of-work-context-DToDpAkv.js → unit-of-work-context-BFCmU92Z.js} +2 -2
  42. package/dist/{unit-of-work-context-DToDpAkv.js.map → unit-of-work-context-BFCmU92Z.js.map} +1 -1
  43. package/dist/{unit-of-work-context-CPz9rdSS.d.ts → unit-of-work-context-BWTJfYPW.d.ts} +3 -3
  44. package/dist/{unit-of-work-context-CPz9rdSS.d.ts.map → unit-of-work-context-BWTJfYPW.d.ts.map} +1 -1
  45. package/package.json +3 -6
  46. package/src/core/backoff.ts +29 -0
  47. package/src/core/classify-failure.ts +60 -0
  48. package/src/core/cloudflare-classification.ts +45 -0
  49. package/src/core/index.ts +14 -0
  50. package/src/infrastructure/better-auth/apple-client-secret.ts +41 -14
  51. package/src/infrastructure/better-auth/rate-limit.ts +44 -1
  52. package/src/infrastructure/cloudflare/queue/index.ts +25 -0
  53. package/src/infrastructure/cloudflare/queue/queue-client.ts +177 -0
  54. package/src/infrastructure/cloudflare/queue/queue-consumer.ts +146 -0
  55. package/src/infrastructure/cloudflare/queue/queue-limits.ts +54 -0
  56. package/src/infrastructure/cloudflare/queue/queue-message.ts +4 -0
  57. package/src/infrastructure/cloudflare/queue/queue.errors.ts +105 -0
  58. package/src/infrastructure/cloudflare/queue/redelivery-policy.ts +64 -0
  59. package/src/infrastructure/cloudflare/scheduled/index.ts +8 -0
  60. package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +62 -0
  61. package/src/infrastructure/tanstack/auth.middleware.ts +5 -3
  62. package/src/infrastructure/tanstack/error.middleware.ts +6 -7
  63. package/src/infrastructure/tanstack/request-context.ts +11 -0
  64. package/dist/core-Cx5SeyKV.js.map +0 -1
  65. package/dist/index-BStnggQO.d.ts.map +0 -1
@@ -1,4 +1,4 @@
1
- import { t as UnitOfWorkAlreadyActiveError } from "./application-DjsQtpgw.js";
1
+ import { t as UnitOfWorkAlreadyActiveError } from "./application-DYfcAy-Q.js";
2
2
  import { panic } from "better-result";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
4
 
@@ -45,4 +45,4 @@ function currentContext() {
45
45
 
46
46
  //#endregion
47
47
  export { stageD1Statement as i, eventCollector as n, runWithUnitOfWorkContext as r, collectD1Statements as t };
48
- //# sourceMappingURL=unit-of-work-context-DToDpAkv.js.map
48
+ //# sourceMappingURL=unit-of-work-context-BFCmU92Z.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"unit-of-work-context-DToDpAkv.js","names":[],"sources":["../src/infrastructure/unit-of-work-context.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks'\n\nimport { panic } from 'better-result'\nimport type { BatchItem } from 'drizzle-orm/batch'\n\nimport { UnitOfWorkAlreadyActiveError, type IEventCollector } from '@/application'\nimport type { DomainEvent } from '@/core'\n\ntype UnitOfWorkContext = { readonly events: DomainEvent[] } & (\n | { readonly database: 'd1'; readonly statements: BatchItem<'sqlite'>[] }\n | { readonly database: 'postgres' }\n)\n\n// One async scope isolates events and D1 statements and rejects nesting across both adapters.\nconst storage = new AsyncLocalStorage<UnitOfWorkContext>()\n\n/** Reject nested transactions before database work starts; preserve independent concurrent execution. */\nexport async function runWithUnitOfWorkContext<T>(\n database: UnitOfWorkContext['database'],\n work: () => Promise<T>,\n): Promise<T> {\n if (storage.getStore() !== undefined) throw new UnitOfWorkAlreadyActiveError()\n const context: UnitOfWorkContext =\n database === 'd1' ? { database, events: [], statements: [] } : { database, events: [] }\n return storage.run(context, work)\n}\n\n/** Inject this shared scope into the unit of work and expose its emitter to application handlers. */\nexport const eventCollector: IEventCollector = {\n emit(event) {\n currentContext().events.push(event)\n },\n collect() {\n return [...currentContext().events]\n },\n}\n\n/** Queue one native D1 statement for the active batch; other contexts cannot accept D1 writes. */\nexport function stageD1Statement(statement: BatchItem<'sqlite'>): void {\n d1Context().statements.push(statement)\n}\n\n/** Read the active D1 batch in staging order for commit. */\nexport function collectD1Statements(): ReadonlyArray<BatchItem<'sqlite'>> {\n return [...d1Context().statements]\n}\n\nfunction d1Context(): Extract<UnitOfWorkContext, { database: 'd1' }> {\n const context = currentContext()\n if (context.database !== 'd1') return panic('D1 statements require a D1 unit of work')\n return context\n}\n\nfunction currentContext(): UnitOfWorkContext {\n return storage.getStore() ?? panic('operation requires an active unit of work')\n}\n"],"mappings":";;;;;AAcA,MAAM,UAAU,IAAI,kBAAqC;;AAGzD,eAAsB,yBACpB,UACA,MACY;CACZ,IAAI,QAAQ,SAAS,MAAM,QAAW,MAAM,IAAI,6BAA6B;CAC7E,MAAM,UACJ,aAAa,OAAO;EAAE;EAAU,QAAQ,CAAC;EAAG,YAAY,CAAC;CAAE,IAAI;EAAE;EAAU,QAAQ,CAAC;CAAE;CACxF,OAAO,QAAQ,IAAI,SAAS,IAAI;AAClC;;AAGA,MAAa,iBAAkC;CAC7C,KAAK,OAAO;EACV,eAAe,CAAC,CAAC,OAAO,KAAK,KAAK;CACpC;CACA,UAAU;EACR,OAAO,CAAC,GAAG,eAAe,CAAC,CAAC,MAAM;CACpC;AACF;;AAGA,SAAgB,iBAAiB,WAAsC;CACrE,UAAU,CAAC,CAAC,WAAW,KAAK,SAAS;AACvC;;AAGA,SAAgB,sBAA0D;CACxE,OAAO,CAAC,GAAG,UAAU,CAAC,CAAC,UAAU;AACnC;AAEA,SAAS,YAA4D;CACnE,MAAM,UAAU,eAAe;CAC/B,IAAI,QAAQ,aAAa,MAAM,OAAO,MAAM,yCAAyC;CACrF,OAAO;AACT;AAEA,SAAS,iBAAoC;CAC3C,OAAO,QAAQ,SAAS,KAAK,MAAM,2CAA2C;AAChF"}
1
+ {"version":3,"file":"unit-of-work-context-BFCmU92Z.js","names":[],"sources":["../src/infrastructure/unit-of-work-context.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks'\n\nimport { panic } from 'better-result'\nimport type { BatchItem } from 'drizzle-orm/batch'\n\nimport { UnitOfWorkAlreadyActiveError, type IEventCollector } from '@/application'\nimport type { DomainEvent } from '@/core'\n\ntype UnitOfWorkContext = { readonly events: DomainEvent[] } & (\n | { readonly database: 'd1'; readonly statements: BatchItem<'sqlite'>[] }\n | { readonly database: 'postgres' }\n)\n\n// One async scope isolates events and D1 statements and rejects nesting across both adapters.\nconst storage = new AsyncLocalStorage<UnitOfWorkContext>()\n\n/** Reject nested transactions before database work starts; preserve independent concurrent execution. */\nexport async function runWithUnitOfWorkContext<T>(\n database: UnitOfWorkContext['database'],\n work: () => Promise<T>,\n): Promise<T> {\n if (storage.getStore() !== undefined) throw new UnitOfWorkAlreadyActiveError()\n const context: UnitOfWorkContext =\n database === 'd1' ? { database, events: [], statements: [] } : { database, events: [] }\n return storage.run(context, work)\n}\n\n/** Inject this shared scope into the unit of work and expose its emitter to application handlers. */\nexport const eventCollector: IEventCollector = {\n emit(event) {\n currentContext().events.push(event)\n },\n collect() {\n return [...currentContext().events]\n },\n}\n\n/** Queue one native D1 statement for the active batch; other contexts cannot accept D1 writes. */\nexport function stageD1Statement(statement: BatchItem<'sqlite'>): void {\n d1Context().statements.push(statement)\n}\n\n/** Read the active D1 batch in staging order for commit. */\nexport function collectD1Statements(): ReadonlyArray<BatchItem<'sqlite'>> {\n return [...d1Context().statements]\n}\n\nfunction d1Context(): Extract<UnitOfWorkContext, { database: 'd1' }> {\n const context = currentContext()\n if (context.database !== 'd1') return panic('D1 statements require a D1 unit of work')\n return context\n}\n\nfunction currentContext(): UnitOfWorkContext {\n return storage.getStore() ?? panic('operation requires an active unit of work')\n}\n"],"mappings":";;;;;AAcA,MAAM,UAAU,IAAI,kBAAqC;;AAGzD,eAAsB,yBACpB,UACA,MACY;CACZ,IAAI,QAAQ,SAAS,MAAM,QAAW,MAAM,IAAI,6BAA6B;CAC7E,MAAM,UACJ,aAAa,OAAO;EAAE;EAAU,QAAQ,CAAC;EAAG,YAAY,CAAC;CAAE,IAAI;EAAE;EAAU,QAAQ,CAAC;CAAE;CACxF,OAAO,QAAQ,IAAI,SAAS,IAAI;AAClC;;AAGA,MAAa,iBAAkC;CAC7C,KAAK,OAAO;EACV,eAAe,CAAC,CAAC,OAAO,KAAK,KAAK;CACpC;CACA,UAAU;EACR,OAAO,CAAC,GAAG,eAAe,CAAC,CAAC,MAAM;CACpC;AACF;;AAGA,SAAgB,iBAAiB,WAAsC;CACrE,UAAU,CAAC,CAAC,WAAW,KAAK,SAAS;AACvC;;AAGA,SAAgB,sBAA0D;CACxE,OAAO,CAAC,GAAG,UAAU,CAAC,CAAC,UAAU;AACnC;AAEA,SAAS,YAA4D;CACnE,MAAM,UAAU,eAAe;CAC/B,IAAI,QAAQ,aAAa,MAAM,OAAO,MAAM,yCAAyC;CACrF,OAAO;AACT;AAEA,SAAS,iBAAoC;CAC3C,OAAO,QAAQ,SAAS,KAAK,MAAM,2CAA2C;AAChF"}
@@ -1,5 +1,5 @@
1
- import "./index-BStnggQO.js";
2
- import { o as IEventCollector } from "./index-DWIRrJMP.js";
1
+ import "./index-6XMPJu7u.js";
2
+ import { o as IEventCollector } from "./index-_Cihfq1J.js";
3
3
  import { BatchItem } from "drizzle-orm/batch";
4
4
  //#region src/infrastructure/drizzle-schema.d.ts
5
5
  /**
@@ -13,4 +13,4 @@ type DrizzleSchema = Record<string, unknown>;
13
13
  declare const eventCollector: IEventCollector;
14
14
  //#endregion
15
15
  export { DrizzleSchema as n, eventCollector as t };
16
- //# sourceMappingURL=unit-of-work-context-CPz9rdSS.d.ts.map
16
+ //# sourceMappingURL=unit-of-work-context-BWTJfYPW.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"unit-of-work-context-CPz9rdSS.d.ts","names":[],"sources":["../src/infrastructure/drizzle-schema.ts","../src/infrastructure/unit-of-work-context.ts"],"mappings":";;;;;;;;KAKY,gBAAgB;;;;cCuBf,gBAAgB"}
1
+ {"version":3,"file":"unit-of-work-context-BWTJfYPW.d.ts","names":[],"sources":["../src/infrastructure/drizzle-schema.ts","../src/infrastructure/unit-of-work-context.ts"],"mappings":";;;;;;;;KAKY,gBAAgB;;;;cCuBf,gBAAgB"}
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.7.0",
4
+ "version": "0.10.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": {
@@ -24,6 +24,8 @@
24
24
  "./better-auth/stripe": "./dist/better-auth/stripe.js",
25
25
  "./cloudflare": "./dist/cloudflare.js",
26
26
  "./cloudflare/auth-secondary-storage": "./dist/cloudflare/auth-secondary-storage.js",
27
+ "./cloudflare/queue": "./dist/cloudflare/queue.js",
28
+ "./cloudflare/scheduled": "./dist/cloudflare/scheduled.js",
27
29
  "./core": "./dist/core.js",
28
30
  "./postgres": "./dist/postgres.js",
29
31
  "./tanstack": "./dist/tanstack.js",
@@ -52,7 +54,6 @@
52
54
  "better-auth": "^1.7.4",
53
55
  "drizzle-kit": "^0.31.10",
54
56
  "drizzle-orm": "^0.45.2",
55
- "jose": "^6.2.12",
56
57
  "postgres": "^3.4.9",
57
58
  "stripe": "^22.6.2",
58
59
  "tsdown": "^0.23.0",
@@ -66,7 +67,6 @@
66
67
  "@tanstack/react-start": "^1.168.52",
67
68
  "better-auth": "^1.7.4",
68
69
  "drizzle-orm": "^0.45.2",
69
- "jose": "^6.2.12",
70
70
  "stripe": "^22.6.2"
71
71
  },
72
72
  "peerDependenciesMeta": {
@@ -76,9 +76,6 @@
76
76
  "stripe": {
77
77
  "optional": true
78
78
  },
79
- "jose": {
80
- "optional": true
81
- },
82
79
  "better-auth": {
83
80
  "optional": true
84
81
  },
@@ -0,0 +1,29 @@
1
+ import { panic } from 'better-result'
2
+
3
+ /** Uniform jitter within one fixed window. */
4
+ export function spreadJitterMs(spreadMs: number): number {
5
+ if (!Number.isFinite(spreadMs) || spreadMs < 0) {
6
+ return panic('spreadMs must be a non-negative number')
7
+ }
8
+ return Math.floor(Math.random() * spreadMs)
9
+ }
10
+
11
+ /**
12
+ * Half the capped exponential delay, plus jitter over the other half. Decorrelates concurrent
13
+ * repeats like full jitter, but keeps a floor: use it when few attempts remain before work is dropped.
14
+ */
15
+ export function exponentialEqualJitterMs(
16
+ baseDelayMs: number,
17
+ maxDelayMs: number,
18
+ attempt: number,
19
+ ): number {
20
+ const halfCapMs = exponentialDelayCapMs(baseDelayMs, maxDelayMs, attempt) / 2
21
+ return Math.floor(halfCapMs) + spreadJitterMs(halfCapMs)
22
+ }
23
+
24
+ function exponentialDelayCapMs(baseDelayMs: number, maxDelayMs: number, attempt: number): number {
25
+ if (!Number.isSafeInteger(attempt) || attempt < 1) {
26
+ return panic('attempt must be a positive integer')
27
+ }
28
+ return Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs)
29
+ }
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod'
2
+
3
+ import { AppError } from './app-error.ts'
4
+ import {
5
+ isCloudflareOverloadedError,
6
+ isCloudflareRetryableError,
7
+ isTransientCloudflareRuntimeError,
8
+ } from './cloudflare-classification.ts'
9
+ import type { FailureClassification } from './failure-classification.ts'
10
+ import { isAbortError, isTransientTransportError } from './transport-classification.ts'
11
+
12
+ /** A boundary's own rule for its raw errors. `undefined` means the boundary has no opinion. */
13
+ export type BoundaryErrorClassifier = (cause: unknown) => FailureClassification | undefined
14
+
15
+ /**
16
+ * Classify one raw external error: the boundary's own rule first, then Cloudflare's signals,
17
+ * then the transport patterns. Cancellation is terminal even when Cloudflare marks it retryable.
18
+ */
19
+ export function classifyBoundaryError(args: {
20
+ readonly cause: unknown
21
+ readonly classifyBoundary: BoundaryErrorClassifier
22
+ readonly includeCloudflare: boolean
23
+ }): FailureClassification {
24
+ const boundary = args.classifyBoundary(args.cause)
25
+ if (boundary !== undefined) return boundary
26
+
27
+ if (isAbortError(args.cause)) return 'terminal'
28
+
29
+ if (
30
+ args.includeCloudflare &&
31
+ (isCloudflareOverloadedError(args.cause) ||
32
+ isCloudflareRetryableError(args.cause) ||
33
+ isTransientCloudflareRuntimeError(args.cause))
34
+ ) {
35
+ return 'transient'
36
+ }
37
+
38
+ if (isTransientTransportError(args.cause)) return 'transient'
39
+
40
+ return 'unknown'
41
+ }
42
+
43
+ /**
44
+ * Judge a final failure for its durable retry owner. An `AppError` carries its own answer.
45
+ * A raw throw nobody judged is unknown, so an immediate owner stops and a durable one may repeat.
46
+ */
47
+ export function classifyFailure(cause: unknown): FailureClassification {
48
+ if (cause instanceof AppError) return cause.classification
49
+ if (isAbortError(cause)) return 'terminal'
50
+ if (isTransientCloudflareRuntimeError(cause)) return 'transient'
51
+ return 'unknown'
52
+ }
53
+
54
+ const RetryAfterSchema = z.object({ retryAfter: z.number().positive() })
55
+
56
+ /** Read a positive server-advertised retry delay in seconds, such as `RateLimitError.retryAfter`. */
57
+ export function retryAfterSecondsOf(cause: unknown): number | null {
58
+ const result = RetryAfterSchema.safeParse(cause)
59
+ return result.success ? result.data.retryAfter : null
60
+ }
@@ -0,0 +1,45 @@
1
+ import { z } from 'zod'
2
+
3
+ /** Stable Cloudflare messages that mean the runtime failed, not our code, and a repeat may pass. */
4
+ export const CLOUDFLARE_TRANSIENT_ERROR_PATTERNS = {
5
+ networkConnectionLost: /network connection lost/,
6
+ objectReset: /caused object to be reset/,
7
+ codeUpdateReset: /reset because its code was updated/,
8
+ objectMoved: /object has moved to a different machine/,
9
+ internalWorkflows: /internal workflows error/,
10
+ } as const
11
+
12
+ const TRANSIENT_PLATFORM_PATTERNS = [
13
+ CLOUDFLARE_TRANSIENT_ERROR_PATTERNS.objectReset,
14
+ CLOUDFLARE_TRANSIENT_ERROR_PATTERNS.codeUpdateReset,
15
+ CLOUDFLARE_TRANSIENT_ERROR_PATTERNS.objectMoved,
16
+ CLOUDFLARE_TRANSIENT_ERROR_PATTERNS.internalWorkflows,
17
+ ]
18
+
19
+ // workerd stamps these flags as own properties on the error it throws across a binding.
20
+ const RetryableFlagSchema = z.object({ retryable: z.literal(true) })
21
+ const OverloadedFlagSchema = z.object({ overloaded: z.literal(true) })
22
+ const ObjectResetFlagSchema = z.object({ durableObjectReset: z.literal(true) })
23
+
24
+ /** workerd stamps `retryable` on a failure it judged safe to repeat. */
25
+ export function isCloudflareRetryableError(cause: unknown): boolean {
26
+ return RetryableFlagSchema.validate(cause)
27
+ }
28
+
29
+ /** workerd stamps `overloaded` when the target could not take the call. */
30
+ export function isCloudflareOverloadedError(cause: unknown): boolean {
31
+ return OverloadedFlagSchema.validate(cause)
32
+ }
33
+
34
+ /** workerd stamps `durableObjectReset` when the object restarted during the call. */
35
+ export function isCloudflareObjectResetError(cause: unknown): boolean {
36
+ return ObjectResetFlagSchema.validate(cause)
37
+ }
38
+
39
+ /** Match transient Cloudflare runtime evidence without the general transport patterns. */
40
+ export function isTransientCloudflareRuntimeError(cause: unknown): boolean {
41
+ if (isCloudflareObjectResetError(cause)) return true
42
+ if (!(cause instanceof Error)) return false
43
+ const message = cause.message.toLowerCase()
44
+ return TRANSIENT_PLATFORM_PATTERNS.some((pattern) => pattern.test(message))
45
+ }
package/src/core/index.ts CHANGED
@@ -57,6 +57,20 @@ export {
57
57
  type RetryOwner,
58
58
  } from './failure-classification.ts'
59
59
  export { isAbortError, isTransientTransportError } from './transport-classification.ts'
60
+ export {
61
+ CLOUDFLARE_TRANSIENT_ERROR_PATTERNS,
62
+ isCloudflareObjectResetError,
63
+ isCloudflareOverloadedError,
64
+ isCloudflareRetryableError,
65
+ isTransientCloudflareRuntimeError,
66
+ } from './cloudflare-classification.ts'
67
+ export {
68
+ classifyBoundaryError,
69
+ classifyFailure,
70
+ retryAfterSecondsOf,
71
+ type BoundaryErrorClassifier,
72
+ } from './classify-failure.ts'
73
+ export { exponentialEqualJitterMs, spreadJitterMs } from './backoff.ts'
60
74
  export {
61
75
  configureLogger,
62
76
  consoleSink,
@@ -1,12 +1,21 @@
1
- import { importPKCS8, SignJWT } from 'jose'
1
+ import { Buffer } from 'node:buffer'
2
+ import { createPrivateKey, sign } from 'node:crypto'
2
3
 
3
4
  import { AppError, createLogger } from '@/core'
4
5
 
5
6
  const logger = createLogger('auth')
6
7
  // Typist reports an invalid key once per isolate because session checks repeatedly construct auth options.
7
8
  let appleKeyFailureLogged = false
9
+ /**
10
+ * Apple caps `exp` at 15,777,000 seconds after `iat`; 180 days stays under it. Source:
11
+ * https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret
12
+ */
13
+ const SECRET_LIFETIME_SECONDS = 180 * 24 * 60 * 60
8
14
 
9
- /** Apple sends its OAuth callback from this origin. Include it in trustedOrigins when enabling Apple. */
15
+ /**
16
+ * Apple's issuer and OAuth callback origin, and the `aud` claim of the client secret. Include it in
17
+ * trustedOrigins when enabling Apple. Source: the same Apple page as `SECRET_LIFETIME_SECONDS`.
18
+ */
10
19
  export const APPLE_ORIGIN = 'https://appleid.apple.com'
11
20
 
12
21
  /** Apple credentials used to sign a client secret. Ported from Porte's apple-client-secret.ts. */
@@ -27,11 +36,23 @@ export class AppleClientSecretError extends AppError {
27
36
  }
28
37
  }
29
38
 
30
- /** Sign Apple's six-month client secret. Invalid private keys return an empty secret and log once per isolate, as in Typist. */
31
- export async function generateAppleClientSecret(config: AppleKeyConfig): Promise<string> {
32
- let key: Awaited<ReturnType<typeof importPKCS8>>
39
+ /** One JWS segment before encoding: the protected header or the claims. */
40
+ type JwsSegment = Readonly<Record<string, string | number>>
41
+
42
+ function base64UrlJson(segment: JwsSegment): string {
43
+ return Buffer.from(JSON.stringify(segment)).toString('base64url')
44
+ }
45
+
46
+ /**
47
+ * Sign Apple's six-month client secret synchronously, so Better Auth options can be built without awaiting it.
48
+ * Signing uses `node:crypto` as Typist does, so the Worker needs the `nodejs_compat` flag.
49
+ * Invalid private keys return an empty secret and log once per isolate, as in Typist.
50
+ */
51
+ export function generateAppleClientSecret(config: AppleKeyConfig): string {
52
+ const key = config.applePrivateKey.replace(/\\n/g, '\n')
53
+ // Validation only: workerd's sign() takes the PEM string, not the KeyObject.
33
54
  try {
34
- key = await importPKCS8(config.applePrivateKey.replace(/\\n/g, '\n'), 'ES256')
55
+ createPrivateKey(key)
35
56
  } catch (error) {
36
57
  if (!appleKeyFailureLogged) {
37
58
  appleKeyFailureLogged = true
@@ -39,15 +60,21 @@ export async function generateAppleClientSecret(config: AppleKeyConfig): Promise
39
60
  }
40
61
  return ''
41
62
  }
63
+ const issuedAt = Math.floor(Date.now() / 1000)
64
+ const header = base64UrlJson({ alg: 'ES256', kid: config.appleKeyId })
65
+ const claims = base64UrlJson({
66
+ iss: config.appleTeamId,
67
+ sub: config.appleClientId,
68
+ aud: APPLE_ORIGIN,
69
+ iat: issuedAt,
70
+ exp: issuedAt + SECRET_LIFETIME_SECONDS,
71
+ })
72
+ const input = `${header}.${claims}`
42
73
  try {
43
- return await new SignJWT({})
44
- .setProtectedHeader({ alg: 'ES256', kid: config.appleKeyId })
45
- .setIssuer(config.appleTeamId)
46
- .setSubject(config.appleClientId)
47
- .setAudience(APPLE_ORIGIN)
48
- .setIssuedAt()
49
- .setExpirationTime('180d')
50
- .sign(key)
74
+ // A JWS carries the raw r||s signature; OpenSSL emits DER unless told otherwise.
75
+ const signature = sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' })
76
+ // TypeScript 7 types the returned buffer without an encoding parameter on toString.
77
+ return `${input}.${Buffer.from(signature).toString('base64url')}`
51
78
  } catch (cause) {
52
79
  throw new AppleClientSecretError(cause)
53
80
  }
@@ -1,7 +1,13 @@
1
- import type { BetterAuthRateLimitStorage } from 'better-auth'
1
+ import type { BetterAuthOptions, BetterAuthRateLimitStorage } from 'better-auth'
2
2
 
3
3
  import { AppError, createLogger } from '@/core'
4
4
 
5
+ /** A rule Better Auth passes to storage: a window in seconds and the requests it allows. */
6
+ export type AuthRateLimitRule = {
7
+ readonly window: number
8
+ readonly max: number
9
+ }
10
+
5
11
  /** Each rule must match its binding's deployed limit and period. Cloudflare accepts only 10 or 60 seconds. */
6
12
  export type AuthRateLimitBinding = {
7
13
  readonly binding: Pick<RateLimit, 'limit'>
@@ -9,6 +15,43 @@ export type AuthRateLimitBinding = {
9
15
  readonly max: number
10
16
  }
11
17
 
18
+ /**
19
+ * Better Auth's built-in rules, which it does not export. Sign-in, sign-up, change-password, and
20
+ * change-email paths get 3 requests per 10 seconds. Password reset, verification email, forget-password,
21
+ * and email OTP send paths get 3 per 60 seconds. A package test pins both against the installed version.
22
+ * Source: `getDefaultSpecialRules` in
23
+ * https://github.com/better-auth/better-auth/blob/v1.7.4/packages/better-auth/src/api/rate-limiter/index.ts
24
+ */
25
+ export const BUILT_IN_AUTH_RATE_LIMIT_RULES: readonly AuthRateLimitRule[] = [
26
+ { window: 10, max: 3 },
27
+ { window: 60, max: 3 },
28
+ ]
29
+
30
+ /**
31
+ * Every rule the configured instance can pass to storage, deduplicated: the general rule, the built-in
32
+ * rules, each plugin's rules, and static custom rules. Function-valued custom rules decide per request
33
+ * and are not listed. Disabled rate limiting returns no rules.
34
+ */
35
+ export function authRateLimitRules(
36
+ options: Pick<BetterAuthOptions, 'rateLimit' | 'plugins'>,
37
+ ): readonly AuthRateLimitRule[] {
38
+ const rateLimit = options.rateLimit
39
+ if (rateLimit?.enabled === false) return []
40
+ // Better Auth's own defaults when the general rule is not set: window 10, max 100. Source:
41
+ // https://github.com/better-auth/better-auth/blob/v1.7.4/packages/better-auth/src/context/create-context.ts
42
+ const rules: AuthRateLimitRule[] = [
43
+ { window: rateLimit?.window ?? 10, max: rateLimit?.max ?? 100 },
44
+ ...BUILT_IN_AUTH_RATE_LIMIT_RULES,
45
+ ]
46
+ for (const plugin of options.plugins ?? [])
47
+ for (const rule of plugin.rateLimit ?? []) rules.push({ window: rule.window, max: rule.max })
48
+ for (const rule of Object.values(rateLimit?.customRules ?? {}))
49
+ if (rule !== false && !(rule instanceof Function))
50
+ rules.push({ window: rule.window, max: rule.max })
51
+ const unique = new Map(rules.map((rule) => [`${rule.window}:${rule.max}`, rule]))
52
+ return [...unique.values()]
53
+ }
54
+
12
55
  /** A binding failure with no known retry classification. The cause preserves the original failure. */
13
56
  export class AuthRateLimitError extends AppError {
14
57
  readonly _tag = 'AuthRateLimitError'
@@ -0,0 +1,25 @@
1
+ export {
2
+ QueueClient,
3
+ type IQueueClient,
4
+ type QueueBinding,
5
+ type QueueClientOptions,
6
+ type QueueSendOptions,
7
+ } from './queue-client.ts'
8
+ export type { QueueMessage } from './queue-message.ts'
9
+ export { QueueConsumer, type DispatchBus, type DispatchWrapper } from './queue-consumer.ts'
10
+ export {
11
+ chunkByLimits,
12
+ isOversized,
13
+ messageByteLength,
14
+ QUEUE_LIMITS,
15
+ sizeMessages,
16
+ type SizedMessage,
17
+ } from './queue-limits.ts'
18
+ export {
19
+ classifyQueueError,
20
+ DeadLetterUnavailableError,
21
+ OversizedMessageError,
22
+ QueueSendError,
23
+ type QueueOperation,
24
+ } from './queue.errors.ts'
25
+ export { decideRedelivery, type RedeliveryDecision } from './redelivery-policy.ts'
@@ -0,0 +1,177 @@
1
+ import { Result } from 'better-result'
2
+
3
+ import { createLogger, shouldRetryFailure } from '@/core'
4
+
5
+ import {
6
+ chunkByLimits,
7
+ isOversized,
8
+ messageByteLength,
9
+ QUEUE_LIMITS,
10
+ sizeMessages,
11
+ type SizedMessage,
12
+ } from './queue-limits.ts'
13
+ import type { QueueMessage } from './queue-message.ts'
14
+ import { OversizedMessageError, QueueSendError, type QueueOperation } from './queue.errors.ts'
15
+
16
+ /** Cloudflare's delivery delay, in seconds. */
17
+ export type QueueSendOptions = { readonly delaySeconds: number }
18
+
19
+ /** The two calls the client makes on a queue binding. */
20
+ export type QueueBinding<Body> = Pick<Queue<Body>, 'send' | 'sendBatch'>
21
+
22
+ /**
23
+ * The lanes and the dead letter of one application. A lane is a queue with its own consumer
24
+ * settings; `laneOf` is the product's routing rule, written once at the composition root.
25
+ */
26
+ export type QueueClientOptions<TMessage extends QueueMessage, Lane extends string> = {
27
+ readonly queues: Readonly<Record<Lane, QueueBinding<TMessage>>>
28
+ /** Where the consumer parks poison bodies. Cloudflare also routes exhausted retries here. */
29
+ readonly deadLetter: Pick<Queue, 'send'>
30
+ readonly laneOf: (message: TMessage) => Lane
31
+ }
32
+
33
+ /** The port an entrypoint, a relay, or a consumer sends through. */
34
+ export interface IQueueClient<TMessage extends QueueMessage> {
35
+ send(message: TMessage, options?: QueueSendOptions): Promise<void>
36
+ sendBatch(messages: ReadonlyArray<TMessage>): Promise<void>
37
+ /** Sends every message within the limits and returns the oversized ones. */
38
+ sendBatchPartial(messages: ReadonlyArray<TMessage>): Promise<ReadonlyArray<TMessage>>
39
+ /** Parks a delivered message's body as it arrived. */
40
+ sendToDlq(message: Message): Promise<void>
41
+ }
42
+
43
+ const logger = createLogger('queue-client')
44
+
45
+ /** Immediate retries of one binding call: three attempts, 250 ms then 500 ms, with jitter. */
46
+ const RETRY_TIMES = 2
47
+ const RETRY_BASE_DELAY_MS = 250
48
+
49
+ /** Every send is safe to repeat, so a transient failure gets an immediate retry; terminal and unknown stop. */
50
+ function shouldRetrySend(error: QueueSendError): boolean {
51
+ return shouldRetryFailure({
52
+ classification: error.classification,
53
+ repeatSafe: true,
54
+ owner: 'immediate',
55
+ })
56
+ }
57
+
58
+ function oversizedError(sized: SizedMessage<QueueMessage>): OversizedMessageError {
59
+ return new OversizedMessageError({
60
+ messageName: sized.message.name,
61
+ bytes: sized.bytes,
62
+ limit: QUEUE_LIMITS.messageBytes,
63
+ })
64
+ }
65
+
66
+ /**
67
+ * Sends messages to the application's queues. Checks the size limits before a send, retries a
68
+ * transient binding failure a bounded number of times, and converts the final failure to `QueueSendError`.
69
+ */
70
+ export class QueueClient<
71
+ TMessage extends QueueMessage,
72
+ Lane extends string,
73
+ > implements IQueueClient<TMessage> {
74
+ constructor(private readonly options: QueueClientOptions<TMessage, Lane>) {}
75
+
76
+ /** @throws OversizedMessageError before the send. @throws QueueSendError after the retries. */
77
+ async send(message: TMessage, options?: QueueSendOptions): Promise<void> {
78
+ const sized = { message, bytes: messageByteLength(message) }
79
+ if (isOversized(sized)) {
80
+ logger.warn('queue_message_oversized', {
81
+ name: message.name,
82
+ type: message.type,
83
+ bytes: sized.bytes,
84
+ })
85
+ throw oversizedError(sized)
86
+ }
87
+
88
+ const queue = this.queueFor(message)
89
+ logger.debug('sending_message', {
90
+ name: message.name,
91
+ type: message.type,
92
+ delaySeconds: options?.delaySeconds,
93
+ })
94
+
95
+ await this.callQueue('send', async () => {
96
+ if (options) {
97
+ await queue.send(message, options)
98
+ } else {
99
+ await queue.send(message)
100
+ }
101
+ })
102
+ }
103
+
104
+ /** Rejects the whole batch before any send when one message is oversized. */
105
+ async sendBatch(messages: ReadonlyArray<TMessage>): Promise<void> {
106
+ const sized = sizeMessages(messages)
107
+ const oversized = sized.find(isOversized)
108
+ if (oversized) throw oversizedError(oversized)
109
+ await this.sendSizedBatch(sized)
110
+ }
111
+
112
+ async sendBatchPartial(messages: ReadonlyArray<TMessage>): Promise<ReadonlyArray<TMessage>> {
113
+ const sized = sizeMessages(messages)
114
+ await this.sendSizedBatch(sized.filter((item) => !isOversized(item)))
115
+ return sized.filter(isOversized).map((item) => item.message)
116
+ }
117
+
118
+ async sendToDlq(message: Message): Promise<void> {
119
+ logger.debug('sending_to_dlq', { queueMessageId: message.id })
120
+ await this.callQueue('send_dlq', async () => {
121
+ await this.options.deadLetter.send(message.body)
122
+ })
123
+ }
124
+
125
+ /** Each lane's chunks retry on their own, so one failed chunk does not resend the others. */
126
+ private async sendSizedBatch(sized: ReadonlyArray<SizedMessage<TMessage>>): Promise<void> {
127
+ const byLane = new Map<Lane, SizedMessage<TMessage>[]>()
128
+ for (const item of sized) {
129
+ const lane = this.options.laneOf(item.message)
130
+ const items = byLane.get(lane) ?? []
131
+ items.push(item)
132
+ byLane.set(lane, items)
133
+ }
134
+
135
+ logger.debug('sending_batch', {
136
+ count: sized.length,
137
+ lanes: [...byLane].map(([lane, items]) => ({ lane, count: items.length })),
138
+ })
139
+
140
+ const sends: Promise<void>[] = []
141
+ for (const [lane, items] of byLane) {
142
+ const queue = this.options.queues[lane]
143
+ for (const group of chunkByLimits(items)) {
144
+ sends.push(
145
+ this.callQueue('send_batch', async () => {
146
+ await queue.sendBatch(group.map((body) => ({ body })))
147
+ }),
148
+ )
149
+ }
150
+ }
151
+ await Promise.all(sends)
152
+ }
153
+
154
+ private queueFor(message: TMessage): QueueBinding<TMessage> {
155
+ return this.options.queues[this.options.laneOf(message)]
156
+ }
157
+
158
+ private async callQueue<T>(operation: QueueOperation, call: () => Promise<T>): Promise<T> {
159
+ const result = await Result.tryPromise(
160
+ {
161
+ try: call,
162
+ catch: (cause) => new QueueSendError({ operation, cause }),
163
+ },
164
+ {
165
+ retry: {
166
+ times: RETRY_TIMES,
167
+ delayMs: RETRY_BASE_DELAY_MS,
168
+ backoff: 'exponential',
169
+ jitter: true,
170
+ shouldRetry: shouldRetrySend,
171
+ },
172
+ },
173
+ )
174
+ if (result.isErr()) throw result.error
175
+ return result.value
176
+ }
177
+ }