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.
- package/dist/{application-DnlgWcOZ.js → application-C4JOf71r.js} +22 -3
- package/dist/application-C4JOf71r.js.map +1 -0
- package/dist/application.d.ts +3 -3
- package/dist/application.js +2 -2
- package/dist/better-auth/apple-client-secret.d.ts +1 -1
- package/dist/better-auth/apple-client-secret.js +1 -1
- package/dist/better-auth/auth-client.js +1 -1
- package/dist/better-auth/rate-limit.d.ts +1 -1
- package/dist/better-auth/rate-limit.js +1 -1
- package/dist/better-auth.js +1 -1
- package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
- package/dist/cloudflare/auth-secondary-storage.js +1 -1
- package/dist/cloudflare/durable-object-client.d.ts +18 -0
- package/dist/cloudflare/durable-object-client.d.ts.map +1 -0
- package/dist/cloudflare/durable-object-client.js +46 -0
- package/dist/cloudflare/durable-object-client.js.map +1 -0
- package/dist/cloudflare/durable-object-errors.d.ts +40 -0
- package/dist/cloudflare/durable-object-errors.d.ts.map +1 -0
- package/dist/cloudflare/durable-object-errors.js +48 -0
- package/dist/cloudflare/durable-object-errors.js.map +1 -0
- package/dist/cloudflare/queue.d.ts +2 -2
- package/dist/cloudflare/queue.js +2 -2
- package/dist/cloudflare/scheduled.d.ts +1 -1
- package/dist/cloudflare/scheduled.js +1 -1
- package/dist/cloudflare.d.ts +3 -3
- package/dist/cloudflare.js +3 -3
- package/dist/{core-BiqdOMwU.js → core-hdaNiCOU.js} +21 -2
- package/dist/core-hdaNiCOU.js.map +1 -0
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/http.d.ts +2 -2
- package/dist/http.js +2 -2
- package/dist/{index-DG4jkwad.d.ts → index-B45Dr5gs.d.ts} +58 -5
- package/dist/index-B45Dr5gs.d.ts.map +1 -0
- package/dist/{index-B5O6mNjZ.d.ts → index-G1QG0P5g.d.ts} +34 -2
- package/dist/index-G1QG0P5g.d.ts.map +1 -0
- package/dist/{outbox-repository-options-BiilQf4_.d.ts → outbox-repository-options-CwTTuy7B.d.ts} +3 -3
- package/dist/{outbox-repository-options-BiilQf4_.d.ts.map → outbox-repository-options-CwTTuy7B.d.ts.map} +1 -1
- package/dist/{outbox-repository-options-BI75j7b7.js → outbox-repository-options-DOTpJH-e.js} +2 -2
- package/dist/{outbox-repository-options-BI75j7b7.js.map → outbox-repository-options-DOTpJH-e.js.map} +1 -1
- package/dist/postgres.d.ts +3 -3
- package/dist/postgres.js +3 -3
- package/dist/posthog/client.d.ts +22 -0
- package/dist/posthog/client.d.ts.map +1 -0
- package/dist/posthog/client.js +34 -0
- package/dist/posthog/client.js.map +1 -0
- package/dist/posthog/metrics.d.ts +18 -0
- package/dist/posthog/metrics.d.ts.map +1 -0
- package/dist/posthog/metrics.js +30 -0
- package/dist/posthog/metrics.js.map +1 -0
- package/dist/posthog/proxy.d.ts +17 -0
- package/dist/posthog/proxy.d.ts.map +1 -0
- package/dist/posthog/proxy.js +34 -0
- package/dist/posthog/proxy.js.map +1 -0
- package/dist/posthog/server.d.ts +37 -0
- package/dist/posthog/server.d.ts.map +1 -0
- package/dist/posthog/server.js +81 -0
- package/dist/posthog/server.js.map +1 -0
- package/dist/resend.d.ts +3 -3
- package/dist/resend.js +3 -3
- package/dist/resend.js.map +1 -1
- package/dist/sentry/client.d.ts +22 -0
- package/dist/sentry/client.d.ts.map +1 -0
- package/dist/sentry/client.js +48 -0
- package/dist/sentry/client.js.map +1 -0
- package/dist/sentry/server.d.ts +24 -0
- package/dist/sentry/server.d.ts.map +1 -0
- package/dist/sentry/server.js +48 -0
- package/dist/sentry/server.js.map +1 -0
- package/dist/sentry/vite.d.ts +11 -0
- package/dist/sentry/vite.d.ts.map +1 -0
- package/dist/sentry/vite.js +17 -0
- package/dist/sentry/vite.js.map +1 -0
- package/dist/tanstack.d.ts +1 -1
- package/dist/tanstack.js +2 -2
- package/dist/testing/outbox-repository.d.ts +2 -2
- package/dist/{webhook-verification-CyBoERFz.d.ts → webhook-verification-D7DIhH6C.d.ts} +2 -2
- package/dist/{webhook-verification-CyBoERFz.d.ts.map → webhook-verification-D7DIhH6C.d.ts.map} +1 -1
- package/dist/{webhook-verification-CeXUJXVP.js → webhook-verification-trEgyz7I.js} +2 -2
- package/dist/{webhook-verification-CeXUJXVP.js.map → webhook-verification-trEgyz7I.js.map} +1 -1
- package/package.json +11 -2
- package/src/application/analytics-service.ts +21 -0
- package/src/application/email-service.ts +2 -2
- package/src/application/index.ts +13 -0
- package/src/application/metrics-service.ts +28 -0
- package/src/application/observability-service.ts +16 -0
- package/src/core/analytics-event.ts +37 -0
- package/src/core/index.ts +9 -0
- package/src/core/missing-configuration.error.ts +12 -0
- package/src/infrastructure/cloudflare/durable-object-client.ts +70 -0
- package/src/infrastructure/cloudflare/durable-object-errors.ts +70 -0
- package/src/infrastructure/posthog/client.ts +41 -0
- package/src/infrastructure/posthog/metrics.ts +30 -0
- package/src/infrastructure/posthog/proxy.ts +47 -0
- package/src/infrastructure/posthog/server.ts +107 -0
- package/src/infrastructure/resend/resend-email-service.ts +4 -1
- package/src/infrastructure/sentry/client.ts +55 -0
- package/src/infrastructure/sentry/server.ts +51 -0
- package/src/infrastructure/sentry/vite.ts +17 -0
- package/dist/application-DnlgWcOZ.js.map +0 -1
- package/dist/core-BiqdOMwU.js.map +0 -1
- package/dist/index-B5O6mNjZ.d.ts.map +0 -1
- package/dist/index-DG4jkwad.d.ts.map +0 -1
package/dist/{webhook-verification-CyBoERFz.d.ts.map → webhook-verification-D7DIhH6C.d.ts.map}
RENAMED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook-verification-
|
|
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 {
|
|
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-
|
|
18
|
+
//# sourceMappingURL=webhook-verification-trEgyz7I.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook-verification-
|
|
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.
|
|
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": "^
|
|
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
|
-
/**
|
|
26
|
-
readonly idempotencyKey
|
|
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
|
package/src/application/index.ts
CHANGED
|
@@ -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(
|
|
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),
|