better-ship 0.28.0 → 0.29.1

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.
@@ -20,8 +20,8 @@ export declare class TurnstileVerificationError extends AppError {
20
20
  type TurnstileCheck = {
21
21
  readonly secretKey: string;
22
22
  readonly token: string;
23
- /** Visitor address from `CF-Connecting-IP`. Omit it when the request has none. */
24
- readonly remoteIp?: string;
23
+ /** Visitor address from `CF-Connecting-IP`. `null` and omission both mean no address. */
24
+ readonly remoteIp?: string | null;
25
25
  /** Widget action. A present, different action rejects. A missing action does not. */
26
26
  readonly expectedAction?: string;
27
27
  /** Allowed widget hostnames. A testing-key success skips this list. */
@@ -104,7 +104,7 @@ async function requestVerdict(check, idempotencyKey, options) {
104
104
  response: check.token,
105
105
  idempotency_key: idempotencyKey
106
106
  };
107
- const payload = check.remoteIp !== void 0 && check.remoteIp !== "" ? {
107
+ const payload = check.remoteIp !== void 0 && check.remoteIp !== null && check.remoteIp !== "" ? {
108
108
  ...fields,
109
109
  remoteip: check.remoteIp
110
110
  } : fields;
@@ -1 +1 @@
1
- {"version":3,"file":"turnstile.js","names":[],"sources":["../../src/infrastructure/cloudflare/turnstile/turnstile.error.ts","../../src/infrastructure/cloudflare/turnstile/turnstile.ts"],"sourcesContent":["import { AppError, type FailureClassification } from '@/core'\n\n/**\n * Siteverify did not produce a verdict.\n * A rejected token is not this error. The caller logs it once.\n */\nexport class TurnstileVerificationError extends AppError {\n readonly _tag = 'TurnstileVerificationError'\n readonly classification: FailureClassification\n readonly errorCodes: readonly string[]\n\n constructor(args: {\n readonly classification: FailureClassification\n readonly errorCodes: readonly string[]\n readonly cause: unknown\n }) {\n super('Turnstile siteverify could not produce a verdict', { cause: args.cause })\n this.classification = args.classification\n this.errorCodes = args.errorCodes\n }\n}\n","import { Result } from 'better-result'\nimport { z } from 'zod'\n\nimport {\n classifyBoundaryError,\n exponentialEqualJitterMs,\n shouldRetryFailure,\n type FailureClassification,\n} from '@/core'\n\nimport { TurnstileVerificationError } from './turnstile.error.ts'\n\n/**\n * Turnstile siteverify. Ported from Suparelay `verify-turnstile.ts` and Typist `turnstile.ts`.\n * A rejection is a verdict. This module does not log, and one call retries with one idempotency key.\n */\n\nconst SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'\n\n// Cloudflare documents 2048 characters. The testing secret still accepts a longer token.\nconst MAX_TOKEN_LENGTH = 2048\n\n// Cloudflare's validation example stops at 10 seconds.\nconst DEFAULT_TIMEOUT_MS = 10_000\n\n// `times` counts retries after the first attempt, so a call stops at three attempts.\nconst RETRY_TIMES = 2\n\nconst CONFIGURATION_CODES: ReadonlySet<string> = new Set([\n 'missing-input-secret',\n 'invalid-input-secret',\n 'bad-request',\n])\n\nconst SuccessBody = z.object({\n success: z.literal(true),\n hostname: z.string(),\n challenge_ts: z.string(),\n action: z.string().optional(),\n metadata: z.object({ result_with_testing_key: z.boolean().optional() }).optional(),\n})\n\nconst FailureBody = z.object({\n success: z.literal(false),\n 'error-codes': z.array(z.string()).default([]),\n})\n\ntype SuccessBody = z.infer<typeof SuccessBody>\n\n/** The values siteverify needs. The secret stays in the caller. */\nexport type TurnstileCheck = {\n readonly secretKey: string\n readonly token: string\n /** Visitor address from `CF-Connecting-IP`. Omit it when the request has none. */\n readonly remoteIp?: string\n /** Widget action. A present, different action rejects. A missing action does not. */\n readonly expectedAction?: string\n /** Allowed widget hostnames. A testing-key success skips this list. */\n readonly expectedHostnames?: readonly string[]\n /** UUID reused for this call's retries. Omit it and the call mints one. */\n readonly idempotencyKey?: string\n}\n\n/** A solved challenge, or a refusal siteverify already decided. */\nexport type TurnstileVerdict =\n | {\n readonly _tag: 'Accepted'\n readonly hostname: string\n readonly challengeTs: string\n readonly action?: string\n }\n | {\n readonly _tag: 'Rejected'\n readonly errorCodes: readonly string[]\n }\n\n/** Deadline and fetch overrides. Production calls omit this argument. */\nexport type TurnstileVerifyOptions = {\n /** Replaces global `fetch`. A test uses it for a response the testing secret cannot return. */\n readonly fetch?: typeof fetch\n /** Siteverify deadline in milliseconds. The default is 10 seconds. */\n readonly timeoutMs?: number\n}\n\nclass SiteverifyRetry extends Error {\n readonly _tag = 'SiteverifyRetry'\n\n constructor(\n readonly errorCodes: readonly string[],\n cause: unknown,\n ) {\n super('Turnstile siteverify did not answer', { cause })\n }\n}\n\n/**\n * Ask siteverify whether this token is a solved challenge.\n * A rejection is a verdict. No verdict throws `TurnstileVerificationError`.\n * A transient failure retries twice with the same idempotency key.\n */\nexport async function verifyTurnstile(\n check: TurnstileCheck,\n options: TurnstileVerifyOptions = {},\n): Promise<TurnstileVerdict> {\n if (check.token.length > MAX_TOKEN_LENGTH) {\n return { _tag: 'Rejected', errorCodes: ['invalid-input-response'] }\n }\n const idempotencyKey = resolveIdempotencyKey(check.idempotencyKey)\n const result = await Result.tryPromise(\n {\n try: () => requestVerdict(check, idempotencyKey, options),\n catch: (cause) => cause,\n },\n {\n retry: {\n times: RETRY_TIMES,\n delayMs: (_cause, context) => exponentialEqualJitterMs(25, 200, context.attempt),\n shouldRetry: (cause) =>\n cause instanceof SiteverifyRetry &&\n shouldRetryFailure({\n classification: 'transient',\n repeatSafe: true,\n owner: 'immediate',\n }),\n },\n },\n )\n if (result.isOk()) return result.value\n if (result.error instanceof TurnstileVerificationError) throw result.error\n if (result.error instanceof SiteverifyRetry) {\n throw new TurnstileVerificationError({\n classification: 'transient',\n errorCodes: result.error.errorCodes,\n cause: result.error.cause,\n })\n }\n throw new TurnstileVerificationError({\n classification: 'unknown',\n errorCodes: [],\n cause: result.error,\n })\n}\n\nfunction resolveIdempotencyKey(key: string | undefined): string {\n if (key === undefined) return crypto.randomUUID()\n if (z.uuid().safeParse(key).success) return key\n throw new TurnstileVerificationError({\n classification: 'terminal',\n errorCodes: [],\n cause: { idempotencyKey: 'invalid' },\n })\n}\n\nasync function requestVerdict(\n check: TurnstileCheck,\n idempotencyKey: string,\n options: TurnstileVerifyOptions,\n): Promise<TurnstileVerdict> {\n const fetchImpl = options.fetch ?? fetch\n const fields = {\n secret: check.secretKey,\n response: check.token,\n idempotency_key: idempotencyKey,\n }\n const payload =\n check.remoteIp !== undefined && check.remoteIp !== ''\n ? { ...fields, remoteip: check.remoteIp }\n : fields\n\n let response: Response\n try {\n response = await fetchImpl(SITEVERIFY_URL, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),\n })\n } catch (cause) {\n throw transportFailure(cause)\n }\n\n const text = await response.text()\n let parsed: unknown\n try {\n parsed = JSON.parse(text)\n } catch (cause) {\n throw unparsedFailure(response.status, cause)\n }\n\n const success = SuccessBody.safeParse(parsed)\n if (success.success) return decideSuccess(success.data, check)\n const failure = FailureBody.safeParse(parsed)\n if (failure.success) return decideFailure(failure.data['error-codes'], response.status)\n throw unparsedFailure(response.status, { status: response.status })\n}\n\nfunction decideSuccess(body: SuccessBody, check: TurnstileCheck): TurnstileVerdict {\n // A testing secret omits action. Compare only an action the response names.\n if (\n check.expectedAction !== undefined &&\n body.action !== undefined &&\n body.action !== check.expectedAction\n ) {\n return { _tag: 'Rejected', errorCodes: ['action-mismatch'] }\n }\n // A testing secret reports example.com, so the allowlist does not apply to it.\n const testingKey = body.metadata?.result_with_testing_key === true\n if (\n !testingKey &&\n check.expectedHostnames !== undefined &&\n !check.expectedHostnames.includes(body.hostname)\n ) {\n return { _tag: 'Rejected', errorCodes: ['hostname-mismatch'] }\n }\n if (body.action === undefined) {\n return { _tag: 'Accepted', hostname: body.hostname, challengeTs: body.challenge_ts }\n }\n return {\n _tag: 'Accepted',\n hostname: body.hostname,\n challengeTs: body.challenge_ts,\n action: body.action,\n }\n}\n\nfunction decideFailure(errorCodes: readonly string[], status: number): TurnstileVerdict {\n if (errorCodes.some((code) => CONFIGURATION_CODES.has(code))) {\n throw verificationFailure('terminal', errorCodes, status)\n }\n if (errorCodes.includes('internal-error') || status === 429 || status >= 500) {\n throw new SiteverifyRetry(errorCodes, { status })\n }\n return { _tag: 'Rejected', errorCodes }\n}\n\nfunction transportFailure(cause: unknown): Error {\n const classification = classifyBoundaryError({\n cause,\n classifyBoundary: () => undefined,\n includeCloudflare: false,\n })\n if (classification === 'transient') return new SiteverifyRetry([], cause)\n return new TurnstileVerificationError({ classification, errorCodes: [], cause })\n}\n\nfunction unparsedFailure(status: number, cause: unknown): Error {\n if (status === 429 || status >= 500) return new SiteverifyRetry([], cause)\n return verificationFailure('terminal', [], status)\n}\n\nfunction verificationFailure(\n classification: FailureClassification,\n errorCodes: readonly string[],\n status: number,\n): TurnstileVerificationError {\n return new TurnstileVerificationError({\n classification,\n errorCodes,\n cause: { status },\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"turnstile.js","names":[],"sources":["../../src/infrastructure/cloudflare/turnstile/turnstile.error.ts","../../src/infrastructure/cloudflare/turnstile/turnstile.ts"],"sourcesContent":["import { AppError, type FailureClassification } from '@/core'\n\n/**\n * Siteverify did not produce a verdict.\n * A rejected token is not this error. The caller logs it once.\n */\nexport class TurnstileVerificationError extends AppError {\n readonly _tag = 'TurnstileVerificationError'\n readonly classification: FailureClassification\n readonly errorCodes: readonly string[]\n\n constructor(args: {\n readonly classification: FailureClassification\n readonly errorCodes: readonly string[]\n readonly cause: unknown\n }) {\n super('Turnstile siteverify could not produce a verdict', { cause: args.cause })\n this.classification = args.classification\n this.errorCodes = args.errorCodes\n }\n}\n","import { Result } from 'better-result'\nimport { z } from 'zod'\n\nimport {\n classifyBoundaryError,\n exponentialEqualJitterMs,\n shouldRetryFailure,\n type FailureClassification,\n} from '@/core'\n\nimport { TurnstileVerificationError } from './turnstile.error.ts'\n\n/**\n * Turnstile siteverify. Ported from Suparelay `verify-turnstile.ts` and Typist `turnstile.ts`.\n * A rejection is a verdict. This module does not log, and one call retries with one idempotency key.\n */\n\nconst SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'\n\n// Cloudflare documents 2048 characters. The testing secret still accepts a longer token.\nconst MAX_TOKEN_LENGTH = 2048\n\n// Cloudflare's validation example stops at 10 seconds.\nconst DEFAULT_TIMEOUT_MS = 10_000\n\n// `times` counts retries after the first attempt, so a call stops at three attempts.\nconst RETRY_TIMES = 2\n\nconst CONFIGURATION_CODES: ReadonlySet<string> = new Set([\n 'missing-input-secret',\n 'invalid-input-secret',\n 'bad-request',\n])\n\nconst SuccessBody = z.object({\n success: z.literal(true),\n hostname: z.string(),\n challenge_ts: z.string(),\n action: z.string().optional(),\n metadata: z.object({ result_with_testing_key: z.boolean().optional() }).optional(),\n})\n\nconst FailureBody = z.object({\n success: z.literal(false),\n 'error-codes': z.array(z.string()).default([]),\n})\n\ntype SuccessBody = z.infer<typeof SuccessBody>\n\n/** The values siteverify needs. The secret stays in the caller. */\nexport type TurnstileCheck = {\n readonly secretKey: string\n readonly token: string\n /** Visitor address from `CF-Connecting-IP`. `null` and omission both mean no address. */\n readonly remoteIp?: string | null\n /** Widget action. A present, different action rejects. A missing action does not. */\n readonly expectedAction?: string\n /** Allowed widget hostnames. A testing-key success skips this list. */\n readonly expectedHostnames?: readonly string[]\n /** UUID reused for this call's retries. Omit it and the call mints one. */\n readonly idempotencyKey?: string\n}\n\n/** A solved challenge, or a refusal siteverify already decided. */\nexport type TurnstileVerdict =\n | {\n readonly _tag: 'Accepted'\n readonly hostname: string\n readonly challengeTs: string\n readonly action?: string\n }\n | {\n readonly _tag: 'Rejected'\n readonly errorCodes: readonly string[]\n }\n\n/** Deadline and fetch overrides. Production calls omit this argument. */\nexport type TurnstileVerifyOptions = {\n /** Replaces global `fetch`. A test uses it for a response the testing secret cannot return. */\n readonly fetch?: typeof fetch\n /** Siteverify deadline in milliseconds. The default is 10 seconds. */\n readonly timeoutMs?: number\n}\n\nclass SiteverifyRetry extends Error {\n readonly _tag = 'SiteverifyRetry'\n\n constructor(\n readonly errorCodes: readonly string[],\n cause: unknown,\n ) {\n super('Turnstile siteverify did not answer', { cause })\n }\n}\n\n/**\n * Ask siteverify whether this token is a solved challenge.\n * A rejection is a verdict. No verdict throws `TurnstileVerificationError`.\n * A transient failure retries twice with the same idempotency key.\n */\nexport async function verifyTurnstile(\n check: TurnstileCheck,\n options: TurnstileVerifyOptions = {},\n): Promise<TurnstileVerdict> {\n if (check.token.length > MAX_TOKEN_LENGTH) {\n return { _tag: 'Rejected', errorCodes: ['invalid-input-response'] }\n }\n const idempotencyKey = resolveIdempotencyKey(check.idempotencyKey)\n const result = await Result.tryPromise(\n {\n try: () => requestVerdict(check, idempotencyKey, options),\n catch: (cause) => cause,\n },\n {\n retry: {\n times: RETRY_TIMES,\n delayMs: (_cause, context) => exponentialEqualJitterMs(25, 200, context.attempt),\n shouldRetry: (cause) =>\n cause instanceof SiteverifyRetry &&\n shouldRetryFailure({\n classification: 'transient',\n repeatSafe: true,\n owner: 'immediate',\n }),\n },\n },\n )\n if (result.isOk()) return result.value\n if (result.error instanceof TurnstileVerificationError) throw result.error\n if (result.error instanceof SiteverifyRetry) {\n throw new TurnstileVerificationError({\n classification: 'transient',\n errorCodes: result.error.errorCodes,\n cause: result.error.cause,\n })\n }\n throw new TurnstileVerificationError({\n classification: 'unknown',\n errorCodes: [],\n cause: result.error,\n })\n}\n\nfunction resolveIdempotencyKey(key: string | undefined): string {\n if (key === undefined) return crypto.randomUUID()\n if (z.uuid().safeParse(key).success) return key\n throw new TurnstileVerificationError({\n classification: 'terminal',\n errorCodes: [],\n cause: { idempotencyKey: 'invalid' },\n })\n}\n\nasync function requestVerdict(\n check: TurnstileCheck,\n idempotencyKey: string,\n options: TurnstileVerifyOptions,\n): Promise<TurnstileVerdict> {\n const fetchImpl = options.fetch ?? fetch\n const fields = {\n secret: check.secretKey,\n response: check.token,\n idempotency_key: idempotencyKey,\n }\n const payload =\n check.remoteIp !== undefined && check.remoteIp !== null && check.remoteIp !== ''\n ? { ...fields, remoteip: check.remoteIp }\n : fields\n\n let response: Response\n try {\n response = await fetchImpl(SITEVERIFY_URL, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),\n })\n } catch (cause) {\n throw transportFailure(cause)\n }\n\n const text = await response.text()\n let parsed: unknown\n try {\n parsed = JSON.parse(text)\n } catch (cause) {\n throw unparsedFailure(response.status, cause)\n }\n\n const success = SuccessBody.safeParse(parsed)\n if (success.success) return decideSuccess(success.data, check)\n const failure = FailureBody.safeParse(parsed)\n if (failure.success) return decideFailure(failure.data['error-codes'], response.status)\n throw unparsedFailure(response.status, { status: response.status })\n}\n\nfunction decideSuccess(body: SuccessBody, check: TurnstileCheck): TurnstileVerdict {\n // A testing secret omits action. Compare only an action the response names.\n if (\n check.expectedAction !== undefined &&\n body.action !== undefined &&\n body.action !== check.expectedAction\n ) {\n return { _tag: 'Rejected', errorCodes: ['action-mismatch'] }\n }\n // A testing secret reports example.com, so the allowlist does not apply to it.\n const testingKey = body.metadata?.result_with_testing_key === true\n if (\n !testingKey &&\n check.expectedHostnames !== undefined &&\n !check.expectedHostnames.includes(body.hostname)\n ) {\n return { _tag: 'Rejected', errorCodes: ['hostname-mismatch'] }\n }\n if (body.action === undefined) {\n return { _tag: 'Accepted', hostname: body.hostname, challengeTs: body.challenge_ts }\n }\n return {\n _tag: 'Accepted',\n hostname: body.hostname,\n challengeTs: body.challenge_ts,\n action: body.action,\n }\n}\n\nfunction decideFailure(errorCodes: readonly string[], status: number): TurnstileVerdict {\n if (errorCodes.some((code) => CONFIGURATION_CODES.has(code))) {\n throw verificationFailure('terminal', errorCodes, status)\n }\n if (errorCodes.includes('internal-error') || status === 429 || status >= 500) {\n throw new SiteverifyRetry(errorCodes, { status })\n }\n return { _tag: 'Rejected', errorCodes }\n}\n\nfunction transportFailure(cause: unknown): Error {\n const classification = classifyBoundaryError({\n cause,\n classifyBoundary: () => undefined,\n includeCloudflare: false,\n })\n if (classification === 'transient') return new SiteverifyRetry([], cause)\n return new TurnstileVerificationError({ classification, errorCodes: [], cause })\n}\n\nfunction unparsedFailure(status: number, cause: unknown): Error {\n if (status === 429 || status >= 500) return new SiteverifyRetry([], cause)\n return verificationFailure('terminal', [], status)\n}\n\nfunction verificationFailure(\n classification: FailureClassification,\n errorCodes: readonly string[],\n status: number,\n): TurnstileVerificationError {\n return new TurnstileVerificationError({\n classification,\n errorCodes,\n cause: { status },\n })\n}\n"],"mappings":"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"}
@@ -14,7 +14,10 @@ export type PostHogClientConfig = {
14
14
  readonly enabled: true;
15
15
  readonly apiKey: string;
16
16
  });
17
- /** Browser options from Typist and Porte; SDK option names and application overrides stay intact. */
17
+ /**
18
+ * Browser options from Typist and Porte. SDK option names and application overrides stay intact.
19
+ * Web Vitals are on. Pass `options.capture_performance` to turn them off.
20
+ */
18
21
  export declare function createPostHogClientOptions(config: PostHogClientOptionsConfig): Partial<PostHogConfig>;
19
22
  /** Initialize once in the browser entrypoint; pass the returned SDK client to PostHogProvider. */
20
23
  export declare function initializePostHog(config: PostHogClientConfig): PostHog;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","names":[],"sources":["../../src/infrastructure/posthog/client.ts"],"mappings":";;;;iBAGiB;WACN;WACA;WACA,UAAU,QAAQ;;;YAIjB;WACG;KACV;WACU;WACA;;;wBAIC,2BACd,QAAQ,6BACP,QAAQ;;wBAkBK,kBAAkB,QAAQ,sBAAsB"}
1
+ {"version":3,"file":"client.d.ts","names":[],"sources":["../../src/infrastructure/posthog/client.ts"],"mappings":";;;;iBAGiB;WACN;WACA;WACA,UAAU,QAAQ;;;YAIjB;WACG;KACV;WACU;WACA;;;;;;wBAOC,2BACd,QAAQ,6BACP,QAAQ;;wBAmBK,kBAAkB,QAAQ,sBAAsB"}
@@ -2,7 +2,10 @@ import posthog from "posthog-js";
2
2
  import { PostHogProvider, usePostHog } from "@posthog/react";
3
3
 
4
4
  //#region src/infrastructure/posthog/client.ts
5
- /** Browser options from Typist and Porte; SDK option names and application overrides stay intact. */
5
+ /**
6
+ * Browser options from Typist and Porte. SDK option names and application overrides stay intact.
7
+ * Web Vitals are on. Pass `options.capture_performance` to turn them off.
8
+ */
6
9
  function createPostHogClientOptions(config) {
7
10
  return {
8
11
  api_host: "/relay",
@@ -11,7 +14,7 @@ function createPostHogClientOptions(config) {
11
14
  defaults: "2026-08-29",
12
15
  disable_surveys: true,
13
16
  advanced_disable_feature_flags: true,
14
- capture_performance: { web_vitals: false },
17
+ capture_performance: { web_vitals: true },
15
18
  ...config.options,
16
19
  loaded: (client) => {
17
20
  client.register({
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","names":[],"sources":["../../src/infrastructure/posthog/client.ts"],"sourcesContent":["import posthog, { type PostHog, type PostHogConfig } from 'posthog-js'\n\n/** Application properties and native SDK options for browser initialization. */\nexport interface PostHogClientOptionsConfig {\n readonly environment: string\n readonly source: string\n readonly options?: Partial<PostHogConfig>\n}\n\n/** The application controls enablement; disabled initialization needs no project key. */\nexport type PostHogClientConfig =\n | { readonly enabled: false }\n | (PostHogClientOptionsConfig & {\n readonly enabled: true\n readonly apiKey: string\n })\n\n/** Browser options from Typist and Porte; SDK option names and application overrides stay intact. */\nexport function createPostHogClientOptions(\n config: PostHogClientOptionsConfig,\n): Partial<PostHogConfig> {\n return {\n api_host: '/relay',\n ui_host: 'https://us.posthog.com',\n person_profiles: 'identified_only',\n defaults: '2026-08-29',\n disable_surveys: true,\n advanced_disable_feature_flags: true,\n capture_performance: { web_vitals: false },\n ...config.options,\n loaded: (client) => {\n client.register({ environment: config.environment, source: config.source })\n config.options?.loaded?.(client)\n },\n }\n}\n\n/** Initialize once in the browser entrypoint; pass the returned SDK client to PostHogProvider. */\nexport function initializePostHog(config: PostHogClientConfig): PostHog {\n if (!config.enabled) return posthog\n posthog.init(config.apiKey, createPostHogClientOptions(config))\n return posthog\n}\n\nexport { PostHogProvider, usePostHog } from '@posthog/react'\n"],"mappings":";;;;;AAkBA,SAAgB,2BACd,QACwB;CACxB,OAAO;EACL,UAAU;EACV,SAAS;EACT,iBAAiB;EACjB,UAAU;EACV,iBAAiB;EACjB,gCAAgC;EAChC,qBAAqB,EAAE,YAAY,MAAM;EACzC,GAAG,OAAO;EACV,SAAS,WAAW;GAClB,OAAO,SAAS;IAAE,aAAa,OAAO;IAAa,QAAQ,OAAO;GAAO,CAAC;GAC1E,OAAO,SAAS,SAAS,MAAM;EACjC;CACF;AACF;;AAGA,SAAgB,kBAAkB,QAAsC;CACtE,IAAI,CAAC,OAAO,SAAS,OAAO;CAC5B,QAAQ,KAAK,OAAO,QAAQ,2BAA2B,MAAM,CAAC;CAC9D,OAAO;AACT"}
1
+ {"version":3,"file":"client.js","names":[],"sources":["../../src/infrastructure/posthog/client.ts"],"sourcesContent":["import posthog, { type PostHog, type PostHogConfig } from 'posthog-js'\n\n/** Application properties and native SDK options for browser initialization. */\nexport interface PostHogClientOptionsConfig {\n readonly environment: string\n readonly source: string\n readonly options?: Partial<PostHogConfig>\n}\n\n/** The application controls enablement; disabled initialization needs no project key. */\nexport type PostHogClientConfig =\n | { readonly enabled: false }\n | (PostHogClientOptionsConfig & {\n readonly enabled: true\n readonly apiKey: string\n })\n\n/**\n * Browser options from Typist and Porte. SDK option names and application overrides stay intact.\n * Web Vitals are on. Pass `options.capture_performance` to turn them off.\n */\nexport function createPostHogClientOptions(\n config: PostHogClientOptionsConfig,\n): Partial<PostHogConfig> {\n return {\n api_host: '/relay',\n ui_host: 'https://us.posthog.com',\n person_profiles: 'identified_only',\n defaults: '2026-08-29',\n disable_surveys: true,\n advanced_disable_feature_flags: true,\n // An absent network_timing key leaves session replay on the project setting.\n capture_performance: { web_vitals: true },\n ...config.options,\n loaded: (client) => {\n client.register({ environment: config.environment, source: config.source })\n config.options?.loaded?.(client)\n },\n }\n}\n\n/** Initialize once in the browser entrypoint; pass the returned SDK client to PostHogProvider. */\nexport function initializePostHog(config: PostHogClientConfig): PostHog {\n if (!config.enabled) return posthog\n posthog.init(config.apiKey, createPostHogClientOptions(config))\n return posthog\n}\n\nexport { PostHogProvider, usePostHog } from '@posthog/react'\n"],"mappings":";;;;;;;;AAqBA,SAAgB,2BACd,QACwB;CACxB,OAAO;EACL,UAAU;EACV,SAAS;EACT,iBAAiB;EACjB,UAAU;EACV,iBAAiB;EACjB,gCAAgC;EAEhC,qBAAqB,EAAE,YAAY,KAAK;EACxC,GAAG,OAAO;EACV,SAAS,WAAW;GAClB,OAAO,SAAS;IAAE,aAAa,OAAO;IAAa,QAAQ,OAAO;GAAO,CAAC;GAC1E,OAAO,SAAS,SAAS,MAAM;EACjC;CACF;AACF;;AAGA,SAAgB,kBAAkB,QAAsC;CACtE,IAAI,CAAC,OAAO,SAAS,OAAO;CAC5B,QAAQ,KAAK,OAAO,QAAQ,2BAA2B,MAAM,CAAC;CAC9D,OAAO;AACT"}
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.28.0",
4
+ "version": "0.29.1",
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": {
@@ -51,8 +51,8 @@ type SuccessBody = z.infer<typeof SuccessBody>
51
51
  export type TurnstileCheck = {
52
52
  readonly secretKey: string
53
53
  readonly token: string
54
- /** Visitor address from `CF-Connecting-IP`. Omit it when the request has none. */
55
- readonly remoteIp?: string
54
+ /** Visitor address from `CF-Connecting-IP`. `null` and omission both mean no address. */
55
+ readonly remoteIp?: string | null
56
56
  /** Widget action. A present, different action rejects. A missing action does not. */
57
57
  readonly expectedAction?: string
58
58
  /** Allowed widget hostnames. A testing-key success skips this list. */
@@ -163,7 +163,7 @@ async function requestVerdict(
163
163
  idempotency_key: idempotencyKey,
164
164
  }
165
165
  const payload =
166
- check.remoteIp !== undefined && check.remoteIp !== ''
166
+ check.remoteIp !== undefined && check.remoteIp !== null && check.remoteIp !== ''
167
167
  ? { ...fields, remoteip: check.remoteIp }
168
168
  : fields
169
169
 
@@ -15,7 +15,10 @@ export type PostHogClientConfig =
15
15
  readonly apiKey: string
16
16
  })
17
17
 
18
- /** Browser options from Typist and Porte; SDK option names and application overrides stay intact. */
18
+ /**
19
+ * Browser options from Typist and Porte. SDK option names and application overrides stay intact.
20
+ * Web Vitals are on. Pass `options.capture_performance` to turn them off.
21
+ */
19
22
  export function createPostHogClientOptions(
20
23
  config: PostHogClientOptionsConfig,
21
24
  ): Partial<PostHogConfig> {
@@ -26,7 +29,8 @@ export function createPostHogClientOptions(
26
29
  defaults: '2026-08-29',
27
30
  disable_surveys: true,
28
31
  advanced_disable_feature_flags: true,
29
- capture_performance: { web_vitals: false },
32
+ // An absent network_timing key leaves session replay on the project setting.
33
+ capture_performance: { web_vitals: true },
30
34
  ...config.options,
31
35
  loaded: (client) => {
32
36
  client.register({ environment: config.environment, source: config.source })