better-ship 0.13.0 → 0.15.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 (103) hide show
  1. package/dist/{application-DnlgWcOZ.js → application-C4JOf71r.js} +22 -3
  2. package/dist/application-C4JOf71r.js.map +1 -0
  3. package/dist/application.d.ts +3 -3
  4. package/dist/application.js +2 -2
  5. package/dist/better-auth/apple-client-secret.d.ts +1 -1
  6. package/dist/better-auth/apple-client-secret.js +1 -1
  7. package/dist/better-auth/auth-client.js +1 -1
  8. package/dist/better-auth/rate-limit.d.ts +1 -1
  9. package/dist/better-auth/rate-limit.js +1 -1
  10. package/dist/better-auth.js +1 -1
  11. package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
  12. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  13. package/dist/cloudflare/durable-object-client.d.ts +18 -0
  14. package/dist/cloudflare/durable-object-client.d.ts.map +1 -0
  15. package/dist/cloudflare/durable-object-client.js +46 -0
  16. package/dist/cloudflare/durable-object-client.js.map +1 -0
  17. package/dist/cloudflare/durable-object-errors.d.ts +40 -0
  18. package/dist/cloudflare/durable-object-errors.d.ts.map +1 -0
  19. package/dist/cloudflare/durable-object-errors.js +48 -0
  20. package/dist/cloudflare/durable-object-errors.js.map +1 -0
  21. package/dist/cloudflare/queue.d.ts +2 -2
  22. package/dist/cloudflare/queue.js +2 -2
  23. package/dist/cloudflare/scheduled.d.ts +1 -1
  24. package/dist/cloudflare/scheduled.js +1 -1
  25. package/dist/cloudflare.d.ts +3 -3
  26. package/dist/cloudflare.js +3 -3
  27. package/dist/{core-BiqdOMwU.js → core-hdaNiCOU.js} +21 -2
  28. package/dist/core-hdaNiCOU.js.map +1 -0
  29. package/dist/core.d.ts +2 -2
  30. package/dist/core.js +2 -2
  31. package/dist/http.d.ts +2 -2
  32. package/dist/http.js +2 -2
  33. package/dist/{index-DG4jkwad.d.ts → index-B45Dr5gs.d.ts} +58 -5
  34. package/dist/index-B45Dr5gs.d.ts.map +1 -0
  35. package/dist/{index-B5O6mNjZ.d.ts → index-G1QG0P5g.d.ts} +34 -2
  36. package/dist/index-G1QG0P5g.d.ts.map +1 -0
  37. package/dist/{outbox-repository-options-BiilQf4_.d.ts → outbox-repository-options-CwTTuy7B.d.ts} +3 -3
  38. package/dist/{outbox-repository-options-BiilQf4_.d.ts.map → outbox-repository-options-CwTTuy7B.d.ts.map} +1 -1
  39. package/dist/{outbox-repository-options-BI75j7b7.js → outbox-repository-options-DOTpJH-e.js} +2 -2
  40. package/dist/{outbox-repository-options-BI75j7b7.js.map → outbox-repository-options-DOTpJH-e.js.map} +1 -1
  41. package/dist/postgres.d.ts +3 -3
  42. package/dist/postgres.js +3 -3
  43. package/dist/posthog/client.d.ts +22 -0
  44. package/dist/posthog/client.d.ts.map +1 -0
  45. package/dist/posthog/client.js +34 -0
  46. package/dist/posthog/client.js.map +1 -0
  47. package/dist/posthog/metrics.d.ts +18 -0
  48. package/dist/posthog/metrics.d.ts.map +1 -0
  49. package/dist/posthog/metrics.js +30 -0
  50. package/dist/posthog/metrics.js.map +1 -0
  51. package/dist/posthog/proxy.d.ts +17 -0
  52. package/dist/posthog/proxy.d.ts.map +1 -0
  53. package/dist/posthog/proxy.js +34 -0
  54. package/dist/posthog/proxy.js.map +1 -0
  55. package/dist/posthog/server.d.ts +37 -0
  56. package/dist/posthog/server.d.ts.map +1 -0
  57. package/dist/posthog/server.js +81 -0
  58. package/dist/posthog/server.js.map +1 -0
  59. package/dist/resend.d.ts +3 -3
  60. package/dist/resend.js +3 -3
  61. package/dist/resend.js.map +1 -1
  62. package/dist/sentry/client.d.ts +22 -0
  63. package/dist/sentry/client.d.ts.map +1 -0
  64. package/dist/sentry/client.js +48 -0
  65. package/dist/sentry/client.js.map +1 -0
  66. package/dist/sentry/server.d.ts +24 -0
  67. package/dist/sentry/server.d.ts.map +1 -0
  68. package/dist/sentry/server.js +48 -0
  69. package/dist/sentry/server.js.map +1 -0
  70. package/dist/sentry/vite.d.ts +11 -0
  71. package/dist/sentry/vite.d.ts.map +1 -0
  72. package/dist/sentry/vite.js +17 -0
  73. package/dist/sentry/vite.js.map +1 -0
  74. package/dist/tanstack.d.ts +1 -1
  75. package/dist/tanstack.js +2 -2
  76. package/dist/testing/outbox-repository.d.ts +2 -2
  77. package/dist/{webhook-verification-CyBoERFz.d.ts → webhook-verification-D7DIhH6C.d.ts} +2 -2
  78. package/dist/{webhook-verification-CyBoERFz.d.ts.map → webhook-verification-D7DIhH6C.d.ts.map} +1 -1
  79. package/dist/{webhook-verification-CeXUJXVP.js → webhook-verification-trEgyz7I.js} +2 -2
  80. package/dist/{webhook-verification-CeXUJXVP.js.map → webhook-verification-trEgyz7I.js.map} +1 -1
  81. package/package.json +11 -2
  82. package/src/application/analytics-service.ts +21 -0
  83. package/src/application/email-service.ts +2 -2
  84. package/src/application/index.ts +13 -0
  85. package/src/application/metrics-service.ts +28 -0
  86. package/src/application/observability-service.ts +16 -0
  87. package/src/core/analytics-event.ts +37 -0
  88. package/src/core/index.ts +9 -0
  89. package/src/core/missing-configuration.error.ts +12 -0
  90. package/src/infrastructure/cloudflare/durable-object-client.ts +70 -0
  91. package/src/infrastructure/cloudflare/durable-object-errors.ts +70 -0
  92. package/src/infrastructure/posthog/client.ts +41 -0
  93. package/src/infrastructure/posthog/metrics.ts +30 -0
  94. package/src/infrastructure/posthog/proxy.ts +47 -0
  95. package/src/infrastructure/posthog/server.ts +107 -0
  96. package/src/infrastructure/resend/resend-email-service.ts +4 -1
  97. package/src/infrastructure/sentry/client.ts +55 -0
  98. package/src/infrastructure/sentry/server.ts +51 -0
  99. package/src/infrastructure/sentry/vite.ts +17 -0
  100. package/dist/application-DnlgWcOZ.js.map +0 -1
  101. package/dist/core-BiqdOMwU.js.map +0 -1
  102. package/dist/index-B5O6mNjZ.d.ts.map +0 -1
  103. package/dist/index-DG4jkwad.d.ts.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"webhook-verification-CyBoERFz.d.ts","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"mappings":";;;cAGa,iCAAiC;WAMjC;WALF;WACA;;EAGT,YACW,qEACT;;;KAOQ,oBAAoB;WACjB;WAA2B,OAAO;;WAClC;WAA6B;;WAC7B;WAA8B,OAAO;;;UAGnC,gBAAgB;;EAE/B,OAAO,cAAc,SAAS,UAAU,QAAQ,oBAAoB"}
1
+ {"version":3,"file":"webhook-verification-D7DIhH6C.d.ts","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"mappings":";;;cAGa,iCAAiC;WAMjC;WALF;WACA;;EAGT,YACW,qEACT;;;KAOQ,oBAAoB;WACjB;WAA2B,OAAO;;WAClC;WAA6B;;WAC7B;WAA8B,OAAO;;;UAGnC,gBAAgB;;EAE/B,OAAO,cAAc,SAAS,UAAU,QAAQ,oBAAoB"}
@@ -1,4 +1,4 @@
1
- import { $ as AppError } from "./core-BiqdOMwU.js";
1
+ import { tt as AppError } from "./core-hdaNiCOU.js";
2
2
 
3
3
  //#region src/infrastructure/http/webhook-verification.ts
4
4
  /** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */
@@ -15,4 +15,4 @@ var WebhookVerificationError = class extends AppError {
15
15
 
16
16
  //#endregion
17
17
  export { WebhookVerificationError as t };
18
- //# sourceMappingURL=webhook-verification-CeXUJXVP.js.map
18
+ //# sourceMappingURL=webhook-verification-trEgyz7I.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"webhook-verification-CeXUJXVP.js","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"sourcesContent":["import { AppError } from '@/core'\n\n/** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */\nexport class WebhookVerificationError extends AppError {\n readonly _tag = 'WebhookVerificationError'\n readonly classification = 'terminal'\n\n /** Keep secrets and payload details out of the public message. */\n constructor(\n readonly reason: 'missing_headers' | 'invalid_signature' | 'invalid_payload',\n cause?: unknown,\n ) {\n super(`Webhook rejected: ${reason}`, { cause })\n }\n}\n\n/** A verified event, an ignored event, or an invalid request. */\nexport type WebhookVerification<Event> =\n | { readonly outcome: 'event'; readonly event: Event }\n | { readonly outcome: 'ignored'; readonly type: string }\n | { readonly outcome: 'rejected'; readonly error: WebhookVerificationError }\n\n/** The provider owns signature verification, timestamp tolerance, and payload parsing. */\nexport interface WebhookVerifier<Event> {\n /** Verify the exact body before parsing it. Return invalid requests; throw operational failures. */\n verify(body: string, headers: Headers): Promise<WebhookVerification<Event>>\n}\n"],"mappings":";;;;AAGA,IAAa,2BAAb,cAA8C,SAAS;CAM1C;CALX,AAAS,OAAO;CAChB,AAAS,iBAAiB;;CAG1B,YACE,AAAS,QACT,OACA;EACA,MAAM,qBAAqB,UAAU,EAAE,MAAM,CAAC;EAHrC;CAIX;AACF"}
1
+ {"version":3,"file":"webhook-verification-trEgyz7I.js","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"sourcesContent":["import { AppError } from '@/core'\n\n/** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */\nexport class WebhookVerificationError extends AppError {\n readonly _tag = 'WebhookVerificationError'\n readonly classification = 'terminal'\n\n /** Keep secrets and payload details out of the public message. */\n constructor(\n readonly reason: 'missing_headers' | 'invalid_signature' | 'invalid_payload',\n cause?: unknown,\n ) {\n super(`Webhook rejected: ${reason}`, { cause })\n }\n}\n\n/** A verified event, an ignored event, or an invalid request. */\nexport type WebhookVerification<Event> =\n | { readonly outcome: 'event'; readonly event: Event }\n | { readonly outcome: 'ignored'; readonly type: string }\n | { readonly outcome: 'rejected'; readonly error: WebhookVerificationError }\n\n/** The provider owns signature verification, timestamp tolerance, and payload parsing. */\nexport interface WebhookVerifier<Event> {\n /** Verify the exact body before parsing it. Return invalid requests; throw operational failures. */\n verify(body: string, headers: Headers): Promise<WebhookVerification<Event>>\n}\n"],"mappings":";;;;AAGA,IAAa,2BAAb,cAA8C,SAAS;CAM1C;CALX,AAAS,OAAO;CAChB,AAAS,iBAAiB;;CAG1B,YACE,AAAS,QACT,OACA;EACA,MAAM,qBAAqB,UAAU,EAAE,MAAM,CAAC;EAHrC;CAIX;AACF"}
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.13.0",
4
+ "version": "0.15.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,12 +24,21 @@
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/durable-object-client": "./dist/cloudflare/durable-object-client.js",
28
+ "./cloudflare/durable-object-errors": "./dist/cloudflare/durable-object-errors.js",
27
29
  "./cloudflare/queue": "./dist/cloudflare/queue.js",
28
30
  "./cloudflare/scheduled": "./dist/cloudflare/scheduled.js",
29
31
  "./core": "./dist/core.js",
30
32
  "./http": "./dist/http.js",
31
33
  "./postgres": "./dist/postgres.js",
34
+ "./posthog/client": "./dist/posthog/client.js",
35
+ "./posthog/metrics": "./dist/posthog/metrics.js",
36
+ "./posthog/proxy": "./dist/posthog/proxy.js",
37
+ "./posthog/server": "./dist/posthog/server.js",
32
38
  "./resend": "./dist/resend.js",
39
+ "./sentry/client": "./dist/sentry/client.js",
40
+ "./sentry/server": "./dist/sentry/server.js",
41
+ "./sentry/vite": "./dist/sentry/vite.js",
33
42
  "./tanstack": "./dist/tanstack.js",
34
43
  "./tanstack/server": "./dist/tanstack/server.js",
35
44
  "./testing/outbox-repository": "./dist/testing/outbox-repository.js",
@@ -58,7 +67,7 @@
58
67
  "@tanstack/start-storage-context": "^1.167.31",
59
68
  "@types/node": "^26.5.1",
60
69
  "@types/react": "^19.3.0",
61
- "@vitest/browser-playwright": "^5.0.0",
70
+ "@vitest/browser-playwright": "^4.1.11",
62
71
  "auth": "^1.7.4",
63
72
  "better-auth": "^1.7.4",
64
73
  "drizzle-kit": "^0.31.10",
@@ -0,0 +1,21 @@
1
+ import type { AnalyticsEventIdentity, AnalyticsEventInput, AnalyticsProperties } from '@/core'
2
+
3
+ /** Product analytics without an SDK dependency, ported from Typist and Porte. */
4
+ export interface AnalyticsService<TEvent extends AnalyticsEventInput = AnalyticsEventInput> {
5
+ /** Attach traits to one person. */
6
+ identify(distinctId: string, properties?: AnalyticsProperties): void
7
+ /** Merge a previous ID into this person; the application must establish that both identify the same person. */
8
+ alias(distinctId: string, previousDistinctId: string): void
9
+ /** Schedule an event for a person; returning does not confirm remote acceptance. */
10
+ track(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void
11
+ /** Schedule an event without creating a person profile for its distinct ID. */
12
+ trackAnonymous(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void
13
+ }
14
+
15
+ /** Reports to nobody. The composition root selects this service when analytics is disabled. */
16
+ export const NULL_ANALYTICS: AnalyticsService = {
17
+ identify: () => {},
18
+ alias: () => {},
19
+ track: () => {},
20
+ trackAnonymous: () => {},
21
+ }
@@ -22,8 +22,8 @@ export type OutboundEmail = {
22
22
  readonly to: string | readonly string[]
23
23
  readonly subject: string
24
24
  readonly html: string
25
- /** The stable identity of this send. Resend deduplicates it for 24 hours. */
26
- readonly idempotencyKey: EmailIdempotencyKey
25
+ /** Optional stable identity of this send. Resend deduplicates supplied keys for 24 hours. */
26
+ readonly idempotencyKey?: EmailIdempotencyKey
27
27
  /** Resend generates plain text from html when omitted. An empty string disables that generation. */
28
28
  readonly text?: string
29
29
  readonly replyTo?: string
@@ -1,4 +1,17 @@
1
1
  export type { CommandHandler, EventHandler, QueryHandler } from './handlers.ts'
2
+ export { NULL_ANALYTICS, type AnalyticsService } from './analytics-service.ts'
3
+ export {
4
+ NULL_METRICS_SERVICE,
5
+ type MetricsService,
6
+ type MetricOptions,
7
+ type MetricAttributes,
8
+ type MetricAttributeValue,
9
+ } from './metrics-service.ts'
10
+ export type {
11
+ ObservabilityService,
12
+ ObservabilityScope,
13
+ ObservabilityUser,
14
+ } from './observability-service.ts'
2
15
  export {
3
16
  RecordingEmailService,
4
17
  type IEmailService,
@@ -0,0 +1,28 @@
1
+ /** A metric dimension; use a small, bounded value set and never user or request IDs. */
2
+ export type MetricAttributeValue = string | number | boolean
3
+
4
+ /** Dimensions shared by observations in one time series. */
5
+ export type MetricAttributes = Readonly<Record<string, MetricAttributeValue>>
6
+
7
+ /** Unit and dimensions for an operational measurement. */
8
+ export interface MetricOptions {
9
+ readonly attributes?: MetricAttributes
10
+ readonly unit?: string
11
+ }
12
+
13
+ /** Operational measurements, ported from Typist's metrics-service.ts; applications own metric names. */
14
+ export interface MetricsService<TName extends string = string> {
15
+ /** Add a nonnegative value to a total; the default increment is one. */
16
+ count(name: TName, value?: number, options?: MetricOptions): void
17
+ /** Record a current value. */
18
+ gauge(name: TName, value: number, options?: MetricOptions): void
19
+ /** Record one observation in a distribution. */
20
+ histogram(name: TName, value: number, options?: MetricOptions): void
21
+ }
22
+
23
+ /** Records nothing when the composition root disables metrics. */
24
+ export const NULL_METRICS_SERVICE: MetricsService = {
25
+ count: () => {},
26
+ gauge: () => {},
27
+ histogram: () => {},
28
+ }
@@ -0,0 +1,16 @@
1
+ /** The identity an error report belongs to. */
2
+ export interface ObservabilityUser {
3
+ readonly id: string
4
+ }
5
+
6
+ /** Identity within one isolated execution. */
7
+ export interface ObservabilityScope {
8
+ /** Attribute reports in this scope to one person. */
9
+ setUser(user: ObservabilityUser): void
10
+ }
11
+
12
+ /** Error identity and isolation, ported from Porte's sentry-observability-service.ts. */
13
+ export interface ObservabilityService extends ObservabilityScope {
14
+ /** Await work in its own scope so concurrent executions do not share identity. */
15
+ withIsolationScope<T>(callback: (scope: ObservabilityScope) => T | Promise<T>): Promise<T>
16
+ }
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod'
2
+
3
+ /** An event name and its application-owned properties, ported from Typist's analytics/types.ts. */
4
+ export interface AnalyticsEventInput<
5
+ TName extends string = string,
6
+ TProperties extends object = object,
7
+ > {
8
+ readonly name: TName
9
+ readonly properties: TProperties
10
+ }
11
+
12
+ /** A value supported by analytics person properties. */
13
+ export type AnalyticsProperty =
14
+ | string
15
+ | number
16
+ | boolean
17
+ | null
18
+ | undefined
19
+ | readonly AnalyticsProperty[]
20
+ | AnalyticsProperties
21
+
22
+ /** Person properties supplied by the application. */
23
+ export interface AnalyticsProperties {
24
+ readonly [name: string]: AnalyticsProperty
25
+ }
26
+
27
+ /** Parse a persisted analytics event UUID; the SDK generates ordinary event UUIDs itself. */
28
+ export const AnalyticsEventIdSchema = z.uuid().brand<'AnalyticsEventId'>()
29
+
30
+ /** The identity of one analytics event across repeated application deliveries. */
31
+ export type AnalyticsEventId = z.infer<typeof AnalyticsEventIdSchema>
32
+
33
+ /** Supply both original values when replaying one event; never share them across different occurrences. */
34
+ export interface AnalyticsEventIdentity {
35
+ readonly uuid: AnalyticsEventId
36
+ readonly timestamp: Date
37
+ }
package/src/core/index.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  export { AppError } from './app-error.ts'
2
+ export { MissingConfigurationError } from './missing-configuration.error.ts'
3
+ export {
4
+ AnalyticsEventIdSchema,
5
+ type AnalyticsEventId,
6
+ type AnalyticsEventIdentity,
7
+ type AnalyticsEventInput,
8
+ type AnalyticsProperties,
9
+ type AnalyticsProperty,
10
+ } from './analytics-event.ts'
2
11
  export { EmailAddressSchema, type EmailAddress } from './email-address.ts'
3
12
  export {
4
13
  emailIdempotencyKey,
@@ -0,0 +1,12 @@
1
+ import { AppError } from './app-error.ts'
2
+
3
+ /** Required configuration is absent. Ported from Porte's configuration.errors.ts. */
4
+ export class MissingConfigurationError extends AppError {
5
+ readonly _tag = 'MissingConfigurationError'
6
+ readonly classification = 'terminal'
7
+
8
+ /** Name the missing setting without including its value. */
9
+ constructor(readonly key: string) {
10
+ super(`${key} is required`)
11
+ }
12
+ }
@@ -0,0 +1,70 @@
1
+ import { Result } from 'better-result'
2
+
3
+ import { isAbortError, shouldRetryFailure } from '@/core'
4
+
5
+ import {
6
+ classifyDurableObjectError,
7
+ DurableObjectCallError,
8
+ isClassifiedDurableObjectError,
9
+ } from './durable-object-errors.ts'
10
+
11
+ /** Resolve a fresh stub on every call, including calls after a failed attempt. */
12
+ export type DurableObjectResolver<Object extends object> = {
13
+ getByName(name: string): Object | Promise<Object>
14
+ }
15
+
16
+ type Call<Object extends object, R> = (object: Object) => Promise<R>
17
+
18
+ /** Product clients name operations and establish repeat safety; this client owns transport retries. */
19
+ export abstract class DurableObjectClient<
20
+ T extends Rpc.DurableObjectBranded,
21
+ Object extends object = DurableObjectStub<T>,
22
+ > {
23
+ constructor(private readonly objects: DurableObjectResolver<Object>) {}
24
+
25
+ /** Retry transient failures only when repeating the entire callback is safe. */
26
+ protected repeatable<R>(name: string, work: Call<Object, R>): Promise<R> {
27
+ return this.run(name, work, true)
28
+ }
29
+
30
+ /** Make one attempt when repetition is unsafe or another caller owns retries. */
31
+ protected once<R>(name: string, work: Call<Object, R>): Promise<R> {
32
+ return this.run(name, work, false)
33
+ }
34
+
35
+ private async run<R>(name: string, work: Call<Object, R>, repeatSafe: boolean): Promise<R> {
36
+ const result = await Result.tryPromise(
37
+ {
38
+ // A failed RPC can break the stub permanently, so every attempt resolves it again.
39
+ try: async () => work(await this.objects.getByName(name)),
40
+ catch: (cause) => cause,
41
+ },
42
+ {
43
+ retry: {
44
+ times: 2,
45
+ delayMs: 25,
46
+ backoff: 'exponential',
47
+ jitter: true,
48
+ shouldRetry: (cause) => {
49
+ if (isAbortError(cause) || isClassifiedDurableObjectError(cause)) return false
50
+ return shouldRetryFailure({
51
+ classification: classifyDurableObjectError(cause),
52
+ repeatSafe,
53
+ owner: 'immediate',
54
+ })
55
+ },
56
+ },
57
+ },
58
+ )
59
+ if (result.isOk()) return result.value
60
+ if (
61
+ isAbortError(result.error) ||
62
+ isClassifiedDurableObjectError(result.error) ||
63
+ classifyDurableObjectError(result.error) === 'unknown'
64
+ ) {
65
+ // oxlint-disable-next-line typescript/only-throw-error -- Preserve raw failures for the owning error boundary.
66
+ throw result.error
67
+ }
68
+ throw new DurableObjectCallError({ cause: result.error })
69
+ }
70
+ }
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod'
2
+
3
+ import {
4
+ AppError,
5
+ isAbortError,
6
+ isCloudflareOverloadedError,
7
+ isCloudflareRetryableError,
8
+ isTransientCloudflareRuntimeError,
9
+ isTransientTransportError,
10
+ type FailureClassification,
11
+ } from '@/core'
12
+
13
+ /** Stable tag for a failed Durable Object call. */
14
+ export const DURABLE_OBJECT_CALL_ERROR = 'DurableObjectCallError'
15
+
16
+ const RemoteClassifiedErrorSchema = z.object({
17
+ _tag: z.string(),
18
+ classification: z.enum(['transient', 'terminal', 'unknown']),
19
+ remote: z.literal(true),
20
+ })
21
+
22
+ const RemoteCallErrorSchema = RemoteClassifiedErrorSchema.extend({
23
+ _tag: z.literal(DURABLE_OBJECT_CALL_ERROR),
24
+ cause: z.unknown(),
25
+ })
26
+
27
+ /** Recognize a judged local or remote error without admitting its tag to the public error table. */
28
+ export function isClassifiedDurableObjectError(
29
+ cause: unknown,
30
+ ): cause is AppError | (Error & z.output<typeof RemoteClassifiedErrorSchema>) {
31
+ return (
32
+ cause instanceof AppError ||
33
+ (cause instanceof Error && RemoteClassifiedErrorSchema.safeParse(cause).success)
34
+ )
35
+ }
36
+
37
+ /** Classify the error itself. Its cause remains diagnostic evidence, not a second retry decision. */
38
+ export function classifyDurableObjectError(cause: unknown): FailureClassification {
39
+ if (isAbortError(cause)) return 'terminal'
40
+ if (isClassifiedDurableObjectError(cause)) return cause.classification
41
+ if (isCloudflareOverloadedError(cause)) return 'terminal'
42
+ if (
43
+ isCloudflareRetryableError(cause) ||
44
+ isTransientCloudflareRuntimeError(cause) ||
45
+ isTransientTransportError(cause)
46
+ )
47
+ return 'transient'
48
+ return 'unknown'
49
+ }
50
+
51
+ /** Identify a local call error or its validated Workers RPC representation. */
52
+ export function isDurableObjectCallError(
53
+ cause: unknown,
54
+ ): cause is DurableObjectCallError | (Error & z.output<typeof RemoteCallErrorSchema>) {
55
+ return (
56
+ cause instanceof DurableObjectCallError ||
57
+ (cause instanceof Error && RemoteCallErrorSchema.safeParse(cause).success)
58
+ )
59
+ }
60
+
61
+ /** A call failed after retries stopped. Classification does not establish repeat safety. */
62
+ export class DurableObjectCallError extends AppError {
63
+ readonly _tag = DURABLE_OBJECT_CALL_ERROR
64
+ readonly classification: FailureClassification
65
+
66
+ constructor(args: { readonly cause: unknown }) {
67
+ super('Durable Object call failed', { cause: args.cause })
68
+ this.classification = classifyDurableObjectError(args.cause)
69
+ }
70
+ }
@@ -0,0 +1,41 @@
1
+ import posthog, { type PostHog, type PostHogConfig } from 'posthog-js'
2
+
3
+ /** The application controls enablement; disabled initialization needs no project key. */
4
+ export type PostHogClientConfig =
5
+ | { readonly enabled: false }
6
+ | {
7
+ readonly enabled: true
8
+ readonly apiKey: string
9
+ readonly environment: string
10
+ readonly source: string
11
+ readonly options?: Partial<PostHogConfig>
12
+ }
13
+
14
+ /** Browser options from Typist and Porte; SDK option names and application overrides stay intact. */
15
+ export function createPostHogClientOptions(
16
+ config: Extract<PostHogClientConfig, { enabled: true }>,
17
+ ): Partial<PostHogConfig> {
18
+ return {
19
+ api_host: '/relay',
20
+ ui_host: 'https://us.posthog.com',
21
+ person_profiles: 'identified_only',
22
+ defaults: '2026-08-29',
23
+ disable_surveys: true,
24
+ advanced_disable_feature_flags: true,
25
+ capture_performance: { web_vitals: false },
26
+ ...config.options,
27
+ loaded: (client) => {
28
+ client.register({ environment: config.environment, source: config.source })
29
+ config.options?.loaded?.(client)
30
+ },
31
+ }
32
+ }
33
+
34
+ /** Initialize once in the browser entrypoint; pass the returned SDK client to PostHogProvider. */
35
+ export function initializePostHog(config: PostHogClientConfig): PostHog {
36
+ if (!config.enabled) return posthog
37
+ posthog.init(config.apiKey, createPostHogClientOptions(config))
38
+ return posthog
39
+ }
40
+
41
+ export { PostHogProvider, usePostHog } from '@posthog/react'
@@ -0,0 +1,30 @@
1
+ import type { PostHog } from 'posthog-node'
2
+
3
+ import type { MetricOptions, MetricsService } from '@/application'
4
+
5
+ /** PostHog Metrics with Worker lifetime management, ported from Typist's metrics-service.ts. */
6
+ export class PostHogMetricsService implements MetricsService {
7
+ /** Use the analytics client's metrics; the runtime owns each flush promise. */
8
+ constructor(
9
+ private readonly client: Pick<PostHog, 'metrics'>,
10
+ private readonly waitUntil: (promise: Promise<unknown>) => void,
11
+ ) {}
12
+
13
+ /** Record a total and schedule its flush. */
14
+ count(name: string, value = 1, options?: MetricOptions): void {
15
+ this.client.metrics.count(name, value, options)
16
+ this.waitUntil(this.client.metrics.flush())
17
+ }
18
+
19
+ /** Record a current value and schedule its flush. */
20
+ gauge(name: string, value: number, options?: MetricOptions): void {
21
+ this.client.metrics.gauge(name, value, options)
22
+ this.waitUntil(this.client.metrics.flush())
23
+ }
24
+
25
+ /** Record an observation and schedule its flush. */
26
+ histogram(name: string, value: number, options?: MetricOptions): void {
27
+ this.client.metrics.histogram(name, value, options)
28
+ this.waitUntil(this.client.metrics.flush())
29
+ }
30
+ }
@@ -0,0 +1,47 @@
1
+ /** Runtime resources and deployment hosts for Typist's same-origin proxy. */
2
+ export interface PostHogProxyOptions {
3
+ readonly cache: Pick<Cache, 'match' | 'put'>
4
+ readonly waitUntil: (promise: Promise<void>) => void
5
+ readonly basePath?: string
6
+ readonly ingestHost?: string
7
+ readonly assetHost?: string
8
+ readonly fetch?: typeof globalThis.fetch
9
+ }
10
+
11
+ /**
12
+ * Mount at /relay/$ with GET, POST, and OPTIONS handlers. Supply Cloudflare's asset cache and waitUntil.
13
+ * The application must use the same base path in the browser's api_host option.
14
+ */
15
+ export async function proxyPostHogRequest(
16
+ request: Request,
17
+ options: PostHogProxyOptions,
18
+ ): Promise<Response> {
19
+ const url = new URL(request.url)
20
+ const basePath = options.basePath ?? '/relay'
21
+ if (!url.pathname.startsWith(`${basePath}/`)) return new Response(null, { status: 404 })
22
+ const pathname = url.pathname.slice(basePath.length)
23
+ const pathWithSearch = pathname + url.search
24
+ const send = options.fetch ?? globalThis.fetch
25
+
26
+ if (pathname.startsWith('/static/') || pathname.startsWith('/array/')) {
27
+ const cached = await options.cache.match(request)
28
+ if (cached) return cached
29
+ const response = await send(
30
+ `https://${options.assetHost ?? 'us-assets.i.posthog.com'}${pathWithSearch}`,
31
+ )
32
+ options.waitUntil(options.cache.put(request, response.clone()))
33
+ return response
34
+ }
35
+
36
+ const headers = new Headers(request.headers)
37
+ headers.delete('cookie')
38
+ headers.delete('authorization')
39
+ headers.set('X-Forwarded-For', request.headers.get('CF-Connecting-IP') ?? '')
40
+ return await send(`https://${options.ingestHost ?? 'us.i.posthog.com'}${pathWithSearch}`, {
41
+ method: request.method,
42
+ headers,
43
+ body:
44
+ request.method !== 'GET' && request.method !== 'HEAD' ? await request.arrayBuffer() : null,
45
+ redirect: request.redirect,
46
+ })
47
+ }
@@ -0,0 +1,107 @@
1
+ import { PostHog, type PostHogOptions } from 'posthog-node'
2
+
3
+ import type { AnalyticsService } from '@/application'
4
+ import {
5
+ MissingConfigurationError,
6
+ type AnalyticsEventIdentity,
7
+ type AnalyticsEventInput,
8
+ type AnalyticsProperties,
9
+ } from '@/core'
10
+
11
+ import { PostHogMetricsService } from './metrics.ts'
12
+
13
+ /** Deployment values and SDK options; the runtime owns delivery promises. */
14
+ export interface PostHogAnalyticsConfig {
15
+ readonly apiKey: string
16
+ readonly environment: string
17
+ readonly source: string
18
+ readonly waitUntil: (promise: Promise<unknown>) => void
19
+ readonly options?: Omit<
20
+ PostHogOptions,
21
+ 'waitUntil' | 'flushAt' | 'flushInterval' | 'metrics' | 'disabled'
22
+ > & {
23
+ readonly metrics?: Omit<NonNullable<PostHogOptions['metrics']>, 'serviceName' | 'environment'>
24
+ }
25
+ }
26
+
27
+ /** Worker analytics ported from Typist; disabled applications select Porte's NULL_ANALYTICS instead. */
28
+ export class PostHogAnalyticsService implements AnalyticsService {
29
+ /** Metrics share this service's SDK client and delivery lifetime. */
30
+ readonly metrics: PostHogMetricsService
31
+
32
+ private constructor(
33
+ /** Raw SDK access for integrations such as PostHog AI tracing. */
34
+ readonly posthog: PostHog,
35
+ private readonly waitUntil: (promise: Promise<unknown>) => void,
36
+ ) {
37
+ this.metrics = new PostHogMetricsService(posthog, waitUntil)
38
+ }
39
+
40
+ /** Construct once per Worker invocation; a blank key throws MissingConfigurationError. */
41
+ static create(config: PostHogAnalyticsConfig): PostHogAnalyticsService {
42
+ const apiKey = config.apiKey.trim()
43
+ if (apiKey.length === 0) throw new MissingConfigurationError('POSTHOG_API_KEY')
44
+ const client = new PostHog(apiKey, {
45
+ host: 'https://us.i.posthog.com',
46
+ ...config.options,
47
+ flushAt: 1,
48
+ flushInterval: 0,
49
+ waitUntil: config.waitUntil,
50
+ metrics: {
51
+ ...config.options?.metrics,
52
+ environment: config.environment,
53
+ serviceName: config.source,
54
+ },
55
+ })
56
+ // The SDK applies in-memory super properties synchronously before this promise resolves.
57
+ void client.register({ environment: config.environment, source: config.source })
58
+ return new PostHogAnalyticsService(client, config.waitUntil)
59
+ }
60
+
61
+ /** Schedule person properties; the SDK's waitUntil option protects queued delivery. */
62
+ identify(distinctId: string, properties?: AnalyticsProperties): void {
63
+ const payload: Parameters<PostHog['identify']>[0] = { distinctId }
64
+ if (properties !== undefined) payload.properties = properties
65
+ this.posthog.identify(payload)
66
+ }
67
+
68
+ /** Schedule an irreversible person merge; only alias IDs the application proves belong to one person. */
69
+ alias(distinctId: string, previousDistinctId: string): void {
70
+ this.waitUntil(this.posthog.aliasImmediate({ distinctId, alias: previousDistinctId }))
71
+ }
72
+
73
+ /** Schedule an event; omit identity for SDK-generated UUID and capture time. */
74
+ track(event: AnalyticsEventInput, distinctId: string, identity?: AnalyticsEventIdentity): void {
75
+ this.capture(event, distinctId, 'person', identity)
76
+ }
77
+
78
+ /** Schedule an event without creating a person profile for an attempt or resource ID. */
79
+ trackAnonymous(
80
+ event: AnalyticsEventInput,
81
+ distinctId: string,
82
+ identity?: AnalyticsEventIdentity,
83
+ ): void {
84
+ this.capture(event, distinctId, 'anonymous', identity)
85
+ }
86
+
87
+ private capture(
88
+ event: AnalyticsEventInput,
89
+ distinctId: string,
90
+ profile: 'person' | 'anonymous',
91
+ identity?: AnalyticsEventIdentity,
92
+ ): void {
93
+ const properties = { ...event.properties }
94
+ const payload: Parameters<PostHog['captureImmediate']>[0] = {
95
+ distinctId,
96
+ event: event.name,
97
+ properties,
98
+ }
99
+ if (profile === 'anonymous')
100
+ payload.properties = { ...properties, $process_person_profile: false }
101
+ if (identity !== undefined) {
102
+ payload.uuid = identity.uuid
103
+ payload.timestamp = identity.timestamp
104
+ }
105
+ this.waitUntil(this.posthog.captureImmediate(payload))
106
+ }
107
+ }
@@ -109,7 +109,10 @@ export class ResendEmailService implements IEmailService {
109
109
  if (email.tags !== undefined)
110
110
  payload.tags = Object.entries(email.tags).map(([name, value]) => ({ name, value }))
111
111
  if (email.attachments !== undefined) payload.attachments = email.attachments.map(toAttachment)
112
- const result = await client.emails.send(payload, { idempotencyKey: email.idempotencyKey })
112
+ const result = await client.emails.send(
113
+ payload,
114
+ email.idempotencyKey === undefined ? undefined : { idempotencyKey: email.idempotencyKey },
115
+ )
113
116
  if (result.error !== null) {
114
117
  throw new EmailServiceError({
115
118
  reason: reasonFromResend(result.error),